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:
parent
7e9f372ce8
commit
22b86b9ff4
25 changed files with 1674 additions and 41 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1,8 +1,8 @@
|
||||||
# Campaign OP connected-gate test script
|
# Campaign OP connected-gate test script
|
||||||
|
|
||||||
**Status:** OP3 and OP7 sections. Later slices (OP4-6, OP8) append their own
|
**Status:** OP3, OP4, and OP7 sections. Later slices (OP5-6, OP8) append
|
||||||
sections here as they land; the campaign's OP9 closeout gate is this
|
their own sections here as they land; the campaign's OP9 closeout gate is
|
||||||
document complete plus every slice code-complete.
|
this document complete plus every slice code-complete.
|
||||||
|
|
||||||
This document is the script the user runs against the live connected
|
This document is the script the user runs against the live connected
|
||||||
client (`ACDREAM_LIVE=1` against the local ACE server) to accept each
|
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`
|
## OP7 — headless `characterOptions`
|
||||||
|
|
||||||
Unlike OP3-OP6, this is not a graphical-client gate: no window is launched.
|
Unlike OP3-OP6, this is not a graphical-client gate: no window is launched.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using AcDream.App.Input;
|
using AcDream.App.Input;
|
||||||
using AcDream.App.Net;
|
using AcDream.App.Net;
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Runtime.Gameplay;
|
using AcDream.Runtime.Gameplay;
|
||||||
using AcDream.UI.Abstractions.Panels.Settings;
|
using AcDream.UI.Abstractions.Panels.Settings;
|
||||||
|
|
||||||
|
|
@ -27,6 +28,42 @@ internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource
|
||||||
public bool ViewCombatTarget => Value.ViewCombatTarget;
|
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
|
internal interface ICombatFeedbackSink
|
||||||
{
|
{
|
||||||
void Show(string message);
|
void Show(string message);
|
||||||
|
|
|
||||||
|
|
@ -596,7 +596,11 @@ internal sealed class FrameRootCompositionPhase
|
||||||
session.LocalPlayerFrame,
|
session.LocalPlayerFrame,
|
||||||
session.LiveSpatialReconciler,
|
session.LiveSpatialReconciler,
|
||||||
new AcDream.App.Combat.CombatCameraTargetSource(
|
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.Combat,
|
||||||
d.Selection,
|
d.Selection,
|
||||||
live.SelectionQuery));
|
live.SelectionQuery));
|
||||||
|
|
|
||||||
|
|
@ -283,7 +283,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
InteractionRetainedUiDependencies d,
|
InteractionRetainedUiDependencies d,
|
||||||
DeferredSelectionUiAuthority selection) =>
|
DeferredSelectionUiAuthority selection) =>
|
||||||
d.CombatTargetOperations.BindOwned(new LiveCombatTargetOperations(
|
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: () =>
|
selectClosestTarget: () =>
|
||||||
selection.SelectClosestCombatTarget(showToast: false)));
|
selection.SelectClosestCombatTarget(showToast: false)));
|
||||||
|
|
||||||
|
|
@ -474,7 +477,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
checkpoint(InteractionRetainedUiCompositionPoint.UiHostAcquired);
|
checkpoint(InteractionRetainedUiCompositionPoint.UiHostAcquired);
|
||||||
inputCapture = d.RetainedInputCapture.Bind(host.Root);
|
inputCapture = d.RetainedInputCapture.Bind(host.Root);
|
||||||
checkpoint(InteractionRetainedUiCompositionPoint.InputCaptureBound);
|
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(
|
var cursorFeedback = new CursorFeedbackController(
|
||||||
itemInteraction,
|
itemInteraction,
|
||||||
|
|
@ -673,7 +684,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
VividTarget: new VividTargetRuntimeBindings(
|
VividTarget: new VividTargetRuntimeBindings(
|
||||||
d.Actions.Selection,
|
d.Actions.Selection,
|
||||||
() => d.PlayerIdentity.ServerGuid,
|
() => 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.Selection.ResolveVividTargetInfo,
|
||||||
late.SelectionCamera.UiSnapshot),
|
late.SelectionCamera.UiSnapshot),
|
||||||
Indicators: new IndicatorRuntimeBindings(
|
Indicators: new IndicatorRuntimeBindings(
|
||||||
|
|
@ -838,7 +856,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
DisplayMouseTurningMacroLine: text =>
|
DisplayMouseTurningMacroLine: text =>
|
||||||
d.Communication.AddText(text, RetailLogTextType.Magic),
|
d.Communication.AddText(text, RetailLogTextType.Magic),
|
||||||
LoadCameraTurning: d.Settings.LoadCameraTurning,
|
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,
|
StackSplitQuantity: d.StackSplitQuantity,
|
||||||
Plugins: d.UiRegistry,
|
Plugins: d.UiRegistry,
|
||||||
Persistence: persistence,
|
Persistence: persistence,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ using AcDream.App.World;
|
||||||
using AcDream.Core.Audio;
|
using AcDream.Core.Audio;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Lighting;
|
using AcDream.Core.Lighting;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
using AcDream.Core.Plugins;
|
using AcDream.Core.Plugins;
|
||||||
using AcDream.Core.Rendering;
|
using AcDream.Core.Rendering;
|
||||||
|
|
@ -27,6 +28,7 @@ using AcDream.Core.Vfx;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Entities;
|
using AcDream.Runtime.Entities;
|
||||||
|
using AcDream.Runtime.Gameplay;
|
||||||
using AcDream.Runtime.World;
|
using AcDream.Runtime.World;
|
||||||
using AcDream.UI.Abstractions.Panels.SpewBox;
|
using AcDream.UI.Abstractions.Panels.SpewBox;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
|
|
@ -83,6 +85,8 @@ internal sealed record LivePresentationDependencies(
|
||||||
|
|
||||||
public RuntimeLocalPlayerMovementState PlayerController =>
|
public RuntimeLocalPlayerMovementState PlayerController =>
|
||||||
Runtime.MovementOwner;
|
Runtime.MovementOwner;
|
||||||
|
|
||||||
|
public RuntimeCharacterState Character => Runtime.CharacterOwner;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record LivePresentationResult(
|
internal sealed record LivePresentationResult(
|
||||||
|
|
@ -833,8 +837,14 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
playerYawRadians: () => d.PlayerController.Controller?.Yaw ?? 0f,
|
playerYawRadians: () => d.PlayerController.Controller?.Yaw ?? 0f,
|
||||||
playerCellId: () => d.PlayerController.Controller?.CellId ?? 0u,
|
playerCellId: () => d.PlayerController.Controller?.CellId ?? 0u,
|
||||||
selectedGuid: () => d.Selection.SelectedObjectId,
|
selectedGuid: () => d.Selection.SelectedObjectId,
|
||||||
coordinatesOnRadar: () => d.Settings.Gameplay.CoordinatesOnRadar,
|
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server
|
||||||
uiLocked: () => d.Settings.Gameplay.LockUI,
|
// 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);
|
spatialQuery: () => worldState);
|
||||||
bindings.Adopt(
|
bindings.Adopt(
|
||||||
"radar snapshot",
|
"radar snapshot",
|
||||||
|
|
|
||||||
|
|
@ -767,7 +767,10 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
live.LiveEntities,
|
live.LiveEntities,
|
||||||
d.EntityObjects.Objects,
|
d.EntityObjects.Objects,
|
||||||
d.PlayerIdentity),
|
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.PlayerController,
|
||||||
d.PlayerOutbound,
|
d.PlayerOutbound,
|
||||||
liveSessionSource,
|
liveSessionSource,
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,14 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
|
||||||
// applies to ordinary held-key movement. Recomputing `!walking`
|
// applies to ordinary held-key movement. Recomputing `!walking`
|
||||||
// unconditionally let a walk-mode toggle demote an active autorun
|
// unconditionally let a walk-mode toggle demote an active autorun
|
||||||
// to walking — impossible in retail.
|
// 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));
|
Jump: dispatcher.IsActionHeld(InputAction.MovementJump));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -382,9 +382,23 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
QueryAge: session.SendQueryAge,
|
QueryAge: session.SendQueryAge,
|
||||||
QueryBirth: session.SendQueryBirth,
|
QueryBirth: session.SendQueryBirth,
|
||||||
ToggleFrameRate: _interaction.Settings.ToggleFrameRate,
|
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: () =>
|
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
|
// ClientCommandController's informational output sink — 0x00
|
||||||
// Default, NOT 0x1A (corrected 2026-08-09, Opus review of
|
// Default, NOT 0x1A (corrected 2026-08-09, Opus review of
|
||||||
// 172c6f9a). Retail types command output like @version/@loc
|
// 172c6f9a). Retail types command output like @version/@loc
|
||||||
|
|
@ -449,9 +463,23 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
.Properties.GetBool(0x6Eu) == true,
|
.Properties.GetBool(0x6Eu) == true,
|
||||||
SetAway: session.SendSetAfkMode,
|
SetAway: session.SendSetAfkMode,
|
||||||
SetAwayMessage: session.SendSetAfkMessage,
|
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: () =>
|
AcceptLootPermits: () =>
|
||||||
_interaction.Settings.Gameplay.AcceptLootPermits,
|
_domain.Character.Options.GetOptionBit(
|
||||||
SetAcceptLootPermits: _interaction.Settings.SetAcceptLootPermits,
|
CharacterOptionId.AcceptLootPermits),
|
||||||
|
SetAcceptLootPermits: value =>
|
||||||
|
{
|
||||||
|
SendSingleCharacterOption(
|
||||||
|
(uint)CharacterOptionId.AcceptLootPermits, value);
|
||||||
|
_interaction.Settings.SetAcceptLootPermits(value);
|
||||||
|
},
|
||||||
DisplayConsent: session.SendDisplayConsent,
|
DisplayConsent: session.SendDisplayConsent,
|
||||||
ClearConsent: session.SendClearConsent,
|
ClearConsent: session.SendClearConsent,
|
||||||
RemoveConsent: session.SendRemoveConsent,
|
RemoveConsent: session.SendRemoveConsent,
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,22 @@ public sealed class GameWindow :
|
||||||
alphaScratchBudgets.QueueBytes);
|
alphaScratchBudgets.QueueBytes);
|
||||||
WorldTime = _worldEnvironment.WorldTime;
|
WorldTime = _worldEnvironment.WorldTime;
|
||||||
Weather = _worldEnvironment.Weather;
|
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();
|
_retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot();
|
||||||
_inputCapture = new AcDream.App.Input.CompositeInputCaptureSource(
|
_inputCapture = new AcDream.App.Input.CompositeInputCaptureSource(
|
||||||
new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools),
|
new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools),
|
||||||
|
|
@ -653,6 +669,13 @@ public sealed class GameWindow :
|
||||||
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
|
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
|
||||||
_playerControllerSlot,
|
_playerControllerSlot,
|
||||||
_inputCapture);
|
_inputCapture);
|
||||||
|
// Campaign OP slice OP4 (2026-08-11): PlayerOption
|
||||||
|
// 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);
|
_framebufferResize = new FramebufferResizeController(_viewportAspect);
|
||||||
_datDir = options.DatDir;
|
_datDir = options.DatDir;
|
||||||
_worldGameState = worldGameState;
|
_worldGameState = worldGameState;
|
||||||
|
|
|
||||||
392
src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs
Normal file
392
src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs
Normal 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->m_default =
|
||||||
|
/// PlayerModule::GetDefaultOptionValue(playerModule, this->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_<Name></c>
|
||||||
|
/// / <c>ID_PlayerOption_<Name>_Help</c> against string table
|
||||||
|
/// <c>0x23000003</c> at row-build time (<c>SetToggleLabel</c>) — never
|
||||||
|
/// hard-coded English. <c><Name></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_<Name></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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,17 @@ public sealed class OptionsPanelController : IRetainedPanelController
|
||||||
private const uint UseMouseTurningSettingsId = 0x100005CCu;
|
private const uint UseMouseTurningSettingsId = 0x100005CCu;
|
||||||
private const uint ExitGameId = 0x10000617u;
|
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
|
/// <summary>Callback delegates this controller wires the seven Gameplay
|
||||||
/// buttons and the close button to. Every field maps to exactly one
|
/// buttons and the close button to. Every field maps to exactly one
|
||||||
/// button; a null field leaves that button INERT (authored, clickable,
|
/// button; a null field leaves that button INERT (authored, clickable,
|
||||||
|
|
@ -191,9 +202,39 @@ public sealed class OptionsPanelController : IRetainedPanelController
|
||||||
BindButton(layout, ReportAbuseId,
|
BindButton(layout, ReportAbuseId,
|
||||||
() => callbacks.DisplaySystemMessage(callbacks.ReportAbuseMessage));
|
() => 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;
|
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>
|
/// <summary>
|
||||||
/// Activates the tab-switching behavior (idempotent — safe even if
|
/// Activates the tab-switching behavior (idempotent — safe even if
|
||||||
/// already active). Must run AFTER <see cref="Bind"/> so this
|
/// already active). Must run AFTER <see cref="Bind"/> so this
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,11 @@ public sealed record OptionsRuntimeBindings(
|
||||||
Action<string> DisplaySystemMessage,
|
Action<string> DisplaySystemMessage,
|
||||||
Action<string> DisplayMouseTurningMacroLine,
|
Action<string> DisplayMouseTurningMacroLine,
|
||||||
Func<CameraTurningSettings> LoadCameraTurning,
|
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(
|
public sealed record InventoryRuntimeBindings(
|
||||||
ClientObjectTable Objects,
|
ClientObjectTable Objects,
|
||||||
|
|
@ -1992,6 +1996,42 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
OptionsPanelController = controller;
|
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();
|
controller.ActivateTabs();
|
||||||
|
|
||||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,32 @@ public abstract class UiElement
|
||||||
InvalidateChildOrder();
|
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)
|
public virtual bool RemoveChild(UiElement child)
|
||||||
{
|
{
|
||||||
if (!_children.Contains(child)) return false;
|
if (!_children.Contains(child)) return false;
|
||||||
|
|
|
||||||
|
|
@ -212,24 +212,4 @@ public sealed class UiTabPanel : UiDatElement, IUiChildrenAttachedListener
|
||||||
ActivePageElementId = pageElementId;
|
ActivePageElementId = pageElementId;
|
||||||
ActivePageChanged?.Invoke(previousPageElementId, 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,19 @@ public sealed class WeatherSystem
|
||||||
// SetKindFromDayGroupName, the internal RollKind hash is disabled.
|
// SetKindFromDayGroupName, the internal RollKind hash is disabled.
|
||||||
private bool _externallyDriven;
|
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)
|
public WeatherSystem(Random? rng = null)
|
||||||
{
|
{
|
||||||
// The random-seed ctor argument remains for test API compat,
|
// The random-seed ctor argument remains for test API compat,
|
||||||
|
|
@ -295,6 +308,7 @@ public sealed class WeatherSystem
|
||||||
Vector3 fogColor = kf.FogColor;
|
Vector3 fogColor = kf.FogColor;
|
||||||
float fogStart = kf.FogStart;
|
float fogStart = kf.FogStart;
|
||||||
float fogEnd = kf.FogEnd;
|
float fogEnd = kf.FogEnd;
|
||||||
|
FogMode fogMode = kf.FogMode;
|
||||||
|
|
||||||
// AdminEnvirons server override: replace fog COLOR only.
|
// AdminEnvirons server override: replace fog COLOR only.
|
||||||
// Keyframe distances unchanged until we find evidence retail
|
// Keyframe distances unchanged until we find evidence retail
|
||||||
|
|
@ -304,13 +318,21 @@ public sealed class WeatherSystem
|
||||||
if (_override != EnvironOverride.None)
|
if (_override != EnvironOverride.None)
|
||||||
fogColor = EnvironOverrideColor(_override);
|
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(
|
return new AtmosphereSnapshot(
|
||||||
Kind: _kind, // informational
|
Kind: _kind, // informational
|
||||||
Intensity: 1f, // no per-Kind easing in retail
|
Intensity: 1f, // no per-Kind easing in retail
|
||||||
FogColor: fogColor,
|
FogColor: fogColor,
|
||||||
FogStart: fogStart,
|
FogStart: fogStart,
|
||||||
FogEnd: fogEnd,
|
FogEnd: fogEnd,
|
||||||
FogMode: kf.FogMode,
|
FogMode: fogMode,
|
||||||
LightningFlash: _flashLevel, // 0 in production; TriggerFlash hook for tests
|
LightningFlash: _flashLevel, // 0 in production; TriggerFlash hook for tests
|
||||||
Override: _override);
|
Override: _override);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -841,6 +841,33 @@ public sealed class RuntimeCharacterOptionsState
|
||||||
return true;
|
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>
|
/// <summary>
|
||||||
/// Set ONE character-option bit locally, by its linear
|
/// Set ONE character-option bit locally, by its linear
|
||||||
/// <c>CharacterOptionId</c> (the same id carried on the wire by
|
/// <c>CharacterOptionId</c> (the same id carried on the wire by
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,22 @@ public sealed class RuntimeCommunicationState : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SpewBoxState SpewBox { get; }
|
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 ChatCommandTargetState CommandTargets { get; }
|
||||||
public TurbineChatState TurbineChat { get; }
|
public TurbineChatState TurbineChat { get; }
|
||||||
public FriendsState Friends { get; }
|
public FriendsState Friends { get; }
|
||||||
|
|
@ -200,6 +216,14 @@ public sealed class RuntimeCommunicationState : IDisposable
|
||||||
return;
|
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);
|
Chat.OnSystemMessage(text, (uint)type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,26 @@ public sealed class RuntimeLocalPlayerMovementState
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool AutoRunActive => _autoRunActive;
|
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 bool HasCommandInput => _hasCommandInput;
|
||||||
public MovementInput CommandInput => _commandInput;
|
public MovementInput CommandInput => _commandInput;
|
||||||
public long Revision => Interlocked.Read(ref _revision);
|
public long Revision => Interlocked.Read(ref _revision);
|
||||||
|
|
|
||||||
|
|
@ -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!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]
|
[Fact]
|
||||||
public void EnvironOverride_ForcesTintedFog()
|
public void EnvironOverride_ForcesTintedFog()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,45 @@ public sealed class RuntimeCharacterStateTests
|
||||||
Assert.Equal(beforeRevision, options.Revision);
|
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
|
// ── OP1 (Campaign OP, 2026-08-10): TrySetOption — the shared
|
||||||
// local-write-then-send/dirty seam, + the dirty/flush state machine ────
|
// local-write-then-send/dirty seam, + the dirty/flush state machine ────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,59 @@ public sealed class RuntimeCommunicationStateTests
|
||||||
Assert.Equal("", state.Chat.Snapshot()[0].Text);
|
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]
|
[Fact]
|
||||||
public void Dispose_ResetsSpewBox()
|
public void Dispose_ResetsSpewBox()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -574,4 +574,31 @@ public sealed class RuntimeLocalPlayerMovementStateTests
|
||||||
controller.ApplyServerPhysicsState(initial));
|
controller.ApplyServerPhysicsState(initial));
|
||||||
Assert.Equal(pushed, body.State);
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue