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:
parent
5242de9f15
commit
9d26ecc623
27 changed files with 25696 additions and 8 deletions
File diff suppressed because one or more lines are too long
114
docs/research/2026-08-11-campaign-op-test-script.md
Normal file
114
docs/research/2026-08-11-campaign-op-test-script.md
Normal 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).
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
106
src/AcDream.App/UI/Layout/MouseTurningSettingsMacro.cs
Normal file
106
src/AcDream.App/UI/Layout/MouseTurningSettingsMacro.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
214
src/AcDream.App/UI/Layout/OptionPageModel.cs
Normal file
214
src/AcDream.App/UI/Layout/OptionPageModel.cs
Normal 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();
|
||||
}
|
||||
235
src/AcDream.App/UI/Layout/OptionsPanelController.cs
Normal file
235
src/AcDream.App/UI/Layout/OptionsPanelController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ICommandBus></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 & 1</c>) — mirrors the SAME
|
||||
/// <c>d.PlayerMode.IsPlayerMode && 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 => 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 & 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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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.";
|
||||
}
|
||||
|
|
|
|||
119
src/AcDream.Core/Chat/OptionsPanelText.cs
Normal file
119
src/AcDream.Core/Chat/OptionsPanelText.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
234
tests/AcDream.App.Tests/UI/Layout/OptionPageModelTests.cs
Normal file
234
tests/AcDream.App.Tests/UI/Layout/OptionPageModelTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
269
tests/AcDream.App.Tests/UI/Layout/OptionsPanelControllerTests.cs
Normal file
269
tests/AcDream.App.Tests/UI/Layout/OptionsPanelControllerTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
49
tests/AcDream.App.Tests/UI/RetailPanelCatalogTests.cs
Normal file
49
tests/AcDream.App.Tests/UI/RetailPanelCatalogTests.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue