feat(ui): Campaign OP slice OP4 — the Character tab

Binds LayoutDesc 0x21000028 (gmCharacterSettingsUI) through OP2's
template-list mechanism and OP3's OptionPage model: 6 authored group
headers + 50 toggle rows (49 from the 2013 build + D3's "Listen to PK
death messages", AP-193) in research doc §2's authored order, each row
resolved by PlayerOption id through CharacterOptionTable, seeded from
live RuntimeCharacterOptionsState, defaulted from CharacterOptionTable.
ClientDefault (byte-verified against UIOption_Checkbox::SetPlayerOption
@0x00486e80's own GetDefaultOptionValue call — AP-194 updated to confirm
the directive was followed), labels/tooltips resolved by name from
string table 0x23000003 (never hard-coded English), and registered with
OptionsPanelController.CharacterPage. Apply/Reset/Defaults
(0x100001FC/FD/FE) are now wired per-page via a scoped subtree search
(UiElement.FindDescendant, promoted from UiTabPanel) since Character/
Chat/Config each author their own physical instance under the SAME
element ids.

Consumers: Group A (29 ids) wire+store only via the existing
SetSingleCharacterOptionRuntimeCmd/TrySetOption seam. Group B: Display
Timestamps prefixes new transcript lines (RuntimeCommunicationState.
DisplayTimestampsSource); Disable Distance Fog forces FogMode.Off
(WeatherSystem.DisableDistanceFogSource, retiring half of TS-73); Run as
Default Movement inverts the walk-mode modifier's default
(RuntimeLocalPlayerMovementState.RunAsDefaultMovementSource). Group C
re-points AutoTarget/AutoRepeatAttack/ViewCombatTarget
(CharacterOptionCombatSettingsSource), VividTargetingIndicator/
CoordinatesOnRadar/LockUI/AcceptLootPermits from the client-local
GameplaySettings record to the canonical server bit — closing two
previously-unfiled divergences where AutoRepeatAttack and
AcceptCorpseLootingPermissions never reached the wire despite being
retail auto-save ids. TS-73 narrowed to its two still-open cases;
TS-75..TS-80 file the genuine gaps (no day/night force, no weather-
particle/profanity-filter/salvage/housing/pickup-preference subsystem,
fellowship-create's unaudited client-sourced field) rather than
inventing stand-ins.

Conformance: CharacterOptionsPageControllerTests pins all 50 rows
against CharacterOptionTable in both directions (an invented or dropped
row fails the build), the authored group/order row-by-row, and the
build/seed/Apply/Reset/Defaults/wire-publish behavior end-to-end against
the committed fixture. 52 new tests; full solution suite 13,008 passed /
4 skipped / 0 failed (was 12,956/4/0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 04:31:34 +02:00
parent 7e9f372ce8
commit 22b86b9ff4
25 changed files with 1674 additions and 41 deletions

View file

@ -1,6 +1,7 @@
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.Core.Combat;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Panels.Settings;
@ -27,6 +28,42 @@ internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource
public bool ViewCombatTarget => Value.ViewCombatTarget;
}
/// <summary>
/// D7 Group-C re-point (Campaign OP slice OP4, 2026-08-11):
/// <c>AutoTarget</c>/<c>AutoRepeatAttack</c>/<c>ViewCombatTarget</c> move
/// from the client-local <c>GameplaySettings</c> record (what
/// <see cref="GameplaySettingsState"/> and the legacy
/// <c>RuntimeSettingsController</c> read) to the canonical
/// server-authoritative <see cref="RuntimeCharacterOptionsState"/> — the
/// CH3 precedent (server bit is authoritative; the Character-tab panel
/// row's LED click writes THROUGH <c>RuntimeCharacterOptionsState.
/// TrySetOption</c> before this source can ever observe the new value, so
/// no separate reseed/sync step is needed here). 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>
internal sealed class CharacterOptionCombatSettingsSource : ICombatGameplaySettingsSource
{
private readonly RuntimeCharacterOptionsState _options;
public CharacterOptionCombatSettingsSource(RuntimeCharacterOptionsState options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public bool AutoTarget =>
_options.GetOptionBit(CharacterOptionId.AutoTarget);
public bool AutoRepeatAttack =>
_options.GetOptionBit(CharacterOptionId.AutoRepeatAttack);
public bool ViewCombatTarget =>
_options.GetOptionBit(CharacterOptionId.ViewCombatTarget);
}
internal interface ICombatFeedbackSink
{
void Show(string message);

View file

@ -596,7 +596,11 @@ internal sealed class FrameRootCompositionPhase
session.LocalPlayerFrame,
session.LiveSpatialReconciler,
new AcDream.App.Combat.CombatCameraTargetSource(
d.Settings,
// D7 Group-C re-point (Campaign OP OP4): server bit, not
// the client-local GameplaySettings record — see
// CharacterOptionCombatSettingsSource's doc comment.
new AcDream.App.Combat.CharacterOptionCombatSettingsSource(
d.Runtime.CharacterOwner.Options),
d.Combat,
d.Selection,
live.SelectionQuery));

View file

@ -283,7 +283,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
InteractionRetainedUiDependencies d,
DeferredSelectionUiAuthority selection) =>
d.CombatTargetOperations.BindOwned(new LiveCombatTargetOperations(
autoTarget: () => d.Settings.Gameplay.AutoTarget,
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server
// bit — see CharacterOptionCombatSettingsSource's doc comment.
autoTarget: () => d.Character.Options.GetOptionBit(
CharacterOptionId.AutoTarget),
selectClosestTarget: () =>
selection.SelectClosestCombatTarget(showToast: false)));
@ -474,7 +477,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
checkpoint(InteractionRetainedUiCompositionPoint.UiHostAcquired);
inputCapture = d.RetainedInputCapture.Bind(host.Root);
checkpoint(InteractionRetainedUiCompositionPoint.InputCaptureBound);
host.Root.UiLocked = d.Settings.Gameplay.LockUI;
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server
// bit at mount time — see CharacterOptionCombatSettingsSource's
// doc comment. Live toggling afterward still flows through
// RuntimeSettingsController.SetUiLocked's existing
// _runtimeTargets?.ApplyUiLock push (ToggleUiLock's Bindings
// site, LiveSessionRuntimeFactory.cs, now also sends the wire
// bit — see its own comment).
host.Root.UiLocked = d.Character.Options.GetOptionBit(
CharacterOptionId.LockUI);
var cursorFeedback = new CursorFeedbackController(
itemInteraction,
@ -673,7 +684,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
VividTarget: new VividTargetRuntimeBindings(
d.Actions.Selection,
() => d.PlayerIdentity.ServerGuid,
() => d.Settings.Gameplay.VividTargetingIndicator,
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11):
// server bit, not the client-local GameplaySettings
// record — CH3 precedent (server-authoritative,
// local-write-then-send; the Character-tab panel row
// writes through RuntimeCharacterOptionsState.
// TrySetOption before this read can observe it).
() => d.Character.Options.GetOptionBit(
CharacterOptionId.VividTargetingIndicator),
late.Selection.ResolveVividTargetInfo,
late.SelectionCamera.UiSnapshot),
Indicators: new IndicatorRuntimeBindings(
@ -838,7 +856,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
DisplayMouseTurningMacroLine: text =>
d.Communication.AddText(text, RetailLogTextType.Magic),
LoadCameraTurning: d.Settings.LoadCameraTurning,
SaveCameraTurning: d.Settings.SaveCameraTurning),
SaveCameraTurning: d.Settings.SaveCameraTurning,
// Campaign OP slice OP4 (2026-08-11): the Character-tab
// panel's row-seed source.
CurrentCharacterOption: id => d.Character.Options.GetOptionBit(id)),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,

View file

@ -19,6 +19,7 @@ using AcDream.App.World;
using AcDream.Core.Audio;
using AcDream.Core.Items;
using AcDream.Core.Lighting;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Plugins;
using AcDream.Core.Rendering;
@ -27,6 +28,7 @@ using AcDream.Core.Vfx;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.World;
using AcDream.UI.Abstractions.Panels.SpewBox;
using DatReaderWriter;
@ -83,6 +85,8 @@ internal sealed record LivePresentationDependencies(
public RuntimeLocalPlayerMovementState PlayerController =>
Runtime.MovementOwner;
public RuntimeCharacterState Character => Runtime.CharacterOwner;
}
internal sealed record LivePresentationResult(
@ -833,8 +837,14 @@ internal sealed class LivePresentationCompositionPhase
playerYawRadians: () => d.PlayerController.Controller?.Yaw ?? 0f,
playerCellId: () => d.PlayerController.Controller?.CellId ?? 0u,
selectedGuid: () => d.Selection.SelectedObjectId,
coordinatesOnRadar: () => d.Settings.Gameplay.CoordinatesOnRadar,
uiLocked: () => d.Settings.Gameplay.LockUI,
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server
// bit, not the client-local GameplaySettings record — see
// CharacterOptionCombatSettingsSource's doc comment
// (AcDream.App.Combat).
coordinatesOnRadar: () => d.Character.Options.GetOptionBit(
CharacterOptionId.CoordinatesOnRadar),
uiLocked: () => d.Character.Options.GetOptionBit(
CharacterOptionId.LockUI),
spatialQuery: () => worldState);
bindings.Adopt(
"radar snapshot",

View file

@ -767,7 +767,10 @@ internal sealed class SessionPlayerCompositionPhase
live.LiveEntities,
d.EntityObjects.Objects,
d.PlayerIdentity),
d.Settings,
// D7 Group-C re-point (Campaign OP OP4): server bit,
// not the client-local GameplaySettings record — see
// CharacterOptionCombatSettingsSource's doc comment.
new CharacterOptionCombatSettingsSource(d.Character.Options),
d.PlayerController,
d.PlayerOutbound,
liveSessionSource,

View file

@ -74,7 +74,14 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
// applies to ordinary held-key movement. Recomputing `!walking`
// unconditionally let a walk-mode toggle demote an active autorun
// to walking — impossible in retail.
Run: !walking || AutoRunActive,
//
// Campaign OP slice OP4 (2026-08-11): `!walking` was a hardcoded
// "run by default" assumption. PlayerOption RunAsDefaultMovement
// (retail default ON, matching the prior hardcoded behavior
// exactly) now supplies the default; the walk-mode modifier
// still temporarily INVERTS whichever default is active, same
// as before.
Run: (_movement.RunAsDefaultMovement != walking) || AutoRunActive,
Jump: dispatcher.IsActionHeld(InputAction.MovementJump));
}

View file

@ -382,9 +382,23 @@ internal sealed class LiveSessionRuntimeFactory
QueryAge: session.SendQueryAge,
QueryBirth: session.SendQueryBirth,
ToggleFrameRate: _interaction.Settings.ToggleFrameRate,
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): flips the
// SERVER bit through the shared write-then-send/dirty seam
// (SendSingleCharacterOption above, itself
// RuntimeCharacterOptionsState.TrySetOption) — LockUI is
// auto-save (CharacterOptionTable), so this sends 0x0005
// immediately, exactly like the Character-tab panel row.
// SetUiLocked still runs too, for its existing
// _runtimeTargets?.ApplyUiLock(locked) immediate visual push
// (host.Root.UiLocked) and its legacy GameplaySettings mirror
// (retired in OP9).
ToggleUiLock: () =>
_interaction.Settings.SetUiLocked(
!_interaction.Settings.Gameplay.LockUI),
{
bool locked = !_domain.Character.Options.GetOptionBit(
CharacterOptionId.LockUI);
SendSingleCharacterOption((uint)CharacterOptionId.LockUI, locked);
_interaction.Settings.SetUiLocked(locked);
},
// ClientCommandController's informational output sink — 0x00
// Default, NOT 0x1A (corrected 2026-08-09, Opus review of
// 172c6f9a). Retail types command output like @version/@loc
@ -449,9 +463,23 @@ internal sealed class LiveSessionRuntimeFactory
.Properties.GetBool(0x6Eu) == true,
SetAway: session.SendSetAfkMode,
SetAwayMessage: session.SendSetAfkMessage,
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): closes the
// unfiled divergence character-options-map.md §0 names —
// `/consent on|off` flipped only the client-local
// GameplaySettings.AcceptLootPermits bool and never reached the
// wire, even though retail auto-saves this id (0x0005
// immediately). Now routes through the SAME
// SendSingleCharacterOption seam the Character-tab panel row
// uses.
AcceptLootPermits: () =>
_interaction.Settings.Gameplay.AcceptLootPermits,
SetAcceptLootPermits: _interaction.Settings.SetAcceptLootPermits,
_domain.Character.Options.GetOptionBit(
CharacterOptionId.AcceptLootPermits),
SetAcceptLootPermits: value =>
{
SendSingleCharacterOption(
(uint)CharacterOptionId.AcceptLootPermits, value);
_interaction.Settings.SetAcceptLootPermits(value);
},
DisplayConsent: session.SendDisplayConsent,
ClearConsent: session.SendClearConsent,
RemoveConsent: session.SendRemoveConsent,

View file

@ -646,6 +646,22 @@ public sealed class GameWindow :
alphaScratchBudgets.QueueBytes);
WorldTime = _worldEnvironment.WorldTime;
Weather = _worldEnvironment.Weather;
// Campaign OP slice OP4 (2026-08-11): PlayerOption
// DisableDistanceFog, polled — see WeatherSystem.
// DisableDistanceFogSource's doc comment. Bound once here (not
// per-session): _runtime.CharacterOwner is the ONE canonical
// RuntimeCharacterOptionsState instance for this GameRuntime's
// whole lifetime, reused (not replaced) across reconnects.
Weather.DisableDistanceFogSource = () =>
_runtime.CharacterOwner.Options.GetOptionBit(
AcDream.Core.Net.Messages.CharacterOptionId.DisableDistanceFog);
// Campaign OP slice OP4 (2026-08-11): PlayerOption
// DisplayTimeStamps, polled — see RuntimeCommunicationState.
// DisplayTimestampsSource's doc comment. Same one-time,
// whole-lifetime bind as the two above.
_runtime.CommunicationOwner.DisplayTimestampsSource = () =>
_runtime.CharacterOwner.Options.GetOptionBit(
AcDream.Core.Net.Messages.CharacterOptionId.DisplayTimeStamps);
_retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot();
_inputCapture = new AcDream.App.Input.CompositeInputCaptureSource(
new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools),
@ -653,6 +669,13 @@ public sealed class GameWindow :
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
_playerControllerSlot,
_inputCapture);
// Campaign OP slice OP4 (2026-08-11): PlayerOption
// RunAsDefaultMovement, polled — see RuntimeLocalPlayerMovementState.
// RunAsDefaultMovementSource's doc comment. Same one-time,
// whole-lifetime bind as DisableDistanceFogSource above.
_playerControllerSlot.RunAsDefaultMovementSource = () =>
_runtime.CharacterOwner.Options.GetOptionBit(
AcDream.Core.Net.Messages.CharacterOptionId.ToggleRun);
_framebufferResize = new FramebufferResizeController(_viewportAspect);
_datDir = options.DatDir;
_worldGameState = worldGameState;

View file

@ -0,0 +1,392 @@
using System;
using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Campaign OP slice OP4 (2026-08-11): binds the Character tab
/// (LayoutDesc <c>0x21000028</c>, root <c>0x100001F9</c>, ListBox
/// <c>0x100001FA</c>) — retail's <c>gmCharacterSettingsUI::InitOptions
/// @0x004A02F0</c> — through OP2's template-list mechanism
/// (<see cref="UiTemplateListBox"/>) and OP3's per-page
/// <see cref="OptionPage"/> model. Six authored group headers, 49 toggle
/// rows from the 2013 build plus D3's 50th ("Listen to PK death
/// messages", <see cref="CharacterOptionId.HearPkDeathMessages"/> —
/// wire+store only, register row AP-193), in
/// <c>docs/research/2026-08-10-character-options-map.md</c> §2's authored
/// order (byte-verified against the same header-boundary decomp §7 of
/// <c>docs/research/2026-08-10-options-panel-structure.md</c> cites).
///
/// <para>
/// <b>Defaults (U1).</b> Retail's <c>UIOption_Checkbox::SetPlayerOption
/// @0x00486e80</c> — the constructor path every <c>AddToggleOption(enum
/// PlayerOption)</c> call reaches — sets <c>this-&gt;m_default =
/// PlayerModule::GetDefaultOptionValue(playerModule, this-&gt;m_playerOption)</c>
/// (pseudo-C line 147375) as a DIRECT side effect of construction; retail
/// never calls the separate <c>SetDefaultValue</c> API for this tab (which
/// is why <c>InitOptions</c> itself shows no such call — options-panel-
/// structure.md §7 flagged this as the tab's biggest open unknown). This
/// IS <c>CharacterOptionTable.ClientDefault</c> — OP1 already ported the
/// exact same byte-verified <c>GetDefaultOptionValue @0x005D2A30</c>
/// table. <b>Coordinator correction:</b> the plan's own "U1 closed" note
/// (§1) points at <c>UIOption::InqDefaultGameplayOptionProperty
/// @0x004ef8d0</c> reading a DAT <c>DBPropertyCollection</c> at
/// <c>DBCache::GetDIDFromEnumStatic(0x16, 2)</c> — that mechanism is real
/// (empirically confirmed against the installed DATs: DID
/// <c>0x78000001</c>, three entries — <c>0x1000007F</c> the Chat tab's
/// filter-bitfield property, <c>0x10000080</c>/<c>0x10000081</c> = 0.5/1.0
/// the Chat tab's two opacity-slider defaults) but it is
/// <c>UIOption_Slider::SetGameplayOptionProperty @0x00485030</c>'s
/// <c>m_propName</c>-bound path — the Chat/Config tabs' PREFERENCE rows,
/// which the structure doc's own §1.2 already established have ZERO
/// <c>PlayerOption</c> rows. It does not apply to this tab; the zero
/// PlayerOption-range keys in that DBProperties collection confirm it by
/// construction. See this slice's final report for the full trace.
/// </para>
///
/// <para>
/// <b>Labels/tooltips (U2).</b> Retail hashes <c>ID_PlayerOption_&lt;Name&gt;</c>
/// / <c>ID_PlayerOption_&lt;Name&gt;_Help</c> against string table
/// <c>0x23000003</c> at row-build time (<c>SetToggleLabel</c>) — never
/// hard-coded English. <c>&lt;Name&gt;</c> is retail's OWN <c>enum
/// PlayerOption</c> member name (<c>acclient.h:4162-4218</c>), which for
/// the six <c>Hear*Chat</c> ids and D3's <c>HearPKDeaths</c> differs from
/// acdream's own <see cref="CharacterOptionId"/> spelling (ACE's
/// <c>ListenTo*Chat</c> convention) — <see cref="RowSpec.RetailName"/>
/// carries the correct retail token for hashing, kept deliberately
/// separate from the wire/storage id.
/// </para>
/// </summary>
public static class CharacterOptionsPageController
{
/// <summary>Character page root — <c>gmCharacterSettingsUI</c>.</summary>
public const uint RootElementId = 0x100001F9u;
/// <summary>The row ListBox (dat Type 5) — <c>m_pOptionBox</c>.</summary>
public const uint ListBoxElementId = 0x100001FAu;
/// <summary>The ListBox's linked scrollbar.</summary>
public const uint ScrollbarElementId = 0x100001FBu;
/// <summary>Template-list index of the header row (Type 12 text).</summary>
private const int HeaderTemplateIndex = 0;
/// <summary>Template-list index of the separator row (Type 3 image).</summary>
private const int SeparatorTemplateIndex = 1;
/// <summary>Template-list index of the toggle-option row.</summary>
private const int ToggleTemplateIndex = 2;
private const uint StringTableId = 0x23000003u;
/// <summary>One authored Character-tab row: the wire/storage id plus
/// retail's OWN <c>PlayerOption</c> enum member name (for the
/// <c>ID_PlayerOption_&lt;Name&gt;</c>/<c>_Help</c> string hash — see
/// the U2 note above).</summary>
public readonly record struct RowSpec(CharacterOptionId Id, string RetailName);
/// <summary>One authored header group: its section string key plus
/// authored-order rows.</summary>
public readonly record struct GroupSpec(string HeaderKey, RowSpec[] Rows);
/// <summary>
/// The complete 6-group / 50-row authored table — research doc §2 /
/// §7, id-for-id, in EXACT authored order. <c>RetailName</c> values
/// verified against <c>acclient.h:4162-4218</c>'s literal enumerator
/// spelling.
/// </summary>
public static readonly GroupSpec[] Groups =
{
new("ID_CharacterOption_UIBehavior_Section", new RowSpec[]
{
new(CharacterOptionId.ViewCombatTarget, "ViewCombatTarget"),
new(CharacterOptionId.SalvageMultiple, "SalvageMultiple"),
new(CharacterOptionId.MainPackPreferred, "MainPackPreferred"),
}),
new("ID_CharacterOption_UIDisplay_Section", new RowSpec[]
{
new(CharacterOptionId.VividTargetingIndicator, "VividTargetingIndicator"),
new(CharacterOptionId.ShowTooltips, "ShowTooltips"),
new(CharacterOptionId.CoordinatesOnRadar, "CoordinatesOnRadar"),
new(CharacterOptionId.SideBySideVitals, "SideBySideVitals"),
new(CharacterOptionId.SpellDuration, "SpellDuration"),
new(CharacterOptionId.DisableMostWeatherEffects, "DisableMostWeatherEffects"),
new(CharacterOptionId.DisableDistanceFog, "DisableDistanceFog"),
new(CharacterOptionId.PersistentAtDay, "PersistentAtDay"),
new(CharacterOptionId.DisableHouseRestrictionEffects, "DisableHouseRestrictionEffects"),
new(CharacterOptionId.UseCraftSuccessDialog, "UseCraftSuccessDialog"),
new(CharacterOptionId.ConfirmVolatileRareUse, "ConfirmVolatileRareUse"),
new(CharacterOptionId.DisplayTimeStamps, "DisplayTimeStamps"),
new(CharacterOptionId.FilterLanguage, "FilterLanguage"),
new(CharacterOptionId.ShowHelm, "ShowHelm"),
new(CharacterOptionId.ShowCloak, "ShowCloak"),
}),
new("ID_CharacterOption_Grouping_Section", new RowSpec[]
{
new(CharacterOptionId.IgnoreAllegianceRequests, "IgnoreAllegianceRequests"),
new(CharacterOptionId.IgnoreFellowshipRequests, "IgnoreFellowshipRequests"),
new(CharacterOptionId.DisplayAllegianceLogonNotifications, "DisplayAllegianceLogonNotifications"),
new(CharacterOptionId.FellowshipShareXP, "FellowshipShareXP"),
new(CharacterOptionId.FellowshipShareLoot, "FellowshipShareLoot"),
new(CharacterOptionId.FellowshipAutoAcceptRequests, "FellowshipAutoAcceptRequests"),
}),
new("ID_CharacterOption_OtherPlayers_Section", new RowSpec[]
{
new(CharacterOptionId.AcceptLootPermits, "AcceptLootPermits"),
new(CharacterOptionId.UseDeception, "UseDeception"),
new(CharacterOptionId.AllowGive, "AllowGive"),
new(CharacterOptionId.IgnoreTradeRequests, "IgnoreTradeRequests"),
new(CharacterOptionId.DragItemOnPlayerOpensSecureTrade, "DragItemOnPlayerOpensSecureTrade"),
new(CharacterOptionId.DisplayDateOfBirth, "DisplayDateOfBirth"),
new(CharacterOptionId.DisplayAge, "DisplayAge"),
new(CharacterOptionId.DisplayChessRank, "DisplayChessRank"),
new(CharacterOptionId.DisplayFishingSkill, "DisplayFishingSkill"),
new(CharacterOptionId.DisplayNumberDeaths, "DisplayNumberDeaths"),
new(CharacterOptionId.DisplayNumberCharacterTitles, "DisplayNumberCharacterTitles"),
}),
new("ID_CharacterOption_CharacterBehavior_Section", new RowSpec[]
{
new(CharacterOptionId.ToggleRun, "ToggleRun"),
new(CharacterOptionId.AdvancedCombatUI, "AdvancedCombatUI"),
new(CharacterOptionId.AutoTarget, "AutoTarget"),
new(CharacterOptionId.AutoRepeatAttack, "AutoRepeatAttack"),
new(CharacterOptionId.UseChargeAttack, "UseChargeAttack"),
new(CharacterOptionId.LeadMissileTargets, "LeadMissileTargets"),
new(CharacterOptionId.UseFastMissiles, "UseFastMissiles"),
}),
new("ID_CharacterOption_Chat_Section", new RowSpec[]
{
new(CharacterOptionId.StayInChatMode, "StayInChatMode"),
new(CharacterOptionId.ListenToAllegianceChat, "HearAllegianceChat"),
new(CharacterOptionId.ListenToGeneralChat, "HearGeneralChat"),
new(CharacterOptionId.ListenToTradeChat, "HearTradeChat"),
new(CharacterOptionId.ListenToLFGChat, "HearLFGChat"),
new(CharacterOptionId.ListenToRoleplayChat, "HearRoleplayChat"),
new(CharacterOptionId.ListenToSocietyChat, "HearSocietyChat"),
// D3: the 50th row. Not in the 2013 build (register row
// AP-193 covers the ACE-sourced id/mask); the DAT string
// 0x0D16E9A3 ("Listen to PK death messages.") IS present, so
// it renders exactly like every other row — wire+store only.
new(CharacterOptionId.HearPkDeathMessages, "HearPKDeaths"),
}),
};
/// <summary>Total authored row count across every group — 50 (49 from
/// the 2013 build + D3's HearPKDeaths). Exposed for conformance
/// tests.</summary>
public static int TotalRowCount
{
get
{
int count = 0;
foreach (GroupSpec group in Groups) count += group.Rows.Length;
return count;
}
}
/// <summary>The seam this page writes/reads live character-option
/// state through — <see cref="RuntimeCharacterOptionsState"/> via the
/// shared Runtime command surface, exactly like OP3's mouse-turning
/// macro and every other Options-panel consumer.</summary>
public sealed record Bindings(
Func<CharacterOptionId, bool> CurrentValue,
Action<CharacterOptionId, bool> SetOption);
/// <summary>
/// Builds all 6 headers + 50 toggle rows + 6 separators (5 interior +
/// 1 trailing, matching retail's own <c>AddSeperator</c> call pattern
/// — structure doc §7) into <paramref name="layout"/>'s Character
/// ListBox, links its scrollbar, seeds every row's current/default
/// triple, and registers each row into <paramref name="page"/>.
/// </summary>
/// <param name="layout">The already-built Options panel tree (the
/// SAME import <see cref="OptionsPanelController.Bind"/> used) — the
/// Character page's ListBox is a descendant of it via the tab host's
/// page-slot base-merge.</param>
/// <param name="page">The Character tab's <see cref="OptionPage"/>
/// model (<see cref="OptionsPanelController.CharacterPage"/>) — every
/// row registers into this SAME instance.</param>
/// <param name="templateResolver">Builds one row template's subtree —
/// wired directly onto the ListBox's <see cref="UiTemplateListBox.
/// TemplateResolver"/> (property <c>0x64</c>). The production caller
/// (<c>RetailUiRuntime.MountOptionsPanel</c>) closes over live DAT
/// access exactly like <see cref="RetailDialogFactory"/>'s shared
/// dialog catalog; a hermetic test closes over a committed fixture's
/// already-resolved <see cref="ElementInfo"/> tree instead — this
/// controller has no DAT dependency of its own.</param>
/// <param name="resolveString">Resolves ONE <c>ID_PlayerOption_*</c>/
/// <c>ID_CharacterOption_*_Section</c> string by (table id, hash) —
/// never hard-coded English. Production passes
/// <c>DatStringResolver.Resolve</c>; tests can inject a small fake
/// table or a constant-null resolver to exercise the "string absent"
/// no-invented-text path.</param>
/// <param name="bindings">The live read/write seam for every row.</param>
/// <returns><see langword="false"/> if the Character page's ListBox
/// did not resolve as a <see cref="UiTemplateListBox"/> (a missing or
/// malformed import) — the caller logs and the Character tab simply
/// has no rows, matching every other "controller could not find its
/// root" degradation in this codebase.</returns>
public static bool Bind(
ImportedLayout layout,
OptionPage page,
Func<uint, uint, UiElement?> templateResolver,
Func<uint, uint, string?> resolveString,
Bindings bindings)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(page);
ArgumentNullException.ThrowIfNull(templateResolver);
ArgumentNullException.ThrowIfNull(resolveString);
ArgumentNullException.ThrowIfNull(bindings);
if (layout.FindElement(ListBoxElementId) is not UiTemplateListBox listBox)
{
Console.WriteLine(
$"[D.2b] CharacterOptionsPageController: ListBox 0x{ListBoxElementId:X8} "
+ "not found (or not a UiTemplateListBox) in the built Options panel tree — "
+ "the Character tab will have no rows.");
return false;
}
listBox.TemplateResolver = templateResolver;
if (layout.FindElement(ScrollbarElementId) is UiScrollbar scrollbar)
scrollbar.Model = listBox.Scroll;
else
Console.WriteLine(
$"[D.2b] CharacterOptionsPageController: scrollbar 0x{ScrollbarElementId:X8} "
+ "not found — the Character tab's row list will not scroll.");
for (int groupIndex = 0; groupIndex < Groups.Length; groupIndex++)
{
GroupSpec group = Groups[groupIndex];
BuildHeaderRow(listBox, group.HeaderKey, resolveString);
foreach (RowSpec spec in group.Rows)
BuildToggleRow(listBox, spec, page, resolveString, bindings);
// Structure doc §7: "6 headers, 5 interior separators + 1
// trailing" — one separator after EVERY group, including the
// last (the trailing separator before the ListBox's own
// bottom padding; Apply/Reset/Defaults are separate elements
// outside the ListBox, not part of this row sequence).
BuildSeparatorRow(listBox);
}
return true;
}
private static void BuildHeaderRow(
UiTemplateListBox listBox, string headerKey, Func<uint, uint, string?> resolveString)
{
if (listBox.AddItemFromTemplateList(HeaderTemplateIndex) is not UiText header)
{
Console.WriteLine(
$"[D.2b] CharacterOptionsPageController: header template did not build as "
+ $"UiText for '{headerKey}'.");
return;
}
string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(headerKey));
if (label is null)
{
Console.WriteLine(
$"[D.2b] CharacterOptionsPageController: header string '{headerKey}' did not "
+ "resolve from the DAT string table — the row renders with no text rather "
+ "than an invented label.");
return;
}
header.LinesProvider = () => new[] { new UiText.Line(label, header.DefaultColor) };
}
private static void BuildSeparatorRow(UiTemplateListBox listBox)
{
if (listBox.AddItemFromTemplateList(SeparatorTemplateIndex) is null)
{
Console.WriteLine(
"[D.2b] CharacterOptionsPageController: separator template did not build.");
}
}
private static void BuildToggleRow(
UiTemplateListBox listBox,
RowSpec spec,
OptionPage page,
Func<uint, uint, string?> resolveString,
Bindings bindings)
{
UiElement? row = listBox.AddItemFromTemplateList(ToggleTemplateIndex);
if (row is null)
{
Console.WriteLine(
$"[D.2b] CharacterOptionsPageController: toggle template did not build for "
+ $"{spec.RetailName} (0x{(uint)spec.Id:X2}).");
return;
}
// The toggle template's root (0x10000218) is a plain Type-3
// container; its one real child is the checkbox leaf
// (0x10000219, UIOption_Checkbox -> BuildCheckbox -> UiButton).
UiButton? checkbox = FindCheckbox(row);
if (checkbox is null)
{
Console.WriteLine(
$"[D.2b] CharacterOptionsPageController: no checkbox child found in the "
+ $"toggle row for {spec.RetailName} (0x{(uint)spec.Id:X2}).");
return;
}
string labelKey = $"ID_PlayerOption_{spec.RetailName}";
string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey));
if (label is not null)
checkbox.Label = label;
else
Console.WriteLine(
$"[D.2b] CharacterOptionsPageController: label '{labelKey}' did not resolve — "
+ "row renders with no caption rather than invented English.");
string? tooltip = resolveString(
StringTableId, DatStringResolver.ComputeHash(labelKey + "_Help"));
if (tooltip is not null)
checkbox.TooltipText = tooltip;
if (!CharacterOptionTable.TryGet(spec.Id, out CharacterOptionTableEntry entry))
{
// Table completeness is conformance-tested — this can only
// fire if the table and this authored list disagree, which
// the test suite catches before it ever reaches a live DAT.
Console.WriteLine(
$"[D.2b] CharacterOptionsPageController: {spec.RetailName} "
+ $"(0x{(uint)spec.Id:X2}) is not in CharacterOptionTable.");
return;
}
bool initial = bindings.CurrentValue(spec.Id);
checkbox.Selected = initial;
var row_ = new BoolOptionRow(
initial,
entry.ClientDefault,
apply: value =>
{
checkbox.Selected = value;
bindings.SetOption(spec.Id, value);
});
page.Register(row_);
checkbox.OnClick = () => row_.SetCurrentValue(checkbox.Selected);
}
private static UiButton? FindCheckbox(UiElement root)
{
if (root is UiButton direct) return direct;
foreach (UiElement child in root.Children)
if (child is UiButton button)
return button;
return null;
}
}

View file

@ -67,6 +67,17 @@ public sealed class OptionsPanelController : IRetainedPanelController
private const uint UseMouseTurningSettingsId = 0x100005CCu;
private const uint ExitGameId = 0x10000617u;
// Apply/Reset/Defaults — research doc §3.1/§10.1: identical geometry
// AND identical element ids on every page that has them (Character,
// Chat, Config — NOT Gameplay). Each page's own LayoutDesc authors its
// OWN physical button instances under these SAME numeric ids, so a
// flat layout.FindElement lookup cannot reliably pick one page's
// instance — Campaign OP OP4 wires each page's copy from a SCOPED
// search rooted at that page's own slot (UiElement.FindDescendant).
private const uint ApplyButtonId = 0x100001FCu;
private const uint ResetButtonId = 0x100001FDu;
private const uint DefaultsButtonId = 0x100001FEu;
/// <summary>Callback delegates this controller wires the seven Gameplay
/// buttons and the close button to. Every field maps to exactly one
/// button; a null field leaves that button INERT (authored, clickable,
@ -191,9 +202,39 @@ public sealed class OptionsPanelController : IRetainedPanelController
BindButton(layout, ReportAbuseId,
() => callbacks.DisplaySystemMessage(callbacks.ReportAbuseMessage));
// Apply/Reset/Defaults — retail's gmCharacterSettingsUI /
// gmChatOptionsUI / gmConfigUI ::ListenToElementMessage
// @0x0049E3A0 (COMDAT-folded — literally the SAME handler body on
// all three pages, structure doc §3.1): idElement == 0x100001FC ->
// SaveCurrentValues (Apply); 0x100001FD -> RestoreSavedValues
// (Reset); 0x100001FE -> RestoreDefaultValues (Defaults). Each
// page's OWN OptionPage model owns the actual semantics
// (OptionPageModel.cs); this loop only wires each page's physical
// button instances to its own model.
foreach (uint pageId in new[] { CharacterPageId, ChatPageId, ConfigPageId })
{
OptionPage page = controller._pages[pageId];
UiElement? pageRoot = UiElement.FindDescendant(tabPanel, pageId);
if (pageRoot is null) continue;
BindPageButton(pageRoot, ApplyButtonId, page.Apply);
BindPageButton(pageRoot, ResetButtonId, page.Reset);
BindPageButton(pageRoot, DefaultsButtonId, page.Defaults);
}
return controller;
}
private static void BindPageButton(UiElement pageRoot, uint elementId, Action onClick)
{
if (UiElement.FindDescendant(pageRoot, elementId) is UiButton button)
button.OnClick = onClick;
else
Console.WriteLine(
$"[D.2b] OptionsPanelController: page 0x{pageRoot.DatElementId:X8}'s button "
+ $"0x{elementId:X8} not found — its handler was not wired.");
}
/// <summary>
/// Activates the tab-switching behavior (idempotent — safe even if
/// already active). Must run AFTER <see cref="Bind"/> so this

View file

@ -195,7 +195,11 @@ public sealed record OptionsRuntimeBindings(
Action<string> DisplaySystemMessage,
Action<string> DisplayMouseTurningMacroLine,
Func<CameraTurningSettings> LoadCameraTurning,
Action<CameraTurningSettings> SaveCameraTurning);
Action<CameraTurningSettings> SaveCameraTurning,
// Campaign OP slice OP4 (2026-08-11): reads a live character-option
// bit by its linear id (RuntimeCharacterOptionsState.GetOptionBit) —
// the Character-tab panel's row-seed source.
Func<uint, bool> CurrentCharacterOption);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
@ -1992,6 +1996,42 @@ public sealed class RetailUiRuntime : IDisposable
}
OptionsPanelController = controller;
// Campaign OP slice OP4 (2026-08-11): the Character tab's 6
// headers + 50 rows. Runs BEFORE ActivateTabs (below) so the
// Character page's model is fully populated before the default
// tab's OnShown fires. Needs a SECOND dat-lock scope — resolving
// each row's template (property 0x64 on the ListBox) is a live
// DAT read per row, unlike the already-built tree the block above
// only wires callbacks into.
lock (_bindings.Assets.DatLock)
{
var strings = new DatStringResolver(_bindings.Assets.Dats);
bool bound = Layout.CharacterOptionsPageController.Bind(
layout,
controller.CharacterPage,
templateResolver: (templateLayoutId, templateElementId) =>
{
ElementInfo? info = LayoutImporter.ImportInfos(
_bindings.Assets.Dats, templateLayoutId, templateElementId);
return info is null
? null
: LayoutImporter.Build(
info,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont,
strings.Resolve).Root;
},
resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
new Layout.CharacterOptionsPageController.Bindings(
CurrentValue: id => _bindings.Options.CurrentCharacterOption((uint)id),
SetOption: (id, value) => _bindings.Options.CommandBus().Publish(
new SetSingleCharacterOptionRuntimeCmd((uint)id, value))));
if (!bound)
Console.WriteLine("[UI] options panel: Character tab rows did not bind.");
}
controller.ActivateTabs();
RetailWindowHandle handle = RetailWindowFrame.Mount(

View file

@ -309,6 +309,32 @@ public abstract class UiElement
InvalidateChildOrder();
}
/// <summary>
/// Retail's <c>GetChildRecursive</c>: a depth-first search of
/// <paramref name="root"/> and every descendant (not just direct
/// children) for a widget carrying <paramref name="datElementId"/>.
/// Shared by <see cref="UiTabPanel"/>'s tab-table resolution and any
/// controller that needs to resolve an element that may be
/// DUPLICATED (same numeric id) across sibling subtrees — e.g. the
/// Options panel's Apply/Reset/Defaults buttons, which the
/// Character/Chat/Config pages each author under the SAME ids
/// (Campaign OP research doc §3.1), so a flat
/// <see cref="ImportedLayout.FindElement"/> id lookup over the whole
/// panel tree cannot reliably pick the right page's own instance —
/// scoping the search to one page's subtree root can.
/// </summary>
public static UiElement? FindDescendant(UiElement root, uint datElementId)
{
ArgumentNullException.ThrowIfNull(root);
if (root.DatElementId == datElementId) return root;
foreach (UiElement child in root.Children)
{
UiElement? found = FindDescendant(child, datElementId);
if (found is not null) return found;
}
return null;
}
public virtual bool RemoveChild(UiElement child)
{
if (!_children.Contains(child)) return false;

View file

@ -212,24 +212,4 @@ public sealed class UiTabPanel : UiDatElement, IUiChildrenAttachedListener
ActivePageElementId = pageElementId;
ActivePageChanged?.Invoke(previousPageElementId, pageElementId);
}
/// <summary>
/// Retail <c>GetChildRecursive</c>: a depth-first search of every descendant (not
/// just direct children) for a widget carrying <paramref name="datElementId"/>.
/// Direct-children-only search (the pre-rework <c>FindDirectChild</c>) missed
/// cross-layout mounts where the tab table's ids name elements nested below an
/// intermediate incorporated container.
/// </summary>
private static UiElement? FindDescendant(UiElement node, uint datElementId)
{
foreach (UiElement child in node.Children)
{
if (child.DatElementId == datElementId)
return child;
UiElement? found = FindDescendant(child, datElementId);
if (found is not null)
return found;
}
return null;
}
}

View file

@ -125,6 +125,19 @@ public sealed class WeatherSystem
// SetKindFromDayGroupName, the internal RollKind hash is disabled.
private bool _externallyDriven;
/// <summary>
/// Campaign OP slice OP4 (2026-08-11): retail <c>PlayerOption
/// DisableDistanceFog</c> — <c>CPlayerModule::OnChanged @0x0059A8E0</c>
/// case <c>0x30</c> writes <c>LScape::m_fFogEnabled = !value</c>. Polled
/// (not pushed) so the App composition layer only needs to bind this
/// ONCE per session to a live <c>RuntimeCharacterOptionsState.GetOptionBit</c>
/// reader — every later toggle (the panel row, a bot, a slash command)
/// is reflected with no separate notify/resync wiring. Null (the
/// default) means the option was never bound — fog stays enabled,
/// matching retail's <c>m_fFogEnabled</c> ctor default.
/// </summary>
public Func<bool>? DisableDistanceFogSource { get; set; }
public WeatherSystem(Random? rng = null)
{
// The random-seed ctor argument remains for test API compat,
@ -295,6 +308,7 @@ public sealed class WeatherSystem
Vector3 fogColor = kf.FogColor;
float fogStart = kf.FogStart;
float fogEnd = kf.FogEnd;
FogMode fogMode = kf.FogMode;
// AdminEnvirons server override: replace fog COLOR only.
// Keyframe distances unchanged until we find evidence retail
@ -304,13 +318,21 @@ public sealed class WeatherSystem
if (_override != EnvironOverride.None)
fogColor = EnvironOverrideColor(_override);
// OP4 (2026-08-11): PlayerOption DisableDistanceFog ->
// LScape::m_fFogEnabled = !value (see DisableDistanceFogSource's
// doc comment). Forces FogMode.Off regardless of the keyframe's
// own authored mode; distances are left alone since the shader
// never reads them once fog is off.
if (DisableDistanceFogSource?.Invoke() == true)
fogMode = FogMode.Off;
return new AtmosphereSnapshot(
Kind: _kind, // informational
Intensity: 1f, // no per-Kind easing in retail
FogColor: fogColor,
FogStart: fogStart,
FogEnd: fogEnd,
FogMode: kf.FogMode,
FogMode: fogMode,
LightningFlash: _flashLevel, // 0 in production; TriggerFlash hook for tests
Override: _override);
}

View file

@ -841,6 +841,33 @@ public sealed class RuntimeCharacterOptionsState
return true;
}
/// <summary>
/// Read ONE character-option bit, by its linear <c>CharacterOptionId</c>,
/// resolved through <see cref="CharacterOptionTable"/> (Campaign OP slice
/// OP4, 2026-08-11) — the read counterpart to <see cref="SetOptionBit"/>.
/// The single seam every OP4 Character-tab row and every re-pointed
/// Group-C consumer reads live server truth through: the panel's
/// checkbox seed at mount time, <c>ICombatGameplaySettingsSource</c>'s
/// re-pointed AutoTarget/AutoRepeatAttack/ViewCombatTarget,
/// VividTargetingIndicator/CoordinatesOnRadar/LockUI/AcceptLootPermits,
/// the Distance-Fog poll, and the Run-as-Default-Movement poll all call
/// this instead of holding their own stale copy. An id outside the
/// table returns <see langword="false"/> (matches retail's own
/// unmodeled-id-is-off default — there is no bit to test).
/// </summary>
public bool GetOptionBit(uint characterOptionId)
{
if (!CharacterOptionTable.TryGet(characterOptionId, out CharacterOptionTableEntry entry))
return false;
uint word = entry.IsOptions1 ? Options1 : Options2;
return (word & entry.Mask) != 0u;
}
/// <summary>Typed overload of <see cref="GetOptionBit(uint)"/>.</summary>
public bool GetOptionBit(CharacterOptionId characterOptionId) =>
GetOptionBit((uint)characterOptionId);
/// <summary>
/// Set ONE character-option bit locally, by its linear
/// <c>CharacterOptionId</c> (the same id carried on the wire by

View file

@ -95,6 +95,22 @@ public sealed class RuntimeCommunicationState : IDisposable
/// </summary>
public SpewBoxState SpewBox { get; }
/// <summary>
/// Campaign OP slice OP4 (2026-08-11): retail <c>PlayerOption
/// DisplayTimeStamps</c> — <c>ClientSystem::AddTextToScroll
/// @0x00563C50</c> (character-options-map.md §2.2, "Display
/// Timestamps") prefixes each transcript line with
/// <c>PlayerModule::m_TimeStampFormat</c> (ctor default the byte-
/// verified CRT strftime string <c>"%#H:%M:%S "</c> — non-zero-padded
/// 24h hour, then zero-padded minute:second, trailing space) when the
/// option is on. Polled — see <see cref="AcDream.Core.World.
/// WeatherSystem.DisableDistanceFogSource"/> for why. Applied only to
/// the persisted <see cref="Chat"/> transcript (matching retail's own
/// <c>ClientLocal</c> exemption in <see cref="AddText"/> below — the
/// transient <see cref="SpewBox"/> is never timestamped).
/// </summary>
public Func<bool>? DisplayTimestampsSource { get; set; }
public ChatCommandTargetState CommandTargets { get; }
public TurbineChatState TurbineChat { get; }
public FriendsState Friends { get; }
@ -200,6 +216,14 @@ public sealed class RuntimeCommunicationState : IDisposable
return;
}
// OP4: DisplayTimeStamps — see DisplayTimestampsSource's doc
// comment. Prefixed AFTER the trim above (retail's own order:
// AddTextToScroll trims first, then ClientSystem's caller-side
// 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);
}

View file

@ -143,6 +143,26 @@ public sealed class RuntimeLocalPlayerMovementState
}
public bool AutoRunActive => _autoRunActive;
/// <summary>
/// Campaign OP slice OP4 (2026-08-11): retail <c>PlayerOption
/// RunAsDefaultMovement</c> (<c>ACCmdInterp::UITogglesRun</c>) —
/// whether an ordinary held movement key runs by default (retail's own
/// client default: <see langword="true"/>) or walks by default,
/// requiring the walk-mode modifier to be held to invert. Distinct
/// from <see cref="AutoRunActive"/>, which is the <c>ToggleRunLock</c>
/// KEYBIND LATCH (auto-walk-forward-without-holding-W), an unrelated
/// acdream feature with no retail <c>PlayerOption</c> behind it. Polled
/// (not pushed) — see <see cref="AcDream.Core.World.WeatherSystem.
/// DisableDistanceFogSource"/> for why. Null (the default) means the
/// option was never bound; <see cref="RunAsDefaultMovement"/> then
/// reports retail's own default (<see langword="true"/>), matching
/// acdream's pre-OP4 hardcoded behavior exactly.
/// </summary>
public Func<bool>? RunAsDefaultMovementSource { get; set; }
public bool RunAsDefaultMovement => RunAsDefaultMovementSource?.Invoke() ?? true;
public bool HasCommandInput => _hasCommandInput;
public MovementInput CommandInput => _commandInput;
public long Revision => Interlocked.Read(ref _revision);