feat(ui): Campaign OP slice OP3 — Options panel shell, open paths, Gameplay tab

Mounts retail's Options panel (LayoutDesc 0x2100002B resolved through host
0x2100006E slot 0x1000018D, gmPanelUI key 10) via the same catalog-import
pattern CharacterController already validates, registered through
RetailPanelUiController so it shares retail's "one active gmPanelUI child"
mutual exclusion with every other sibling panel for free. F11 and the
toolbar's options button (0x1000019B, already authoring panel id 10) both
now open it; the close button fires the same ToggleOptionsPanel action.

OptionPageModel (OptionPage/BoolOptionRow) ports retail's exact
Apply/Reset/Defaults/visibility semantics from
UIOption_Checkbox/PlayerOptionPage — LED clicks apply live immediately,
Apply commits every row unconditionally + flushes the batched blob, Reset
reverts only Changed rows, Defaults restores without committing, and
tab-switch/window-hide revert uncommitted edits. Wired for all four tabs;
this slice registers real rows on none of them (Gameplay authentically has
none — a pure button list). UiTabPanel gains an ActivePageChanged event so
the page model can hook every tab transition, including the initial
default-tab activation.

The seven Gameplay-tab buttons: Exit Game reuses the existing graceful
window-close path; Exit to Character Selection gets retail's confirmation
dialog and byte-verified mid-air refusal but still behaves as Exit Game
(AD-74 — no pre-world character-select flow exists); Configure Keyboard
and In-Game Help Files are inert this slice (AD-76 for Help — the
plugin retail depends on doesn't exist); Urgent Assistance/Report Abuse
short-circuit to their own byte-verified failure text through the
interface-text seam instead of ShellExecute against a dead URL (AD-75);
Use Mouse Turning Settings runs the pure MouseTurningSettingsMacro port,
persisting five new CameraTurningSettings preferences and sending
PlayerOption.UseMouseTurning — TS-74 records that acdream has no
persistent mouse-turning camera mode for the bit to drive yet.

Full Release suite: 12,918 passed / 4 skipped / 0 failed (baseline
12,871/4/0 — only new tests added).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 02:14:40 +02:00
parent 5242de9f15
commit 9d26ecc623
27 changed files with 25696 additions and 8 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,114 @@
# Campaign OP connected-gate test script
**Status:** OP3 section only. Later slices (OP4-6, OP8) append their own
sections here as they land; the campaign's OP9 closeout gate is this
document complete plus every slice code-complete.
This document is the script the user runs against the live connected
client (`ACDREAM_LIVE=1` against the local ACE server) to accept each
slice. Each item states what to do and what retail-faithful behavior to
expect. Anything marked **INERT** is authored and clickable but
deliberately does nothing yet — that is the correct, contracted behavior
for this slice, not a bug.
---
## OP3 — panel shell, open paths, Gameplay tab
### Opening and closing the panel
1. **Press F11.** The Options panel opens — a floating, resizable window
titled by its own chrome (no title-bar text is authored; the window
itself is the visual identity). The **Gameplay Options** tab is
selected by default (leftmost tab, matching the user's own retail
screenshot).
2. **Press F11 again.** The panel closes.
3. **Click the toolbar's Options button** (the icon at the right end of
the shortcut row, next to Inventory). The panel opens. If a DIFFERENT
`gmPanelUI`-family panel was already open (Character Info, Skills,
Vitae, Inventory, etc.), opening Options closes it first — this is
retail's real "one active panel" behavior (`gmPanelUI`'s shared
geometry/exclusive-child model), not new to this panel.
4. **Click the panel's own close (X) button**, top-right of the window
chrome. The panel closes — same action as F11.
5. **Drag the window by its border/chrome, resize it from any edge or
corner.** Behaves like the CH6 chat floaties: draggable, resizable on
all four edges, remembers its geometry across a close/reopen within
the same session.
### Tab switching
6. **Click each of the four tabs** (Gameplay Options, Character, Chat,
Config). Each switches the visible page; exactly one page is visible
at a time. Character/Chat/Config show their AUTHORED content (row
templates, Apply/Reset/Defaults buttons, scrollbars) but nothing on
those three tabs is wired to live data yet — that is OP4/OP5/OP6's
scope, not a bug in this slice. Only the currently-selected tab's
button highlights as "open"; the others read "closed".
7. **Switch away from Gameplay and back.** No crash, no stuck state.
### The seven Gameplay-tab buttons
8. **Exit Game.** Click it. The client logs off and closes gracefully —
same behavior as pressing Escape then confirming, or closing the
window. **No confirmation dialog** (retail has none on this path).
9. **Exit to Character Selection.** Click it. A confirmation dialog
appears ("Are you sure you want to end this character session?" or
the DAT-resolved retail equivalent). Click **Yes** while standing on
solid ground: the client logs off and closes — **today this behaves
identically to Exit Game**, NOT a return to a character-select
screen (acdream has no pre-world character-select flow yet — register
row AD-74). Click **No**: nothing happens, panel stays open.
10. **Exit to Character Selection while airborne** (jump and click the
button mid-air, or confirm while still in the air). Expect the chat
line **"Cannot log off while in mid-air."** in the SpewBox/chat
scroll instead of logging off — this is retail's own byte-verified
refusal, faithfully ported.
11. **Configure Keyboard.** Click it. **INERT — nothing happens.** This
is the contracted behavior for OP3; OP8 wires the real Configure
Keyboard screen, and OP8's own gate re-tests this exact button. Do
not report this as a bug for OP3.
12. **In-Game Help Files.** Click it. **INERT — nothing happens.**
Retail's own help viewer is a third-party plugin
(`plugins\ACHelpPlugin.dll`) acdream does not have; retail itself
fails silently with the plugin absent, and this button mirrors that
(register row AD-76).
13. **Urgent Assistance.** Click it. Expect a chat line explaining the
web browser could not be launched and pointing at
`http://support.turbine.com/ics/support/ticketnewwizard.asp?style=classic`
— retail's own byte-verified failure text, always shown (the URL is
dead in 2026, so acdream never attempts to open a browser — register
row AD-75). **No native browser window opens, no OS dialog box.**
14. **Report Abuse.** Click it. Same shape as Urgent Assistance, with
its own retail text ("...to submit an abuse report...").
15. **Use Mouse Turning Settings.** Click it. Expect one retail chat
line per changed value among: Camera Stiffness, Camera Adjustment,
Mouse Sensitivity, Align To Slope, Invert Mouselook Axes, Turn to
Face Camera — a FRESH character (never touched these settings) sees
all six lines; clicking the button a second time in the same
session sees zero lines (everything already at the macro's target).
**Known, registered gap (TS-74): acdream has no persistent
"turn to face camera" mouse-turning MODE yet, so this button changes
stored preferences and sends the wire bit, but you will not see the
camera actually start turning with mouse movement** — that consumer
does not exist yet. Verify the CHAT LINES and that the button is
clickable/does not crash; do not expect a camera-behavior change.
### What to report
- Any of the above NOT matching (wrong text, wrong tab default, window
not draggable/resizable, close button not working, F11/toolbar not
opening the panel, opening Options not closing a sibling panel).
- Any exception/crash on any of the 15 steps.
- Whether the confirmation dialog's exact wording looks retail-correct
(it resolves from the DAT string table at runtime; a fallback English
string only appears if that resolution fails, which would itself be
worth reporting).
### Explicitly NOT in scope for this gate
- Character/Chat/Config tab content (rows, checkboxes, sliders, live
data) — OP4/OP5/OP6.
- Configure Keyboard's actual screen — OP8.
- Any observable camera change from "Use Mouse Turning Settings" — no
acdream consumer exists yet (TS-74).

View file

@ -15,6 +15,7 @@ using AcDream.Content;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Player;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
@ -797,6 +798,24 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
inscription),
text =>
d.Communication.AddText(text, RetailLogTextType.ClientLocal)),
Options: new OptionsRuntimeBindings(
CommandBus: () => late.Session.Commands,
// Same playerOnGround shape SessionPlayerComposition
// already computes for the diagnostic dumper (line
// ~322): "on the ground" is meaningless outside player
// mode, so the mid-air refusal never fires while flying/
// spectating.
IsGrounded: () => d.PlayerMode.IsPlayerMode
&& d.PlayerController.Controller is { IsAirborne: false },
IsUseMouseTurningEnabled: () =>
CharacterOptionTable.TryGet(
CharacterOptionId.UseMouseTurning,
out CharacterOptionTableEntry entry)
&& (d.Character.Options.Options2 & entry.Mask) != 0u,
DisplaySystemMessage: text =>
d.Communication.AddText(text, RetailLogTextType.ClientLocal),
LoadCameraTurning: d.Settings.LoadCameraTurning,
SaveCameraTurning: d.Settings.SaveCameraTurning),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,

View file

@ -13,6 +13,17 @@ internal interface IRetainedGameplayWindowCommands
/// <summary>Toggle floating chat window <paramref name="windowId"/> (1-4).</summary>
void ToggleFloatingChatWindow(int windowId);
/// <summary>
/// Campaign OP slice OP3: retail's F11 <c>ToggleOptionsPanel</c> action
/// (research doc <c>2026-08-10-options-panel-structure.md</c> §2.1) —
/// opens/closes the retail Options panel via the same
/// <c>RetailPanelUiController</c>/<c>RetailWindowManager</c> toggle path
/// the toolbar's options button (element <c>0x1000019B</c>, authored
/// panel id 10) already reaches through
/// <c>RetailUiRuntime.BindToolbarPanelButtons</c>.
/// </summary>
void ToggleOptionsPanel();
}
internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
@ -25,6 +36,9 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
public void ToggleFloatingChatWindow(int windowId) =>
_runtime?.ToggleFloatingChatWindow(windowId);
public void ToggleOptionsPanel() =>
_runtime?.ToggleWindow(WindowNames.Options);
}
internal interface IDevToolsGameplayCommands
@ -222,7 +236,13 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
_devTools.FocusChatInput();
return true;
case InputAction.ToggleOptionsPanel:
_devTools.ToggleSettingsPanel();
// Campaign OP slice OP3 (D1): F11 opens the RETAIL Options
// panel now, not the old (unrendered since Campaign V slice
// V11 — DevToolsGameplayCommands' own doc) ImGui-era Settings
// panel. IDevToolsGameplayCommands.ToggleSettingsPanel() is a
// SEPARATE action retired in OP9; its no-op wiring elsewhere
// is untouched by this change (#358's lesson).
_retained.ToggleOptionsPanel();
return true;
case InputAction.CombatToggleCombat:
_combat.Execute(

View file

@ -22,6 +22,10 @@ internal interface IRuntimeSettingsStorage
CharacterSettings LoadCharacter(string toonKey);
/// <summary>Campaign OP slice OP3: the five client-local preferences the
/// "Use Mouse Turning Settings" Gameplay-tab macro writes.</summary>
CameraTurningSettings LoadCameraTurning();
void SaveDisplay(DisplaySettings display);
void SaveAudio(AudioSettings audio);
@ -31,6 +35,8 @@ internal interface IRuntimeSettingsStorage
void SaveChat(ChatSettings chat);
void SaveCharacter(string toonKey, CharacterSettings character);
void SaveCameraTurning(CameraTurningSettings cameraTurning);
}
internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
@ -59,6 +65,8 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
public CharacterSettings LoadCharacter(string toonKey) =>
_store.LoadCharacter(toonKey);
public CameraTurningSettings LoadCameraTurning() => _store.LoadCameraTurning();
public void SaveDisplay(DisplaySettings display) => _store.SaveDisplay(display);
public void SaveAudio(AudioSettings audio) => _store.SaveAudio(audio);
@ -70,6 +78,9 @@ internal sealed class JsonRuntimeSettingsStorage : IRuntimeSettingsStorage
public void SaveCharacter(string toonKey, CharacterSettings character) =>
_store.SaveCharacter(toonKey, character);
public void SaveCameraTurning(CameraTurningSettings cameraTurning) =>
_store.SaveCameraTurning(cameraTurning);
}
internal sealed record RuntimeSettingsSnapshot(
@ -430,6 +441,33 @@ internal sealed class RuntimeSettingsController :
}
}
/// <summary>
/// Campaign OP slice OP3: the five client-local preferences the "Use
/// Mouse Turning Settings" Gameplay-tab macro reads/writes. Read
/// directly through storage (no startup-snapshot cache, unlike
/// <see cref="Display"/>/<see cref="Gameplay"/>/<see cref="Chat"/>) —
/// this section has no UI surface of its own yet (Campaign OP slice
/// OP6's Config tab), so there is nothing today that needs a cached,
/// change-notified copy.
/// </summary>
public CameraTurningSettings LoadCameraTurning() => _storage.LoadCameraTurning();
/// <summary>Persists the camera-turning preferences. Failures are logged,
/// not thrown — matching every other <c>Set*</c> save call in this
/// class.</summary>
public void SaveCameraTurning(CameraTurningSettings cameraTurning)
{
ArgumentNullException.ThrowIfNull(cameraTurning);
try
{
_storage.SaveCameraTurning(cameraTurning);
}
catch (Exception ex)
{
_log($"settings: camera-turning save failed: {ex.Message}");
}
}
public void SetActiveCharacter(string characterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);

View file

@ -0,0 +1,106 @@
using System.Collections.Generic;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Pure port of <c>gmConfigUI::SetMouseTurningDefaults @0x0049E8F0</c> — the
/// Gameplay tab's "Use Mouse Turning Settings" button
/// (<c>docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md</c> §4.4).
/// A one-shot "apply the recommended mouse-turning values" macro over six
/// options: five CLIENT-LOCAL preferences
/// (<see cref="CameraTurningSettings"/>) plus one server-synced bit
/// (<c>PlayerOption.UseMouseTurning</c>, NOT modeled here — the caller
/// threads the current/target value in and reads
/// <see cref="Result.UseMouseTurningChanged"/> back out to decide whether to
/// send <c>SetSingleCharacterOption (0x0005)</c>).
///
/// <para>
/// Byte-verified per-value conditions (research doc §4.4, the MSVC
/// <c>fcomp</c>/<c>fnstsw</c>/<c>jnp</c> idiom — the body runs when the
/// CURRENT value differs from the macro's target, exact float equality, no
/// epsilon): Stiffness → 0.95 iff <c>≠ 0.95</c>; AdjustmentSpeed → 50.0 iff
/// <c>≠ 50.0</c>; MouseLookSensitivity → 0.7 iff <c>≠ 0.7</c>; AlignToSlope
/// → off iff currently on; InvertMouseLookYAxis → on iff currently off;
/// UseMouseTurning → on iff currently off. Retail's directional bool
/// conditions (<c>== 1</c> / <c>== 0</c>) are behaviourally identical to a
/// plain inequality against the fixed target for a two-state bool, so this
/// port uses <c>!=</c> uniformly.
/// </para>
///
/// <para>
/// Retail emits one retail-verbatim chat line PER CHANGED value (never for
/// an already-at-target value) and finishes with
/// <c>SaveCurrentValues()</c> (the caller's job — the macro's target page is
/// the Config tab's own <see cref="OptionPage"/>, which this class does not
/// know about).
/// </para>
/// </summary>
public static class MouseTurningSettingsMacro
{
public readonly record struct Result(
CameraTurningSettings Updated,
bool UseMouseTurningChanged,
IReadOnlyList<string> ChatLines);
/// <summary>
/// Computes the macro's outcome. Pure — no I/O, no side effects; the
/// caller applies <see cref="Result.Updated"/> (persist + live apply),
/// sends the wire bit if <see cref="Result.UseMouseTurningChanged"/>,
/// and emits <see cref="Result.ChatLines"/> through the interface-text
/// seam, in that order (retail's own call order at
/// <c>0x0049E924</c>-<c>0x0049EB57</c>).
/// </summary>
public static Result Compute(CameraTurningSettings current, bool useMouseTurningCurrent)
{
CameraTurningSettings target = CameraTurningSettings.MouseTurningTarget;
var lines = new List<string>();
float stiffness = current.Stiffness;
if (stiffness != target.Stiffness)
{
lines.Add(OptionsPanelText.CameraStiffnessChanged(stiffness, target.Stiffness));
stiffness = target.Stiffness;
}
float adjustmentSpeed = current.AdjustmentSpeed;
if (adjustmentSpeed != target.AdjustmentSpeed)
{
lines.Add(OptionsPanelText.CameraAdjustmentChanged(adjustmentSpeed, target.AdjustmentSpeed));
adjustmentSpeed = target.AdjustmentSpeed;
}
float sensitivity = current.MouseLookSensitivity;
if (sensitivity != target.MouseLookSensitivity)
{
lines.Add(OptionsPanelText.MouseSensitivityChanged(sensitivity, target.MouseLookSensitivity));
sensitivity = target.MouseLookSensitivity;
}
bool alignToSlope = current.AlignToSlope;
if (alignToSlope != target.AlignToSlope)
{
lines.Add(OptionsPanelText.AlignToSlopeChanged);
alignToSlope = target.AlignToSlope;
}
bool invertY = current.InvertMouseLookYAxis;
if (invertY != target.InvertMouseLookYAxis)
{
lines.Add(OptionsPanelText.InvertMouseLookAxesChanged);
invertY = target.InvertMouseLookYAxis;
}
// UseMouseTurning's target is always ON (true) — retail's
// `== 0` condition ("currently off").
bool useMouseTurningChanged = useMouseTurningCurrent != true;
if (useMouseTurningChanged)
lines.Add(OptionsPanelText.TurnToFaceCameraChanged);
return new Result(
new CameraTurningSettings(stiffness, adjustmentSpeed, sensitivity, alignToSlope, invertY),
useMouseTurningChanged,
lines);
}
}

View file

@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AcDream.App.UI.Layout;
/// <summary>
/// One retail <c>OptionPage</c>/<c>PlayerOptionPage</c> row: a
/// <c>(m_current, m_saved, m_default)</c> triple plus the three verbs a row
/// leaf implements (<c>UIOption_Checkbox</c> is the canonical case — Campaign
/// OP slices OP4-6 add slider/menu/bitfield leaves behind the same shape).
/// Retail anchors: <c>docs/research/2026-08-10-options-panel-structure.md</c>
/// §3.3 (<c>UIOption_Checkbox::Changed @0x004868C0</c>,
/// <c>SaveCurrentValue @0x004868E0</c>, <c>RestoreSavedValue @0x00486900</c>,
/// <c>RestoreDefaultValue @0x00486930</c>, <c>SetCurrentValue @0x00486970</c>).
/// </summary>
public interface IOptionRow
{
/// <summary><c>UIOption_Checkbox::Changed</c>: <c>m_saved != m_current</c>.</summary>
bool Changed { get; }
/// <summary><c>SaveCurrentValue</c>: <c>m_saved = m_current</c>. No live
/// side effect — the value is already live (every mutator below applies
/// immediately).</summary>
void SaveCurrentValue();
/// <summary><c>RestoreSavedValue</c>: <c>m_current = m_saved</c>, then
/// applies the reverted value live.</summary>
void RestoreSavedValue();
/// <summary><c>RestoreDefaultValue</c>: <c>m_current = m_default</c>,
/// then applies the default live.</summary>
void RestoreDefaultValue();
}
/// <summary>
/// The canonical retail leaf — <c>UIOption_Checkbox</c>'s current/saved/default
/// triple over a <see cref="bool"/>. <see cref="SetCurrentValue"/> is what a
/// user's LED click runs: it writes <c>m_current</c> and applies it live
/// IMMEDIATELY (retail's <c>SetCurrentValue @0x00486970</c> calls
/// <c>Apply(1)</c> synchronously) — Apply/Reset/Defaults never gate this; they
/// only move the <c>m_saved</c>/<c>m_default</c> baselines and re-apply.
/// </summary>
public sealed class BoolOptionRow : IOptionRow
{
private readonly Action<bool>? _apply;
private bool _current;
private bool _saved;
private bool _default;
public BoolOptionRow(bool initial, bool defaultValue, Action<bool>? apply = null)
{
_current = initial;
_saved = initial;
_default = defaultValue;
_apply = apply;
}
/// <summary>The live value — what the LED currently shows.</summary>
public bool Current => _current;
/// <summary>The committed baseline Reset reverts to.</summary>
public bool Saved => _saved;
/// <summary>The value Defaults restores. Mutable via
/// <see cref="SetDefaultValue"/> — retail's <c>SetDefaultValue</c> is
/// authored per-row in each page's <c>InitOptions</c>, sometimes from a
/// DAT-resolved default rather than a compile-time literal (OP4's
/// Character-tab U1 closure).</summary>
public bool DefaultValue => _default;
public bool Changed => _saved != _current;
/// <summary>Retail <c>UIOption_Checkbox::SetDefaultValue @0x00486960</c>.</summary>
public void SetDefaultValue(bool value) => _default = value;
/// <summary>
/// Retail <c>SetCurrentValue @0x00486970</c> — the LED-click entry point.
/// Writes <c>m_current</c> and applies it live immediately; does NOT
/// touch <see cref="Saved"/> (Apply is the only verb that commits).
/// </summary>
public void SetCurrentValue(bool value)
{
_current = value;
_apply?.Invoke(value);
}
public void SaveCurrentValue() => _saved = _current;
public void RestoreSavedValue()
{
_current = _saved;
_apply?.Invoke(_current);
}
public void RestoreDefaultValue()
{
_current = _default;
_apply?.Invoke(_current);
}
}
/// <summary>
/// Retail <c>OptionPage</c>/<c>PlayerOptionPage</c>: a page's registered-option
/// array plus the four verbs (Apply/Reset/Defaults/visibility) with retail's
/// EXACT semantics — <c>docs/research/2026-08-10-options-panel-structure.md</c>
/// §3 and §10.4:
///
/// <list type="number">
/// <item><description>Clicking an LED applies immediately
/// (<see cref="BoolOptionRow.SetCurrentValue"/> → <c>Apply(1)</c>). Apply and
/// Reset operate on an undo baseline, not a staging buffer.</description></item>
/// <item><description><see cref="Apply"/> commits EVERY row's baseline
/// unconditionally (<c>OptionPage::SaveCurrentValues @0x004F2C60</c> — no
/// <c>Changed</c> gate), then flushes the batched character-options blob via
/// <see cref="AfterApply"/> (<c>PlayerOptionPage::SaveCurrentValues
/// @0x004F2710</c>'s <c>CPlayerModule::SaveToServer(0)</c> tailcall).</description></item>
/// <item><description><see cref="Reset"/> reverts only rows whose
/// <see cref="IOptionRow.Changed"/> is true
/// (<c>OptionPage::RestoreSavedValues @0x004F2D00</c>).</description></item>
/// <item><description><see cref="Defaults"/> restores every row unconditionally,
/// live, WITHOUT committing — <see cref="IOptionRow.Changed"/> can go true
/// afterward, re-enabling Apply/Reset
/// (<c>OptionPage::RestoreDefaultValues @0x004F2CB0</c>).</description></item>
/// <item><description><see cref="OnHidden"/> (page becomes invisible — a tab
/// switch away, or the window closing) reverts uncommitted edits exactly like
/// Reset (<c>PlayerOptionPage::OnVisibilityChanged(false) @0x004F26E0</c> →
/// <c>RestoreSavedValues</c>).</description></item>
/// <item><description><see cref="OnShown"/> (page becomes visible — the
/// initial default tab, a tab switch in, or the window (re)opening) applies +
/// commits exactly like Apply (<c>OnVisibilityChanged(true)</c> →
/// <c>SaveCurrentValues</c>, which ALSO flushes the blob via
/// <see cref="AfterApply"/>).</description></item>
/// </list>
///
/// <para>
/// An empty page (zero registered rows — the Gameplay tab's own model, which
/// has no <c>UIOption</c> rows at all per research doc §6) makes every verb a
/// no-op and <see cref="Changed"/> permanently false; <see cref="AfterApply"/>
/// still fires on <see cref="Apply"/>/<see cref="OnShown"/> (retail's
/// <c>SaveCurrentValues</c> flushes the blob regardless of whether THIS
/// page's own rows changed anything — the module's dirty flag is global, not
/// per-page).
/// </para>
/// </summary>
public sealed class OptionPage
{
private readonly List<IOptionRow> _rows = new();
public IReadOnlyList<IOptionRow> Rows => _rows;
/// <summary>
/// Invoked after every <see cref="Apply"/> (button click OR
/// <see cref="OnShown"/>) — the seam a controller wires to the batched
/// <c>SaveOptions</c>/blob-flush command. Never invoked by
/// <see cref="Reset"/> or <see cref="Defaults"/> (retail's Reset/Defaults
/// call <c>Apply(0)</c> per-row and re-run <c>OnOptionChanged(0)</c>
/// directly — they never reach <c>PlayerOptionPage::SaveCurrentValues</c>,
/// so they never flush).
/// </summary>
public Action? AfterApply { get; set; }
/// <summary>Registers one row. Retail's <c>OptionPage::RegisterOption
/// @0x004F2E90</c>, called from each page's <c>InitOptions</c>.</summary>
public void Register(IOptionRow row)
{
ArgumentNullException.ThrowIfNull(row);
_rows.Add(row);
}
/// <summary><c>OptionPage::Changed @0x004F2D60</c>: true if ANY
/// registered row's own <see cref="IOptionRow.Changed"/> is true.</summary>
public bool Changed => _rows.Any(static row => row.Changed);
/// <summary><c>OptionPage::SaveCurrentValues @0x004F2C60</c> — Apply:
/// commits every row's baseline unconditionally, then flushes via
/// <see cref="AfterApply"/>.</summary>
public void Apply()
{
foreach (IOptionRow row in _rows)
row.SaveCurrentValue();
AfterApply?.Invoke();
}
/// <summary><c>OptionPage::RestoreSavedValues @0x004F2D00</c> — Reset:
/// reverts only the rows that are currently <see cref="IOptionRow.Changed"/>.
/// Snapshotted before iterating so a row's own revert (which flips
/// <see cref="IOptionRow.Changed"/> back to false) cannot skip a later
/// row.</summary>
public void Reset()
{
foreach (IOptionRow row in _rows.Where(static row => row.Changed).ToArray())
row.RestoreSavedValue();
}
/// <summary><c>OptionPage::RestoreDefaultValues @0x004F2CB0</c> —
/// Defaults: restores every row unconditionally, live, without
/// committing.</summary>
public void Defaults()
{
foreach (IOptionRow row in _rows)
row.RestoreDefaultValue();
}
/// <summary><c>PlayerOptionPage::OnVisibilityChanged(true)</c> — the page
/// became visible (initial default tab, a tab switch in, or the window
/// (re)opening): applies + commits, same as <see cref="Apply"/>.</summary>
public void OnShown() => Apply();
/// <summary><c>PlayerOptionPage::OnVisibilityChanged(false)</c> — the page
/// became hidden (a tab switch away, or the window closing): reverts
/// uncommitted edits, same as <see cref="Reset"/>.</summary>
public void OnHidden() => Reset();
}

View file

@ -0,0 +1,235 @@
using System;
using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.Core.Chat;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Mounts retail's Options panel — LayoutDesc <c>0x2100002B</c> (the tab host,
/// class <c>gmFloatyPanelUI</c>'s slot content) resolved through host
/// <c>0x2100006E</c> at slot <c>0x1000018D</c> (stack key
/// <see cref="RetailPanelCatalog.Options"/> = 10) — the SAME catalog-import
/// mechanism <see cref="CharacterController"/> already uses for its own
/// <c>0x2100006E</c> slot (<c>0x10000183</c>), byte-verified empirically: a
/// throwaway probe against the live installed DATs confirmed
/// <c>LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x1000018Du)</c> resolves
/// directly to the fully base-merged tab-host content (buttons, page slots,
/// each page's own children) at the slot's authored 300×362 extent — no
/// separate import of the OTHER ~15 sibling <c>gmPanelUI</c> panels sharing
/// that host is needed. <c>RetailPanelUiController</c>
/// (<c>RetailUiRuntime.MountOptionsPanel</c>'s <c>RegisterMainPanel</c> call)
/// is what gives Options the SAME retail
/// "one active <c>gmPanelUI</c> child, opening one hides the others" mutual
/// exclusion every sibling panel (Character Info, Vitae, Inventory, ...)
/// already has — this controller owns only the panel's OWN content: tab
/// activation, the per-tab <see cref="OptionPage"/> model, the close button,
/// and the seven Gameplay-tab buttons.
///
/// <para>
/// Research anchors: <c>docs/research/2026-08-10-options-panel-structure.md</c>
/// §1.3 (tab table), §1.4 (host/slot), §3.6 (visibility semantics), §10.1
/// (structural inventory); <c>docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md</c>
/// §1-4 (the seven buttons, byte-verified anchors).
/// </para>
/// </summary>
public sealed class OptionsPanelController : IRetainedPanelController
{
/// <summary>The floating host LayoutDesc the tab panel is resolved through.</summary>
public const uint HostLayoutId = 0x2100006Eu;
/// <summary>The Options panel's slot within <see cref="HostLayoutId"/>'s
/// shared <c>gmPanelUI</c> page stack — also its
/// <see cref="RetailPanelCatalog.Options"/> panel id's element identity.</summary>
public const uint SlotElementId = 0x1000018Du;
// Tab page slot ids (research doc §10.1 — the four page slots mounted
// inside the tab host 0x10000208, each already base-merged with its own
// page LayoutDesc content by the time this element resolves).
private const uint GameplayPageId = 0x10000212u;
private const uint CharacterPageId = 0x10000211u;
private const uint ChatPageId = 0x1000050Cu;
private const uint ConfigPageId = 0x10000213u;
/// <summary>The tab host's close (X) button — fires the SAME
/// <c>ToggleOptionsPanel</c> action as F11 and the toolbar button
/// (research doc §2.3).</summary>
private const uint CloseButtonId = 0x10000210u;
// Gameplay tab (0x2100002A) button ids — research doc §6, byte-verified
// against the committed options_gameplay_2100002A.json fixture.
private const uint ExitToCharacterSelectionId = 0x10000203u;
private const uint ConfigureKeyboardId = 0x10000204u;
private const uint InGameHelpFilesId = 0x10000205u;
private const uint UrgentAssistanceId = 0x10000206u;
private const uint ReportAbuseId = 0x10000207u;
private const uint UseMouseTurningSettingsId = 0x100005CCu;
private const uint ExitGameId = 0x10000617u;
/// <summary>Callback delegates this controller wires the seven Gameplay
/// buttons and the close button to. Every field maps to exactly one
/// button; a null field leaves that button INERT (authored, clickable,
/// no handler) — the shape D5's In-Game Help Files and OP8's still-
/// unimplemented Configure Keyboard both need.</summary>
public sealed record Callbacks(
Action Toggle,
Action RequestExitToCharacterSelection,
Action ExitGame,
Action UseMouseTurningSettings,
Action<string> DisplaySystemMessage,
Action? AfterApply = null)
{
/// <summary>Urgent Assistance's own byte-verified retail failure text.</summary>
public string UrgentAssistanceMessage { get; init; } =
OptionsPanelText.UrgentAssistanceUnavailable;
/// <summary>Report Abuse's own byte-verified retail failure text.</summary>
public string ReportAbuseMessage { get; init; } =
OptionsPanelText.ReportAbuseUnavailable;
}
private readonly UiTabPanel _tabPanel;
private readonly Dictionary<uint, OptionPage> _pages = new();
private bool _disposed;
/// <summary>Root element of the imported panel (the tab host itself —
/// this widget IS a <see cref="UiTabPanel"/>).</summary>
public UiElement Root => _tabPanel;
/// <summary>The underlying tab-control widget, for callers that need
/// direct tab-switch access (e.g. tests).</summary>
public UiTabPanel TabPanel => _tabPanel;
/// <summary>Per-tab option-page models, keyed by page slot element id.
/// Every entry exists from construction (Campaign OP slice OP3) even
/// though only <see cref="GameplayPageId"/>'s stays permanently empty —
/// Character/Chat/Config slices (OP4-6) register their rows into these
/// SAME instances rather than re-deriving the page-tracking dictionary.</summary>
public IReadOnlyDictionary<uint, OptionPage> Pages => _pages;
/// <summary>The Gameplay tab's page model — always empty (research doc
/// §6: a pure button list, no <c>UIOption</c> rows, no Apply/Reset/
/// Defaults). Exposed by name for tests exercising the empty-page case.</summary>
public OptionPage GameplayPage => _pages[GameplayPageId];
public OptionPage CharacterPage => _pages[CharacterPageId];
public OptionPage ChatPage => _pages[ChatPageId];
public OptionPage ConfigPage => _pages[ConfigPageId];
private OptionsPanelController(UiTabPanel tabPanel, Action? afterApply)
{
_tabPanel = tabPanel;
foreach (uint pageId in new[] { GameplayPageId, CharacterPageId, ChatPageId, ConfigPageId })
{
var page = new OptionPage { AfterApply = afterApply };
_pages.Add(pageId, page);
}
_tabPanel.ActivePageChanged += OnActivePageChanged;
}
/// <summary>
/// Bind an imported <see cref="HostLayoutId"/>/<see cref="SlotElementId"/>
/// layout to live behavior. <paramref name="layout"/>'s root MUST be the
/// built <see cref="UiTabPanel"/> — the caller imports via
/// <c>LayoutImporter.ImportInfos(dats, HostLayoutId, SlotElementId)</c>
/// then <c>LayoutImporter.Build</c>, exactly like every other catalog-style
/// import in this codebase (<see cref="CharacterController"/>,
/// <see cref="RetailDialogFactory"/>'s dialog catalog).
/// </summary>
/// <returns>Null if <paramref name="layout"/>'s root did not build as a
/// <see cref="UiTabPanel"/> (a missing/malformed LayoutDesc).</returns>
public static OptionsPanelController? Bind(ImportedLayout layout, Callbacks callbacks)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(callbacks);
if (layout.Root is not UiTabPanel tabPanel)
{
Console.WriteLine(
"[D.2b] OptionsPanelController.Bind: root did not build as UiTabPanel "
+ $"(actual type {layout.Root.GetType().Name}) — Options panel will not open.");
return null;
}
var controller = new OptionsPanelController(tabPanel, callbacks.AfterApply);
if (layout.FindElement(CloseButtonId) is UiButton close)
close.OnClick = callbacks.Toggle;
BindButton(layout, ExitToCharacterSelectionId, callbacks.RequestExitToCharacterSelection);
// ConfigureKeyboardId: INERT this slice — authored, clickable, no
// handler. OP8 wires the real Configure Keyboard screen; the campaign
// cannot close with this button still inert (plan §4 OP3).
// InGameHelpFilesId: INERT — retail's own KeyStone::OpenHelp fails
// without the missing plugins\ACHelpPlugin.dll (D5, register row).
BindButton(layout, UseMouseTurningSettingsId, callbacks.UseMouseTurningSettings);
BindButton(layout, ExitGameId, callbacks.ExitGame);
BindButton(layout, UrgentAssistanceId,
() => callbacks.DisplaySystemMessage(callbacks.UrgentAssistanceMessage));
BindButton(layout, ReportAbuseId,
() => callbacks.DisplaySystemMessage(callbacks.ReportAbuseMessage));
return controller;
}
/// <summary>
/// Activates the tab-switching behavior (idempotent — safe even if
/// already active). Must run AFTER <see cref="Bind"/> so this
/// controller's <see cref="OnActivePageChanged"/> subscription is in
/// place before the default-entry switch fires (Gameplay's
/// <see cref="OptionPage.OnShown"/> for the initial tab).
/// </summary>
public void ActivateTabs() => _tabPanel.ActivateTabBehavior();
private static void BindButton(ImportedLayout layout, uint elementId, Action? onClick)
{
if (onClick is null) return;
if (layout.FindElement(elementId) is UiButton button)
button.OnClick = onClick;
else
Console.WriteLine(
$"[D.2b] OptionsPanelController: Gameplay-tab button 0x{elementId:X8} "
+ "not found in the built layout — its handler was not wired.");
}
private void OnActivePageChanged(uint previousPageElementId, uint newPageElementId)
{
// Retail PlayerOptionPage::OnVisibilityChanged(false) -> RestoreSavedValues:
// leaving a page reverts its uncommitted edits.
if (previousPageElementId != 0 && _pages.TryGetValue(previousPageElementId, out OptionPage? previous))
previous.OnHidden();
// OnVisibilityChanged(true) -> SaveCurrentValues: entering a page
// (including the initial default-tab activation, previous == 0)
// applies + commits.
if (_pages.TryGetValue(newPageElementId, out OptionPage? next))
next.OnShown();
}
/// <summary>Retail's whole-window close also hides whichever page slot
/// is currently visible — same revert as a tab switch away.</summary>
public void OnHidden()
{
if (_pages.TryGetValue(_tabPanel.ActivePageElementId, out OptionPage? page))
page.OnHidden();
}
/// <summary>Re-opening the window re-shows the last-active page — same
/// apply+commit as a tab switch in.</summary>
public void OnShown()
{
if (_pages.TryGetValue(_tabPanel.ActivePageElementId, out OptionPage? page))
page.OnShown();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_tabPanel.ActivePageChanged -= OnActivePageChanged;
}
}

View file

@ -17,6 +17,17 @@ public static class RetailPanelCatalog
public const uint Magic = 13u;
public const uint Vitae = 15u;
/// <summary>
/// The retail Options panel's <c>gmPanelUI</c> slot key — byte-verified
/// from the toolbar options button's OWN authored property
/// <c>0x10000029 = 10</c> (fixture <c>toolbar_21000016.json</c>,
/// element <c>0x1000019B</c>) and independently corroborated by
/// <c>docs/research/2026-08-10-options-panel-structure.md</c> §1.4's
/// decompiled slot-key table (host <c>0x2100006E</c>, slot
/// <c>0x1000018D</c> → key 10). Campaign OP slice OP3.
/// </summary>
public const uint Options = 10u;
private static readonly (uint PanelId, string WindowName)[] Mounted =
{
(CharacterInformation, WindowNames.CharacterInformation),
@ -28,6 +39,7 @@ public static class RetailPanelCatalog
(Character, WindowNames.Character),
(Magic, WindowNames.Spellbook),
(Vitae, WindowNames.Vitae),
(Options, WindowNames.Options),
};
private static readonly (uint PanelId, string WindowName)[] Toolbar =
@ -35,6 +47,7 @@ public static class RetailPanelCatalog
(Inventory, WindowNames.Inventory),
(Character, WindowNames.Character),
(Magic, WindowNames.Spellbook),
(Options, WindowNames.Options),
};
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;

View file

@ -2,6 +2,7 @@ using System.Collections.Generic;
using AcDream.App.Plugins;
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.App.Spells;
using AcDream.App.UI.Layout;
@ -143,6 +144,39 @@ public sealed record ToolbarRuntimeBindings(
public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider);
/// <summary>
/// Campaign OP slice OP3 (2026-08-11): bindings the retail Options panel
/// needs beyond what the generic <see cref="RetailPanelUiController"/> mount
/// already supplies (retained-window registration, geometry, opacity — all
/// shared with every other <c>gmPanelUI</c> sibling through
/// <see cref="RetailUiRuntimeBindings"/> itself).
/// </summary>
/// <param name="CommandBus">Late-resolved live command surface — the SAME
/// <c>Func&lt;ICommandBus&gt;</c> provider pattern <see cref="ChatRuntimeBindings.CommandBus"/>
/// already uses, since the live session's bus is not yet constructed at the
/// point this binding record is built (composition order).</param>
/// <param name="IsGrounded">Retail's mid-air logout-refusal check
/// (<c>transient_state &amp; 1</c>) — mirrors the SAME
/// <c>d.PlayerMode.IsPlayerMode &amp;&amp; d.PlayerController.Controller is
/// { IsAirborne: false }</c> pattern already used for
/// <c>InteractionRetainedUiComposition</c>'s own <c>playerOnGround</c> read.</param>
/// <param name="IsUseMouseTurningEnabled">Live read of
/// <c>PlayerOption.UseMouseTurning</c> (<c>CharacterOptions2</c> bit
/// <c>0x00400000</c>) from the canonical <c>RuntimeCharacterOptionsState</c> —
/// the mouse-turning macro's sixth, server-synced target.</param>
/// <param name="DisplaySystemMessage">The interface-text seam
/// (<c>RetailLogTextType.ClientLocal</c>, SpewBox-only) — the SAME
/// <c>text =&gt; d.Communication.AddText(text, RetailLogTextType.ClientLocal)</c>
/// delegate shape <see cref="VendorRuntimeBindings.DisplaySystemMessage"/>/
/// <see cref="AppraisalRuntimeBindings"/> already use.</param>
public sealed record OptionsRuntimeBindings(
Func<ICommandBus> CommandBus,
Func<bool> IsGrounded,
Func<bool> IsUseMouseTurningEnabled,
Action<string> DisplaySystemMessage,
Func<CameraTurningSettings> LoadCameraTurning,
Action<CameraTurningSettings> SaveCameraTurning);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
Func<uint> PlayerGuid,
@ -231,6 +265,7 @@ public sealed record RetailUiRuntimeBindings(
RetailUiCursorBindings Cursor,
ConfirmationRuntimeBindings Confirmations,
AppraisalRuntimeBindings Appraisal,
OptionsRuntimeBindings Options,
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence,
@ -309,6 +344,7 @@ public sealed class RetailUiRuntime : IDisposable
MountAppraisal();
MountEffects();
MountIndicatorDetailPanels();
MountOptionsPanel();
MountIndicators();
MountJumpPowerbar();
MountDialogFactory();
@ -404,6 +440,7 @@ public sealed class RetailUiRuntime : IDisposable
public RetailDialogFactory? DialogFactory { get; private set; }
public ExternalContainerController? ExternalContainerController { get; private set; }
public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; }
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
@ -1783,25 +1820,178 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[UI] retail seven-control indicator bar from LayoutDesc 0x21000071.");
}
private void RequestEndCharacterSession()
/// <summary>
/// Retail <c>ID_Client_EndCharacterSessionConfirm</c>
/// (<c>CM_UI::SendNotice_EndCharacterSession</c>'s confirmation dialog
/// text — research doc <c>2026-08-10-keyboard-config-and-gameplay-tab.md</c>
/// §2.1), shared by both the indicator bar's own end-session control
/// (<see cref="RequestEndCharacterSession"/>) and the Options panel's
/// Exit to Character Selection button
/// (<see cref="RequestExitToCharacterSelection"/>).
/// </summary>
private string ResolveEndCharacterSessionConfirmMessage()
{
const string fallback = "Are you sure you want to end this character session?";
string message;
lock (_bindings.Assets.DatLock)
{
message = new DatStringResolver(_bindings.Assets.Dats).Resolve(
return new DatStringResolver(_bindings.Assets.Dats).Resolve(
0x23000001u,
DatStringResolver.ComputeHash("ID_Client_EndCharacterSessionConfirm"))
?? fallback;
}
}
ShowConfirmation(message, accepted =>
private void RequestEndCharacterSession()
{
ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted =>
{
if (accepted)
_bindings.Indicators.EndCharacterSession();
});
}
/// <summary>
/// Campaign OP slice OP3: the Options panel's Exit to Character
/// Selection button (element <c>0x10000203</c>) — D6's "behaves as Exit
/// Game" adaptation (register row, same commit), PLUS retail's
/// confirmation dialog and mid-air refusal, which DO port exactly.
/// <c>gmGamePlayUI::UseTime @0x004EA3A0</c>'s drain: on confirmation
/// accept, refuse with <see cref="ClientTextRefusals.CantLogOffMidAir"/>
/// via the interface-text seam while airborne
/// (<c>transient_state &amp; 1</c>); otherwise proceed exactly like Exit
/// Game.
/// </summary>
private void RequestExitToCharacterSelection()
{
ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted =>
{
if (!accepted) return;
if (_bindings.Options.IsGrounded())
{
_bindings.Indicators.EndCharacterSession();
}
else
{
_bindings.Options.DisplaySystemMessage(ClientTextRefusals.CantLogOffMidAir);
}
});
}
/// <summary>
/// Campaign OP slice OP3: the Options panel's Use Mouse Turning Settings
/// button (element <c>0x100005CC</c>) — the pure
/// <see cref="MouseTurningSettingsMacro"/> orchestrated against live
/// bindings: load current values, compute the macro, persist + apply
/// live, send the wire bit if it changed, emit every retail chat line,
/// then commit the (currently empty) Config page exactly like retail's
/// own trailing <c>SaveCurrentValues()</c>
/// (<c>gmConfigUI::SetMouseTurningDefaults @0x0049EB57</c>).
/// </summary>
private void ApplyMouseTurningSettingsMacro()
{
CameraTurningSettings current = _bindings.Options.LoadCameraTurning();
bool useMouseTurningCurrent = _bindings.Options.IsUseMouseTurningEnabled();
MouseTurningSettingsMacro.Result result =
MouseTurningSettingsMacro.Compute(current, useMouseTurningCurrent);
_bindings.Options.SaveCameraTurning(result.Updated);
if (result.UseMouseTurningChanged)
{
_bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd(
(uint)CharacterOptionId.UseMouseTurning, true));
}
foreach (string line in result.ChatLines)
_bindings.Options.DisplaySystemMessage(line);
OptionsPanelController?.ConfigPage.Apply();
}
private void MountOptionsPanel()
{
ElementInfo? rootInfo;
ImportedLayout? layout;
lock (_bindings.Assets.DatLock)
{
rootInfo = LayoutImporter.ImportInfos(
_bindings.Assets.Dats,
Layout.OptionsPanelController.HostLayoutId,
Layout.OptionsPanelController.SlotElementId);
var resolver = new DatStringResolver(_bindings.Assets.Dats);
layout = rootInfo is null
? null
: LayoutImporter.Build(
rootInfo,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
resolver.Resolve);
}
if (rootInfo is null || layout is null)
{
Console.WriteLine("[UI] options panel: LayoutDesc 0x2100006E slot 0x1000018D not found.");
return;
}
var callbacks = new Layout.OptionsPanelController.Callbacks(
Toggle: () => ToggleWindow(WindowNames.Options),
RequestExitToCharacterSelection: RequestExitToCharacterSelection,
ExitGame: _bindings.Indicators.EndCharacterSession,
UseMouseTurningSettings: ApplyMouseTurningSettingsMacro,
DisplaySystemMessage: _bindings.Options.DisplaySystemMessage,
AfterApply: () => _bindings.Options.CommandBus().Publish(
new SaveCharacterOptionsRuntimeCmd()));
Layout.OptionsPanelController? controller =
Layout.OptionsPanelController.Bind(layout, callbacks);
if (controller is null)
{
Console.WriteLine("[UI] options panel: required root did not build as UiTabPanel.");
return;
}
OptionsPanelController = controller;
controller.ActivateTabs();
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
controller.Root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Options,
Chrome = RetailWindowChrome.NineSlice,
Left = 150f,
Top = 80f,
Visible = false,
ResizeX = true,
ResizeY = true,
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right
| ResizeEdges.Top | ResizeEdges.Bottom,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
ContentClickThrough = false,
MinWidth = 300f,
MinHeight = 200f,
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
Controller = controller,
});
_panelUi.RegisterMainPanel(
RetailPanelCatalog.Options,
WindowNames.Options,
handle,
rootInfo.TryGetEffectiveBool(
RetailPanelUiController.RestorePreviousPropertyId,
out bool restorePrevious)
&& restorePrevious);
Console.WriteLine("[UI] retail Options panel from LayoutDesc importer (0x2100006E slot 0x1000018D).");
}
private void MountJumpPowerbar()
{
ElementInfo? info;

View file

@ -102,6 +102,19 @@ public sealed class UiTabPanel : UiDatElement, IUiChildrenAttachedListener
/// direct <see cref="SwitchTo"/> call).</summary>
public uint ActivePageElementId { get; private set; }
/// <summary>
/// Fires at the end of every <see cref="SwitchTo"/> call that actually
/// changes the active page — including the FIRST switch, from
/// <see cref="ActivateTabBehavior"/>'s default-entry activation (old = 0).
/// A page-model owner (Campaign OP slice OP3's <c>OptionsPanelController</c>)
/// subscribes here to drive retail's per-page
/// <c>OnVisibilityChanged(false)</c>/<c>OnVisibilityChanged(true)</c> pair
/// (research doc §3.6) on the leaving/entering page — this widget only
/// owns which page slot is <see cref="UiElement.Visible"/>, not the
/// page-model semantics layered on top of that switch.
/// </summary>
public event Action<uint, uint>? ActivePageChanged;
/// <summary>
/// True once <see cref="ActivateTabBehavior"/> has run. Dormant instances (every
/// pre-existing Type-8 host today) never flip this.
@ -184,6 +197,7 @@ public sealed class UiTabPanel : UiDatElement, IUiChildrenAttachedListener
public void SwitchTo(uint pageElementId)
{
if (ActivePageElementId == pageElementId) return;
uint previousPageElementId = ActivePageElementId;
foreach (UiTabTableEntry entry in _tabs)
{
@ -196,6 +210,7 @@ public sealed class UiTabPanel : UiDatElement, IUiChildrenAttachedListener
}
ActivePageElementId = pageElementId;
ActivePageChanged?.Invoke(previousPageElementId, pageElementId);
}
/// <summary>

View file

@ -27,4 +27,5 @@ public static class WindowNames
public const string Vitae = "vitae";
public const string Examination = "examination";
public const string Vendor = "vendor";
public const string Options = "options";
}

View file

@ -123,4 +123,18 @@ public static class ClientTextRefusals
/// refusal lands in the chat transcript, not the SpewBox.
/// </summary>
public const string TurbineChatUnavailable = "Turbine chat is not available.";
/// <summary>
/// Campaign OP slice OP3 (2026-08-11): retail
/// <c>gmGamePlayUI::UseTime @0x004EA3A0</c>'s per-frame end-session drain
/// refuses to log off while the player is airborne
/// (<c>transient_state &amp; 1</c> — the on-contact/ON_WALKABLE bit) and
/// raises this line instead of proceeding to
/// <c>CPlayerSystem::LogOffCharacter</c>. Byte-read UTF-16LE off the
/// PDB-paired binary at VA <c>0x007C29C0</c> (pushed at
/// <c>0x004EA481</c>). Sent via <c>ECM_UI::SendNotice_DisplayStringInfo
/// (0x1A, ...)</c> — the SAME <c>RetailLogTextType.ClientLocal</c>
/// SpewBox-only channel every other refusal in this file uses.
/// </summary>
public const string CantLogOffMidAir = "Cannot log off while in mid-air.";
}

View file

@ -0,0 +1,119 @@
using System.Globalization;
namespace AcDream.Core.Chat;
/// <summary>
/// Byte-verified retail string literals for the Gameplay Options tab's
/// button family (Campaign OP slice OP3, 2026-08-11) —
/// <c>docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md</c>
/// §4.4 (Use Mouse Turning Settings) and §4.1/§4.2 (Urgent Assistance /
/// Report Abuse). Every literal was recovered from the PDB-paired binary
/// <c>C:\Users\erikn\Downloads\acclient.exe</c> by a push-imm32 sweep of the
/// cited VA range, same discipline as <see cref="ClientTextRefusals"/>.
/// </summary>
public static class OptionsPanelText
{
/// <summary>
/// <c>gmConfigUI::SetMouseTurningDefaults @0x0049E8F0</c>, format string
/// VA <c>0x007A8A70</c>: <c>'Camera Stiffness was changed from %f to the
/// mouse turning default of %f.'</c>. <paramref name="from"/>/
/// <paramref name="to"/> render with 6 fractional digits, matching
/// retail's default <c>printf %f</c> precision.
/// </summary>
public static string CameraStiffnessChanged(float from, float to) => string.Format(
CultureInfo.InvariantCulture,
"Camera Stiffness was changed from {0} to the mouse turning default of {1}.",
from.ToString("F6", CultureInfo.InvariantCulture),
to.ToString("F6", CultureInfo.InvariantCulture));
/// <summary>
/// Same site, format string VA <c>0x007A8A20</c>: <c>'Camera Adjustment
/// was changed from %f to the mouse turning default of %f.'</c>.
/// </summary>
public static string CameraAdjustmentChanged(float from, float to) => string.Format(
CultureInfo.InvariantCulture,
"Camera Adjustment was changed from {0} to the mouse turning default of {1}.",
from.ToString("F6", CultureInfo.InvariantCulture),
to.ToString("F6", CultureInfo.InvariantCulture));
/// <summary>
/// Same site, format string VA <c>0x007A89D0</c>: <c>'Mouse Sensitivity
/// was changed from %f to the mouse turning default of %f.'</c>.
/// </summary>
public static string MouseSensitivityChanged(float from, float to) => string.Format(
CultureInfo.InvariantCulture,
"Mouse Sensitivity was changed from {0} to the mouse turning default of {1}.",
from.ToString("F6", CultureInfo.InvariantCulture),
to.ToString("F6", CultureInfo.InvariantCulture));
/// <summary>
/// Same site, VA <c>0x007A8980</c>, byte-verified literal (no format
/// specifiers — retail hardcodes TRUE/FALSE for this one bool pair):
/// <c>'Align To Slope was changed from TRUE to the mouse turning default
/// of FALSE.'</c>. Only fires when the current value IS true (the
/// macro's own condition, §4.4) so the "from TRUE" half is always
/// accurate.
/// </summary>
public const string AlignToSlopeChanged =
"Align To Slope was changed from TRUE to the mouse turning default of FALSE.";
/// <summary>
/// Same site, VA <c>0x007A8928</c>: <c>'Invert Mouselook Axes was
/// changed from FALSE to the mouse turning default of TRUE.'</c>. Only
/// fires when the current value IS false.
/// </summary>
public const string InvertMouseLookAxesChanged =
"Invert Mouselook Axes was changed from FALSE to the mouse turning default of TRUE.";
/// <summary>
/// Same site, VA <c>0x007A88D0</c>: <c>'Turn to Face Camera was changed
/// from FALSE to the mouse turning default of TRUE.'</c>. This is the
/// Config-tab UI LABEL for <c>PlayerOption.UseMouseTurning</c> — the
/// option itself is named "Use Mouse Turning" in code, "Turn to Face
/// Camera" in the string table. Only fires when the current value IS
/// false.
/// </summary>
public const string TurnToFaceCameraChanged =
"Turn to Face Camera was changed from FALSE to the mouse turning default of TRUE.";
/// <summary>
/// Urgent Assistance (element <c>0x10000206</c>) /
/// Report Abuse (element <c>0x10000207</c>) — retail's
/// <c>ShellExecuteA</c> failure <c>MessageBoxA</c> body, byte-verified at
/// VA <c>0x007A81E0</c> (Urgent) / <c>0x007A8128</c> (Abuse):
/// <c>'An error occurred while trying to launch your web browser. (Error
/// code %d)\nThe web site to submit an urgent assistance request /
/// abuse report is listed below. Please go there to complete your
/// request.\n%s\n'</c>, title <c>"Asheron's Call Error"</c> (VA
/// <c>0x00794078</c>).
///
/// <para>
/// D5 adaptation (register row, same commit): acdream never attempts
/// <c>ShellExecuteA</c> against the dead <c>support.turbine.com</c>
/// endpoint (§0 of the research doc — both EoR buttons already point at
/// a URL that is not live today), so there is no real Win32 error code
/// to interpolate and the <c>(Error code %d)</c> clause is dropped; the
/// trailing <c>%s</c> URL substitution is KEPT verbatim (the byte-verified
/// URL below) so the user can still copy it manually, matching retail's
/// own intent for that clause. Delivered through the interface-text
/// seam (<c>RetailLogTextType.ClientLocal</c>) rather than a native
/// <c>MessageBoxA</c> popup — see the register row for the short-circuit.
/// </para>
/// </summary>
public const string SupportUrl =
"http://support.turbine.com/ics/support/ticketnewwizard.asp?style=classic";
public static string UrgentAssistanceUnavailable => string.Format(
CultureInfo.InvariantCulture,
"An error occurred while trying to launch your web browser.\n"
+ "The web site to submit an urgent assistance request is listed below. "
+ "Please go there to complete your request.\n{0}",
SupportUrl);
public static string ReportAbuseUnavailable => string.Format(
CultureInfo.InvariantCulture,
"An error occurred while trying to launch your web browser.\n"
+ "The web site to submit an abuse report is listed below. "
+ "Please go there to complete your request.\n{0}",
SupportUrl);
}

View file

@ -0,0 +1,52 @@
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// The five CLIENT-LOCAL preferences retail's "Use Mouse Turning Settings"
/// Gameplay-tab button (<c>gmConfigUI::SetMouseTurningDefaults @0x0049E8F0</c>)
/// writes alongside the one server-synced bit
/// (<c>PlayerOption.UseMouseTurning</c>, carried through
/// <c>RuntimeCharacterOptionsState</c> / <c>SetSingleCharacterOption (0x0005)</c>
/// — NOT stored here). Persisted to <c>settings.json</c> the same way
/// <see cref="AudioSettings"/>/<see cref="DisplaySettings"/> are; this is the
/// forward-compatible home Campaign OP slice OP6's full Config tab (27 rows)
/// re-homes into its own preference groups without a storage-shape change —
/// these five keys ARE four of that tab's Camera/Input rows
/// (<c>Camera_Stiffness</c>, <c>Camera_AdjustmentSpeed</c>,
/// <c>Camera_AlignToSlope</c>, <c>Input_MouseLookSensitivity</c>,
/// <c>Input_InvertMouseLookYAxis</c> — research doc
/// <c>2026-08-10-options-panel-structure.md</c> §4).
/// </summary>
public sealed record CameraTurningSettings(
float Stiffness,
float AdjustmentSpeed,
float MouseLookSensitivity,
bool AlignToSlope,
bool InvertMouseLookYAxis)
{
/// <summary>
/// Retail's ORDINARY Config-tab defaults (NOT the mouse-turning macro's
/// targets — <c>gmConfigUI::InitOptions @0x0049E400</c>, research doc §4):
/// Stiffness 0.45 (<c>0x3EE66666</c>), AdjustmentSpeed 40.0
/// (<c>0x42200000</c>), MouseLookSensitivity 0.55 (<c>0x3F0CCCCD</c>),
/// AlignToSlope on, InvertMouseLookYAxis off.
/// </summary>
public static CameraTurningSettings Default { get; } = new(
Stiffness: 0.45f,
AdjustmentSpeed: 40.0f,
MouseLookSensitivity: 0.55f,
AlignToSlope: true,
InvertMouseLookYAxis: false);
/// <summary>
/// The mouse-turning macro's recommended targets
/// (<c>SetMouseTurningDefaults</c>, research doc §4.4): Stiffness 0.95,
/// AdjustmentSpeed 50.0, MouseLookSensitivity 0.7, AlignToSlope OFF,
/// InvertMouseLookYAxis ON.
/// </summary>
public static CameraTurningSettings MouseTurningTarget { get; } = new(
Stiffness: 0.95f,
AdjustmentSpeed: 50.0f,
MouseLookSensitivity: 0.7f,
AlignToSlope: false,
InvertMouseLookYAxis: true);
}

View file

@ -214,6 +214,43 @@ public sealed class SettingsStore
public void SaveChat(ChatSettings chat)
=> SaveSection("chat", BuildChatObject(chat));
/// <summary>
/// Load the five client-local Camera/Input preferences the "Use Mouse
/// Turning Settings" Gameplay-tab macro writes (Campaign OP slice OP3).
/// Same fall-back behaviour as <see cref="LoadDisplay"/>.
/// </summary>
public CameraTurningSettings LoadCameraTurning()
{
if (!File.Exists(_path)) return CameraTurningSettings.Default;
try
{
using var stream = File.OpenRead(_path);
var doc = JsonDocument.Parse(stream);
var root = doc.RootElement;
if (!root.TryGetProperty("cameraTurning", out var ct)
|| ct.ValueKind != JsonValueKind.Object)
return CameraTurningSettings.Default;
var d = CameraTurningSettings.Default;
return new CameraTurningSettings(
Stiffness: ReadFloat(ct, "stiffness", d.Stiffness),
AdjustmentSpeed: ReadFloat(ct, "adjustmentSpeed", d.AdjustmentSpeed),
MouseLookSensitivity: ReadFloat(ct, "mouseLookSensitivity", d.MouseLookSensitivity),
AlignToSlope: ReadBool (ct, "alignToSlope", d.AlignToSlope),
InvertMouseLookYAxis: ReadBool (ct, "invertMouseLookYAxis", d.InvertMouseLookYAxis));
}
catch (Exception ex)
{
Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
return CameraTurningSettings.Default;
}
}
/// <summary>Save the camera-turning preferences, preserving all other
/// top-level keys.</summary>
public void SaveCameraTurning(CameraTurningSettings cameraTurning)
=> SaveSection("cameraTurning", BuildCameraTurningObject(cameraTurning));
/// <summary>
/// Load per-character settings keyed by <paramref name="toonKey"/>.
/// Missing file or missing toon entry → <see cref="CharacterSettings.Default"/>.
@ -594,6 +631,16 @@ public sealed class SettingsStore
["vsync"] = d.VSync,
};
private static SortedDictionary<string, object> BuildCameraTurningObject(CameraTurningSettings c)
=> new(StringComparer.Ordinal)
{
["adjustmentSpeed"] = c.AdjustmentSpeed,
["alignToSlope"] = c.AlignToSlope,
["invertMouseLookYAxis"] = c.InvertMouseLookYAxis,
["mouseLookSensitivity"] = c.MouseLookSensitivity,
["stiffness"] = c.Stiffness,
};
private static SortedDictionary<string, object> BuildAudioObject(AudioSettings a)
=> new(StringComparer.Ordinal)
{

View file

@ -15,7 +15,10 @@ public sealed class GameplayInputCommandControllerTests
[InlineData(InputAction.AcdreamToggleFlyMode, "fly-or-chase")]
[InlineData(InputAction.AcdreamTogglePlayerMode, "player-mode")]
[InlineData(InputAction.ToggleChatEntry, "chat")]
[InlineData(InputAction.ToggleOptionsPanel, "settings")]
// Campaign OP slice OP3 (D1): F11 now opens the retail Options panel
// through IRetainedGameplayWindowCommands, not the retired
// IDevToolsGameplayCommands.ToggleSettingsPanel() no-op.
[InlineData(InputAction.ToggleOptionsPanel, "options")]
[InlineData(InputAction.CombatToggleCombat, "combat")]
[InlineData(InputAction.ToggleFloatingChatWindow1, "chat-window-1")]
[InlineData(InputAction.ToggleFloatingChatWindow2, "chat-window-2")]
@ -126,6 +129,8 @@ public sealed class GameplayInputCommandControllerTests
public void ToggleFloatingChatWindow(int windowId) =>
calls.Add($"chat-window-{windowId}");
public void ToggleOptionsPanel() => calls.Add("options");
}
private sealed class FakeDevTools(List<string> calls)

View file

@ -1292,6 +1292,9 @@ public sealed class RuntimeSettingsControllerTests
public CharacterSettings DefaultCharacterValue { get; set; } =
CharacterSettings.Default;
public CameraTurningSettings CameraTurningValue { get; set; } =
CameraTurningSettings.Default;
public Dictionary<string, CharacterSettings> Characters { get; } =
new(StringComparer.OrdinalIgnoreCase);
@ -1412,6 +1415,14 @@ public sealed class RuntimeSettingsControllerTests
DefaultCharacterValue = character;
}
public CameraTurningSettings LoadCameraTurning() => CameraTurningValue;
public void SaveCameraTurning(CameraTurningSettings cameraTurning)
{
_events.Add("save-camera-turning");
CameraTurningValue = cameraTurning;
}
public void ClearEvents() => _events.Clear();
}

View file

@ -248,6 +248,21 @@ public static class FixtureLoader
public static ElementInfo LoadOptionsConfigInfos()
=> LoadInfos("options_config_21000029.json");
/// <summary>The Options panel resolved through its retail host — LayoutDesc
/// <c>0x2100006E</c> (<c>gmFloatyPanelUI</c>) at slot <c>0x1000018D</c> (stack
/// key 10, <see cref="AcDream.App.UI.RetailPanelCatalog.Options"/>) — the
/// SAME catalog-import shape <c>CharacterController</c>'s own
/// <c>character_info_2100006E_10000183.json</c> fixture uses. This is what
/// <see cref="OptionsPanelController.Bind"/> actually mounts (Campaign OP
/// slice OP3), distinct from <see cref="LoadOptionsPanel"/> above (the
/// standalone <c>0x2100002B</c> import, which OP2 pinned for widget-mapping
/// conformance).</summary>
public static ImportedLayout LoadOptionsPanelHost()
=> LayoutImporter.Build(LoadOptionsPanelHostInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadOptionsPanelHostInfos()
=> LoadInfos("options_panel_2100006E_1000018D.json");
// ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -0,0 +1,113 @@
using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Pure logic tests for <see cref="MouseTurningSettingsMacro"/> — the port of
/// <c>gmConfigUI::SetMouseTurningDefaults @0x0049E8F0</c> (research doc
/// <c>2026-08-10-keyboard-config-and-gameplay-tab.md</c> §4.4). No DAT, no
/// runtime — pure input/output over <see cref="CameraTurningSettings"/>.
/// </summary>
public sealed class MouseTurningSettingsMacroTests
{
[Fact]
public void AllSixValuesAtOrdinaryDefaults_ChangesEveryOne()
{
// Retail's ordinary Config-tab defaults (0.45/40.0/0.55/on/off) all
// differ from the macro's targets (0.95/50.0/0.7/off/on) — a fresh
// character clicking this button changes all six.
MouseTurningSettingsMacro.Result result = MouseTurningSettingsMacro.Compute(
CameraTurningSettings.Default,
useMouseTurningCurrent: false);
CameraTurningSettings target = CameraTurningSettings.MouseTurningTarget;
Assert.Equal(target, result.Updated);
Assert.True(result.UseMouseTurningChanged);
Assert.Equal(6, result.ChatLines.Count);
}
[Fact]
public void AllSixValuesAlreadyAtTarget_ChangesNone()
{
MouseTurningSettingsMacro.Result result = MouseTurningSettingsMacro.Compute(
CameraTurningSettings.MouseTurningTarget,
useMouseTurningCurrent: true);
Assert.Equal(CameraTurningSettings.MouseTurningTarget, result.Updated);
Assert.False(result.UseMouseTurningChanged);
Assert.Empty(result.ChatLines);
}
[Fact]
public void OnlyStiffnessDiffers_ChangesOnlyStiffness_WithItsOwnChatLine()
{
CameraTurningSettings current = CameraTurningSettings.MouseTurningTarget with
{
Stiffness = 0.45f,
};
MouseTurningSettingsMacro.Result result =
MouseTurningSettingsMacro.Compute(current, useMouseTurningCurrent: true);
Assert.Equal(0.95f, result.Updated.Stiffness);
Assert.Equal(CameraTurningSettings.MouseTurningTarget.AdjustmentSpeed, result.Updated.AdjustmentSpeed);
Assert.False(result.UseMouseTurningChanged);
Assert.Equal(
[OptionsPanelText.CameraStiffnessChanged(0.45f, 0.95f)],
result.ChatLines);
}
[Fact]
public void OnlyAlignToSlopeDiffers_EmitsTheByteVerifiedLiteralLine()
{
CameraTurningSettings current = CameraTurningSettings.MouseTurningTarget with
{
AlignToSlope = true,
};
MouseTurningSettingsMacro.Result result =
MouseTurningSettingsMacro.Compute(current, useMouseTurningCurrent: true);
Assert.False(result.Updated.AlignToSlope);
Assert.Equal([OptionsPanelText.AlignToSlopeChanged], result.ChatLines);
}
[Fact]
public void OnlyUseMouseTurningDiffers_ReportsChangedWithoutTouchingCameraSettings()
{
MouseTurningSettingsMacro.Result result = MouseTurningSettingsMacro.Compute(
CameraTurningSettings.MouseTurningTarget,
useMouseTurningCurrent: false);
Assert.Equal(CameraTurningSettings.MouseTurningTarget, result.Updated);
Assert.True(result.UseMouseTurningChanged);
Assert.Equal([OptionsPanelText.TurnToFaceCameraChanged], result.ChatLines);
}
[Fact]
public void ChatLines_AreEmittedInRetailOrder()
{
// Retail's own call order: Stiffness, AdjustmentSpeed, Sensitivity,
// AlignToSlope, InvertMouseLookYAxis, UseMouseTurning
// (0x0049E924..0x0049EB57).
MouseTurningSettingsMacro.Result result = MouseTurningSettingsMacro.Compute(
CameraTurningSettings.Default,
useMouseTurningCurrent: false);
CameraTurningSettings d = CameraTurningSettings.Default;
CameraTurningSettings t = CameraTurningSettings.MouseTurningTarget;
Assert.Equal(
new[]
{
OptionsPanelText.CameraStiffnessChanged(d.Stiffness, t.Stiffness),
OptionsPanelText.CameraAdjustmentChanged(d.AdjustmentSpeed, t.AdjustmentSpeed),
OptionsPanelText.MouseSensitivityChanged(d.MouseLookSensitivity, t.MouseLookSensitivity),
OptionsPanelText.AlignToSlopeChanged,
OptionsPanelText.InvertMouseLookAxesChanged,
OptionsPanelText.TurnToFaceCameraChanged,
},
result.ChatLines);
}
}

View file

@ -0,0 +1,234 @@
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Pure logic tests for <see cref="OptionPage"/>/<see cref="BoolOptionRow"/> —
/// no DAT, no widgets, no runtime. Campaign OP slice OP3 ships the model
/// against an EMPTY page (the Gameplay tab has no options at all — research
/// doc <c>2026-08-10-options-panel-structure.md</c> §6); this file also
/// exercises a synthetic 2-option page to prove Apply/Reset/Defaults'
/// per-row semantics before OP4-6 wire real DAT-backed rows into the same
/// model.
/// </summary>
public sealed class OptionPageModelTests
{
// ── Empty page (the Gameplay tab's own shape) ───────────────────────────
[Fact]
public void EmptyPage_ChangedIsAlwaysFalse()
{
var page = new OptionPage();
Assert.False(page.Changed);
}
[Fact]
public void EmptyPage_ApplyResetDefaults_AreNoOps()
{
var page = new OptionPage();
page.Apply();
page.Reset();
page.Defaults();
Assert.Empty(page.Rows);
Assert.False(page.Changed);
}
[Fact]
public void EmptyPage_OnShownAndOnHidden_DoNotThrow()
{
var page = new OptionPage();
page.OnShown();
page.OnHidden();
}
[Fact]
public void EmptyPage_Apply_StillInvokesAfterApply()
{
// Retail's PlayerOptionPage::SaveCurrentValues flushes the batched
// module regardless of whether THIS page's own rows changed
// anything — the module's dirty flag is global, not per-page.
var page = new OptionPage();
int flushCount = 0;
page.AfterApply = () => flushCount++;
page.Apply();
Assert.Equal(1, flushCount);
}
[Fact]
public void EmptyPage_OnShown_InvokesAfterApply()
{
var page = new OptionPage();
int flushCount = 0;
page.AfterApply = () => flushCount++;
page.OnShown();
Assert.Equal(1, flushCount);
}
[Fact]
public void EmptyPage_ResetAndDefaults_DoNotInvokeAfterApply()
{
var page = new OptionPage();
int flushCount = 0;
page.AfterApply = () => flushCount++;
page.Reset();
page.Defaults();
Assert.Equal(0, flushCount);
}
// ── BoolOptionRow leaf semantics (UIOption_Checkbox port) ───────────────
[Fact]
public void BoolOptionRow_SetCurrentValue_AppliesLiveImmediately_WithoutCommitting()
{
var applied = new List<bool>();
var row = new BoolOptionRow(initial: false, defaultValue: false, apply: v => applied.Add(v));
row.SetCurrentValue(true);
Assert.True(row.Current);
Assert.False(row.Saved); // NOT committed — SetCurrentValue never touches Saved.
Assert.True(row.Changed);
Assert.Equal([true], applied);
}
[Fact]
public void BoolOptionRow_SaveCurrentValue_CommitsBaseline()
{
var row = new BoolOptionRow(initial: false, defaultValue: false);
row.SetCurrentValue(true);
row.SaveCurrentValue();
Assert.True(row.Saved);
Assert.False(row.Changed);
}
[Fact]
public void BoolOptionRow_RestoreSavedValue_RevertsAndReapplies()
{
var applied = new List<bool>();
var row = new BoolOptionRow(initial: false, defaultValue: false, apply: v => applied.Add(v));
row.SetCurrentValue(true);
applied.Clear();
row.RestoreSavedValue();
Assert.False(row.Current);
Assert.False(row.Changed);
Assert.Equal([false], applied);
}
[Fact]
public void BoolOptionRow_RestoreDefaultValue_AppliesLiveWithoutCommitting()
{
var applied = new List<bool>();
var row = new BoolOptionRow(initial: false, defaultValue: true, apply: v => applied.Add(v));
row.RestoreDefaultValue();
Assert.True(row.Current);
Assert.False(row.Saved); // uncommitted — Changed re-arms Apply/Reset.
Assert.True(row.Changed);
Assert.Equal([true], applied);
}
// ── Synthetic 2-option page ──────────────────────────────────────────────
private static (OptionPage Page, BoolOptionRow A, BoolOptionRow B) MakeTwoOptionPage()
{
var page = new OptionPage();
var a = new BoolOptionRow(initial: true, defaultValue: true);
var b = new BoolOptionRow(initial: false, defaultValue: true);
page.Register(a);
page.Register(b);
return (page, a, b);
}
[Fact]
public void TwoOptionPage_Changed_TrueWhenAnyRowChanged()
{
var (page, a, _) = MakeTwoOptionPage();
Assert.False(page.Changed);
a.SetCurrentValue(false);
Assert.True(page.Changed);
}
[Fact]
public void TwoOptionPage_Apply_CommitsEveryRowUnconditionally()
{
var (page, a, b) = MakeTwoOptionPage();
a.SetCurrentValue(false); // a changed
// b left at its initial (unchanged) value.
page.Apply();
Assert.False(page.Changed);
Assert.True(a.Saved == a.Current);
Assert.True(b.Saved == b.Current);
}
[Fact]
public void TwoOptionPage_Reset_RevertsOnlyChangedRows()
{
var (page, a, b) = MakeTwoOptionPage();
a.SetCurrentValue(false); // a: true -> false, changed
// b stays at its initial false (unchanged, Saved == Current == false).
page.Reset();
Assert.True(a.Current); // reverted to its original saved baseline
Assert.False(b.Current); // untouched — was never Changed
Assert.False(page.Changed);
}
[Fact]
public void TwoOptionPage_Defaults_RestoresEveryRow_WithoutCommitting()
{
var (page, a, b) = MakeTwoOptionPage();
a.SetCurrentValue(false);
page.Apply(); // commit a's change so Changed starts false
page.Defaults();
Assert.True(a.Current); // default is true
Assert.True(b.Current); // default is true (was false)
// Defaults never commits — b's baseline (Saved) is still its old
// committed value (false), so Changed re-arms Apply/Reset.
Assert.True(page.Changed);
}
[Fact]
public void TwoOptionPage_OnHidden_RevertsUncommittedEdits()
{
var (page, a, _) = MakeTwoOptionPage();
a.SetCurrentValue(false);
page.OnHidden();
Assert.True(a.Current);
Assert.False(page.Changed);
}
[Fact]
public void TwoOptionPage_OnShown_AppliesAndCommits()
{
var (page, a, _) = MakeTwoOptionPage();
a.SetCurrentValue(false);
page.OnShown();
Assert.False(page.Changed);
Assert.False(a.Saved != a.Current);
}
}

View file

@ -0,0 +1,269 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Controller-level tests for <see cref="OptionsPanelController"/> against
/// the committed <c>options_panel_2100006E_1000018D.json</c> fixture
/// (Campaign OP slice OP3) — the SAME <c>LayoutImporter.ImportInfos(dats,
/// 0x2100006Eu, 0x1000018Du)</c> catalog import
/// <see cref="RetailUiRuntime.MountOptionsPanel"/> performs against real
/// DATs. No DAT access, no live runtime — dat-free per the established
/// <c>FixtureLoader</c> pattern.
/// </summary>
public sealed class OptionsPanelControllerTests
{
private static UiButton Click(ImportedLayout layout, uint elementId)
{
var button = Assert.IsType<UiButton>(layout.FindElement(elementId));
button.OnEvent(new UiEvent(0, button, UiEventType.Click));
return button;
}
private static OptionsPanelController.Callbacks MakeCallbacks(
List<string> calls,
Action<string>? displaySystemMessage = null)
=> new(
Toggle: () => calls.Add("toggle"),
RequestExitToCharacterSelection: () => calls.Add("exit-to-char-select"),
ExitGame: () => calls.Add("exit-game"),
UseMouseTurningSettings: () => calls.Add("mouse-turning"),
DisplaySystemMessage: displaySystemMessage ?? (text => calls.Add($"message:{text}")));
// ── Mount conformance ────────────────────────────────────────────────────
[Fact]
public void Bind_RootBuildsAsUiTabPanel()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
Assert.IsType<UiTabPanel>(layout.Root);
}
[Fact]
public void Bind_Succeeds_AndExposesFourEmptyPages()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController? controller =
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Assert.NotNull(controller);
Assert.Equal(4, controller!.Pages.Count);
Assert.Empty(controller.GameplayPage.Rows);
Assert.Empty(controller.CharacterPage.Rows);
Assert.Empty(controller.ChatPage.Rows);
Assert.Empty(controller.ConfigPage.Rows);
}
[Fact]
public void ActivateTabs_SelectsGameplayAsDefault_AndAppliesItsPage()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
int gameplayFlushCount = 0;
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
MakeCallbacks(calls) with { AfterApply = () => gameplayFlushCount++ })!;
controller.ActivateTabs();
Assert.True(controller.TabPanel.BehaviorActive);
Assert.Equal(0x10000212u, controller.TabPanel.ActivePageElementId); // Gameplay slot
// OnShown() fired for the initial default tab -> Apply() -> AfterApply.
Assert.Equal(1, gameplayFlushCount);
}
[Fact]
public void TabSwitch_RevertsLeavingPage_AndAppliesEnteringPage()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
var flushes = new List<string>();
OptionsPanelController controller = OptionsPanelController.Bind(
layout, MakeCallbacks(calls) with { AfterApply = () => flushes.Add("flush") })!;
controller.ActivateTabs();
flushes.Clear(); // drop the initial-activation flush
controller.TabPanel.SwitchTo(0x10000211u); // Character page slot
Assert.Equal(0x10000211u, controller.TabPanel.ActivePageElementId);
// Both OnHidden (Gameplay, Reset — no flush) and OnShown (Character, Apply — flush).
Assert.Equal(["flush"], flushes);
}
[Fact]
public void WholeWindowHide_RevertsCurrentlyActivePage()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController controller =
OptionsPanelController.Bind(layout, MakeCallbacks(calls))!;
controller.ActivateTabs();
var row = new BoolOptionRow(initial: false, defaultValue: false);
controller.GameplayPage.Register(row);
row.SetCurrentValue(true);
Assert.True(controller.GameplayPage.Changed);
controller.OnHidden(); // IRetainedPanelController hook — whole window closing
Assert.False(controller.GameplayPage.Changed);
}
[Fact]
public void WholeWindowShow_AppliesCurrentlyActivePage()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
int flushCount = 0;
OptionsPanelController controller = OptionsPanelController.Bind(
layout, MakeCallbacks(calls) with { AfterApply = () => flushCount++ })!;
controller.ActivateTabs();
flushCount = 0;
controller.OnShown();
Assert.Equal(1, flushCount);
}
// ── Close button ─────────────────────────────────────────────────────────
[Fact]
public void CloseButton_FiresToggleCallback()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000210u);
Assert.Equal(["toggle"], calls);
}
// ── The seven Gameplay-tab buttons ───────────────────────────────────────
[Fact]
public void ExitToCharacterSelectionButton_FiresRequestCallback()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000203u);
Assert.Equal(["exit-to-char-select"], calls);
}
[Fact]
public void ExitGameButton_FiresExitGameCallback()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000617u);
Assert.Equal(["exit-game"], calls);
}
[Fact]
public void UseMouseTurningSettingsButton_FiresMacroCallback()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x100005CCu);
Assert.Equal(["mouse-turning"], calls);
}
[Fact]
public void UrgentAssistanceButton_DisplaysItsOwnByteVerifiedFailureText()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000206u);
Assert.Equal([$"message:{OptionsPanelText.UrgentAssistanceUnavailable}"], calls);
}
[Fact]
public void ReportAbuseButton_DisplaysItsOwnByteVerifiedFailureText()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000207u);
Assert.Equal([$"message:{OptionsPanelText.ReportAbuseUnavailable}"], calls);
}
[Fact]
public void UrgentAssistanceAndReportAbuse_UseDifferentText()
{
// The two buttons share retail's identical body template but differ
// in ONE clause ("urgent assistance request" vs "abuse report").
Assert.NotEqual(
OptionsPanelText.UrgentAssistanceUnavailable,
OptionsPanelText.ReportAbuseUnavailable);
Assert.Contains(OptionsPanelText.SupportUrl, OptionsPanelText.UrgentAssistanceUnavailable);
Assert.Contains(OptionsPanelText.SupportUrl, OptionsPanelText.ReportAbuseUnavailable);
}
[Fact]
public void ConfigureKeyboardButton_IsInert_ClickDoesNothing()
{
// D4/OP8: authored, clickable, no handler this slice.
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000204u);
Assert.Empty(calls);
}
[Fact]
public void InGameHelpFilesButton_IsInert_ClickDoesNothing()
{
// D5: retail's own KeyStone::OpenHelp fails silently without the
// missing ACHelpPlugin.dll — mirrored as an inert button.
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController.Bind(layout, MakeCallbacks(calls));
Click(layout, 0x10000205u);
Assert.Empty(calls);
}
[Fact]
public void AllSevenGameplayButtons_ResolveInTheBuiltLayout()
{
// Guards against a future fixture regeneration silently dropping an
// id (a missing button degrades to a logged no-op, not a test
// failure, unless asserted here).
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
uint[] buttonIds =
{
0x10000203u, // Exit to Character Selection
0x10000204u, // Configure Keyboard
0x10000205u, // In-Game Help Files
0x10000206u, // Urgent Assistance
0x10000207u, // Report Abuse
0x100005CCu, // Use Mouse Turning Settings
0x10000617u, // Exit Game
};
foreach (uint id in buttonIds)
Assert.IsType<UiButton>(layout.FindElement(id));
}
}

View file

@ -575,4 +575,48 @@ public class OptionsPanelLayoutConformanceTests
Assert.Equal(before, tabControl.ActivePageElementId);
Assert.True(layout.FindElement(before)!.Visible);
}
// ── ActivePageChanged (Campaign OP slice OP3 — the page-model hook) ──────
[Fact]
public void ActivePageChanged_FiresOnInitialActivation_WithOldEqualsZero()
{
var layout = FixtureLoader.LoadOptionsPanel();
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
var transitions = new List<(uint Old, uint New)>();
tabControl.ActivePageChanged += (oldId, newId) => transitions.Add((oldId, newId));
tabControl.ActivateTabBehavior();
Assert.Equal([(0u, 0x10000212u)], transitions); // Gameplay default, no prior page.
}
[Fact]
public void ActivePageChanged_FiresOnEverySwitch_WithBothOldAndNew()
{
var layout = FixtureLoader.LoadOptionsPanel();
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
tabControl.ActivateTabBehavior();
var transitions = new List<(uint Old, uint New)>();
tabControl.ActivePageChanged += (oldId, newId) => transitions.Add((oldId, newId));
tabControl.SwitchTo(0x1000050Cu); // Chat page slot
Assert.Equal([(0x10000212u, 0x1000050Cu)], transitions);
}
[Fact]
public void ActivePageChanged_DoesNotFireForANoOpSwitch()
{
var layout = FixtureLoader.LoadOptionsPanel();
var tabControl = Assert.IsType<UiTabPanel>(layout.FindElement(0x10000208u));
tabControl.ActivateTabBehavior();
uint before = tabControl.ActivePageElementId;
var transitions = new List<(uint Old, uint New)>();
tabControl.ActivePageChanged += (oldId, newId) => transitions.Add((oldId, newId));
tabControl.SwitchTo(before);
Assert.Empty(transitions);
}
}

View file

@ -144,6 +144,8 @@ public sealed class RetailLayoutFixtureGenerator
"examine_component_2100006B_1000032E.json"),
(VendorUiController.LayoutId, VendorUiController.RootId,
"vendor_21000012_100000B7.json"),
(OptionsPanelController.HostLayoutId, OptionsPanelController.SlotElementId,
"options_panel_2100006E_1000018D.json"),
})
{
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,49 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Campaign OP slice OP3: pins the Options panel's entry in
/// <see cref="RetailPanelCatalog"/> — the data the toolbar's ALREADY-GENERIC
/// panel-button mechanism (<c>ToolbarController.PanelButtonIds</c> already
/// includes <c>0x1000019B</c>, byte-verified authored panel id 10 —
/// <c>tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs</c>'s own
/// <c>PanelButtons</c> fixture at <c>(0x1000019Bu, 10u)</c>) reads to route
/// the toolbar Options button and <c>RetailUiRuntime.OnWindowVisibilityChanged</c>
/// to the retail Options window. No new toolbar wiring was needed — only
/// this catalog entry, which the existing generic mechanism (
/// <c>RetailUiRuntime.BindToolbarPanelButtons</c>,
/// <c>RetailPanelUiController</c>) picks up automatically.
/// </summary>
public sealed class RetailPanelCatalogTests
{
[Fact]
public void Options_PanelId_Is10_MatchingTheToolbarButtonsAuthoredProperty()
=> Assert.Equal(10u, RetailPanelCatalog.Options);
[Fact]
public void Options_ResolvesToTheOptionsWindowName()
{
Assert.True(RetailPanelCatalog.TryGetWindowName(RetailPanelCatalog.Options, out string name));
Assert.Equal(WindowNames.Options, name);
}
[Fact]
public void OptionsWindowName_ResolvesBackToThePanelId()
{
Assert.True(RetailPanelCatalog.TryGetPanelId(WindowNames.Options, out uint panelId));
Assert.Equal(RetailPanelCatalog.Options, panelId);
}
[Fact]
public void Options_IsListedInMountedPanels()
=> Assert.Contains(
(RetailPanelCatalog.Options, WindowNames.Options),
RetailPanelCatalog.MountedPanels);
[Fact]
public void Options_IsListedInToolbarPanels()
=> Assert.Contains(
(RetailPanelCatalog.Options, WindowNames.Options),
RetailPanelCatalog.ToolbarPanels);
}

View file

@ -225,4 +225,22 @@ public class KeyBindingsRetailTests
Assert.Contains(b.ForAction(InputAction.ScrollUp),
x => x.Chord == new KeyChord(Key.Up, ModifierMask.Ctrl));
}
/// <summary>
/// Campaign OP slice OP3: F11 opens retail's Options panel
/// (<c>docs/research/named-retail/retail-default.keymap.txt:148</c> —
/// <c>ToggleOptionsPanel [ "" [ 0 DIK_F11 ] ]</c>). This row already
/// existed in <see cref="KeyBindings.RetailDefaults"/> before OP3 (the
/// binding was correct; only the routing it fed — the old, unrendered
/// Settings-panel no-op — needed to change). This test pins the ONLY
/// production table stays authoritative going forward (#358's lesson).
/// </summary>
[Fact]
public void ToggleOptionsPanel_bound_to_F11()
{
var b = KeyBindings.RetailDefaults();
var bound = b.Find(new KeyChord(Key.F11, ModifierMask.None), ActivationType.Press);
Assert.NotNull(bound);
Assert.Equal(InputAction.ToggleOptionsPanel, bound!.Value.Action);
}
}