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
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue