acdream/docs/research/2026-08-11-op4-review-blast.md
Erik 0acb341c29 docs: OP4 blast-radius review — APPROVE-WITH-FIXES (3 MUST before gate)
M1: the Character tab binds at startup, pre-session — rows seed from
constructor words and nothing re-seeds on PlayerDescription; Reset (and
tab-switch, which is OnHidden->Reset) then publishes the STALE baseline
over server truth, and the one-shot UiLocked assignment can permanently
disagree with the radar's polled read. M2: the Combat panel's three LEDs
still write/read the orphaned GameplaySettings copy — decorative,
wire-less, and visibly divergent from the Character tab's rows for the
same PlayerOptions (the CH3 two-writable-copies mode); headless
hardcodes for the same ids filed alongside. M3: ToggleUiLock's
convergence guard (initialized converged, now comparing values from two
different stores) can early-return past ApplyUiLock — one press flips
the wire and radar but not the windows.

S1: AddText is NOT acdream's transcript chokepoint — speech/tells,
emotes, Turbine channels, combat text and @-replies write ChatLog
directly, so the timestamp prefix reaches only ServerMessage/WeenieError
lines and gate step 13's tell/say half cannot pass. S2: sky.vert never
reads the fog-disable param — crisp terrain against a fogged horizon
with the option on. S3/S4: missing register rows for the seven
default-source changes (ViewCombatTarget's observable default flips) and
the orphaned-but-written GameplaySettings; the timestamp format is
culture-dependent where retail strftime is literal. S6: the two changed
consumer files ship without their test files touched.

Clean: scoped per-page searches structurally sound (fixture-verified),
GetOptionBit thread-safe/allocation-free, the run-XOR byte-correct vs
SetHoldRun @0x006b3370 with wire encoding untouched.

Gate steps 3 and 13 cannot pass pre-fix — the OP4 gate must wait for
the fix round.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 04:48:52 +02:00

399 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# OP4 review — regression / blast-radius lens
**Commit under review:** `22b86b9f` "feat(ui): Campaign OP slice OP4 — the Character tab"
**Reviewer lens:** regression + blast radius (both axes on every changed surface)
**Method:** read-only. Diff walk, pre/post consumer enumeration by `git grep` at
`22b86b9f^` and `22b86b9f`, fixture-tree analysis of
`options_panel_2100006E_1000018D.json`, decomp cross-check against
`docs/research/named-retail/acclient_2013_pseudo_c.txt` and `acclient.h`.
No build, no test run, no launch.
**Verdict: APPROVE-WITH-FIXES.** M1M3 must land before the OP4 connected gate
is run — the gate script this commit ships (`docs/research/2026-08-11-campaign-op-test-script.md`
§OP4 steps 3 and 13) contains steps that cannot pass against this code, so
running it now burns a user session on a known-failing check.
---
## 1. Group-C re-point: per-option old-consumer enumeration
Seven ids moved from the client-local `GameplaySettings` record (settings.json)
to `RuntimeCharacterOptionsState.GetOptionBit`. For each: every read site of the
OLD value at `22b86b9f^`, and whether it moved.
### AutoTarget (`0x0D`, Options1 `0x2000`, batched, ClientDefault **ON**)
| Old read site (`22b86b9f^`) | Moved? |
|---|---|
| `Composition/FrameRootComposition.cs:599``CombatCameraTargetSource(d.Settings, …)` | ✅ → `CharacterOptionCombatSettingsSource` |
| `Composition/SessionPlayerComposition.cs:770``LiveCombatAttackOperations(… d.Settings …)` | ✅ |
| `Composition/InteractionRetainedUiComposition.cs:286``LiveCombatTargetOperations(autoTarget: …)` | ✅ |
| `UI/Layout/CombatUiController.cs:116` (LED click writes) / `:193` (LED seed reads) | ❌ **NOT moved** |
| `Settings/RuntimeSettingsController.cs:236` `public bool AutoTarget => Gameplay.AutoTarget` | ❌ now dead (no consumer) |
| `Settings/RuntimeSettingsController.cs:422,433` (`SetCombatGameplay` VM mirrors) | ❌ still mirrors the orphan |
| `Headless/Hosting/HeadlessGameplayOperations.cs:127` `AutoTarget => true` (hardcoded) | ❌ never re-pointed |
### AutoRepeatAttack (`0x00`, Options1 `0x02`, **auto-save**, ClientDefault ON)
| Old read site | Moved? |
|---|---|
| `Composition/SessionPlayerComposition.cs:770``LiveCombatAttackOperations` (→ `RuntimeCombatAttackState:380,403,411`) | ✅ |
| `UI/Layout/CombatUiController.cs:114` / `:192` | ❌ **NOT moved** |
| `Settings/RuntimeSettingsController.cs:238` | ❌ now dead |
| `Headless/Hosting/HeadlessGameplayOperations.cs:126` `AutoRepeatAttack => false` | ❌ never re-pointed |
### ViewCombatTarget (`0x07`, Options1 `0x80`, batched, ClientDefault **OFF**)
| Old read site | Moved? |
|---|---|
| `Combat/CombatCameraTargetSource.cs:44` (via injected source) | ✅ |
| `UI/Layout/CombatUiController.cs:118` / `:194` | ❌ **NOT moved** |
| `Settings/RuntimeSettingsController.cs:240` | ❌ now dead |
⚠️ **Observable-default flip.** `GameplaySettings.Default.ViewCombatTarget = true`
(`src/AcDream.UI.Abstractions/Panels/Settings/GameplaySettings.cs:51`) but bit
`0x80` is CLEAR in retail's `CharacterOptions1.Default = 0x50C4A54A`
(→ `CharacterOptionTable.cs:118` `ClientDefault: false`). A fresh install's
combat camera stops tracking the target by default. No register row filed.
### VividTargetingIndicator (`0x0E`, Options1 `0x8000`, batched, ClientDefault ON)
| Old read site | Moved? |
|---|---|
| `Composition/InteractionRetainedUiComposition.cs:676``VividTargetRuntimeBindings` | ✅ (polled lambda) |
| `Panels/Settings/SettingsPanel.cs:322` | n/a — `SettingsPanel` has **no production construction site** (`new SettingsPanel(` appears only in `tests/AcDream.UI.Abstractions.Tests/…:40`) |
### CoordinatesOnRadar (`0x14`, Options1 `0x400000`, batched, ClientDefault ON)
| Old read site | Moved? |
|---|---|
| `Composition/LivePresentationComposition.cs:836` → radar snapshot | ✅ (polled lambda) |
### LockUI (`0x33`, Options2 `0x1000000`, **auto-save**, ClientDefault OFF)
| Old read site | Moved? |
|---|---|
| `Composition/LivePresentationComposition.cs:837` `uiLocked:` (radar) | ✅ polled — converges live |
| `Composition/InteractionRetainedUiComposition.cs:477` `host.Root.UiLocked = …` | ⚠️ moved but **one-shot at mount**, never re-read → does NOT converge |
| `Net/LiveSessionRuntimeFactory.cs:387` `ToggleUiLock` read | ✅ reads the bit — but see **M3** |
| `Settings/RuntimeSettingsController.cs:355,359,362,369` (`SetUiLocked` writer) | ❌ still writes the orphan, and is still called from `ToggleUiLock` |
Note: `LockUI` is deliberately NOT a Character-tab row
(`CharacterOptionsPageControllerTests.ExcludedIds_HaveNoRow`), and the OP4 gate
script's Group-C section (items 1718) does not list it — so this re-point ships
ungated.
### AcceptLootPermits (`0x10`, Options1 `0x80000`, **auto-save**, ClientDefault OFF)
| Old read site | Moved? |
|---|---|
| `Net/LiveSessionRuntimeFactory.cs:452` (`ClientCommandController.Bindings.AcceptLootPermits`) | ✅ |
| `Net/LiveSessionRuntimeFactory.cs:454` (`SetAcceptLootPermits`) | ✅ (now sends 0x0005 **and** writes the orphan) |
| `UI/ClientCommandController.cs:444,450` (`/consent on|off`) | ✅ via the bindings above |
| `Net/LiveSessionCommandRouter.cs:458,459` | ✅ pure forwarders |
### ToggleRun (`0x0A`, Options1 `0x400`, batched, ClientDefault ON) — Group B
| Old read site | Moved? |
|---|---|
| `Input/DispatcherMovementInputSource.cs:77` hardcoded `!walking` | ✅ → `RunAsDefaultMovement != walking` |
| `Panels/Settings/SettingsPanel.cs:314` | n/a (test-only surface) |
---
## 2. MUST-FIX
### M1 — The Character tab is seeded before login and never re-seeded; Reset then writes the stale baseline over server truth
`RetailUiRuntime.Initialize()` (`src/AcDream.App/UI/RetailUiRuntime.cs:371`)
calls `MountOptionsPanel()`, which calls
`CharacterOptionsPageController.Bind` (`RetailUiRuntime.cs:2010`). That whole
chain runs inside `lease.Mount(…)`
(`src/AcDream.App/Composition/InteractionRetainedUiComposition.cs:876`), which
runs inside `InteractionRetainedUiCompositionPhase` — a **startup** phase of
`GameWindow.OnLoad` (`src/AcDream.App/Rendering/GameWindow.cs:1231`, chain at
`:1325-1369`). No network session exists yet.
So at `CharacterOptionsPageController.cs:368`:
```csharp
bool initial = bindings.CurrentValue(spec.Id); // == GetOptionBit(...)
checkbox.Selected = initial;
var row_ = new BoolOptionRow(initial, entry.ClientDefault, apply: );
```
`initial` is read from `RuntimeCharacterOptionsState`'s **constructor** words
(`RuntimeCharacterState.cs:671-672`, `DefaultOptions1 = 0x50C4A54A` /
`DefaultOptions2 = 0x00948700`), not the character's server options.
`PlayerDescription` installs the real words later at
`src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:223` (`Options.Replace`),
and the only subscriber to `OnCharacterOptionsChanged` is
`SyncChatFromServerOptions` (`Net/LiveSessionRuntimeFactory.cs:323-324`).
**Nothing re-seeds the 50 rows.** Consequences:
1. Gate-script step 3 ("Confirm the checkboxes reflect your character's actual
state … seeded from the live server bit at panel-mount time") is false for
every option whose server value differs from the client constructor default.
2. Worse — the `BoolOptionRow` `_saved` baseline is also stale. Take a batched
option the server has ON but whose constructor default is OFF (e.g.
`SideBySideVitals`). Row seeds `_current = _saved = false`. User clicks it →
`SetCurrentValue(true)``TrySetOption` sees the bit already true → no-op
(correct). User changes their mind and clicks **Reset** (or just switches
tab, which is `OnHidden() → Reset()`, `OptionPageModel.cs:261`) →
`Changed == true``RestoreSavedValue()``_apply(false)`
`SetSingleCharacterOptionRuntimeCmd(id, false)` → **the server bit is turned
OFF.** A visually-idempotent "toggle then cancel" mutates real character
state in the wrong direction. This is the "contract asserting a mechanism
that does not exist" class the C4 closeout handoff flags as binding.
3. `host.Root.UiLocked = d.Character.Options.GetOptionBit(LockUI)`
(`InteractionRetainedUiComposition.cs:477-486`) has the same shape but is a
one-shot assignment, so retained-window locking never converges to the
server bit — while the radar's `uiLocked` lambda
(`LivePresentationComposition.cs:843-844`) polls and does converge. Two
consumers of one option, permanently able to disagree.
The fix shape is a re-seed hook on `OnCharacterOptionsChanged` (the seam CH3
already established for chat) that re-runs each row's
`SetSeed(current)` — i.e. rewrite `_current`, `_saved`, and `checkbox.Selected`
without invoking `_apply`. That verb does not exist on `BoolOptionRow` today.
### M2 — Two writable copies: the Combat panel's three LEDs still drive the orphaned `GameplaySettings`
`src/AcDream.App/UI/Layout/CombatUiController.cs:113-118` writes and `:192-194`
reads `GameplaySettings.AutoRepeatAttack / AutoTarget / ViewCombatTarget`,
wired in production at `InteractionRetainedUiComposition.cs:644-648`
(`() => d.Settings.Gameplay`, `d.Settings.SetCombatGameplay`) and mounted at
`RetailUiRuntime.cs:366` (`MountCombat()`). Nothing gameplay-facing reads that
record any more — every behavioral consumer now reads the server bit. Net
effect:
- The retail Combat window's **Repeat Attacks / Auto Target / Keep in View**
checkboxes are now decorative: clicking them changes combat behavior not at
all, and never reaches the wire.
- They and the Character tab's rows for the *same three retail
`PlayerOption`s* read from different stores, so they will visibly disagree.
- `RuntimeSettingsController.SetCombatGameplay` (`:415-442`) keeps persisting
the divergent copy to settings.json, so the disagreement survives restarts.
This is exactly the "two writable copies" failure mode the CH3 research warned
about, and it is the one old read site of the three combat options that the
re-point missed. The Combat panel's LEDs must route through
`SetSingleCharacterOptionRuntimeCmd` and seed from `GetOptionBit`, in this
slice or an immediately-following one.
### M3 — `ToggleUiLock`'s new dual write can silently drop the visual lock
`src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:392-400`:
```csharp
bool locked = !_domain.Character.Options.GetOptionBit(CharacterOptionId.LockUI);
SendSingleCharacterOption((uint)CharacterOptionId.LockUI, locked);
_interaction.Settings.SetUiLocked(locked);
```
`RuntimeSettingsController.SetUiLocked` opens with
(`src/AcDream.App/Settings/RuntimeSettingsController.cs:355`):
```csharp
if (Gameplay.LockUI == locked && _uiLockConverged) return;
```
with `_uiLockConverged = true` at construction (`:185`). **Before** this commit
`locked` was computed as `!Gameplay.LockUI`, so the guard could never trip.
Now `locked` is derived from a *different* store, so whenever the persisted
`Gameplay.LockUI` already equals the newly-computed value the method
early-returns and `_runtimeTargets?.ApplyUiLock(locked)` (`:360`) never runs —
the wire bit flips, the radar (polled) follows, and `host.Root.UiLocked` does
not. Concretely: a user with `"lockUI": true` in settings.json (set by
`/lockui` in any pre-OP4 session) presses the lock toggle once after login and
nothing happens to their windows, while the server is told the UI is locked.
---
## 3. SHOULD-FIX
### S1 — `DisplayTimeStamps` only reaches a small subset of transcript lines
`RuntimeCommunicationState.AddText` (`:219-227`) is documented as "the single
chokepoint every producer of player-visible interface text must go through",
and retail's `ClientSystem::AddTextToScroll @0x00563C50` genuinely is that
chokepoint. In acdream it is not. These producers write `ChatLog` directly and
therefore never get a timestamp:
| Producer | Site |
|---|---|
| All heard speech (say / tell / ranged) | `Runtime/Session/LiveSessionEventRouter.cs:293` `Chat.OnLocalSpeech` |
| Emotes / soul emotes | `LiveSessionEventRouter.cs:319,321` |
| Player-kill messages | `LiveSessionEventRouter.cs:325` |
| Turbine channels (allegiance/general/trade/LFG/RP/society) | `LiveSessionEventRouter.cs:~535-556` via `RouteTurbineChat` |
| All combat text | `CombatChatTranslator` (`LiveSessionEventRouter.cs:306`) writes `social.Chat` directly |
| `@age` / `@index` / `@clist` / `@hslist` / allegiance-info replies | `Core.Net/GameEventWiring.cs:162,176,183,190,197` |
| App-side echoes | `Net/LiveSessionRuntimeFactory.cs:165,169,418` |
| Headless chat | `Headless/Hosting/HeadlessGameplayOperations.cs:242` |
Only `ServerMessage (0xF7E0)` and the WeenieError family route through `AddText`
(`LiveSessionEventRouter.cs:310-317`, `GameEventWiring.cs:~655-668`). The gate
script's own step 13 ("send/receive a new chat line … e.g. `/who` or any inbound
tell/say. The NEW line should be prefixed") is therefore unsatisfiable for the
tell/say half. Either move the remaining producers behind `AddText` or narrow
the register/gate wording to what is actually implemented.
Direction check (nothing leaks where it must not): the `ClientLocal`/SpewBox
branch returns before the prefix (`:213-217`) — correct and tested; there is no
chat-log-file writer in the tree (`grep` for `ChatLogFile`/`WriteChatLog` is
empty); `AddText` is display-only so nothing reaches the wire; the chat
filters in `Panels/Chat/ChatCommandRouter.cs` / `ChatInputParser.cs` match on
*outbound input*, not transcript lines, so a prefix cannot confuse them.
### S2 — `FogMode.Off` is not honored by the sky shader
`WeatherSystem` forces `fogMode = FogMode.Off` (`WeatherState.cs:326-327`) and
the mesh/terrain fragment shaders respect it
(`Rendering/Shaders/mesh_modern.frag:48-49` `if (mode == 0) return lit;`,
`terrain_modern.frag:160`). But `Rendering/Shaders/sky.vert:159-162` computes
`vFogFactor` from `uFogParams.x/.y` only and **never reads `uFogParams.w`**;
`sky.frag:62` then blends the dome toward the fog color. With Disable Distance
Fog ON the world is crisp to the horizon while the sky dome keeps its fog-color
band — a visible seam exactly where the option is supposed to remove one. This
is a visual-gate item (gate step 14) and needs the user's eyes either way, but
the shader asymmetry is a code fact, not a judgement call.
### S3 — Observable-default changes are not registered
The register hunk adds/updates `AP-194`, `TS-73`, `TS-75``TS-80`. None of them
records that seven options changed their *effective default source* from
`GameplaySettings.Default` (or the user's persisted settings.json) to the
retail constructor word, nor that `ViewCombatTarget`'s observable default flips
`true → false`, nor that `GameplaySettings.{AutoTarget, AutoRepeatAttack,
ViewCombatTarget, LockUI, VividTargetingIndicator, CoordinatesOnRadar,
AcceptLootPermits, ToggleRun}` are now orphaned-but-still-written. The campaign
contracted register rows for observable-default changes; these are the rows.
### S4 — The timestamp format is culture-dependent
`RuntimeCommunicationState.cs:225`:
```csharp
text = DateTime.Now.ToString("H:mm:ss ") + text;
```
No `IFormatProvider`, so `:` is `CurrentCulture.DateTimeFormat.TimeSeparator`.
Retail's CRT `strftime("%#H:%M:%S ")` emits literal colons unconditionally. On a
machine whose culture uses `.` (e.g. `fi-FI`) acdream renders `13.05.09 `.
The new test `AddText_TimestampsTrue_PrefixesTranscriptLine` pins
`^\d{1,2}:\d{2}:\d{2} …` and would fail on such a machine — i.e. the bug is
also a latent CI/locale flake. Use `CultureInfo.InvariantCulture`.
### S5 — The headless host's combat-option reads were not swept
`src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs:126-127` still
hardcodes `AutoRepeatAttack => false` and `AutoTarget => true`. OP7 gave
headless bots a `characterOptions` config that writes these very bits, so a bot
configured with `AutoRepeatAttack` now sets the server bit while its own combat
loop still reads `false`. Same interface (`ICombatGameplaySettingsSource`
shape), same options, opposite direction from the graphical host after this
commit.
### S6 — Test coverage gaps on the two changed *consumers*
The Runtime-level OFF paths are well covered (fog unbound/false/true, timestamps
unbound/false/true + SpewBox exemption, `RunAsDefaultMovement` unbound/true/false
live-poll). What is **not** covered:
- `DispatcherMovementInputSource.Capture()` — the file changed
(`:83`) but `tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs`
was not touched. The existing
`CapturesDispatcherHeldStateAndRetailWalkModifier` only exercises
option-unbound (⇒ `true`) + walk-held. Nothing pins the four-way truth table
(option × modifier), nor the `|| AutoRunActive` override with the option OFF.
- The per-page scoped Apply/Reset/Defaults wiring —
`OptionsPanelControllerTests.cs` was not touched, so the mechanism the commit
message calls out (`UiElement.FindDescendant` scoping) has no test that a
Chat-page Apply drives the Chat page's model rather than Character's.
- Nothing pins the login-before-seed transition (M1) — unsurprising, since the
mechanism does not exist.
---
## 4. NOTE
- **N1 — `FindDescendant` promotion changed semantics, and the scoping is
sound.** The old private `UiTabPanel.FindDescendant` skipped the root; the new
`UiElement.FindDescendant` (`src/AcDream.App/UI/UiElement.cs:312-334`) matches
the root first. `UiTabPanel:162,163,205,208` now calls the root-inclusive
version — harmless here because the panel's own id (`0x1000018D`) is not a tab
button or page id, but it is a real behavior change worth knowing.
On the OP3 re-review's duplicate-id hazard: the committed fixture
`options_panel_2100006E_1000018D.json` has 111 nodes / 38 unique ids and **16
duplicated ids** (`0x100002CE/CF/D0` ×16 each; `0x100001FC/FD/FE` ×3). The
scoped searches are genuinely scoped — each page slot is a direct child of the
root and owns its own Apply/Reset/Defaults instance:
`/0x1000018D/0x10000211/{0x100001FC,FD,FE}`,
`/0x1000018D/0x10000213/{…}`, `/0x1000018D/0x1000050C/{…}`. A Character-page
search structurally cannot reach a Chat-page button. Separately,
`0x100001FA`/`0x100001FB` (the Character ListBox + scrollbar) are **unique**,
so `CharacterOptionsPageController`'s flat `layout.FindElement` lookups
(`:280`, `:293`) are unambiguous — but that is a property of the data, not of
the code, and nothing asserts it.
- **N2 — dead surface.** `RuntimeSettingsController.AutoTarget` /
`AutoRepeatAttack` / `ViewCombatTarget` (`:236-240`) now have zero consumers.
`GameplaySettingsState` (`Combat/LiveCombatAttackOperations.cs:23`) survives
only in `tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs:16`.
- **N3 — thread-safety is consistent; the binding fields are not volatile.**
`GetOptionBit` (`RuntimeCharacterState.cs:858-865`) reads one word through the
existing `Volatile.Read`-backed `Options1`/`Options2` properties, one static
`Dictionary` lookup, no lock, no allocation (`CharacterOptionTableEntry` is a
`readonly record struct`, `TryGetValue` uses `out`). That matches every other
read on the type. The three new `Func<bool>?` source properties are plain auto
properties written once in the `GameWindow` constructor
(`Rendering/GameWindow.cs:649-678`) before the render loop and the network
thread exist, so publication is fine in practice but is not formally
guaranteed. `WeatherSystem` is a get-only property assigned once
(`Runtime/World/RuntimeWorldEnvironmentState.cs:144,149`), so the fog binding
cannot be lost on session reset. Per-frame cost is ~3 dictionary lookups
(fog + radar ×2) + 1 in `Capture()` — negligible, and none of them is on a
Slice-I zero-allocation resolve path.
- **N4 — `FindCheckbox` is direct-children-only.**
`CharacterOptionsPageController.cs:385-392` searches the row root and its
*direct* children while the commit simultaneously promotes a recursive
`FindDescendant` for exactly this class of lookup. If a template ever nests
the checkbox one level deeper, all 50 rows silently degrade to a log line.
- **N5 — the timestamp weakens `ChatLog`'s text-keyed dedupe.**
`ChatLog.OnSystemMessage` (`src/AcDream.Core/Chat/ChatLog.cs:266-278`) dedupes
on exact text within 1 s. Timestamped and untimestamped copies of the same
logical line (one via `AddText`, one via a direct producer from S1) can no
longer match; two identical lines straddling a second boundary also stop
matching. This is an acdream-only workaround, not retail behavior, so the
consequence is a re-appearing duplicate, not a correctness break.
- **N6 — autorun ignores the new default; retail's own seam does not.**
`Run: (_movement.RunAsDefaultMovement != walking) || AutoRunActive`
(`Input/DispatcherMovementInputSource.cs:83`). The `!= walking` half is
byte-correct against retail: `CommandInterpreter::SetHoldRun @0x006b3370`
computes `(!hold_run) != (!UITogglesRun())` — i.e. `holdRun XOR ToggleRun`
and passes that to `CMotionInterp::set_hold_run`, which is precisely where
acdream's `MovementInput.Run` lands
(`Runtime/Gameplay/PlayerMovementController.cs:1166-1168`). The wire encoding
is untouched and stays retail-correct in both option states: `Run`
`IsRunning``HoldKey.Run`/`HoldKey.None` + `ForwardSpeed`
(`Runtime/Gameplay/LocalPlayerOutboundController.cs:234-248`), so no
double-inversion. There is also no collision with the `ToggleRunLock`
keybind latch — that drives `_autoRunActive`, a separate field, and the
property's doc comment says so explicitly. What is newly *reachable* is the
`|| AutoRunActive` clause: `CommandInterpreter::SetAutoRun @0x006b4850` sets
`auto_run` and calls `ApplyCurrentMovement()` — it does **not** force run —
so with the option OFF an autorunning acdream player runs where retail would
walk. That `||` predates this commit (2026-07-30 movement-parity audit) but
was unobservable while the default was hardcoded.
- **N7 — citation accuracy.** `RuntimeLocalPlayerMovementState`'s doc comment
names "retail `PlayerOption RunAsDefaultMovement`". No such member exists —
`acclient.h:4165` spells id `0xA` `ToggleRun_PlayerOption`, and
`RunAsDefaultMovement` is ACE's name for the bit. The binding
(`GameWindow.cs:677`) and the row's `RetailName` (`"ToggleRun"`, which is what
hashes `ID_PlayerOption_ToggleRun` — confirmed at pseudo-C `:171586`) are both
correct; only the prose is wrong.
- **N8 — `Defaults` can emit a burst of 21 immediate `0x0005` sends.**
`OptionPage.Defaults()` calls `RestoreDefaultValue()` on all 50 rows, each of
which publishes `SetSingleCharacterOptionRuntimeCmd`. `TrySetOption` no-ops on
unchanged values, so the worst case is bounded by the auto-save set (21 ids).
Retail does the same thing; recorded here only so a future wire-rate reviewer
does not treat it as a bug.