fix(ui,runtime): OP4 review fixes — live re-seed, enable-gating, Combat panel re-point, universal timestamps

Both OP4 reviews converged on one headline bug (Character-tab rows never
re-read live server truth after their pre-login constructor-word seed) plus
overlapping MUST-FIXes. All ten converged/consolidated findings land here:

MUST-FIX:
- BoolOptionRow.SaveCurrentValue now re-reads its live binding (retail's
  GetValue()-into-SaveCurrentValue) on every OnShown — panel open, tab
  switch in, initial activation — instead of trusting the pre-login
  constructor word it was built with. Reset/tab-switch can now only
  restore values that were actually live at the last show. LockUI's
  host.Root.UiLocked one-shot mount seed now also converges on every
  PlayerDescription via the existing OnCharacterOptionsChanged hook.
- Apply/Reset are wired to OptionPage.OnOptionChanged in production
  (Ghosted when nothing changed, Normal when dirty, run once at bind so
  both start disabled per retail's PostInit); Defaults stays ungated.
- The Combat panel's three LEDs (Repeat Attacks/Auto Target/Keep in View)
  now read/write the same RuntimeCharacterOptionsState seam the Character
  tab uses instead of a disconnected client-local GameplaySettings copy —
  closes the "two writable copies" divergence. The three now-orphaned
  GameplaySettings fields and RuntimeSettingsController's mirror
  properties/SetCombatGameplay are deleted outright; the headless host's
  hardcoded AutoRepeatAttack/AutoTarget now read the live option bit.
- RuntimeSettingsController.SetUiLocked's convergence guard now compares
  against the last value actually applied to the runtime target instead
  of the persisted GameplaySettings.LockUI snapshot, which could already
  match a server-derived request without ever having been pushed.

SHOULD-FIX:
- DisplayTimeStamps now prefixes every chat producer (ChatLog.Append is
  the one seam all of them funnel through), not just AddText's own
  callers — heard speech, emotes, Turbine channels, and combat text were
  previously missed. The prefix format escapes its colons and forces
  InvariantCulture instead of the culture-dependent TimeSeparator
  placeholder.
- sky.frag now honors uFogParams.w (fog mode) like the mesh/terrain
  shaders, so Disable Distance Fog stops the sky dome's horizon band from
  blending toward fog color too.
- Corrected the "byte-verified" overclaim on the timestamp format string
  doc comment (BN-sourced, wire doc U6) and the AP-194 anchor-column
  class-name typo; the RunAsDefaultMovement doc comments now cite retail's
  actual acclient.h enumerator name.
- Added: DispatcherMovementInputSource's option x modifier truth table
  (incl. || AutoRunActive with the option off), the per-page Apply/Reset
  enable-gate tests, a real checkbox.OnClick/ToggleBehavior-driven click
  test, and hash-pins for the six header string keys.
- Gate script step 8 corrected for the logout-flush false-failure
  (closing the panel before relogging is load-bearing); a new step
  documents the enable-gate sequence and the Combat-panel/Character-tab
  cross-check.

Register: AP-196 (the Group-C default-source change + GameplaySettings
retirement) and AP-197 (the ignored per-character timestamp format
override) filed in this commit.

Full Release suite: 13,044 passed / 4 skipped / 0 failed (was 13,008/4/0;
net +36 tests from new coverage and legitimate assertion updates from the
GameplaySettings retirement).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 05:30:26 +02:00
parent 85afaae5fd
commit bc43fb1d1d
36 changed files with 923 additions and 220 deletions

File diff suppressed because one or more lines are too long

View file

@ -219,11 +219,22 @@ TrySetOption` seam every other Options-panel consumer already uses.
Relog and reopen the panel: the row should still read your new value Relog and reopen the panel: the row should still read your new value
(Apply flushed the `0x01A1` blob, ACE persisted it, the fresh (Apply flushed the `0x01A1` blob, ACE persisted it, the fresh
`PlayerDescription` echoes it back). `PlayerDescription` echoes it back).
8. **Toggle a batched row WITHOUT clicking Apply**, then relog. The row 8. **Close the Options panel (F11) FIRST, then toggle a batched row
should revert to its PRE-toggle value on reopen — an un-flushed WITHOUT clicking Apply**, then relog. The row should revert to its
batched change never reached the wire (the 480 s auto-save timer is a PRE-toggle value on reopen — an un-flushed batched change never
Runtime-level mechanism tested at OP1; do not wait 8 minutes for this reached the wire (the 480 s auto-save timer is a Runtime-level
gate — just don't click Apply). mechanism tested at OP1; do not wait 8 minutes for this gate — just
don't click Apply). **Closing the panel first is load-bearing, not
optional**: logout flushes the dirty blob (`CPlayerSystem::
LogOffCharacter` calls `SaveToServer`), so "relog without Apply
reverts" only holds because reaching Exit Game requires switching to
the Gameplay tab first — retail's own `OnVisibilityChanged(false) ->
RestoreSavedValues` — which reverts the uncommitted edit BEFORE
logout's flush ever sees it. A user who force-quits the client (or any
route that skips hiding the Character page) will see the OPPOSITE:
the un-Applied toggle DOES persist, because logout still flushes
whatever is dirty at that moment. Both are retail-correct; this step
exercises the panel-close path specifically.
### Apply / Reset / Defaults semantics ### Apply / Reset / Defaults semantics
@ -296,6 +307,27 @@ TrySetOption` seam every other Options-panel consumer already uses.
18. **Relog and confirm all six Group-C options above read their 18. **Relog and confirm all six Group-C options above read their
server-persisted value**, not a locally-cached default. server-persisted value**, not a locally-cached default.
### Apply/Reset enable-gating (MUST-FIX 2, OP4 review-fix round)
19. **On first open of the Character tab, Apply and Reset are greyed out**
(disabled — retail's `PostInit` runs `OnOptionChanged(0)` so the pair
starts disabled). **Click any LED and they light up** (enabled).
**Click Apply and they grey out again.** **Defaults is never greyed
out** — before a change, immediately after clicking it, or after
Apply — at any point in this sequence.
### Combat panel — the SAME LEDs, a second surface (MUST-FIX 3 / blast M2)
20. **Open the Combat window (its own toolbar button, not the Options
panel) and confirm its own Repeat Attacks / Auto Target / Keep in
View checkboxes match the Character tab's rows for the SAME three
options** (Automatically Repeat Attacks / Auto Target / Keep Combat
Targets in View) — both surfaces now read the identical server bit,
so toggling one and reopening/refreshing the other must show the
SAME state. Before this fix round the Combat panel read a separate,
disconnected client-local copy that could silently disagree with the
Character tab and never reached the wire for two of the three.
### What to report ### What to report
- Any row with a missing label/tooltip (note which one). - Any row with a missing label/tooltip (note which one).

View file

@ -3,7 +3,6 @@ using AcDream.App.Net;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Combat; namespace AcDream.App.Combat;
@ -20,30 +19,28 @@ internal interface ICombatGameplaySettingsSource
bool ViewCombatTarget { get; } bool ViewCombatTarget { get; }
} }
internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource
{
public GameplaySettings Value { get; set; } = GameplaySettings.Default;
public bool AutoTarget => Value.AutoTarget;
public bool AutoRepeatAttack => Value.AutoRepeatAttack;
public bool ViewCombatTarget => Value.ViewCombatTarget;
}
/// <summary> /// <summary>
/// D7 Group-C re-point (Campaign OP slice OP4, 2026-08-11): /// D7 Group-C re-point (Campaign OP slice OP4, 2026-08-11), widened at the
/// <c>AutoTarget</c>/<c>AutoRepeatAttack</c>/<c>ViewCombatTarget</c> move /// OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2):
/// from the client-local <c>GameplaySettings</c> record (what /// <c>AutoTarget</c>/<c>AutoRepeatAttack</c>/<c>ViewCombatTarget</c> read
/// <see cref="GameplaySettingsState"/> and the legacy /// exclusively from the canonical server-authoritative
/// <c>RuntimeSettingsController</c> read) to the canonical /// <see cref="RuntimeCharacterOptionsState"/> — the CH3 precedent (server
/// server-authoritative <see cref="RuntimeCharacterOptionsState"/> — the /// bit is authoritative; the Character-tab panel row's LED click writes
/// CH3 precedent (server bit is authoritative; the Character-tab panel /// THROUGH <c>RuntimeCharacterOptionsState.TrySetOption</c> before this
/// row's LED click writes THROUGH <c>RuntimeCharacterOptionsState. /// source can ever observe the new value, so no separate reseed/sync step
/// TrySetOption</c> before this source can ever observe the new value, so /// is needed here). This is now the ONLY <see cref="ICombatGameplaySettingsSource"/>
/// no separate reseed/sync step is needed here). Also closes two /// implementation — the client-local <c>GameplaySettings</c> record's own
/// previously-unfiled divergences (character-options-map.md §0): /// three same-named fields, the legacy <c>RuntimeSettingsController</c>
/// <c>AutoRepeatAttack</c> and (via <c>ClientCommandController</c>'s /// mirror properties, and the dead <c>GameplaySettingsState</c> adapter
/// <c>/consent</c> re-point, same commit) <c>AcceptCorpseLootingPermissions</c> /// class were all deleted the same round (register row AP-196); the
/// were client-local and never reached the wire even though retail /// Combat panel's own three LEDs (<c>CombatUiController</c>) were
/// auto-saves both (<c>0x0005</c> immediately). /// re-pointed to this SAME seam, closing the "two writable copies"
/// divergence the fix round found. Also closes two previously-unfiled
/// divergences (character-options-map.md §0): <c>AutoRepeatAttack</c> and
/// (via <c>ClientCommandController</c>'s <c>/consent</c> re-point, same
/// commit) <c>AcceptCorpseLootingPermissions</c> were client-local and
/// never reached the wire even though retail auto-saves both
/// (<c>0x0005</c> immediately).
/// </summary> /// </summary>
internal sealed class CharacterOptionCombatSettingsSource : ICombatGameplaySettingsSource internal sealed class CharacterOptionCombatSettingsSource : ICombatGameplaySettingsSource
{ {

View file

@ -643,9 +643,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.Settings.SetUiLocked), d.Settings.SetUiLocked),
Combat: new CombatRuntimeBindings( Combat: new CombatRuntimeBindings(
d.Actions.Combat, d.Actions.Combat,
combatAttack, combatAttack),
() => d.Settings.Gameplay,
d.Settings.SetCombatGameplay),
Magic: new MagicRuntimeBindings( Magic: new MagicRuntimeBindings(
d.Character.Spellbook, d.Character.Spellbook,
magic.Casting, magic.Casting,

View file

@ -76,7 +76,9 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
// to walking — impossible in retail. // to walking — impossible in retail.
// //
// Campaign OP slice OP4 (2026-08-11): `!walking` was a hardcoded // Campaign OP slice OP4 (2026-08-11): `!walking` was a hardcoded
// "run by default" assumption. PlayerOption RunAsDefaultMovement // "run by default" assumption. Retail PlayerOption id 0xA
// (acclient.h's ToggleRun_PlayerOption — ACE calls the same bit
// RunAsDefaultMovement; N7, OP4 review-fix round 2026-08-11)
// (retail default ON, matching the prior hardcoded behavior // (retail default ON, matching the prior hardcoded behavior
// exactly) now supplies the default; the walk-mode modifier // exactly) now supplies the default; the walk-mode modifier
// still temporarily INVERTS whichever default is active, same // still temporarily INVERTS whichever default is active, same

View file

@ -320,8 +320,25 @@ internal sealed class LiveSessionRuntimeFactory
// still diverge from server truth (e.g. an older save, or a // still diverge from server truth (e.g. an older save, or a
// character whose allegiance/society changed), and the server // character whose allegiance/society changed), and the server
// is always authoritative. // is always authoritative.
//
// OP4 review-fix round (2026-08-11, MF-1/blast M1): re-seed
// LockUI's visual push the SAME way — the radar's polled
// `GetOptionBit` lambda self-corrects every frame, but
// `host.Root.UiLocked` (InteractionRetainedUiComposition.cs)
// is a ONE-SHOT assignment taken pre-login, at composition
// time, from RuntimeCharacterOptionsState's constructor-
// default word. Routing the real value through SetUiLocked
// here — the exact seam ToggleUiLock/SaveGameplay already
// use to push RuntimeSettingsTargets.ApplyUiLock — converges
// the retained window lock to server truth on every fresh
// PlayerDescription, matching the radar's own convergence
// instead of only updating on the next manual /lockui toggle.
OnCharacterOptionsChanged: (_, options2) => OnCharacterOptionsChanged: (_, options2) =>
_interaction.Settings.SyncChatFromServerOptions(options2)); {
_interaction.Settings.SyncChatFromServerOptions(options2);
_interaction.Settings.SetUiLocked(
_domain.Character.Options.GetOptionBit(CharacterOptionId.LockUI));
});
} }
private LiveSessionCommandBindings CreateCommandBindings( private LiveSessionCommandBindings CreateCommandBindings(

View file

@ -669,8 +669,10 @@ public sealed class GameWindow :
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource( _movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
_playerControllerSlot, _playerControllerSlot,
_inputCapture); _inputCapture);
// Campaign OP slice OP4 (2026-08-11): PlayerOption // Campaign OP slice OP4 (2026-08-11): retail PlayerOption id 0xA
// RunAsDefaultMovement, polled — see RuntimeLocalPlayerMovementState. // (acclient.h's ToggleRun_PlayerOption — ACE's name for the same
// bit is RunAsDefaultMovement; N7, OP4 review-fix round
// 2026-08-11), polled — see RuntimeLocalPlayerMovementState.
// RunAsDefaultMovementSource's doc comment. Same one-time, // RunAsDefaultMovementSource's doc comment. Same one-time,
// whole-lifetime bind as DisableDistanceFogSource above. // whole-lifetime bind as DisableDistanceFogSource above.
_playerControllerSlot.RunAsDefaultMovementSource = () => _playerControllerSlot.RunAsDefaultMovementSource = () =>

View file

@ -57,7 +57,16 @@ void main() {
vec3 rgb = sampled.rgb * vTint; vec3 rgb = sampled.rgb * vTint;
if (uApplyFog > 0.5) { // SHOULD-FIX S2/mech NOTE N-3 (OP4 review-fix round, 2026-08-11):
// PlayerOption DisableDistanceFog forces FogMode.Off (uFogParams.w
// == 0) — mesh_modern.frag/terrain_modern.frag both gate their own
// fog blend on this same word (`if (mode == 0) return lit;`), but the
// sky dome's blend here read only uApplyFog (the CPU per-submesh
// "is this layer foggable at all" flag) and never uFogParams.w, so
// toggling the option stopped terrain/objects fading into fog while
// the dome's horizon band kept blending toward fog color.
int fogMode = int(uFogParams.w);
if (uApplyFog > 0.5 && fogMode != 0) {
const float SKY_FOG_FLOOR = 0.2; const float SKY_FOG_FLOOR = 0.2;
float skyFogFactor = max(vFogFactor, SKY_FOG_FLOOR); float skyFogFactor = max(vFogFactor, SKY_FOG_FLOOR);
rgb = mix(uFogColor.rgb, rgb, skyFogFactor); rgb = mix(uFogColor.rgb, rgb, skyFogFactor);

View file

@ -92,7 +92,7 @@
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "8de9a5d8f819d1abf893f134cf8ed9fa7edf937a353a74d2befab7b05a3ff700", "sourceSha256": "2ddf210d69b0c4a3c0870eecfb0ccba2097d93b365729739402bfe91e3b120d0",
"compiled": true "compiled": true
} }
] ]

View file

@ -1,4 +1,3 @@
using AcDream.App.Combat;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Settings;
@ -168,8 +167,7 @@ internal interface IRuntimeSettingsPreviewSource
/// are never constructed or disposed here. /// are never constructed or disposed here.
/// </summary> /// </summary>
internal sealed class RuntimeSettingsController : internal sealed class RuntimeSettingsController :
IRuntimeSettingsPreviewSource, IRuntimeSettingsPreviewSource
ICombatGameplaySettingsSource
{ {
private const string DefaultToonKey = "default"; private const string DefaultToonKey = "default";
@ -183,6 +181,11 @@ internal sealed class RuntimeSettingsController :
private bool _startupAudioApplied; private bool _startupAudioApplied;
private bool _startupApplied; private bool _startupApplied;
private bool _uiLockConverged = true; private bool _uiLockConverged = true;
// MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the last
// `locked` value actually pushed to `_runtimeTargets.ApplyUiLock` —
// the guard `SetUiLocked` compares against, decoupled from whatever
// `Gameplay.LockUI`'s own persisted/draft snapshot currently holds.
private bool? _lastAppliedUiLocked;
public RuntimeSettingsController( public RuntimeSettingsController(
IRuntimeSettingsStorage storage, IRuntimeSettingsStorage storage,
@ -233,12 +236,6 @@ internal sealed class RuntimeSettingsController :
public AudioSettings AudioPreview => _viewModel?.AudioDraft ?? Audio; public AudioSettings AudioPreview => _viewModel?.AudioDraft ?? Audio;
public bool AutoTarget => Gameplay.AutoTarget;
public bool AutoRepeatAttack => Gameplay.AutoRepeatAttack;
public bool ViewCombatTarget => Gameplay.ViewCombatTarget;
public void ApplyStartup(IRuntimeSettingsStartupTarget target) public void ApplyStartup(IRuntimeSettingsStartupTarget target)
{ {
ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(target);
@ -352,12 +349,22 @@ internal sealed class RuntimeSettingsController :
public void SetUiLocked(bool locked) public void SetUiLocked(bool locked)
{ {
if (Gameplay.LockUI == locked && _uiLockConverged) // MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the
// guard used to compare `locked` against `Gameplay.LockUI` — valid
// only while `ToggleUiLock` computed `locked` AS `!Gameplay.LockUI`
// (pre-OP4). OP4 re-pointed `ToggleUiLock` to derive `locked` from
// the SERVER bit (`RuntimeCharacterOptionsState.GetOptionBit`)
// instead, a DIFFERENT store that can already equal the persisted
// `Gameplay.LockUI` without this method ever having pushed that
// value to `_runtimeTargets` — the guard must compare against what
// was ACTUALLY applied, not a value from an unrelated store.
if (_lastAppliedUiLocked == locked && _uiLockConverged)
return; return;
_uiLockConverged = false; _uiLockConverged = false;
Gameplay = Gameplay with { LockUI = locked }; Gameplay = Gameplay with { LockUI = locked };
_runtimeTargets?.ApplyUiLock(locked); _runtimeTargets?.ApplyUiLock(locked);
_lastAppliedUiLocked = locked;
_viewModel?.SetGameplay( _viewModel?.SetGameplay(
_viewModel.GameplayDraft with { LockUI = locked }); _viewModel.GameplayDraft with { LockUI = locked });
@ -412,35 +419,6 @@ internal sealed class RuntimeSettingsController :
} }
} }
public void SetCombatGameplay(GameplaySettings gameplay)
{
Gameplay = gameplay ?? throw new ArgumentNullException(nameof(gameplay));
if (_viewModel is not null)
{
_viewModel.SetGameplay(_viewModel.GameplayDraft with
{
AutoTarget = gameplay.AutoTarget,
AutoRepeatAttack = gameplay.AutoRepeatAttack,
ViewCombatTarget = gameplay.ViewCombatTarget,
});
}
try
{
_storage.SaveGameplay(gameplay);
_viewModel?.ApplyExternalGameplayChange(current => current with
{
AutoTarget = gameplay.AutoTarget,
AutoRepeatAttack = gameplay.AutoRepeatAttack,
ViewCombatTarget = gameplay.ViewCombatTarget,
});
}
catch (Exception ex)
{
_log($"settings: combat option save failed: {ex.Message}");
}
}
/// <summary> /// <summary>
/// Campaign OP slice OP3: the five client-local preferences the "Use /// Campaign OP slice OP3: the five client-local preferences the "Use
/// Mouse Turning Settings" Gameplay-tab macro reads/writes. Read /// Mouse Turning Settings" Gameplay-tab macro reads/writes. Read
@ -545,6 +523,7 @@ internal sealed class RuntimeSettingsController :
Gameplay = gameplay; Gameplay = gameplay;
_uiLockConverged = false; _uiLockConverged = false;
_runtimeTargets?.ApplyUiLock(gameplay.LockUI); _runtimeTargets?.ApplyUiLock(gameplay.LockUI);
_lastAppliedUiLocked = gameplay.LockUI;
_uiLockConverged = true; _uiLockConverged = true;
_log($"settings: gameplay saved to {_storage.Location}"); _log($"settings: gameplay saved to {_storage.Location}");
} }

View file

@ -368,6 +368,11 @@ public static class CharacterOptionsPageController
bool initial = bindings.CurrentValue(spec.Id); bool initial = bindings.CurrentValue(spec.Id);
checkbox.Selected = initial; checkbox.Selected = initial;
// MUST-FIX 1 (OP4 review-fix round, 2026-08-11): `read` re-seeds
// this row from the LIVE option word on every OnShown (panel
// open, tab switch in) — retail's SaveCurrentValue/GetValue.
// `refresh` pushes the re-read value onto the checkbox WITHOUT
// going through `apply` (which would send it back over the wire).
var row_ = new BoolOptionRow( var row_ = new BoolOptionRow(
initial, initial,
entry.ClientDefault, entry.ClientDefault,
@ -375,7 +380,9 @@ public static class CharacterOptionsPageController
{ {
checkbox.Selected = value; checkbox.Selected = value;
bindings.SetOption(spec.Id, value); bindings.SetOption(spec.Id, value);
}); },
read: () => bindings.CurrentValue(spec.Id),
refresh: value => checkbox.Selected = value);
page.Register(row_); page.Register(row_);
checkbox.OnClick = () => row_.SetCurrentValue(checkbox.Selected); checkbox.OnClick = () => row_.SetCurrentValue(checkbox.Selected);

View file

@ -1,6 +1,6 @@
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -15,9 +15,26 @@ namespace AcDream.App.UI.Layout;
/// <c>RecvNotice_DesiredAttackPowerChanged</c> (0x004CC110), /// <c>RecvNotice_DesiredAttackPowerChanged</c> (0x004CC110),
/// <c>ListenToElementMessage</c> (0x004CC430), and /// <c>ListenToElementMessage</c> (0x004CC430), and
/// <c>RecvNotice_SetCombatMode</c> (0x004CC620). /// <c>RecvNotice_SetCombatMode</c> (0x004CC620).
///
/// <para>
/// MUST-FIX 3 (OP4 review-fix round, 2026-08-11, blast M2): the three
/// LEDs (Repeat Attacks / Auto Target / Keep in View) read and write
/// through the SAME <c>RuntimeCharacterOptionsState</c> server-bit seam
/// the Character tab's rows for these same retail <c>PlayerOption</c>s
/// already use — retired the client-local <c>GameplaySettings</c> mirror
/// this panel used to read/write, which had silently diverged into a
/// second, disconnected copy of the same three options (register row
/// AP-196).
/// </para>
/// </remarks> /// </remarks>
public sealed class CombatUiController : IRetainedPanelController public sealed class CombatUiController : IRetainedPanelController
{ {
/// <summary>The live read/write seam for the three combat LEDs —
/// identical shape to <see cref="CharacterOptionsPageController.Bindings"/>.</summary>
public sealed record Bindings(
Func<CharacterOptionId, bool> CurrentValue,
Action<CharacterOptionId, bool> SetOption);
public const uint LayoutId = 0x21000073u; public const uint LayoutId = 0x21000073u;
public const uint BasicPanelId = 0x1000005Cu; public const uint BasicPanelId = 0x1000005Cu;
public const uint SpellcastingPanelId = 0x10000061u; public const uint SpellcastingPanelId = 0x10000061u;
@ -47,8 +64,7 @@ public sealed class CombatUiController : IRetainedPanelController
private readonly UiButton _keepInView; private readonly UiButton _keepInView;
private readonly CombatState _combat; private readonly CombatState _combat;
private readonly RuntimeCombatAttackState _attacks; private readonly RuntimeCombatAttackState _attacks;
private readonly Func<GameplaySettings> _gameplay; private readonly Bindings _bindings;
private readonly Action<GameplaySettings> _setGameplay;
private readonly Action<bool> _setWindowVisible; private readonly Action<bool> _setWindowVisible;
private bool _disposed; private bool _disposed;
@ -65,8 +81,7 @@ public sealed class CombatUiController : IRetainedPanelController
UiButton keepInView, UiButton keepInView,
CombatState combat, CombatState combat,
RuntimeCombatAttackState attacks, RuntimeCombatAttackState attacks,
Func<GameplaySettings> gameplay, Bindings bindings,
Action<GameplaySettings> setGameplay,
CombatUiLabels labels, CombatUiLabels labels,
Action<bool> setWindowVisible) Action<bool> setWindowVisible)
{ {
@ -82,8 +97,7 @@ public sealed class CombatUiController : IRetainedPanelController
_keepInView = keepInView; _keepInView = keepInView;
_combat = combat; _combat = combat;
_attacks = attacks; _attacks = attacks;
_gameplay = gameplay; _bindings = bindings;
_setGameplay = setGameplay;
_setWindowVisible = setWindowVisible; _setWindowVisible = setWindowVisible;
// Retail layout 0x21000073 contains two sibling pages: gmCombatUI's // Retail layout 0x21000073 contains two sibling pages: gmCombatUI's
@ -110,12 +124,18 @@ public sealed class CombatUiController : IRetainedPanelController
SetStaticText(speedLabel, labels.Speed, rightAligned: false); SetStaticText(speedLabel, labels.Speed, rightAligned: false);
SetStaticText(powerLabel, labels.Power, rightAligned: true); SetStaticText(powerLabel, labels.Power, rightAligned: true);
// MUST-FIX 3 (OP4 review-fix round, 2026-08-11, blast M2): writes
// go through the SAME server-bit seam the Character tab's rows for
// these same retail PlayerOptions use — TrySetOption writes the
// local bit first, then either sends 0x0005 immediately
// (AutoRepeatAttack/AutoTarget are auto-save) or marks the batched
// module dirty (ViewCombatTarget is batched).
_repeatAttacks.OnClick = () => _repeatAttacks.OnClick = () =>
_setGameplay(_gameplay() with { AutoRepeatAttack = _repeatAttacks.Selected }); _bindings.SetOption(CharacterOptionId.AutoRepeatAttack, _repeatAttacks.Selected);
_autoTarget.OnClick = () => _autoTarget.OnClick = () =>
_setGameplay(_gameplay() with { AutoTarget = _autoTarget.Selected }); _bindings.SetOption(CharacterOptionId.AutoTarget, _autoTarget.Selected);
_keepInView.OnClick = () => _keepInView.OnClick = () =>
_setGameplay(_gameplay() with { ViewCombatTarget = _keepInView.Selected }); _bindings.SetOption(CharacterOptionId.ViewCombatTarget, _keepInView.Selected);
_combat.CombatModeChanged += OnCombatModeChanged; _combat.CombatModeChanged += OnCombatModeChanged;
_attacks.StateChanged += OnAttackStateChanged; _attacks.StateChanged += OnAttackStateChanged;
@ -126,16 +146,14 @@ public sealed class CombatUiController : IRetainedPanelController
ImportedLayout layout, ImportedLayout layout,
CombatState combat, CombatState combat,
RuntimeCombatAttackState attacks, RuntimeCombatAttackState attacks,
Func<GameplaySettings> gameplay, Bindings bindings,
Action<GameplaySettings> setGameplay,
CombatUiLabels labels, CombatUiLabels labels,
Action<bool> setWindowVisible) Action<bool> setWindowVisible)
{ {
ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(combat); ArgumentNullException.ThrowIfNull(combat);
ArgumentNullException.ThrowIfNull(attacks); ArgumentNullException.ThrowIfNull(attacks);
ArgumentNullException.ThrowIfNull(gameplay); ArgumentNullException.ThrowIfNull(bindings);
ArgumentNullException.ThrowIfNull(setGameplay);
ArgumentNullException.ThrowIfNull(labels); ArgumentNullException.ThrowIfNull(labels);
ArgumentNullException.ThrowIfNull(setWindowVisible); ArgumentNullException.ThrowIfNull(setWindowVisible);
@ -153,7 +171,7 @@ public sealed class CombatUiController : IRetainedPanelController
return new CombatUiController( return new CombatUiController(
layout, basic, spellcasting, power, high, medium, low, layout, basic, spellcasting, power, high, medium, low,
repeatAttacks, autoTarget, keepInView, repeatAttacks, autoTarget, keepInView,
combat, attacks, gameplay, setGameplay, labels, setWindowVisible); combat, attacks, bindings, labels, setWindowVisible);
} }
public void SyncVisibility() => OnCombatModeChanged(_combat.CurrentMode); public void SyncVisibility() => OnCombatModeChanged(_combat.CurrentMode);
@ -188,10 +206,9 @@ public sealed class CombatUiController : IRetainedPanelController
_high.Selected = _attacks.RequestedHeight == AttackHeight.High; _high.Selected = _attacks.RequestedHeight == AttackHeight.High;
_medium.Selected = _attacks.RequestedHeight == AttackHeight.Medium; _medium.Selected = _attacks.RequestedHeight == AttackHeight.Medium;
_low.Selected = _attacks.RequestedHeight == AttackHeight.Low; _low.Selected = _attacks.RequestedHeight == AttackHeight.Low;
GameplaySettings gameplay = _gameplay(); _repeatAttacks.Selected = _bindings.CurrentValue(CharacterOptionId.AutoRepeatAttack);
_repeatAttacks.Selected = gameplay.AutoRepeatAttack; _autoTarget.Selected = _bindings.CurrentValue(CharacterOptionId.AutoTarget);
_autoTarget.Selected = gameplay.AutoTarget; _keepInView.Selected = _bindings.CurrentValue(CharacterOptionId.ViewCombatTarget);
_keepInView.Selected = gameplay.ViewCombatTarget;
} }
private static void SetStaticText(UiText? text, string value, bool rightAligned) private static void SetStaticText(UiText? text, string value, bool rightAligned)

View file

@ -59,17 +59,43 @@ public interface IOptionRow
public sealed class BoolOptionRow : IOptionRow public sealed class BoolOptionRow : IOptionRow
{ {
private readonly Action<bool>? _apply; private readonly Action<bool>? _apply;
private readonly Func<bool>? _read;
private readonly Action<bool>? _refresh;
private Action? _notifyPageOptionChanged; private Action? _notifyPageOptionChanged;
private bool _current; private bool _current;
private bool _saved; private bool _saved;
private bool _default; private bool _default;
public BoolOptionRow(bool initial, bool defaultValue, Action<bool>? apply = null) /// <param name="read">
/// MUST-FIX 1 (OP4 review-fix round, 2026-08-11 — converged mechanism
/// MF-1 / blast M1): retail's <c>UIOption_Checkbox::GetValue
/// @0x00486f60</c> — <c>PlayerModule::GetOption</c>, the LIVE
/// server-synced option word, not a widget-local cache. Optional so
/// every pre-existing non-DAT-backed caller (the synthetic pages in
/// <c>OptionPageModelTests</c>) keeps working unchanged; when supplied,
/// <see cref="SaveCurrentValue"/> re-reads through it instead of
/// trusting the row's own possibly-stale <see cref="_current"/>.
/// </param>
/// <param name="refresh">
/// Retail's own <c>Refresh()</c> push of the re-read value onto the
/// widget (e.g. <c>checkbox.Selected = value</c>) — invoked ONLY from
/// <see cref="SaveCurrentValue"/>'s re-read path, never through
/// <paramref name="apply"/>, so a re-seed never sends the value back
/// out over the wire.
/// </param>
public BoolOptionRow(
bool initial,
bool defaultValue,
Action<bool>? apply = null,
Func<bool>? read = null,
Action<bool>? refresh = null)
{ {
_current = initial; _current = initial;
_saved = initial; _saved = initial;
_default = defaultValue; _default = defaultValue;
_apply = apply; _apply = apply;
_read = read;
_refresh = refresh;
} }
/// <summary>The live value — what the LED currently shows.</summary> /// <summary>The live value — what the LED currently shows.</summary>
@ -106,7 +132,28 @@ public sealed class BoolOptionRow : IOptionRow
public void AttachPageNotify(Action notify) => _notifyPageOptionChanged = notify; public void AttachPageNotify(Action notify) => _notifyPageOptionChanged = notify;
public void SaveCurrentValue() => _saved = _current; /// <summary>
/// MUST-FIX 1 (OP4 review-fix round, 2026-08-11): retail
/// <c>SaveCurrentValue @0x004868E0</c> is <c>m_current = GetValue();
/// m_saved = m_current;</c> — it re-reads the LIVE option word, not
/// just <c>m_saved = m_current</c> over whatever <c>m_current</c>
/// already held. <see cref="OptionPage.OnShown"/> calls
/// <see cref="OptionPage.Apply"/>, which calls this on every row —
/// so every panel (re)open, tab switch in, and the initial default-
/// tab activation self-corrects this row from the CURRENT server
/// truth, exactly on retail's own schedule. When no <c>read</c>
/// delegate was supplied (the synthetic non-DAT-backed test pages),
/// this degrades to the pre-fix <c>m_saved = m_current</c> shape.
/// </summary>
public void SaveCurrentValue()
{
if (_read is not null)
{
_current = _read();
_refresh?.Invoke(_current);
}
_saved = _current;
}
public void RestoreSavedValue() public void RestoreSavedValue()
{ {

View file

@ -217,22 +217,47 @@ public sealed class OptionsPanelController : IRetainedPanelController
UiElement? pageRoot = UiElement.FindDescendant(tabPanel, pageId); UiElement? pageRoot = UiElement.FindDescendant(tabPanel, pageId);
if (pageRoot is null) continue; if (pageRoot is null) continue;
BindPageButton(pageRoot, ApplyButtonId, page.Apply); UiButton? apply = BindPageButton(pageRoot, ApplyButtonId, page.Apply);
BindPageButton(pageRoot, ResetButtonId, page.Reset); UiButton? reset = BindPageButton(pageRoot, ResetButtonId, page.Reset);
BindPageButton(pageRoot, DefaultsButtonId, page.Defaults); BindPageButton(pageRoot, DefaultsButtonId, page.Defaults);
// MUST-FIX 2 (OP4 review-fix round, 2026-08-11): retail
// PlayerOptionPage::OnOptionChanged @0x004F27D0 — Apply/Reset
// Ghosted (disabled) when the page has nothing to commit/
// revert, Normal (enabled) otherwise; Defaults is NEVER gated
// (retail's override never fetches its child id at all).
if (apply is not null && reset is not null)
{
page.OnOptionChanged = () =>
{
uint state = page.Changed
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted;
apply.TrySetRetailState(state);
reset.TrySetRetailState(state);
};
// Retail's PostInit calls InitOptions() then
// OnOptionChanged(0) so the pair starts disabled — run the
// gate once now, at bind time, for the same effect.
page.OnOptionChanged();
}
} }
return controller; return controller;
} }
private static void BindPageButton(UiElement pageRoot, uint elementId, Action onClick) private static UiButton? BindPageButton(UiElement pageRoot, uint elementId, Action onClick)
{ {
if (UiElement.FindDescendant(pageRoot, elementId) is UiButton button) if (UiElement.FindDescendant(pageRoot, elementId) is UiButton button)
{
button.OnClick = onClick; button.OnClick = onClick;
else return button;
Console.WriteLine( }
$"[D.2b] OptionsPanelController: page 0x{pageRoot.DatElementId:X8}'s button "
+ $"0x{elementId:X8} not found — its handler was not wired."); Console.WriteLine(
$"[D.2b] OptionsPanelController: page 0x{pageRoot.DatElementId:X8}'s button "
+ $"0x{elementId:X8} not found — its handler was not wired.");
return null;
} }
/// <summary> /// <summary>

View file

@ -67,11 +67,14 @@ public sealed record RadarRuntimeBindings(
SelectionState Selection, SelectionState Selection,
Action<bool> SetUiLocked); Action<bool> SetUiLocked);
// MUST-FIX 3 (OP4 review-fix round, 2026-08-11, blast M2): the
// GameplaySettings read/write pair was removed — MountCombat wires the
// three LEDs through the same OptionsRuntimeBindings server-bit seam the
// Character tab's rows use (CombatUiController.Bindings), not a
// client-local settings mirror.
public sealed record CombatRuntimeBindings( public sealed record CombatRuntimeBindings(
CombatState State, CombatState State,
RuntimeCombatAttackState Attacks, RuntimeCombatAttackState Attacks);
Func<GameplaySettings> Gameplay,
Action<GameplaySettings> SetGameplay);
public sealed record MagicRuntimeBindings( public sealed record MagicRuntimeBindings(
Spellbook Spellbook, Spellbook Spellbook,
@ -1190,12 +1193,17 @@ public sealed class RetailUiRuntime : IDisposable
} }
float combatWidth = RetailCombatLayout.FitFavoriteSlots(layout); float combatWidth = RetailCombatLayout.FitFavoriteSlots(layout);
// MUST-FIX 3 (OP4 review-fix round, 2026-08-11, blast M2): the SAME
// server-bit seam MountOptionsPanel wires the Character tab's rows
// through — see CharacterOptionsPageController.Bindings below.
CombatUiController? controller = Layout.CombatUiController.Bind( CombatUiController? controller = Layout.CombatUiController.Bind(
layout, layout,
_bindings.Combat.State, _bindings.Combat.State,
_bindings.Combat.Attacks, _bindings.Combat.Attacks,
_bindings.Combat.Gameplay, new Layout.CombatUiController.Bindings(
_bindings.Combat.SetGameplay, CurrentValue: id => _bindings.Options.CurrentCharacterOption((uint)id),
SetOption: (id, value) => _bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd((uint)id, value))),
labels, labels,
visible => visible =>
{ {

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Globalization;
using System.Threading; using System.Threading;
namespace AcDream.Core.Chat; namespace AcDream.Core.Chat;
@ -45,6 +46,27 @@ public sealed class ChatLog
_maxEntries = maxEntries; _maxEntries = maxEntries;
} }
/// <summary>
/// OP4 review-fix round (2026-08-11, SHOULD-FIX S1): retail
/// <c>PlayerOption DisplayTimeStamps</c> — <c>ClientSystem::
/// AddTextToScroll @0x00563C50</c> — prefixes EVERY transcript line
/// with the timestamp, not just the subset that happens to route
/// through <c>RuntimeCommunicationState.AddText</c>. Moved HERE
/// (from AddText) because <see cref="Append"/> is the ONE seam every
/// chat producer (<see cref="OnLocalSpeech"/>, <see cref="OnEmote"/>,
/// <see cref="OnSoulEmote"/>, <see cref="OnPlayerKilled"/>,
/// <see cref="OnChannelBroadcast"/>, <see cref="OnTellReceived"/>,
/// <see cref="OnSystemMessage"/>, <see cref="OnPopup"/>,
/// <see cref="OnCombatLine"/>, <see cref="OnSelfSent"/>) funnels
/// through — AddText's own callers (ServerMessage/WeenieError) were a
/// strict subset, so heard speech, emotes, Turbine channels, and
/// combat text never gained the prefix. The transient SpewBox
/// (<c>RetailLogTextType.ClientLocal</c>) never touches this class at
/// all, so it stays exempt automatically — matching retail's own
/// exemption without a special case here.
/// </summary>
public Func<bool>? DisplayTimestampsSource { get; set; }
/// <summary>Fires every time a new entry is appended.</summary> /// <summary>Fires every time a new entry is appended.</summary>
public event Action<ChatEntry>? EntryAppended; public event Action<ChatEntry>? EntryAppended;
@ -413,6 +435,14 @@ public sealed class ChatLog
private void Append(ChatEntry entry) private void Append(ChatEntry entry)
{ {
if (DisplayTimestampsSource?.Invoke() == true)
{
entry = entry with
{
Text = FormatTimestampPrefix() + entry.Text,
};
}
_buffer.Enqueue(entry); _buffer.Enqueue(entry);
while (_buffer.Count > _maxEntries) while (_buffer.Count > _maxEntries)
_buffer.TryDequeue(out _); _buffer.TryDequeue(out _);
@ -420,6 +450,24 @@ public sealed class ChatLog
EntryAppended?.Invoke(entry); EntryAppended?.Invoke(entry);
} }
/// <summary>
/// SF-1/S4 (OP4 review-fix round, 2026-08-11): retail's constructor
/// default <c>"%#H:%M:%S "</c> (<c>PlayerModule::PlayerModule
/// @0x005D51F0</c>, BN-sourced string literal — wire research doc U6,
/// NOT byte-verified) is non-zero-padded 24h hour, then zero-padded
/// minute:second, trailing space. <c>H\:mm\:ss </c> with the colons
/// ESCAPED (not the culture-dependent <c>DateTimeFormatInfo.
/// TimeSeparator</c> placeholder) plus <see cref="CultureInfo.
/// InvariantCulture"/> is the exact .NET equivalent — the CRT's
/// <c>strftime</c> always emits a literal colon regardless of locale.
/// acdream hardcodes this constructor default rather than reading the
/// per-character override retail sources from
/// <c>GenericQualitiesData::InqString(m_pPlayerOptionsData, 1, ...)</c>
/// — see register row AP-197.
/// </summary>
private static string FormatTimestampPrefix() =>
DateTime.Now.ToString(@"H\:mm\:ss ", CultureInfo.InvariantCulture);
public void Clear() public void Clear()
{ {
while (_buffer.TryDequeue(out _)) { /* drain */ } while (_buffer.TryDequeue(out _)) { /* drain */ }

View file

@ -1,6 +1,7 @@
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Content; using AcDream.Content;
using AcDream.Runtime; using AcDream.Runtime;
@ -124,8 +125,17 @@ internal sealed class HeadlessGameplayOperations
runtime.ActionOwner.Combat.CurrentMode); runtime.ActionOwner.Combat.CurrentMode);
} }
} }
public bool AutoRepeatAttack => false; // S5 (OP4 review-fix round, 2026-08-11, blast M2): OP7 gives headless
public bool AutoTarget => true; // bots a real `characterOptions` config that writes these very bits
// (HeadlessCharacterOptionsSeeder) — reading the hardcoded values here
// instead of the live server bit meant a bot configured with
// AutoRepeatAttack ON would set the server bit while its own combat
// loop kept reading `false`. Read the SAME RuntimeCharacterOptionsState
// seam the graphical host's CharacterOptionCombatSettingsSource uses.
public bool AutoRepeatAttack =>
RequireRuntime().CharacterOwner.Options.GetOptionBit(CharacterOptionId.AutoRepeatAttack);
public bool AutoTarget =>
RequireRuntime().CharacterOwner.Options.GetOptionBit(CharacterOptionId.AutoTarget);
public uint? SelectClosestTarget() public uint? SelectClosestTarget()
{ {

View file

@ -100,16 +100,32 @@ public sealed class RuntimeCommunicationState : IDisposable
/// DisplayTimeStamps</c> — <c>ClientSystem::AddTextToScroll /// DisplayTimeStamps</c> — <c>ClientSystem::AddTextToScroll
/// @0x00563C50</c> (character-options-map.md §2.2, "Display /// @0x00563C50</c> (character-options-map.md §2.2, "Display
/// Timestamps") prefixes each transcript line with /// Timestamps") prefixes each transcript line with
/// <c>PlayerModule::m_TimeStampFormat</c> (ctor default the byte- /// <c>PlayerModule::m_TimeStampFormat</c> (ctor default the
/// verified CRT strftime string <c>"%#H:%M:%S "</c> — non-zero-padded /// BN-sourced CRT strftime string <c>"%#H:%M:%S "</c> — wire research
/// 24h hour, then zero-padded minute:second, trailing space) when the /// doc U6, NOT byte-verified — non-zero-padded 24h hour, then
/// option is on. Polled — see <see cref="AcDream.Core.World. /// zero-padded minute:second, trailing space) when the option is on.
/// WeatherSystem.DisableDistanceFogSource"/> for why. Applied only to /// Polled — see <see cref="AcDream.Core.World.
/// the persisted <see cref="Chat"/> transcript (matching retail's own /// WeatherSystem.DisableDistanceFogSource"/> for why.
/// <c>ClientLocal</c> exemption in <see cref="AddText"/> below — the ///
/// transient <see cref="SpewBox"/> is never timestamped). /// <para>
/// SHOULD-FIX S1 (OP4 review-fix round, 2026-08-11): the actual
/// prefixing now happens inside <see cref="Chat"/>'s own
/// <c>Append</c> — the ONE seam every chat producer funnels through,
/// not just this class's own <see cref="AddText"/> callers (a strict
/// subset that missed heard speech, emotes, Turbine channels, and
/// combat text). This property forwards to
/// <see cref="ChatLog.DisplayTimestampsSource"/> so existing callers
/// (<c>GameWindow</c>'s one-time bind) keep working unchanged. The
/// transient <see cref="SpewBox"/> never touches <see cref="Chat"/>
/// at all, so it stays exempt automatically — matching retail's own
/// <c>ClientLocal</c> exemption without a special case here.
/// </para>
/// </summary> /// </summary>
public Func<bool>? DisplayTimestampsSource { get; set; } public Func<bool>? DisplayTimestampsSource
{
get => Chat.DisplayTimestampsSource;
set => Chat.DisplayTimestampsSource = value;
}
public ChatCommandTargetState CommandTargets { get; } public ChatCommandTargetState CommandTargets { get; }
public TurbineChatState TurbineChat { get; } public TurbineChatState TurbineChat { get; }
@ -216,14 +232,9 @@ public sealed class RuntimeCommunicationState : IDisposable
return; return;
} }
// OP4: DisplayTimeStamps — see DisplayTimestampsSource's doc // S1 (OP4 review-fix round, 2026-08-11): the DisplayTimeStamps
// comment. Prefixed AFTER the trim above (retail's own order: // prefix now applies inside Chat's own Append — see
// AddTextToScroll trims first, then ClientSystem's caller-side // DisplayTimestampsSource's doc comment.
// timestamp prepend applies to the transcript line, never to the
// ClientLocal/SpewBox branch already returned above).
if (DisplayTimestampsSource?.Invoke() == true)
text = DateTime.Now.ToString("H:mm:ss ") + text;
Chat.OnSystemMessage(text, (uint)type); Chat.OnSystemMessage(text, (uint)type);
} }

View file

@ -145,8 +145,11 @@ public sealed class RuntimeLocalPlayerMovementState
public bool AutoRunActive => _autoRunActive; public bool AutoRunActive => _autoRunActive;
/// <summary> /// <summary>
/// Campaign OP slice OP4 (2026-08-11): retail <c>PlayerOption /// Campaign OP slice OP4 (2026-08-11): retail <c>PlayerOption</c> id
/// RunAsDefaultMovement</c> (<c>ACCmdInterp::UITogglesRun</c>) — /// <c>0xA</c> — <c>acclient.h</c>'s own enumerator spelling is
/// <c>ToggleRun_PlayerOption</c>; "RunAsDefaultMovement" is ACE's name
/// for the same bit, corrected here at the OP4 review-fix round
/// (2026-08-11, N7) — read via <c>ACCmdInterp::UITogglesRun</c> —
/// whether an ordinary held movement key runs by default (retail's own /// whether an ordinary held movement key runs by default (retail's own
/// client default: <see langword="true"/>) or walks by default, /// client default: <see langword="true"/>) or walks by default,
/// requiring the walk-mode modifier to be held to invert. Distinct /// requiring the walk-mode modifier to be held to invert. Distinct

View file

@ -24,9 +24,16 @@ namespace AcDream.UI.Abstractions.Panels.Settings;
/// </summary> /// </summary>
public sealed record GameplaySettings( public sealed record GameplaySettings(
// CharacterOption (32-bit) subset — most-used gameplay toggles. // CharacterOption (32-bit) subset — most-used gameplay toggles.
bool AutoTarget, // 0x2000 — combat: auto-acquire target on attack //
bool AutoRepeatAttack, // 0x2 — combat: keep attacking after first hit // OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2): AutoTarget,
bool ViewCombatTarget, // 0x80 — keep the current combat target in view // AutoRepeatAttack, and ViewCombatTarget were REMOVED from this record
// — the Combat panel's three LEDs now read/write the SAME canonical
// server-bit seam (RuntimeCharacterOptionsState via
// CharacterOptionCombatSettingsSource) the Character tab's rows for
// these same retail PlayerOptions already used, closing the
// "two writable copies" divergence (register row AP-196). This record
// remains the client-local persistence/draft store for every OTHER
// gameplay preference that has no such server-authoritative seam.
bool ToggleRun, // 0x400 — run-mode is tap-once vs hold-to-run bool ToggleRun, // 0x400 — run-mode is tap-once vs hold-to-run
bool AdvancedCombatUI, // 0x1000 — show extra combat tooltips/panels bool AdvancedCombatUI, // 0x1000 — show extra combat tooltips/panels
bool ShowTooltips, // 0x100 — show item tooltips on hover bool ShowTooltips, // 0x100 — show item tooltips on hover
@ -46,9 +53,6 @@ public sealed record GameplaySettings(
/// to retail's <c>Default_CharacterOption = 0x50C4A54A</c> + /// to retail's <c>Default_CharacterOption = 0x50C4A54A</c> +
/// <c>Default_CharacterOptions2 = 0x948700</c> — see class remarks.</summary> /// <c>Default_CharacterOptions2 = 0x948700</c> — see class remarks.</summary>
public static GameplaySettings Default { get; } = new( public static GameplaySettings Default { get; } = new(
AutoTarget: true,
AutoRepeatAttack: true,
ViewCombatTarget: true,
ToggleRun: true, ToggleRun: true,
AdvancedCombatUI: false, AdvancedCombatUI: false,
ShowTooltips: true, ShowTooltips: true,

View file

@ -299,17 +299,15 @@ public sealed class SettingsPanel : IPanel
renderer.Text("Combat"); renderer.Text("Combat");
renderer.Separator(); renderer.Separator();
bool autoTarget = g.AutoTarget; // OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2):
if (renderer.Checkbox("Auto-target on attack", ref autoTarget)) // Auto-target/Auto-repeat/Keep-in-view were removed from
_vm.SetGameplay(g with { AutoTarget = autoTarget }); // GameplaySettings — they now read/write the canonical server bit
// (RuntimeCharacterOptionsState) through CombatUiController, not
bool autoRepeat = g.AutoRepeatAttack; // this client-local draft. This panel has no production
if (renderer.Checkbox("Auto-repeat attacks", ref autoRepeat)) // construction site (D.2b's retained UiHost/UiRoot stack is the
_vm.SetGameplay(g with { AutoRepeatAttack = autoRepeat }); // one presentation surface — see CLAUDE.md's UI strategy section);
// the three checkboxes are simply retired rather than re-pointed
bool viewCombatTarget = g.ViewCombatTarget; // to a seam this dat-free ImGui-shaped panel has no way to reach.
if (renderer.Checkbox("Keep combat target in view", ref viewCombatTarget))
_vm.SetGameplay(g with { ViewCombatTarget = viewCombatTarget });
bool toggleRun = g.ToggleRun; bool toggleRun = g.ToggleRun;
if (renderer.Checkbox("Run mode is toggle (vs hold)", ref toggleRun)) if (renderer.Checkbox("Run mode is toggle (vs hold)", ref toggleRun))

View file

@ -144,9 +144,6 @@ public sealed class SettingsStore
var d = GameplaySettings.Default; var d = GameplaySettings.Default;
return new GameplaySettings( return new GameplaySettings(
AutoTarget: ReadBool(gp, "autoTarget", d.AutoTarget),
AutoRepeatAttack: ReadBool(gp, "autoRepeatAttack", d.AutoRepeatAttack),
ViewCombatTarget: ReadBool(gp, "viewCombatTarget", d.ViewCombatTarget),
ToggleRun: ReadBool(gp, "toggleRun", d.ToggleRun), ToggleRun: ReadBool(gp, "toggleRun", d.ToggleRun),
AdvancedCombatUI: ReadBool(gp, "advancedCombatUI", d.AdvancedCombatUI), AdvancedCombatUI: ReadBool(gp, "advancedCombatUI", d.AdvancedCombatUI),
ShowTooltips: ReadBool(gp, "showTooltips", d.ShowTooltips), ShowTooltips: ReadBool(gp, "showTooltips", d.ShowTooltips),
@ -603,9 +600,6 @@ public sealed class SettingsStore
["advancedCombatUI"] = g.AdvancedCombatUI, ["advancedCombatUI"] = g.AdvancedCombatUI,
["acceptLootPermits"] = g.AcceptLootPermits, ["acceptLootPermits"] = g.AcceptLootPermits,
["allowGive"] = g.AllowGive, ["allowGive"] = g.AllowGive,
["autoRepeatAttack"] = g.AutoRepeatAttack,
["autoTarget"] = g.AutoTarget,
["viewCombatTarget"] = g.ViewCombatTarget,
["coordinatesOnRadar"] = g.CoordinatesOnRadar, ["coordinatesOnRadar"] = g.CoordinatesOnRadar,
["lockUI"] = g.LockUI, ["lockUI"] = g.LockUI,
["showCloak"] = g.ShowCloak, ["showCloak"] = g.ShowCloak,

View file

@ -5,12 +5,12 @@ using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.Combat; namespace AcDream.App.Tests.Combat;
/// <summary> /// <summary>
/// Campaign OP slice OP4 (2026-08-11) — D7 Group-C re-point: /// Campaign OP slice OP4 (2026-08-11) — D7 Group-C re-point, widened at the
/// <see cref="CharacterOptionCombatSettingsSource"/> reads /// OP4 review-fix round (2026-08-11, MUST-FIX 3): <see cref="CharacterOptionCombatSettingsSource"/>
/// AutoTarget/AutoRepeatAttack/ViewCombatTarget from the canonical /// reads AutoTarget/AutoRepeatAttack/ViewCombatTarget from the canonical
/// <see cref="RuntimeCharacterOptionsState"/> instead of the client-local /// <see cref="RuntimeCharacterOptionsState"/> — the ONLY implementation of
/// <c>GameplaySettings</c> record <see cref="GameplaySettingsState"/> used /// <c>ICombatGameplaySettingsSource</c> now, since the dead client-local
/// to be the only implementation of. /// <c>GameplaySettingsState</c> adapter was retired the same round.
/// </summary> /// </summary>
public sealed class CharacterOptionCombatSettingsSourceTests public sealed class CharacterOptionCombatSettingsSourceTests
{ {

View file

@ -2,10 +2,19 @@ using System.Numerics;
using AcDream.App.Combat; using AcDream.App.Combat;
using AcDream.App.Interaction; using AcDream.App.Interaction;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.Combat; namespace AcDream.App.Tests.Combat;
/// <summary>
/// OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2): the dead
/// <c>GameplaySettingsState</c> adapter over the client-local
/// <c>GameplaySettings</c> record is retired — <see cref="ICombatGameplaySettingsSource"/>
/// has exactly one implementation now, <see cref="CharacterOptionCombatSettingsSource"/>,
/// reading the canonical server-authoritative <see cref="RuntimeCharacterOptionsState"/>.
/// </summary>
public sealed class CombatCameraTargetSourceTests public sealed class CombatCameraTargetSourceTests
{ {
[Fact] [Fact]
@ -13,7 +22,9 @@ public sealed class CombatCameraTargetSourceTests
{ {
const uint target = 0x70000001u; const uint target = 0x70000001u;
Vector3 point = new(10f, 20f, 30f); Vector3 point = new(10f, 20f, 30f);
var settings = new GameplaySettingsState(); var options = new RuntimeCharacterOptionsState();
options.SetOptionBit((uint)CharacterOptionId.ViewCombatTarget, false);
var settings = new CharacterOptionCombatSettingsSource(options);
var combat = new CombatState(); var combat = new CombatState();
var selection = new SelectionState(); var selection = new SelectionState();
var world = new TargetQuery(point); var world = new TargetQuery(point);
@ -25,7 +36,7 @@ public sealed class CombatCameraTargetSourceTests
Assert.Null(source.GetTrackedTargetPoint()); Assert.Null(source.GetTrackedTargetPoint());
settings.Value = settings.Value with { ViewCombatTarget = true }; options.SetOptionBit((uint)CharacterOptionId.ViewCombatTarget, true);
combat.SetCombatMode(CombatMode.Melee); combat.SetCombatMode(CombatMode.Melee);
selection.Select(target, SelectionChangeSource.World); selection.Select(target, SelectionChangeSource.World);

View file

@ -107,6 +107,58 @@ public sealed class DispatcherMovementInputSourceTests
Assert.False(source.AutoRunActive); Assert.False(source.AutoRunActive);
} }
// ── S6 (OP4 review-fix round blast, 2026-08-11): the option × modifier
// truth table for Run — previously pinned only at option-unbound
// (⇒ true) + walk-held. ──────────────────────────────────────────
[Theory]
[InlineData(true, false, true)] // run-by-default, no walk modifier -> runs
[InlineData(true, true, false)] // run-by-default, walk modifier held -> walks
[InlineData(false, false, false)] // walk-by-default, no modifier -> walks
[InlineData(false, true, true)] // walk-by-default, modifier held -> runs
public void Capture_RunReflectsOptionXorWalkModifier(
bool runAsDefault, bool walkModifierHeld, bool expectedRun)
{
var (dispatcher, _, _) = CreateDispatcher();
var movement = new RuntimeLocalPlayerMovementState
{
RunAsDefaultMovementSource = () => runAsDefault,
};
var source = new DispatcherMovementInputSource(movement);
source.Bind(dispatcher);
dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, held: true);
if (walkModifierHeld)
dispatcher.TrySetAutomationActionHeld(InputAction.MovementWalkMode, held: true);
MovementInput captured = source.Capture();
Assert.Equal(expectedRun, captured.Run);
}
[Fact]
public void Capture_AutoRunActive_ForcesRun_EvenWhenOptionIsOff()
{
// The `|| AutoRunActive` clause predates OP4 but was unobservable
// while the default was hardcoded true. Pinned as a
// CURRENT-BEHAVIOR test (not a retail-correctness claim — see
// mechanism review N6, which flags this as a possibly-divergent
// "|| AutoRunActive" that retail's SetAutoRun does not force) so a
// future change to this clause is deliberate, not accidental.
var (dispatcher, _, _) = CreateDispatcher();
var movement = new RuntimeLocalPlayerMovementState
{
RunAsDefaultMovementSource = () => false,
};
var source = new DispatcherMovementInputSource(movement);
source.Bind(dispatcher);
source.HandlePressedAction(InputAction.MovementRunLock); // arm autorun
Assert.True(source.AutoRunActive);
MovementInput captured = source.Capture();
Assert.True(captured.Run);
}
[Fact] [Fact]
public void BindingIsIdempotentOnlyForTheSameDispatcher() public void BindingIsIdempotentOnlyForTheSameDispatcher()
{ {

View file

@ -23,12 +23,6 @@ public sealed class RuntimeSettingsControllerTests
VSync = false, VSync = false,
Quality = QualityPreset.Ultra, Quality = QualityPreset.Ultra,
}, },
GameplayValue = GameplaySettings.Default with
{
AutoTarget = true,
AutoRepeatAttack = true,
ViewCombatTarget = true,
},
}; };
var resolved = new QualitySettings(7, 18, 8, 16, true, 9); var resolved = new QualitySettings(7, 18, 8, 16, true, 9);
int resolveCount = 0; int resolveCount = 0;
@ -57,9 +51,6 @@ public sealed class RuntimeSettingsControllerTests
Assert.Same(storage.DefaultCharacterValue, controller.Startup.Character); Assert.Same(storage.DefaultCharacterValue, controller.Startup.Character);
Assert.Equal(resolved, controller.Startup.Quality); Assert.Equal(resolved, controller.Startup.Quality);
Assert.Equal(resolved, controller.ResolvedQuality); Assert.Equal(resolved, controller.ResolvedQuality);
Assert.True(controller.AutoTarget);
Assert.True(controller.AutoRepeatAttack);
Assert.True(controller.ViewCombatTarget);
Assert.Equal("default", controller.ActiveToonKey); Assert.Equal("default", controller.ActiveToonKey);
} }
@ -549,7 +540,7 @@ public sealed class RuntimeSettingsControllerTests
ParticleRange = ParticleRange.Retail, ParticleRange = ParticleRange.Retail,
}); });
viewModel.SetAudio(viewModel.AudioDraft with { Sfx = 0.33f }); viewModel.SetAudio(viewModel.AudioDraft with { Sfx = 0.33f });
viewModel.SetGameplay(viewModel.GameplayDraft with { AutoTarget = true }); viewModel.SetGameplay(viewModel.GameplayDraft with { ShowTooltips = false });
Assert.True(controller.HasDraftPreview); Assert.True(controller.HasDraftPreview);
Assert.Equal(91f, controller.DisplayPreview.FieldOfView); Assert.Equal(91f, controller.DisplayPreview.FieldOfView);
@ -565,7 +556,7 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal(91f, controller.DisplayPreview.FieldOfView); Assert.Equal(91f, controller.DisplayPreview.FieldOfView);
Assert.True(controller.Gameplay.LockUI); Assert.True(controller.Gameplay.LockUI);
Assert.True(controller.Gameplay.AcceptLootPermits); Assert.True(controller.Gameplay.AcceptLootPermits);
Assert.True(viewModel.GameplayDraft.AutoTarget); Assert.False(viewModel.GameplayDraft.ShowTooltips);
Assert.True(viewModel.GameplayDraft.LockUI); Assert.True(viewModel.GameplayDraft.LockUI);
Assert.True(viewModel.GameplayDraft.AcceptLootPermits); Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
Assert.Contains("target-ui-lock:True", events); Assert.Contains("target-ui-lock:True", events);
@ -590,12 +581,9 @@ public sealed class RuntimeSettingsControllerTests
CoordinatesOnRadar = false, CoordinatesOnRadar = false,
}); });
controller.SetCombatGameplay(controller.Gameplay with { AutoTarget = false });
controller.SetUiLocked(true); controller.SetUiLocked(true);
controller.SetAcceptLootPermits(true); controller.SetAcceptLootPermits(true);
Assert.False(controller.Gameplay.AutoTarget);
Assert.False(viewModel.GameplayDraft.AutoTarget);
Assert.False(viewModel.GameplayDraft.ShowTooltips); Assert.False(viewModel.GameplayDraft.ShowTooltips);
Assert.False(viewModel.GameplayDraft.CoordinatesOnRadar); Assert.False(viewModel.GameplayDraft.CoordinatesOnRadar);
Assert.True(viewModel.GameplayDraft.LockUI); Assert.True(viewModel.GameplayDraft.LockUI);
@ -609,14 +597,12 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal( Assert.Equal(
controller.Gameplay.CoordinatesOnRadar, controller.Gameplay.CoordinatesOnRadar,
viewModel.GameplayDraft.CoordinatesOnRadar); viewModel.GameplayDraft.CoordinatesOnRadar);
Assert.False(viewModel.GameplayDraft.AutoTarget);
Assert.True(viewModel.GameplayDraft.LockUI); Assert.True(viewModel.GameplayDraft.LockUI);
Assert.True(viewModel.GameplayDraft.AcceptLootPermits); Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
viewModel.SetGameplay(viewModel.GameplayDraft with { ShowHelm = false }); viewModel.SetGameplay(viewModel.GameplayDraft with { ShowHelm = false });
viewModel.Save(); viewModel.Save();
Assert.False(storage.GameplayValue.AutoTarget);
Assert.True(storage.GameplayValue.LockUI); Assert.True(storage.GameplayValue.LockUI);
Assert.True(storage.GameplayValue.AcceptLootPermits); Assert.True(storage.GameplayValue.AcceptLootPermits);
Assert.False(storage.GameplayValue.ShowHelm); Assert.False(storage.GameplayValue.ShowHelm);
@ -638,26 +624,19 @@ public sealed class RuntimeSettingsControllerTests
static _ => { }); static _ => { });
controller.SetUiLocked(true); controller.SetUiLocked(true);
controller.SetCombatGameplay(controller.Gameplay with { AutoTarget = false });
Assert.Throws<IOException>(() => controller.SetAcceptLootPermits(true)); Assert.Throws<IOException>(() => controller.SetAcceptLootPermits(true));
Assert.True(viewModel.GameplayDraft.LockUI); Assert.True(viewModel.GameplayDraft.LockUI);
Assert.False(viewModel.GameplayDraft.AutoTarget);
Assert.True(viewModel.GameplayDraft.AcceptLootPermits); Assert.True(viewModel.GameplayDraft.AcceptLootPermits);
viewModel.Cancel(); viewModel.Cancel();
Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI); Assert.Equal(GameplaySettings.Default.LockUI, viewModel.GameplayDraft.LockUI);
Assert.Equal(
GameplaySettings.Default.AutoTarget,
viewModel.GameplayDraft.AutoTarget);
Assert.Equal( Assert.Equal(
GameplaySettings.Default.AcceptLootPermits, GameplaySettings.Default.AcceptLootPermits,
viewModel.GameplayDraft.AcceptLootPermits); viewModel.GameplayDraft.AcceptLootPermits);
Assert.Contains(logs, line => Assert.Contains(logs, line =>
line.Contains("radar lock save failed", StringComparison.Ordinal)); line.Contains("radar lock save failed", StringComparison.Ordinal));
Assert.Contains(logs, line =>
line.Contains("combat option save failed", StringComparison.Ordinal));
} }
[Fact] [Fact]
@ -928,6 +907,42 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal(["target-quality"], events); Assert.Equal(["target-quality"], events);
} }
[Fact]
public void SetUiLocked_AppliesEvenWhenGameplayLockUIAlreadyMatches_IfNeverActuallyApplied()
{
// MUST-FIX 4 (OP4 review-fix round, 2026-08-11, blast M3): the
// guard used to compare the requested value against
// `Gameplay.LockUI` — valid only while `ToggleUiLock` derived
// `locked` AS `!Gameplay.LockUI`. OP4 re-pointed `ToggleUiLock` to
// read the SERVER bit (RuntimeCharacterOptionsState) instead, a
// DIFFERENT store that can already equal a value this controller
// never actually pushed to `_runtimeTargets`. A pre-existing save
// seeds Gameplay.LockUI = true; the runtime target has never seen
// `true` — the FIRST SetUiLocked(true) call must still apply.
var events = new List<string>();
var storage = new FakeStorage(events)
{
GameplayValue = GameplaySettings.Default with { LockUI = true },
};
var controller = new RuntimeSettingsController(
storage,
static preset => QualitySettings.From(preset),
static _ => { });
Assert.True(controller.Gameplay.LockUI); // already true, but never applied
var targets = new FakeRuntimeTargets(events);
controller.BindRuntimeTargets(targets);
controller.SetUiLocked(true);
Assert.Equal(1, targets.UiLockCalls);
// A second call with the SAME value now correctly no-ops — this
// time it really was applied.
controller.SetUiLocked(true);
Assert.Equal(1, targets.UiLockCalls);
}
[Fact] [Fact]
public void UiLockTargetFailureCanRetryTheSameRequestedValue() public void UiLockTargetFailureCanRetryTheSameRequestedValue()
{ {

View file

@ -142,6 +142,25 @@ public sealed class CharacterOptionsPageControllerTests
DatStringResolver.ComputeHash("ID_PlayerOption_HearPKDeaths")); DatStringResolver.ComputeHash("ID_PlayerOption_HearPKDeaths"));
} }
[Theory]
[InlineData("ID_CharacterOption_UIBehavior_Section", 0x06489B6Eu)]
[InlineData("ID_CharacterOption_UIDisplay_Section", 0x0A9BC99Eu)]
[InlineData("ID_CharacterOption_Grouping_Section", 0x0CBAAFAEu)]
[InlineData("ID_CharacterOption_OtherPlayers_Section", 0x0872DFFEu)]
[InlineData("ID_CharacterOption_CharacterBehavior_Section", 0x08674D5Eu)]
[InlineData("ID_CharacterOption_Chat_Section", 0x0987FE8Eu)]
public void HeaderKey_RetailNameHash_MatchesByteVerifiedStringId(
string headerKey, uint expectedHash)
{
// SF-2 (OP4 review-fix round, 2026-08-11): structure doc §7's six
// byte-verified header string ids — previously verified only by
// hand in the mechanism review, not pinned by a test. A typo in a
// HeaderKey literal would otherwise produce a silently blank
// header that only the user's eye catches.
Assert.Equal(expectedHash, DatStringResolver.ComputeHash(headerKey));
Assert.Contains(headerKey, CharacterOptionsPageController.Groups.Select(g => g.HeaderKey));
}
[Theory] [Theory]
[MemberData(nameof(RetailEnumNameCases))] [MemberData(nameof(RetailEnumNameCases))]
public void RetailName_MatchesVerbatimAcclientEnumSpelling( public void RetailName_MatchesVerbatimAcclientEnumSpelling(
@ -479,6 +498,147 @@ public sealed class CharacterOptionsPageControllerTests
Assert.False(controller.CharacterPage.Changed); Assert.False(controller.CharacterPage.Changed);
} }
// ── MUST-FIX 1 (OP4 review-fix round, 2026-08-11): the panel re-seeds
// from the live binding on OnShown instead of the pre-login
// constructor-default word it was constructed with. ─────────────────
[Fact]
public void OnShown_ReSeedsRow_FromLiveBindingValue_ChangedBehindItsBack()
{
// Simulates a fresh PlayerDescription landing (or simply "the
// character's real server value differs from the word this row
// was constructed with") without ever calling SetCurrentValue.
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
CharacterOptionId id = AllRows().First().Id;
var row = Assert.IsType<BoolOptionRow>(controller.CharacterPage.Rows[0]);
bool initial = row.Current;
bindings.Values[id] = !initial;
bindings.Sets.Clear();
controller.CharacterPage.OnShown();
Assert.Equal(!initial, row.Current);
Assert.Equal(!initial, row.Saved);
Assert.False(controller.CharacterPage.Changed);
// The re-read must never round-trip through SetOption — that would
// send the re-seeded value back out over the wire (retail's own
// GetValue()-into-SaveCurrentValue never calls SetPlayerOption).
Assert.Empty(bindings.Sets);
}
[Fact]
public void OnShown_AllFiftyRows_ConvergeToTheLiveBindingSnapshot()
{
// The pre-login case: bind against a constructor-default word (the
// fake's dictionary starts empty -> every row seeds false), THEN
// the "server" state is populated (a PlayerDescription landing),
// THEN the page is shown — every one of the 50 rows must converge,
// matching retail's own InitOptions()+PostInit() / first tab-
// activation schedule (SaveCurrentValue re-reads GetValue() live).
var fakeBindings = new FakeBindings();
var random = new Random(20260811);
foreach (CharacterOptionsPageController.RowSpec spec in AllRows())
fakeBindings.Values[spec.Id] = random.Next(2) == 0;
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
new OptionsPanelController.Callbacks(
Toggle: () => { },
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { }))!;
bool bound = CharacterOptionsPageController.Bind(
layout,
controller.CharacterPage,
MakeTemplateResolver(),
(_, _) => null,
fakeBindings.ToBindings());
Assert.True(bound);
controller.CharacterPage.OnShown();
int i = 0;
foreach (CharacterOptionsPageController.RowSpec spec in AllRows())
{
var row = (BoolOptionRow)controller.CharacterPage.Rows[i++];
Assert.Equal(fakeBindings.Values[spec.Id], row.Current);
Assert.Equal(fakeBindings.Values[spec.Id], row.Saved);
}
Assert.False(controller.CharacterPage.Changed);
}
[Fact]
public void Reset_AfterReseed_RestoresTheLiveValue_NotTheStaleConstructionDefault()
{
// The historical bug MF-1 closes: before the fix, Reset/tab-switch
// could only ever restore whatever the row was seeded with AT BIND
// TIME (the pre-login constructor word) — visually-idempotent
// "toggle then cancel" could silently mutate the server bit in the
// wrong direction. After the fix, OnShown re-seeds _saved from the
// live bit first, so Reset can only revert to what was ACTUALLY
// live at the last show.
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
CharacterOptionId id = AllRows().First().Id;
var row = Assert.IsType<BoolOptionRow>(controller.CharacterPage.Rows[0]);
bindings.Values[id] = true;
controller.CharacterPage.OnShown(); // re-seed: current == saved == true
bindings.Sets.Clear();
row.SetCurrentValue(false); // user toggles it off, never clicks Apply
controller.CharacterPage.Reset();
Assert.True(row.Current);
Assert.Contains(bindings.Sets, s => s.Id == id && s.Value);
}
[Fact]
public void ClickingTheRealCheckboxWidget_PublishesSetOption_ViaMouseDownUpClick()
{
// SF-2/S6 (OP4 review-fix round, 2026-08-11): every other test in
// this suite drives BoolOptionRow.SetCurrentValue directly, which
// would stay green even if the toggle template's checkbox
// (0x10000219) ever lost its authored DAT property 0x0B
// (UiButton.ToggleBehavior) — the mechanism the whole LED click
// interaction rests on (mechanism review §1.5). This drives the
// REAL MouseDown/MouseUp/Click sequence: MouseUp flips
// UiButton.Selected FIRST (ToggleBehavior), then Click invokes
// checkbox.OnClick, which reads the NEW Selected value.
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
new OptionsPanelController.Callbacks(
Toggle: () => { },
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { }))!;
var fakeBindings = new FakeBindings();
bool bound = CharacterOptionsPageController.Bind(
layout, controller.CharacterPage, MakeTemplateResolver(), (_, _) => null,
fakeBindings.ToBindings());
Assert.True(bound);
var listBox = Assert.IsType<UiTemplateListBox>(
layout.FindElement(CharacterOptionsPageController.ListBoxElementId));
var checkbox = Assert.IsType<UiButton>(
UiElement.FindDescendant(listBox, 0x10000219u));
CharacterOptionId id = AllRows().First().Id;
Assert.False(checkbox.Selected);
checkbox.OnEvent(new UiEvent(0, checkbox, UiEventType.MouseDown, Data1: 0, Data2: 0));
checkbox.OnEvent(new UiEvent(0, checkbox, UiEventType.MouseUp, Data1: 0, Data2: 0));
checkbox.OnEvent(new UiEvent(0, checkbox, UiEventType.Click));
Assert.True(checkbox.Selected);
var set = Assert.Single(fakeBindings.Sets);
Assert.Equal(id, set.Id);
Assert.True(set.Value);
}
[Fact] [Fact]
public void ScrollbarLinkage_ModelPointsAtTheListBoxScroll() public void ScrollbarLinkage_ModelPointsAtTheListBoxScroll()
{ {

View file

@ -2,8 +2,7 @@ using AcDream.App.Combat;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Runtime.Gameplay; using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -11,6 +10,21 @@ public sealed class CombatUiControllerTests
{ {
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0); private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
/// <summary>
/// OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2): the three
/// combat LEDs now read/write through the SAME server-bit seam the
/// Character tab's rows use — this fake is the test-local stand-in for
/// the production RuntimeCharacterOptionsState-backed binding.
/// </summary>
private sealed class FakeOptionBindings
{
public Dictionary<CharacterOptionId, bool> Values { get; } = new();
public CombatUiController.Bindings ToBindings() => new(
CurrentValue: id => Values.TryGetValue(id, out bool v) && v,
SetOption: (id, value) => Values[id] = value);
}
[Fact] [Fact]
public void CombatMode_ShowsPhysicalAndMagicPages_AndSelectsMediumByDefault() public void CombatMode_ShowsPhysicalAndMagicPages_AndSelectsMediumByDefault()
{ {
@ -19,9 +33,9 @@ public sealed class CombatUiControllerTests
using var attacks = CreateAttacks(combat, () => now, []); using var attacks = CreateAttacks(combat, () => now, []);
var (layout, basic, spellcasting, power, high, medium, low) = BuildLayout(); var (layout, basic, spellcasting, power, high, medium, low) = BuildLayout();
var visibility = new List<bool>(); var visibility = new List<bool>();
GameplaySettings gameplay = GameplaySettings.Default; var options = new FakeOptionBindings();
using var controller = CombatUiController.Bind( using var controller = CombatUiController.Bind(
layout, combat, attacks, () => gameplay, value => gameplay = value, layout, combat, attacks, options.ToBindings(),
Labels, visibility.Add)!; Labels, visibility.Add)!;
controller.SyncVisibility(); controller.SyncVisibility();
@ -45,9 +59,9 @@ public sealed class CombatUiControllerTests
var combat = new CombatState(); var combat = new CombatState();
using var attacks = CreateAttacks(combat, () => now, sent); using var attacks = CreateAttacks(combat, () => now, sent);
var (layout, basic, advanced, power, high, _, _) = BuildLayout(); var (layout, basic, advanced, power, high, _, _) = BuildLayout();
GameplaySettings gameplay = GameplaySettings.Default; var options = new FakeOptionBindings();
using var controller = CombatUiController.Bind( using var controller = CombatUiController.Bind(
layout, combat, attacks, () => gameplay, value => gameplay = value, layout, combat, attacks, options.ToBindings(),
Labels, _ => { })!; Labels, _ => { })!;
combat.SetCombatMode(CombatMode.Melee); combat.SetCombatMode(CombatMode.Melee);
@ -69,9 +83,10 @@ public sealed class CombatUiControllerTests
var combat = new CombatState(); var combat = new CombatState();
using var attacks = CreateAttacks(combat, () => 0d, []); using var attacks = CreateAttacks(combat, () => 0d, []);
var (layout, _, _, _, _, _, _) = BuildLayout(); var (layout, _, _, _, _, _, _) = BuildLayout();
GameplaySettings gameplay = GameplaySettings.Default; var options = new FakeOptionBindings();
options.Values[CharacterOptionId.AutoTarget] = true;
using var controller = CombatUiController.Bind( using var controller = CombatUiController.Bind(
layout, combat, attacks, () => gameplay, value => gameplay = value, layout, combat, attacks, options.ToBindings(),
Labels, _ => { })!; Labels, _ => { })!;
var autoTarget = Assert.IsType<UiButton>(layout.FindElement(CombatUiController.AutoTargetId)); var autoTarget = Assert.IsType<UiButton>(layout.FindElement(CombatUiController.AutoTargetId));
@ -79,7 +94,7 @@ public sealed class CombatUiControllerTests
autoTarget.OnEvent(new UiEvent(0, autoTarget, UiEventType.MouseUp, Data1: 3, Data2: 3)); autoTarget.OnEvent(new UiEvent(0, autoTarget, UiEventType.MouseUp, Data1: 3, Data2: 3));
autoTarget.OnEvent(new UiEvent(0, autoTarget, UiEventType.Click, Data1: 3, Data2: 3)); autoTarget.OnEvent(new UiEvent(0, autoTarget, UiEventType.Click, Data1: 3, Data2: 3));
Assert.False(gameplay.AutoTarget); Assert.False(options.Values[CharacterOptionId.AutoTarget]);
} }
[Fact] [Fact]
@ -88,9 +103,9 @@ public sealed class CombatUiControllerTests
var combat = new CombatState(); var combat = new CombatState();
using var attacks = CreateAttacks(combat, () => 0d, []); using var attacks = CreateAttacks(combat, () => 0d, []);
var (layout, _, _, power, _, _, _) = BuildLayout(); var (layout, _, _, power, _, _, _) = BuildLayout();
GameplaySettings gameplay = GameplaySettings.Default; var options = new FakeOptionBindings();
using var controller = CombatUiController.Bind( using var controller = CombatUiController.Bind(
layout, combat, attacks, () => gameplay, value => gameplay = value, layout, combat, attacks, options.ToBindings(),
Labels, _ => { })!; Labels, _ => { })!;
var speed = Assert.IsType<UiText>(layout.FindElement(CombatUiController.SpeedLabelId)); var speed = Assert.IsType<UiText>(layout.FindElement(CombatUiController.SpeedLabelId));
@ -108,9 +123,9 @@ public sealed class CombatUiControllerTests
ImportedLayout layout = LayoutImporter.Build(info, NoTex, datFont: null); ImportedLayout layout = LayoutImporter.Build(info, NoTex, datFont: null);
var combat = new CombatState(); var combat = new CombatState();
using var attacks = CreateAttacks(combat, () => 0d, []); using var attacks = CreateAttacks(combat, () => 0d, []);
GameplaySettings gameplay = GameplaySettings.Default; var options = new FakeOptionBindings();
using var controller = CombatUiController.Bind( using var controller = CombatUiController.Bind(
layout, combat, attacks, () => gameplay, value => gameplay = value, layout, combat, attacks, options.ToBindings(),
Labels, _ => { })!; Labels, _ => { })!;
ApplyAnchors(layout.Root); ApplyAnchors(layout.Root);

View file

@ -158,6 +158,113 @@ public sealed class OptionsPanelControllerTests
Assert.Equal(0, flushCount); Assert.Equal(0, flushCount);
} }
// ── MUST-FIX 2 (OP4 review-fix round, 2026-08-11): Apply/Reset gating ──────
private static (UiButton Apply, UiButton Reset, UiButton Defaults) GetCharacterPageButtons(
OptionsPanelController controller)
{
UiElement pageRoot = UiElement.FindDescendant(controller.Root, 0x10000211u)!; // Character page slot
var apply = Assert.IsType<UiButton>(UiElement.FindDescendant(pageRoot, 0x100001FCu));
var reset = Assert.IsType<UiButton>(UiElement.FindDescendant(pageRoot, 0x100001FDu));
var defaults = Assert.IsType<UiButton>(UiElement.FindDescendant(pageRoot, 0x100001FEu));
return (apply, reset, defaults);
}
[Fact]
public void CharacterPage_ApplyAndReset_StartDisabled_OnFreshBind()
{
// Retail's PostInit calls InitOptions() then OnOptionChanged(0), so
// the pair starts Ghosted before any row has ever been touched.
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController controller =
OptionsPanelController.Bind(layout, MakeCallbacks(calls))!;
(UiButton apply, UiButton reset, _) = GetCharacterPageButtons(controller);
Assert.False(apply.Enabled);
Assert.False(reset.Enabled);
}
[Fact]
public void CharacterPage_OneLedClick_EnablesApplyAndReset()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController controller =
OptionsPanelController.Bind(layout, MakeCallbacks(calls))!;
var row = new BoolOptionRow(initial: false, defaultValue: false);
controller.CharacterPage.Register(row);
(UiButton apply, UiButton reset, _) = GetCharacterPageButtons(controller);
row.SetCurrentValue(true);
Assert.True(apply.Enabled);
Assert.True(reset.Enabled);
}
[Fact]
public void CharacterPage_Apply_DisablesApplyAndResetAgain()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController controller =
OptionsPanelController.Bind(layout, MakeCallbacks(calls))!;
var row = new BoolOptionRow(initial: false, defaultValue: false);
controller.CharacterPage.Register(row);
(UiButton apply, UiButton reset, _) = GetCharacterPageButtons(controller);
row.SetCurrentValue(true);
controller.CharacterPage.Apply();
Assert.False(apply.Enabled);
Assert.False(reset.Enabled);
}
[Fact]
public void CharacterPage_Defaults_LeavesApplyAndResetEnabled_WhenSomethingActuallyChanged()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController controller =
OptionsPanelController.Bind(layout, MakeCallbacks(calls))!;
var row = new BoolOptionRow(initial: false, defaultValue: true);
controller.CharacterPage.Register(row);
(UiButton apply, UiButton reset, _) = GetCharacterPageButtons(controller);
controller.CharacterPage.Defaults();
Assert.True(row.Current);
Assert.True(controller.CharacterPage.Changed);
Assert.True(apply.Enabled);
Assert.True(reset.Enabled);
}
[Fact]
public void CharacterPage_Defaults_IsNeverGated()
{
// Retail's Defaults override never fetches its own child id at all
// — it must never grey itself out, before OR after a real change.
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController controller =
OptionsPanelController.Bind(layout, MakeCallbacks(calls))!;
var row = new BoolOptionRow(initial: false, defaultValue: false);
controller.CharacterPage.Register(row);
(_, _, UiButton defaults) = GetCharacterPageButtons(controller);
Assert.True(defaults.Enabled);
row.SetCurrentValue(true);
Assert.True(defaults.Enabled);
controller.CharacterPage.Apply();
Assert.True(defaults.Enabled);
controller.CharacterPage.Defaults();
Assert.True(defaults.Enabled);
}
// ── Close button ───────────────────────────────────────────────────────── // ── Close button ─────────────────────────────────────────────────────────
[Fact] [Fact]

View file

@ -1,3 +1,5 @@
using System.Globalization;
using System.Threading;
using AcDream.Core.Chat; using AcDream.Core.Chat;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using Xunit; using Xunit;
@ -336,4 +338,83 @@ public sealed class ChatLogTests
log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).", logTextType: 0x06u); log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).", logTextType: 0x06u);
Assert.Equal(0x06u, log.Snapshot()[0].LogTextType); Assert.Equal(0x06u, log.Snapshot()[0].LogTextType);
} }
// ── SF-1/S1 (OP4 review-fix round, 2026-08-11): DisplayTimestampsSource
// prefixes EVERY producer through the shared Append seam, not just
// RuntimeCommunicationState.AddText's own subset of callers. ────────
[Fact]
public void DisplayTimestampsSource_Unbound_NoPrefix()
{
var log = new ChatLog();
log.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
Assert.Equal("hi", log.Snapshot()[0].Text);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DisplayTimestampsSource_GatesThePrefix(bool timestampsOn)
{
var log = new ChatLog { DisplayTimestampsSource = () => timestampsOn };
log.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
string text = log.Snapshot()[0].Text;
if (timestampsOn)
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} hi$", text);
else
Assert.Equal("hi", text);
}
[Theory]
// Every public ingestion method — proving the prefix applies at the
// ONE shared Append seam, not per-caller.
[InlineData("OnEmote")]
[InlineData("OnSoulEmote")]
[InlineData("OnChannelBroadcast")]
[InlineData("OnTellReceived")]
[InlineData("OnSystemMessage")]
[InlineData("OnPopup")]
[InlineData("OnCombatLine")]
[InlineData("OnSelfSent")]
[InlineData("OnPlayerKilled")]
public void DisplayTimestampsSource_AppliesToEveryProducer(string method)
{
var log = new ChatLog { DisplayTimestampsSource = () => true };
switch (method)
{
case "OnEmote": log.OnEmote("Caith", "waves", 0xCAFEu); break;
case "OnSoulEmote": log.OnSoulEmote("Bob", "dances", 0xBEEFu); break;
case "OnChannelBroadcast": log.OnChannelBroadcast(42u, "Alice", "motd"); break;
case "OnTellReceived": log.OnTellReceived("Alice", "psst", 0xAAu, logTextType: 0x03u); break;
case "OnSystemMessage": log.OnSystemMessage("fizzled", chatType: 5); break;
case "OnPopup": log.OnPopup("modal"); break;
case "OnCombatLine": log.OnCombatLine("hit", logTextType: 0x06u); break;
case "OnSelfSent": log.OnSelfSent(ChatKind.Tell, "hey", logTextType: 0x04u, targetOrChannel: "Alice"); break;
case "OnPlayerKilled": log.OnPlayerKilled("died", 0x1u, 0x2u); break;
}
string text = log.Snapshot()[0].Text;
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} .+$", text);
}
[Fact]
public void DisplayTimestampsSource_UsesLiteralColons_RegardlessOfCurrentCulture()
{
CultureInfo original = Thread.CurrentThread.CurrentCulture;
try
{
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fi-FI");
var log = new ChatLog { DisplayTimestampsSource = () => true };
log.OnSystemMessage("fizzled", chatType: 0);
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} fizzled$", log.Snapshot()[0].Text);
}
finally
{
Thread.CurrentThread.CurrentCulture = original;
}
}
} }

View file

@ -1,3 +1,5 @@
using System.Globalization;
using System.Threading;
using AcDream.Core.Chat; using AcDream.Core.Chat;
using AcDream.Core.Social; using AcDream.Core.Social;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
@ -276,10 +278,56 @@ public sealed class RuntimeCommunicationStateTests
Assert.NotEqual("Your spell fizzled.", text); Assert.NotEqual("Your spell fizzled.", text);
// Retail ctor default format "%#H:%M:%S " (non-zero-padded 24h // Retail ctor default format "%#H:%M:%S " (non-zero-padded 24h
// hour, zero-padded minute:second, trailing space before the // hour, zero-padded minute:second, trailing space before the
// text) — .NET "H:mm:ss " is the exact equivalent. // text) — .NET "H\:mm\:ss " (colons ESCAPED, not the
// culture-dependent TimeSeparator placeholder) is the exact
// equivalent (SF-1/S4, OP4 review-fix round, 2026-08-11).
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", text); Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", text);
} }
[Fact]
public void AddText_TimestampsTrue_UsesLiteralColons_RegardlessOfCurrentCulture()
{
// SF-1/S4 (OP4 review-fix round, 2026-08-11): an unescaped "H:mm:ss"
// format string renders ':' as CurrentCulture.DateTimeFormat.
// TimeSeparator, which is NOT ':' on cultures like fi-FI ("."). The
// fix escapes the colons and forces InvariantCulture — prove the
// output stays colon-separated even under a culture that would
// otherwise substitute a different separator.
CultureInfo original = Thread.CurrentThread.CurrentCulture;
try
{
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fi-FI");
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
string text = state.Chat.Snapshot()[0].Text;
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", text);
}
finally
{
Thread.CurrentThread.CurrentCulture = original;
}
}
[Fact]
public void DisplayTimestampsSource_ForwardsToChat_TimestampingEveryProducer_NotJustAddText()
{
// S1 (OP4 review-fix round, 2026-08-11, blast lens): AddText's own
// callers (ServerMessage/WeenieError) were a strict SUBSET of
// every chat producer — heard speech, emotes, Turbine channels,
// and combat text all bypassed it by calling ChatLog's own OnXxx
// methods directly. Setting DisplayTimestampsSource on this class
// must timestamp lines that never go through AddText at all.
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
state.Chat.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
string text = state.Chat.Snapshot()[0].Text;
Assert.EndsWith("hi", text);
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} hi$", text);
}
[Fact] [Fact]
public void AddText_TimestampsTrue_NeverAppliedToClientLocalSpewBox() public void AddText_TimestampsTrue_NeverAppliedToClientLocalSpewBox()
{ {

View file

@ -16,8 +16,6 @@ public sealed class GameplaySettingsTests
// starting point, not retail-bitmask. A change to any of these // starting point, not retail-bitmask. A change to any of these
// should be a deliberate decision, not a drive-by. // should be a deliberate decision, not a drive-by.
var d = GameplaySettings.Default; var d = GameplaySettings.Default;
Assert.True(d.AutoTarget);
Assert.True(d.AutoRepeatAttack);
Assert.True(d.ToggleRun); Assert.True(d.ToggleRun);
Assert.False(d.AdvancedCombatUI); Assert.False(d.AdvancedCombatUI);
Assert.True(d.ShowTooltips); Assert.True(d.ShowTooltips);
@ -36,8 +34,8 @@ public sealed class GameplaySettingsTests
public void Equality_is_value_based() public void Equality_is_value_based()
{ {
var a = GameplaySettings.Default; var a = GameplaySettings.Default;
var b = GameplaySettings.Default with { AutoTarget = false }; var b = GameplaySettings.Default with { ToggleRun = false };
var c = GameplaySettings.Default with { AutoTarget = false }; var c = GameplaySettings.Default with { ToggleRun = false };
Assert.NotEqual(a, b); Assert.NotEqual(a, b);
Assert.Equal(b, c); Assert.Equal(b, c);
} }
@ -48,7 +46,7 @@ public sealed class GameplaySettingsTests
var d = GameplaySettings.Default with { LockUI = true }; var d = GameplaySettings.Default with { LockUI = true };
Assert.True(d.LockUI); Assert.True(d.LockUI);
// Other fields untouched. // Other fields untouched.
Assert.Equal(GameplaySettings.Default.AutoTarget, d.AutoTarget); Assert.Equal(GameplaySettings.Default.ToggleRun, d.ToggleRun);
Assert.Equal(GameplaySettings.Default.ShowHelm, d.ShowHelm); Assert.Equal(GameplaySettings.Default.ShowHelm, d.ShowHelm);
} }
} }

View file

@ -395,8 +395,15 @@ public sealed class SettingsPanelTests
.Select(c => (string)c.Args[0]!).ToList(); .Select(c => (string)c.Args[0]!).ToList();
// Spot check the major retail-named toggles. Don't assert exact // Spot check the major retail-named toggles. Don't assert exact
// count — adding new toggles shouldn't break this test. // count — adding new toggles shouldn't break this test.
Assert.Contains("Auto-target on attack", checks); //
Assert.Contains("Auto-repeat attacks", checks); // OP4 review-fix round (2026-08-11, MUST-FIX 3 / blast M2):
// "Auto-target on attack" / "Auto-repeat attacks" were retired
// from this panel — those two options (plus "Keep combat target
// in view") now read/write the canonical server bit through
// CombatUiController, not the client-local GameplaySettings
// record this dat-free panel edits.
Assert.DoesNotContain("Auto-target on attack", checks);
Assert.DoesNotContain("Auto-repeat attacks", checks);
Assert.Contains("Run mode is toggle (vs hold)", checks); Assert.Contains("Run mode is toggle (vs hold)", checks);
Assert.Contains("Show item tooltips", checks); Assert.Contains("Show item tooltips", checks);
Assert.Contains("Show helm on character", checks); Assert.Contains("Show helm on character", checks);
@ -415,7 +422,7 @@ public sealed class SettingsPanelTests
var checks = r.Calls.Where(c => c.Method == "Checkbox") var checks = r.Calls.Where(c => c.Method == "Checkbox")
.Select(c => (string)c.Args[0]!).ToList(); .Select(c => (string)c.Args[0]!).ToList();
Assert.DoesNotContain("Auto-target on attack", checks); Assert.DoesNotContain("Run mode is toggle (vs hold)", checks);
Assert.DoesNotContain("Lock UI (disable panel drag/resize)", checks); Assert.DoesNotContain("Lock UI (disable panel drag/resize)", checks);
} }

View file

@ -221,7 +221,6 @@ public sealed class SettingsStoreTests : System.IDisposable
var store = new SettingsStore(_tempPath); var store = new SettingsStore(_tempPath);
var original = GameplaySettings.Default with var original = GameplaySettings.Default with
{ {
AutoTarget = false,
AdvancedCombatUI = true, AdvancedCombatUI = true,
ShowHelm = false, ShowHelm = false,
LockUI = true, LockUI = true,
@ -249,7 +248,7 @@ public sealed class SettingsStoreTests : System.IDisposable
var loaded = store.LoadGameplay(); var loaded = store.LoadGameplay();
Assert.True(loaded.LockUI); Assert.True(loaded.LockUI);
Assert.Equal(GameplaySettings.Default.AutoTarget, loaded.AutoTarget); Assert.Equal(GameplaySettings.Default.ToggleRun, loaded.ToggleRun);
Assert.Equal(GameplaySettings.Default.ShowHelm, loaded.ShowHelm); Assert.Equal(GameplaySettings.Default.ShowHelm, loaded.ShowHelm);
} }

View file

@ -420,7 +420,7 @@ public sealed class SettingsVMTests
[Fact] [Fact]
public void GameplayDraft_initial_value_matches_persisted() public void GameplayDraft_initial_value_matches_persisted()
{ {
var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; var custom = GameplaySettings.Default with { LockUI = true };
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom);
Assert.Equal(custom, vm.GameplayDraft); Assert.Equal(custom, vm.GameplayDraft);
Assert.False(vm.HasUnsavedChanges); Assert.False(vm.HasUnsavedChanges);
@ -438,7 +438,7 @@ public sealed class SettingsVMTests
public void ApplyExternalGameplayChange_updates_both_snapshots_and_preserves_other_drafts() public void ApplyExternalGameplayChange_updates_both_snapshots_and_preserves_other_drafts()
{ {
var (vm, _, _, _, _, _, _, _, _, _) = Build(); var (vm, _, _, _, _, _, _, _, _, _) = Build();
bool persistedAutoTarget = !GameplaySettings.Default.AutoTarget; bool persistedLockUI = !GameplaySettings.Default.LockUI;
vm.SetGameplay(vm.GameplayDraft with vm.SetGameplay(vm.GameplayDraft with
{ {
@ -448,10 +448,10 @@ public sealed class SettingsVMTests
vm.ApplyExternalGameplayChange(gameplay => gameplay with vm.ApplyExternalGameplayChange(gameplay => gameplay with
{ {
AutoTarget = persistedAutoTarget, LockUI = persistedLockUI,
}); });
Assert.Equal(persistedAutoTarget, vm.GameplayDraft.AutoTarget); Assert.Equal(persistedLockUI, vm.GameplayDraft.LockUI);
Assert.Equal( Assert.Equal(
!GameplaySettings.Default.ShowTooltips, !GameplaySettings.Default.ShowTooltips,
vm.GameplayDraft.ShowTooltips); vm.GameplayDraft.ShowTooltips);
@ -462,7 +462,7 @@ public sealed class SettingsVMTests
vm.Cancel(); vm.Cancel();
Assert.Equal(persistedAutoTarget, vm.GameplayDraft.AutoTarget); Assert.Equal(persistedLockUI, vm.GameplayDraft.LockUI);
Assert.Equal( Assert.Equal(
GameplaySettings.Default.ShowTooltips, GameplaySettings.Default.ShowTooltips,
vm.GameplayDraft.ShowTooltips); vm.GameplayDraft.ShowTooltips);
@ -478,7 +478,7 @@ public sealed class SettingsVMTests
var (vm, _, _, _, _, _, _, savedGameplayHistory, _, _) = Build(); var (vm, _, _, _, _, _, _, savedGameplayHistory, _, _) = Build();
vm.SetGameplay(vm.GameplayDraft with vm.SetGameplay(vm.GameplayDraft with
{ {
AutoTarget = false, LockUI = true,
ShowTooltips = false, ShowTooltips = false,
UseMouseTurning = true, UseMouseTurning = true,
}); });
@ -486,7 +486,7 @@ public sealed class SettingsVMTests
vm.Save(); vm.Save();
Assert.Single(savedGameplayHistory); Assert.Single(savedGameplayHistory);
Assert.False(savedGameplayHistory[0].AutoTarget); Assert.True(savedGameplayHistory[0].LockUI);
Assert.False(savedGameplayHistory[0].ShowTooltips); Assert.False(savedGameplayHistory[0].ShowTooltips);
Assert.True(savedGameplayHistory[0].UseMouseTurning); Assert.True(savedGameplayHistory[0].UseMouseTurning);
Assert.False(vm.HasUnsavedChanges); Assert.False(vm.HasUnsavedChanges);
@ -509,7 +509,7 @@ public sealed class SettingsVMTests
[Fact] [Fact]
public void ResetAllToDefaults_resets_gameplay_to_default() public void ResetAllToDefaults_resets_gameplay_to_default()
{ {
var custom = GameplaySettings.Default with { AutoTarget = false, LockUI = true }; var custom = GameplaySettings.Default with { LockUI = true };
var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom); var (vm, _, _, _, _, _, _, _, _, _) = Build(persistedGameplay: custom);
Assert.NotEqual(GameplaySettings.Default, vm.GameplayDraft); Assert.NotEqual(GameplaySettings.Default, vm.GameplayDraft);