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

File diff suppressed because one or more lines are too long

View file

@ -1,8 +1,8 @@
# Campaign OP connected-gate test script
**Status:** OP3 and OP7 sections. Later slices (OP4-6, OP8) append their own
sections here as they land; the campaign's OP9 closeout gate is this
document complete plus every slice code-complete.
**Status:** OP3, OP4, and OP7 sections. Later slices (OP5-6, OP8) append
their own sections here as they land; the campaign's OP9 closeout gate is
this document complete plus every slice code-complete.
This document is the script the user runs against the live connected
client (`ACDREAM_LIVE=1` against the local ACE server) to accept each
@ -166,6 +166,171 @@ for this slice, not a bug.
---
## OP4 — the Character tab
The Character tab (`0x21000028`) is the biggest single tab: 6 authored
group headers and 50 toggle rows (49 from the 2013 build + D3's "Listen to
PK death messages"), each bound by its retail `PlayerOption` id through
`CharacterOptionTable` and the shared `RuntimeCharacterOptionsState.
TrySetOption` seam every other Options-panel consumer already uses.
### Opening the tab and reading the rows
1. **Open the Options panel (F11) and click the Character tab.** Six
group headers appear top-to-bottom: **User Interface Behavior** (3
rows), **User Interface Display** (15 rows), **Grouping** (6 rows),
**Other Players** (11 rows), **Character Behavior** (7 rows), **Chat**
(8 rows — 7 from retail plus "Listen to PK death messages"). Every row
has a label; hover a few and confirm a tooltip appears. If ANY row
shows no label at all, that is a DAT string-resolution miss worth
reporting (the code deliberately renders nothing rather than invented
English when a string fails to resolve — a blank row is the correct
FAILURE shape, not a crash, but still worth flagging which row).
2. **Scroll the list** via the scrollbar. All 50+6 rows are reachable;
the list does not clip or overlap the Apply/Reset/Defaults buttons at
the bottom.
3. **Confirm the checkboxes reflect your character's actual state**
e.g. if you have `IgnoreAllegianceRequests` on from a prior session,
its row should show checked on open (seeded from the live server bit
at panel-mount time, not always-off).
### Auto-save rows — immediate `0x0005`
4. **Toggle "Listen to General Chat" off, then on again**, without
clicking Apply. Confirm the effect is IMMEDIATE and matches the
existing Settings-panel Hear\*Chat behavior: turning it off silently
drops you from general chat (no more `/g` messages appear); turning it
back on restores them. This id is auto-save — the wire send happens on
the click itself, not on Apply.
5. **Toggle "Automatically Repeat Attacks" or "Accept Corpse Looting
Permissions"** (both auto-save). These previously flipped ONLY a
client-local flag and never reached the wire (an unfiled divergence
this slice closes) — verify the *behavior* actually changes in
combat/looting, not just the checkbox art.
6. **Relog the same character** after toggling a couple of auto-save
rows. Reopen the panel — the rows should still read whatever you left
them at (server echo), confirming the send actually reached ACE and
persisted, not just the local bit.
### Batched rows — the `0x01A1` blob via Apply
7. **Toggle a BATCHED row** (e.g. "Side By Side Vitals" or "Display Date
of Birth" — anything NOT in the auto-save set) and click **Apply**.
Relog and reopen the panel: the row should still read your new value
(Apply flushed the `0x01A1` blob, ACE persisted it, the fresh
`PlayerDescription` echoes it back).
8. **Toggle a batched row WITHOUT clicking Apply**, then relog. The row
should revert to its PRE-toggle value on reopen — an un-flushed
batched change never reached the wire (the 480 s auto-save timer is a
Runtime-level mechanism tested at OP1; do not wait 8 minutes for this
gate — just don't click Apply).
### Apply / Reset / Defaults semantics
9. **Toggle several rows (mix of auto-save and batched), then click
Reset.** Every row you touched reverts to its pre-edit value; rows you
didn't touch are unaffected. Auto-save rows you toggled will have
ALREADY sent their change on the click — Reset sends the REVERTED
value as a fresh change (a second `0x0005`), which is retail-correct
(Reset re-applies live, it does not "undo" a wire message that already
happened).
10. **Click Defaults.** Every row jumps to its retail default value
LIVE — auto-save rows whose default differs from current send
immediately; batched rows just dirty the module. Apply/Reset stay (or
become) ENABLED afterward if anything actually changed — Defaults
does NOT commit a new baseline by itself.
11. **After clicking Defaults, click Apply.** The defaulted batched rows'
values now flush via `0x01A1`; the new state becomes the baseline
(Reset can no longer undo past this point).
12. **Switch to another tab (e.g. Gameplay) without clicking Apply after
an edit.** Uncommitted edits silently revert — switching tabs is a
Reset, not a save. Switch back to Character and confirm the
reverted state.
### Group B consumers — presentation bindings
13. **Toggle "Display Timestamps" on, then send/receive a new chat
line** (e.g. `/who` or any inbound tell/say). The NEW line should be
prefixed with a timestamp like `14:32:07 ` (no leading zero on the
hour). Lines already in the transcript before you toggled the option
do NOT retroactively gain a timestamp. Toggle it back off — new lines
stop getting the prefix.
14. **Toggle "Disable Distance Fog" on** while outdoors somewhere with
visible atmospheric fog at a distance (dusk/dawn or an overcast
keyframe shows this most clearly). Distant terrain/objects should
stop fading into the fog color — toggle back off and the fog returns.
15. **Toggle "Run as Default Movement" off.** Press W (or your bound
forward-movement key) alone, with no modifier held: your character
should now WALK by default instead of run. Hold the walk-mode
modifier key while this option is off: it should now temporarily make
you RUN (the modifier always inverts whichever default is active).
Toggle the option back on and confirm W-alone runs again (today's
pre-OP4 behavior).
16. **"Display 3D Tooltips", "Side By Side Vitals", "Display Spell
Durations", "Advanced Combat Interface", "Stay in Chat Mode",
"Disable Most Weather Effects", "Always Daylight Outdoors", "Filter
Language", "Use Main Pack as Default for Picking Up Items" — verify
these are TOGGLEABLE and PERSIST (store+wire only, per the plan's own
contract), but do NOT expect any observable client behavior change
from them yet.** acdream has no existing consumer surface for these
(no side-by-side vitals layout, no 3D tooltip rendering, no discrete
weather-particle system to gate, no client-side pickup-container
preference, no profanity filter) — this is an honest, registered gap,
not a bug to report. "Always Daylight Outdoors" specifically does NOT
force daytime lighting yet; do not confuse it with the unrelated
weather-VARIETY forcing mechanism already in the codebase.
### Group C — re-pointed to server truth
17. **Toggle "Auto Target", "Automatically Repeat Attacks", "Keep Combat
Targets in View", "Vivid Targeting Indicator", "Show Coordinates By
the Radar", or "Accept Corpse Looting Permissions" and confirm the
EXISTING behavior for each (auto-targeting in combat, the combat
camera tracking your target, the vivid target overlay, radar
coordinate labels, corpse-loot consent) still works exactly as
before — these now read the SERVER bit instead of the local
`settings.json` copy, so the observable behavior should be unchanged
UNLESS your character's server-side value differs from whatever
`settings.json` had (in which case the NEW, server-driven behavior is
correct and the old local-only behavior was the bug).
18. **Relog and confirm all six Group-C options above read their
server-persisted value**, not a locally-cached default.
### What to report
- Any row with a missing label/tooltip (note which one).
- Any auto-save row whose behavior doesn't persist across a relog.
- Any batched row that persists WITHOUT clicking Apply, or reverts
DESPITE clicking Apply.
- Any Reset/Defaults button that stays permanently disabled, or Defaults
that becomes disabled (it must never be).
- Any crash, freeze, or scroll glitch anywhere in the 56-row list.
- Timestamps, fog toggling, or run-as-default NOT working as described in
13-15.
- Any Group-C option (17-18) behaving DIFFERENTLY than it did before this
slice, other than "now reads the correct server value instead of a
stale local one."
### Explicitly NOT in scope for this gate
- Chat tab / Config tab content — OP5/OP6.
- Configure Keyboard — OP8.
- Any observable change from Group A rows (privacy flags, fellowship
grouping, missile/charge-attack options, helm/cloak visibility, and
similar) beyond the checkbox itself toggling and persisting — retail's
OWN client has no local consumer for most of these either; the server
is the only place their effect is visible (and largely invisible to the
player triggering them).
- "Always Daylight Outdoors" and "Disable Most Weather Effects" actually
changing the sky/weather — no acdream consumer exists yet (see item 16
and this slice's register rows).
- "Use Main Pack as Default for Picking Up Items" actually changing
pickup routing — no client-side preferred-container consumer exists.
- "Filter Language" actually filtering profanity — no filter subsystem
exists.
---
## OP7 — headless `characterOptions`
Unlike OP3-OP6, this is not a graphical-client gate: no window is launched.

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);

View file

@ -0,0 +1,62 @@
using AcDream.App.Combat;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.Combat;
/// <summary>
/// Campaign OP slice OP4 (2026-08-11) — D7 Group-C re-point:
/// <see cref="CharacterOptionCombatSettingsSource"/> reads
/// AutoTarget/AutoRepeatAttack/ViewCombatTarget from the canonical
/// <see cref="RuntimeCharacterOptionsState"/> instead of the client-local
/// <c>GameplaySettings</c> record <see cref="GameplaySettingsState"/> used
/// to be the only implementation of.
/// </summary>
public sealed class CharacterOptionCombatSettingsSourceTests
{
[Fact]
public void ReadsLiveBits_NotAConstructionTimeSnapshot()
{
var options = new RuntimeCharacterOptionsState();
options.SetOptionBit((uint)CharacterOptionId.AutoTarget, false);
options.SetOptionBit((uint)CharacterOptionId.AutoRepeatAttack, false);
options.SetOptionBit((uint)CharacterOptionId.ViewCombatTarget, false);
var source = new CharacterOptionCombatSettingsSource(options);
Assert.False(source.AutoTarget);
Assert.False(source.AutoRepeatAttack);
Assert.False(source.ViewCombatTarget);
// Live: a later write is observed with no re-construction, matching
// ICombatGameplaySettingsSource's poll-per-call contract.
options.SetOptionBit((uint)CharacterOptionId.AutoTarget, true);
Assert.True(source.AutoTarget);
Assert.False(source.AutoRepeatAttack);
Assert.False(source.ViewCombatTarget);
options.SetOptionBit((uint)CharacterOptionId.AutoRepeatAttack, true);
options.SetOptionBit((uint)CharacterOptionId.ViewCombatTarget, true);
Assert.True(source.AutoRepeatAttack);
Assert.True(source.ViewCombatTarget);
}
[Fact]
public void ReflectsClientDefaults_ForAFreshCharacterOptionsState()
{
// CharacterOptionTable's byte-verified client-Defaults column:
// AutoTarget/AutoRepeatAttack ON, ViewCombatTarget OFF.
var options = new RuntimeCharacterOptionsState();
var source = new CharacterOptionCombatSettingsSource(options);
Assert.True(source.AutoTarget);
Assert.True(source.AutoRepeatAttack);
Assert.False(source.ViewCombatTarget);
}
[Fact]
public void Constructor_RejectsNull()
{
Assert.Throws<ArgumentNullException>(
static () => new CharacterOptionCombatSettingsSource(null!));
}
}

View file

@ -0,0 +1,536 @@
using System.Collections.Generic;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign OP slice OP4 (2026-08-11) conformance + behavior tests for
/// <see cref="CharacterOptionsPageController"/> — the CH4 registry-
/// conformance pattern: every one of the 50 authored rows pinned against
/// <see cref="CharacterOptionTable"/> in BOTH directions (an invented row
/// or a dropped row fails the build), the authored group/order pinned
/// against the committed fixture, and the row-building/Apply-Reset-
/// Defaults/wire-publish behavior exercised end-to-end against the
/// committed <c>options_panel_2100006E_1000018D.json</c> fixture — no
/// live DAT access, following the same hermetic pattern
/// <c>OptionsPanelControllerTests</c>/<c>OptionsPanelLayoutConformanceTests</c>
/// already established.
/// </summary>
public sealed class CharacterOptionsPageControllerTests
{
// The three PlayerOption ids that exist but carry NO Character-tab row
// (research doc §2.7): AppearOffline, UseMouseTurning, LockUI.
private static readonly CharacterOptionId[] NotOnCharacterTab =
[
CharacterOptionId.AppearOffline,
CharacterOptionId.UseMouseTurning,
CharacterOptionId.LockUI,
];
private static IEnumerable<CharacterOptionsPageController.RowSpec> AllRows() =>
CharacterOptionsPageController.Groups.SelectMany(static g => g.Rows);
// ── Pure data conformance (no fixtures, no widgets) ─────────────────────
[Fact]
public void Groups_HasSixGroups_InAuthoredHeaderOrder()
{
string[] expectedHeaders =
[
"ID_CharacterOption_UIBehavior_Section",
"ID_CharacterOption_UIDisplay_Section",
"ID_CharacterOption_Grouping_Section",
"ID_CharacterOption_OtherPlayers_Section",
"ID_CharacterOption_CharacterBehavior_Section",
"ID_CharacterOption_Chat_Section",
];
Assert.Equal(6, CharacterOptionsPageController.Groups.Length);
Assert.Equal(
expectedHeaders,
CharacterOptionsPageController.Groups.Select(static g => g.HeaderKey));
}
[Fact]
public void Groups_RowCountsPerGroup_Match3_15_6_11_7_8()
{
// research doc §2 / §7: 3/15/6/11/7/7, +1 for D3's HearPKDeaths
// appended to the Chat group -> 8.
int[] expected = { 3, 15, 6, 11, 7, 8 };
Assert.Equal(
expected,
CharacterOptionsPageController.Groups.Select(static g => g.Rows.Length));
}
[Fact]
public void TotalRowCount_Is50()
{
Assert.Equal(50, CharacterOptionsPageController.TotalRowCount);
Assert.Equal(50, AllRows().Count());
}
[Fact]
public void EveryRow_ResolvesInCharacterOptionTable()
{
// "An invented row fails the build" — direction 1.
foreach (CharacterOptionsPageController.RowSpec row in AllRows())
{
Assert.True(
CharacterOptionTable.TryGet(row.Id, out _),
$"{row.RetailName} (0x{(uint)row.Id:X2}) is authored on the Character tab "
+ "but missing from CharacterOptionTable.");
}
}
[Fact]
public void EveryRow_IsPairwiseDistinct()
{
var ids = AllRows().Select(static r => r.Id).ToList();
Assert.Equal(ids.Count, ids.Distinct().Count());
}
[Fact]
public void EveryCharacterOptionTableId_ExceptTheThreeExcluded_HasExactlyOneRow()
{
// "A dropped row fails the build" — direction 2. CharacterOptionTable
// has 53 ids; the Character tab authors exactly 53 - 3 = 50 of them
// (research doc §2.7's three exclusions).
var rowIds = AllRows().Select(static r => r.Id).ToHashSet();
foreach (CharacterOptionTableEntry entry in CharacterOptionTable.All)
{
bool expectedOnTab = !NotOnCharacterTab.Contains(entry.Id);
Assert.True(
rowIds.Contains(entry.Id) == expectedOnTab,
$"{entry.Id} (0x{(uint)entry.Id:X2}): expected authored-on-tab="
+ $"{expectedOnTab} but rowIds.Contains={rowIds.Contains(entry.Id)}.");
}
}
[Theory]
[InlineData(CharacterOptionId.AppearOffline)]
[InlineData(CharacterOptionId.UseMouseTurning)]
[InlineData(CharacterOptionId.LockUI)]
public void ExcludedIds_HaveNoRow(CharacterOptionId excludedId)
{
Assert.DoesNotContain(AllRows(), r => r.Id == excludedId);
}
[Fact]
public void HearPkDeathMessages_D3Row_IsLastInTheChatGroup()
{
CharacterOptionsPageController.RowSpec[] chatRows =
CharacterOptionsPageController.Groups[5].Rows;
Assert.Equal(
CharacterOptionId.HearPkDeathMessages, chatRows[^1].Id);
Assert.Equal("HearPKDeaths", chatRows[^1].RetailName);
}
[Fact]
public void HearPkDeathMessages_RetailNameHash_MatchesByteVerifiedStringId()
{
// structure doc §7 build-version divergence note:
// compute_str_hash("ID_PlayerOption_HearPKDeaths") == 0x0D16E9A3
// exactly, matching the DAT string "Listen to PK death messages."
Assert.Equal(
0x0D16E9A3u,
DatStringResolver.ComputeHash("ID_PlayerOption_HearPKDeaths"));
}
[Theory]
[MemberData(nameof(RetailEnumNameCases))]
public void RetailName_MatchesVerbatimAcclientEnumSpelling(
CharacterOptionId id, string expectedRetailName)
{
// acclient.h:4162-4218's OWN PlayerOption enumerator names — the six
// Hear*Chat ids (+ D3's HearPKDeaths) differ from acdream's own
// ListenTo*Chat CharacterOptionId spelling; every other id matches
// 1:1. Only a representative spot-check here — the FULL authored
// list is pinned row-by-row in AuthoredOrder_MatchesResearchDocRowByRow.
CharacterOptionsPageController.RowSpec row =
AllRows().Single(r => r.Id == id);
Assert.Equal(expectedRetailName, row.RetailName);
}
public static IEnumerable<object[]> RetailEnumNameCases()
{
yield return [CharacterOptionId.ListenToAllegianceChat, "HearAllegianceChat"];
yield return [CharacterOptionId.ListenToGeneralChat, "HearGeneralChat"];
yield return [CharacterOptionId.ListenToTradeChat, "HearTradeChat"];
yield return [CharacterOptionId.ListenToLFGChat, "HearLFGChat"];
yield return [CharacterOptionId.ListenToRoleplayChat, "HearRoleplayChat"];
yield return [CharacterOptionId.ListenToSocietyChat, "HearSocietyChat"];
yield return [CharacterOptionId.HearPkDeathMessages, "HearPKDeaths"];
// Spot-check a few that DON'T differ from CharacterOptionId's own name.
yield return [CharacterOptionId.ViewCombatTarget, "ViewCombatTarget"];
yield return [CharacterOptionId.MainPackPreferred, "MainPackPreferred"];
yield return [CharacterOptionId.AutoRepeatAttack, "AutoRepeatAttack"];
}
[Fact]
public void AuthoredOrder_MatchesResearchDocRowByRow()
{
// docs/research/2026-08-10-options-panel-structure.md §7's exact
// authored PlayerOption enumerator sequence, transcribed
// independently of CharacterOptionsPageController.cs.
CharacterOptionId[][] expectedGroups =
[
[
CharacterOptionId.ViewCombatTarget,
CharacterOptionId.SalvageMultiple,
CharacterOptionId.MainPackPreferred,
],
[
CharacterOptionId.VividTargetingIndicator,
CharacterOptionId.ShowTooltips,
CharacterOptionId.CoordinatesOnRadar,
CharacterOptionId.SideBySideVitals,
CharacterOptionId.SpellDuration,
CharacterOptionId.DisableMostWeatherEffects,
CharacterOptionId.DisableDistanceFog,
CharacterOptionId.PersistentAtDay,
CharacterOptionId.DisableHouseRestrictionEffects,
CharacterOptionId.UseCraftSuccessDialog,
CharacterOptionId.ConfirmVolatileRareUse,
CharacterOptionId.DisplayTimeStamps,
CharacterOptionId.FilterLanguage,
CharacterOptionId.ShowHelm,
CharacterOptionId.ShowCloak,
],
[
CharacterOptionId.IgnoreAllegianceRequests,
CharacterOptionId.IgnoreFellowshipRequests,
CharacterOptionId.DisplayAllegianceLogonNotifications,
CharacterOptionId.FellowshipShareXP,
CharacterOptionId.FellowshipShareLoot,
CharacterOptionId.FellowshipAutoAcceptRequests,
],
[
CharacterOptionId.AcceptLootPermits,
CharacterOptionId.UseDeception,
CharacterOptionId.AllowGive,
CharacterOptionId.IgnoreTradeRequests,
CharacterOptionId.DragItemOnPlayerOpensSecureTrade,
CharacterOptionId.DisplayDateOfBirth,
CharacterOptionId.DisplayAge,
CharacterOptionId.DisplayChessRank,
CharacterOptionId.DisplayFishingSkill,
CharacterOptionId.DisplayNumberDeaths,
CharacterOptionId.DisplayNumberCharacterTitles,
],
[
CharacterOptionId.ToggleRun,
CharacterOptionId.AdvancedCombatUI,
CharacterOptionId.AutoTarget,
CharacterOptionId.AutoRepeatAttack,
CharacterOptionId.UseChargeAttack,
CharacterOptionId.LeadMissileTargets,
CharacterOptionId.UseFastMissiles,
],
[
CharacterOptionId.StayInChatMode,
CharacterOptionId.ListenToAllegianceChat,
CharacterOptionId.ListenToGeneralChat,
CharacterOptionId.ListenToTradeChat,
CharacterOptionId.ListenToLFGChat,
CharacterOptionId.ListenToRoleplayChat,
CharacterOptionId.ListenToSocietyChat,
CharacterOptionId.HearPkDeathMessages,
],
];
for (int g = 0; g < expectedGroups.Length; g++)
{
CharacterOptionId[] actual = CharacterOptionsPageController.Groups[g].Rows
.Select(static r => r.Id).ToArray();
Assert.Equal(expectedGroups[g], actual);
}
}
// ── Behavioral: built against the committed fixture ─────────────────────
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
private static ElementInfo? Find(ElementInfo n, uint id)
{
if (n.Id == id) return n;
foreach (ElementInfo c in n.Children)
{
ElementInfo? f = Find(c, id);
if (f is not null) return f;
}
return null;
}
/// <summary>The SAME "resolve a row template from the standalone
/// 0x2100002B fixture" resolver <c>OptionsPanelLayoutConformanceTests</c>
/// uses for its own end-to-end template-mechanism test.</summary>
private static Func<uint, uint, UiElement?> MakeTemplateResolver()
{
ElementInfo panelRoot = FixtureLoader.LoadOptionsPanelInfos();
return (layoutId, elementId) =>
{
if (layoutId != 0x2100002Bu) return null;
ElementInfo? templateInfo = Find(panelRoot, elementId);
return templateInfo is null ? null : LayoutImporter.Build(templateInfo, NoTex, null).Root;
};
}
private sealed class FakeBindings
{
public Dictionary<CharacterOptionId, bool> Values { get; } = new();
public List<(CharacterOptionId Id, bool Value)> Sets { get; } = new();
public CharacterOptionsPageController.Bindings ToBindings() => new(
CurrentValue: id => Values.TryGetValue(id, out bool v) && v,
SetOption: (id, value) =>
{
Values[id] = value;
Sets.Add((id, value));
});
}
private static (OptionsPanelController Panel, FakeBindings Bindings, bool Bound) BindReal(
Func<uint, uint, string?>? resolveString = null)
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
new OptionsPanelController.Callbacks(
Toggle: () => calls.Add("toggle"),
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { }))!;
var fakeBindings = new FakeBindings();
bool bound = CharacterOptionsPageController.Bind(
layout,
controller.CharacterPage,
MakeTemplateResolver(),
resolveString ?? ((_, _) => null),
fakeBindings.ToBindings());
return (controller, fakeBindings, bound);
}
[Fact]
public void Bind_Succeeds_AndRegistersExactly50Rows()
{
(OptionsPanelController controller, _, bool bound) = BindReal();
Assert.True(bound);
Assert.Equal(50, controller.CharacterPage.Rows.Count);
}
[Fact]
public void Bind_SeedsEveryRowFromCurrentValue()
{
var fakeBindings = new FakeBindings();
// Seed a handful of ids ON; everything else defaults to off in the
// fake's dictionary lookup.
fakeBindings.Values[CharacterOptionId.ViewCombatTarget] = true;
fakeBindings.Values[CharacterOptionId.IgnoreAllegianceRequests] = true;
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);
// No SetOption calls yet — seeding reads CurrentValue, it never
// writes back through the bindings.
Assert.Empty(fakeBindings.Sets);
}
[Fact]
public void ClickingARow_PublishesSetOption_WithTheAuthoredId()
{
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
// AutoRepeatAttack (0x00) is the FIRST row's checkbox
// (0x10000219) instantiated for the Character page — resolve it by
// walking the built tree rather than layout.FindElement (which
// collides across the Chat/Config pages' own same-id copies, per
// OptionsPanelController's Apply/Reset/Defaults comment).
IOptionRow row = Assert.Single(
controller.CharacterPage.Rows.Skip(0).Take(1));
var boolRow = Assert.IsType<BoolOptionRow>(row);
boolRow.SetCurrentValue(true);
Assert.Single(bindings.Sets);
}
[Fact]
public void Apply_CommitsBaseline_AndReset_NoLongerReverts()
{
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
var row = Assert.IsType<BoolOptionRow>(controller.CharacterPage.Rows[0]);
bool initial = row.Current;
row.SetCurrentValue(!initial);
Assert.True(controller.CharacterPage.Changed);
controller.CharacterPage.Apply();
Assert.False(controller.CharacterPage.Changed);
Assert.Equal(!initial, row.Saved);
controller.CharacterPage.Reset(); // nothing changed since Apply -> no-op
Assert.Equal(!initial, row.Current);
}
[Fact]
public void Reset_RevertsToSavedBaseline_AndRePublishesTheRevertedValue()
{
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
var row = Assert.IsType<BoolOptionRow>(controller.CharacterPage.Rows[0]);
bool initial = row.Current;
row.SetCurrentValue(!initial);
bindings.Sets.Clear();
controller.CharacterPage.Reset();
Assert.Equal(initial, row.Current);
Assert.Contains(bindings.Sets, s => s.Value == initial);
}
[Fact]
public void Defaults_AppliesClientDefault_ForEveryRow_WithoutCommitting()
{
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
// Drive every row to the OPPOSITE of its own default first.
foreach (IOptionRow r in controller.CharacterPage.Rows)
{
var b = (BoolOptionRow)r;
b.SetCurrentValue(!b.DefaultValue);
}
bindings.Sets.Clear();
controller.CharacterPage.Defaults();
foreach (IOptionRow r in controller.CharacterPage.Rows)
{
var b = (BoolOptionRow)r;
Assert.Equal(b.DefaultValue, b.Current);
}
// Defaults applies live but does NOT commit — Changed stays true
// for every row that isn't already equal to its own saved value.
Assert.True(controller.CharacterPage.Changed);
}
[Fact]
public void EveryRow_DefaultValue_MatchesCharacterOptionTableClientDefault()
{
// U1: the Defaults button restores PlayerModule::GetDefaultOptionValue
// (CharacterOptionTable.ClientDefault), NOT a DAT DBPropertyCollection
// read — see CharacterOptionsPageController's own type doc for the
// full trace. This is the direct pin.
(OptionsPanelController controller, _, _) = BindReal();
var rowsById = new Dictionary<CharacterOptionId, BoolOptionRow>();
int i = 0;
foreach (CharacterOptionsPageController.RowSpec spec in AllRows())
rowsById[spec.Id] = (BoolOptionRow)controller.CharacterPage.Rows[i++];
foreach ((CharacterOptionId id, BoolOptionRow row) in rowsById)
{
Assert.True(CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry));
Assert.Equal(entry.ClientDefault, row.DefaultValue);
}
}
[Fact]
public void TabHide_RevertsUncommittedCharacterEdits_ViaOnVisibilityChanged()
{
// Structure doc §3.6: switching tabs away reverts uncommitted edits
// on the page you left — exercised through the SAME
// OnActivePageChanged wiring OptionsPanelControllerTests already
// covers for the (then-empty) Character page; this proves it still
// holds once the page has REAL rows.
(OptionsPanelController controller, FakeBindings bindings, _) = BindReal();
controller.ActivateTabs();
controller.TabPanel.SwitchTo(0x10000211u); // Character page slot
var row = Assert.IsType<BoolOptionRow>(controller.CharacterPage.Rows[0]);
bool initial = row.Current;
row.SetCurrentValue(!initial);
Assert.True(controller.CharacterPage.Changed);
controller.TabPanel.SwitchTo(0x10000212u); // Gameplay page slot
Assert.Equal(initial, row.Current);
Assert.False(controller.CharacterPage.Changed);
}
[Fact]
public void ScrollbarLinkage_ModelPointsAtTheListBoxScroll()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
OptionsPanelController controller = OptionsPanelController.Bind(
layout,
new OptionsPanelController.Callbacks(
Toggle: () => { },
RequestExitToCharacterSelection: () => { },
ExitGame: () => { },
UseMouseTurningSettings: () => { },
DisplaySystemMessage: _ => { }))!;
var fakeBindings = new FakeBindings();
CharacterOptionsPageController.Bind(
layout, controller.CharacterPage, MakeTemplateResolver(), (_, _) => null,
fakeBindings.ToBindings());
var listBox = Assert.IsType<UiTemplateListBox>(
layout.FindElement(CharacterOptionsPageController.ListBoxElementId));
var scrollbar = Assert.IsType<UiScrollbar>(
layout.FindElement(CharacterOptionsPageController.ScrollbarElementId));
Assert.Same(listBox.Scroll, scrollbar.Model);
}
[Fact]
public void Bind_MissingListBox_ReturnsFalse_AndDoesNotThrow()
{
// A layout whose root did not build the Character ListBox at all —
// exercises the degrade-gracefully path.
var emptyRoot = new ElementInfo { Id = 0, Type = 3 };
ImportedLayout emptyLayout = LayoutImporter.Build(emptyRoot, NoTex, null);
var page = new OptionPage();
bool bound = CharacterOptionsPageController.Bind(
emptyLayout, page, MakeTemplateResolver(), (_, _) => null,
new CharacterOptionsPageController.Bindings(
CurrentValue: _ => false, SetOption: (_, _) => { }));
Assert.False(bound);
Assert.Empty(page.Rows);
}
[Fact]
public void LabelResolutionFailure_LeavesCheckboxLabelNull_NeverInventsEnglish()
{
(OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => null);
Assert.True(bound);
// Every row still registers (structural build succeeds) even
// though every string lookup returns null — "no invented text"
// degrades to "no text", never a fabricated label.
Assert.Equal(50, controller.CharacterPage.Rows.Count);
}
}

View file

@ -64,6 +64,46 @@ public sealed class WeatherSystemTests
}
}
// ── OP4 (Campaign OP, 2026-08-11): DisableDistanceFogSource —
// PlayerOption DisableDistanceFog -> LScape::m_fFogEnabled = !value.
[Fact]
public void DisableDistanceFogSource_Unbound_LeavesKeyframeFogModeUnchanged()
{
var sys = new WeatherSystem();
var kf = SkyStateProvider.Default().Interpolate(0.5f);
var snap = sys.Snapshot(in kf);
Assert.Equal(kf.FogMode, snap.FogMode);
}
[Fact]
public void DisableDistanceFogSource_True_ForcesFogModeOff()
{
var sys = new WeatherSystem { DisableDistanceFogSource = () => true };
var kf = SkyStateProvider.Default().Interpolate(0.5f);
Assert.NotEqual(FogMode.Off, kf.FogMode); // sanity: the keyframe itself authors real fog
var snap = sys.Snapshot(in kf);
Assert.Equal(FogMode.Off, snap.FogMode);
// Distances are left alone (the shader never reads them once off).
Assert.Equal(kf.FogStart, snap.FogStart, precision: 2);
Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2);
}
[Fact]
public void DisableDistanceFogSource_False_LeavesFogModeAlone()
{
var sys = new WeatherSystem { DisableDistanceFogSource = () => false };
var kf = SkyStateProvider.Default().Interpolate(0.5f);
var snap = sys.Snapshot(in kf);
Assert.Equal(kf.FogMode, snap.FogMode);
}
[Fact]
public void EnvironOverride_ForcesTintedFog()
{

View file

@ -286,6 +286,45 @@ public sealed class RuntimeCharacterStateTests
Assert.Equal(beforeRevision, options.Revision);
}
// ── OP4 (Campaign OP, 2026-08-11): GetOptionBit — the read
// counterpart every Character-tab row seed and every re-pointed
// Group-C consumer polls.
[Theory]
[InlineData(CharacterOptionId.AutoTarget)]
[InlineData(CharacterOptionId.ViewCombatTarget)]
[InlineData(CharacterOptionId.ListenToGeneralChat)]
[InlineData(CharacterOptionId.DisableDistanceFog)]
public void GetOptionBit_RoundTripsWithSetOptionBit(CharacterOptionId id)
{
var options = new RuntimeCharacterOptionsState();
options.SetOptionBit((uint)id, true);
Assert.True(options.GetOptionBit(id));
Assert.True(options.GetOptionBit((uint)id));
options.SetOptionBit((uint)id, false);
Assert.False(options.GetOptionBit(id));
}
[Fact]
public void GetOptionBit_UnrecognizedId_ReturnsFalse()
{
var options = new RuntimeCharacterOptionsState();
Assert.False(options.GetOptionBit(0xFFFFu));
}
[Fact]
public void GetOptionBit_ReflectsReplace_NotJustSetOptionBit()
{
var options = new RuntimeCharacterOptionsState();
Assert.True(options.GetOptionBit(CharacterOptionId.ListenToGeneralChat)); // default ON
options.Replace(options.Options1, 0u); // every Options2 bit off, incl. ListenToGeneralChat
Assert.False(options.GetOptionBit(CharacterOptionId.ListenToGeneralChat));
}
// ── OP1 (Campaign OP, 2026-08-10): TrySetOption — the shared
// local-write-then-send/dirty seam, + the dirty/flush state machine ────

View file

@ -241,6 +241,59 @@ public sealed class RuntimeCommunicationStateTests
Assert.Equal("", state.Chat.Snapshot()[0].Text);
}
// ── OP4 (Campaign OP, 2026-08-11): DisplayTimestampsSource —
// PlayerOption DisplayTimeStamps prefix.
[Fact]
public void AddText_TimestampsUnbound_NoPrefix()
{
using var state = new RuntimeCommunicationState();
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text);
}
[Fact]
public void AddText_TimestampsFalse_NoPrefix()
{
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => false };
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text);
}
[Fact]
public void AddText_TimestampsTrue_PrefixesTranscriptLine()
{
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
string text = state.Chat.Snapshot()[0].Text;
Assert.EndsWith("Your spell fizzled.", text);
Assert.NotEqual("Your spell fizzled.", text);
// Retail ctor default format "%#H:%M:%S " (non-zero-padded 24h
// hour, zero-padded minute:second, trailing space before the
// text) — .NET "H:mm:ss " is the exact equivalent.
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", text);
}
[Fact]
public void AddText_TimestampsTrue_NeverAppliedToClientLocalSpewBox()
{
// Retail's own ClientLocal (0x1A) exemption already skips the
// whole AddTextToScroll destination — timestamps are a transcript
// concept, never applied to the transient SpewBox line.
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
state.AddText("Out of Range!", RetailLogTextType.ClientLocal);
state.SpewBox.Tick(0d);
Assert.Equal("Out of Range!", state.SpewBox.Snapshot()[0].Text);
}
[Fact]
public void Dispose_ResetsSpewBox()
{

View file

@ -574,4 +574,31 @@ public sealed class RuntimeLocalPlayerMovementStateTests
controller.ApplyServerPhysicsState(initial));
Assert.Equal(pushed, body.State);
}
// ── OP4 (Campaign OP, 2026-08-11): RunAsDefaultMovementSource —
// PlayerOption RunAsDefaultMovement, polled.
[Fact]
public void RunAsDefaultMovement_Unbound_DefaultsToTrue()
{
var movement = new RuntimeLocalPlayerMovementState();
Assert.Null(movement.RunAsDefaultMovementSource);
Assert.True(movement.RunAsDefaultMovement);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void RunAsDefaultMovement_ReflectsBoundSourceLive(bool value)
{
var movement = new RuntimeLocalPlayerMovementState();
bool current = value;
movement.RunAsDefaultMovementSource = () => current;
Assert.Equal(value, movement.RunAsDefaultMovement);
current = !value;
Assert.Equal(!value, movement.RunAsDefaultMovement); // polled, not cached
}
}