diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index c8a34cee..b65e7efe 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -183,7 +183,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
-| AP-194 | `CharacterOptionTable`'s `ClientDefault` column (what the Character tab's Defaults button restores) disagrees with the raw constructor default word for three ids: `ConfirmVolatileRareUse` (`0x2D`), `ShowHelm` (`0x2F`), and `ShowCloak` (`0x32`) are all ON in retail's constructor default `CharacterOptions2 = 0x00948700` (`PlayerModule::PlayerModule @0x005D51F0`, byte-verified literal write) but report default-OFF via `PlayerModule::GetDefaultOptionValue @0x005D2A30`, whose own per-option table stops at id `0x2A` and returns `false` for everything past it. This is retail's OWN behavior, reproduced deliberately — the Defaults button does not reproduce a fresh `PlayerModule`. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`ClientDefault` column; see the type's XML doc) | Byte-verified at both addresses (wire research §2.5 for the constructor literals, §8.2 for `GetDefaultOptionValue`'s own table and bounds check) — this is not a guess, it is retail's documented quirk. "Fixing" it to match the constructor default would make acdream's Defaults button MORE correct than retail's own, which is the opposite of this project's goal. | A future OP-campaign slice (OP4, the Character tab's Defaults button) must consult THIS column, not the constructor default word, or a future reader may "fix" this back and silently diverge from retail. | `PlayerModule::GetDefaultOptionValue @0x005D2A30`; `PlayerModule::PlayerModule @0x005D51F0`; `docs/research/2026-08-10-set-character-options-wire.md` §8.2 |
+| AP-194 | `CharacterOptionTable`'s `ClientDefault` column (what the Character tab's Defaults button restores) disagrees with the raw constructor default word for three ids: `ConfirmVolatileRareUse` (`0x2D`), `ShowHelm` (`0x2F`), and `ShowCloak` (`0x32`) are all ON in retail's constructor default `CharacterOptions2 = 0x00948700` (`PlayerModule::PlayerModule @0x005D51F0`, byte-verified literal write) but report default-OFF via `PlayerModule::GetDefaultOptionValue @0x005D2A30`, whose own per-option table stops at id `0x2A` and returns `false` for everything past it. This is retail's OWN behavior, reproduced deliberately — the Defaults button does not reproduce a fresh `PlayerModule`. **CONFIRMED 2026-08-11 at Campaign OP slice OP4**: `CharacterOptionsPageController` seeds every `BoolOptionRow`'s default directly from this column (`EveryRow_DefaultValue_MatchesCharacterOptionTableClientDefault`, `tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs`); the directive below was followed, not re-litigated. OP4 also independently traced retail's OWN mechanism for the Character tab specifically — `UIOption_Checkbox::SetPlayerOption @0x00486e80` (pseudo-C line 147375) sets `m_default` directly from `GetDefaultOptionValue`, confirming this column (not the separate `DBPropertyCollection`/`InqDefaultGameplayOptionProperty` mechanism that governs the Chat/Config tabs' `m_propName`-bound rows) is the correct and ONLY source for this tab. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`ClientDefault` column; see the type's XML doc); `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` | Byte-verified at both addresses (wire research §2.5 for the constructor literals, §8.2 for `GetDefaultOptionValue`'s own table and bounds check) — this is not a guess, it is retail's documented quirk. "Fixing" it to match the constructor default would make acdream's Defaults button MORE correct than retail's own, which is the opposite of this project's goal. | A future OP-campaign slice (OP4, the Character tab's Defaults button) must consult THIS column, not the constructor default word, or a future reader may "fix" this back and silently diverge from retail. | `PlayerModule::GetDefaultOptionValue @0x005D2A30`; `PlayerModule::SetPlayerOption @0x00486e80`; `PlayerModule::PlayerModule @0x005D51F0`; `docs/research/2026-08-10-set-character-options-wire.md` §8.2 |
| AP-195 | **Filed 2026-08-11 (Campaign OP OP2 re-review closure; supersedes the ported half of retired TS-72).** `UIOption_CheckboxBitfield64` ports HALF of retail's `Refresh @0x004859C0`: the ANY-set predicate driving each row's checkbox bool is exact, but the same retail pass also computes the ALL-set predicate to swap each row's LED media between the two surfaces authored on template consumer `0x10000520` (`P0x10000082 = 0x06004D17` checked-art / `P0x10000083 = 0x06004D19` unchecked-art) — acdream reads neither property anywhere. Retail's `CreateChildren` tail also self-sizes the block (`ResizeTo(GetWidth(), CalculatePaperSize(0, -1))`) because `UIOption_CheckboxBitfield64` IS a `UIElement_ListBox` (its `PostInit` tail-calls `UIElement_Scrollable::PostInit`); acdream's block instead keeps its authored 272×100 extent while stacking rows itself — a second, divergent implementation of the row-stacking mechanism beside `UiTemplateListBox` (which stacks into a lazy `UiScrollablePanel`). The row-index attribute stamp (`SetAttribute_Int(cb, 0x10000084, i)` @`0x00485E3E`) is deliberately replaced by a typed mask closure — equivalent click routing without the attribute round-trip; NOT part of this row's gap. `src/AcDream.App/UI/UiCheckboxBitfield64.cs` (`IsSet`/`AddChild`) | The checkbox bool — the half every wire-visible behavior flows through — is exact and conformance-tested (multi-bit discriminating test); the LED media swap and self-sizing are presentation-only, invisible until the Chat tab actually mounts a bitfield block, and porting them properly wants the ListBox unification (reuse `UiTemplateListBox`'s stacking or record why not) rather than a third stacking path bolted on in a closure commit | The Chat tab's five filter blocks (the only authored bitfield consumers, 12/13 rows each) would render rows whose LED art never switches between checked/unchecked surfaces and whose block height stays the authored 272×100 instead of growing to fit 13 rows (~260 px + chrome) — visibly wrong the moment OP5 mounts them; both gaps MUST close (or this row be consciously re-scoped) in OP5 before its connected gate | `UIOption_CheckboxBitfield64::Refresh @0x004859C0`; `CreateChildren @0x00485DF0`; `ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`); `docs/research/2026-08-11-op2-rereview-mechanism.md` §2.1–§2.3 |
| AP-193 | Character option id `0x34` (`ListenToPKDeathMessages` / "Listen to PK death messages") is mapped to `CharacterOptions2` bit `0x02000000` and modeled as a batched (non-auto-save) option purely on ACE's own enum — the id does not exist in the 2013 EoR PDB (`PlayerOption` there terminates at `TotalNumberOfPlayerOptions_PlayerOption = 0x34`), so neither the mask nor its `IsAutoSaveOption`/`GetDefaultOptionValue` classification is byte-verifiable against our binary. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`HearPkDeathMessages` row) | The user's retail memory (and ACE's own `CharacterOption` enum) both carry this option; shipping wire+store coverage for it is strictly better than omitting the row the Character tab's screenshots show, and ACE never actually reads the bit server-side (`PlayerFactory.cs:659-660` — "possibly was added to Defaults post PDB we have"), so a wrong id/mask/auto-save guess here has zero server-observable consequence either way. | If the final EoR client's real id/mask/auto-save classification ever surfaces (a later PDB, or a byte-level trace against a 2015+ binary), this row's values may be wrong and need correcting — until then treat them as ACE-sourced, not retail-verified. | ACE `PlayerFactory.cs:659-660`, `CharacterOptions2.cs` (`ListenToPKDeathMessages = 0x02000000`); `named-retail/acclient.h:4162-4218` (2013 `PlayerOption` terminates at `0x34`); `docs/research/2026-08-10-set-character-options-wire.md` §8.1 |
| AP-172 | **Filed 2026-08-08 (#354 fix — spell-bar drag reorder).** Retail removes a lifted favorite from `PlayerModule` (+ UI list + wire) the instant a drag starts and the remaining shortcuts visibly slide left to close the gap for the rest of the gesture (`RecvNotice_ItemListBeginDrag` → `RemoveSpellFromMenu`, live). acdream's controller performs the same PlayerModule/wire removal at drag-begin but DEFERS the whole favorite-list's visual rebuild until the drag concludes (drop or off-bar release) — the lifted cell's icon stays visible in its old slot and siblings do not slide until release, instead of reflowing continuously through the gesture. `DropFavorite` compensates by porting retail's own `AddFavorite`-side index adjustment (decrement the target index by one when the lifted item's original index was before it) against the now-intentionally-stale sibling numbering, so the FINAL landed position is byte-identical to retail's in every case exercised (`DragFavoriteOntoAnotherSlot_ThroughTheRealPointerPipeline_ReordersAndSyncsWire`). **NARROWED + CORRECTED 2026-08-08 (drop-ring change).** Correction: this row originally claimed empty-tail-slot drops were "already-live-count-relative and are untouched" — false. The #354 `-1` adjustment sat inside `DropFavorite`, which the empty-cell path also calls, so its live-count-clamped (post-lift-numbered) index was double-corrected: lifting a non-last favorite onto the empty tail landed it second-to-last instead of last. `FavoriteDropIndex` is now THE one landing computation and applies retail's rule exactly — the `-1` is gated on the lifted spell's pre-lift-numbered removal site (retail's `RemoveSpellFromMenu`-return-gated decrement @0x004C7157), which for a live-numbered empty-tail target is retail's `RemoveSpellFromMenu == -1` no-adjustment case (test `SpellFavoriteDrag_DroppedOnTheEmptyTail_AppendsAtTheEnd` fails against the double-correcting code). Narrowing: the mid-drag presentation now includes retail's authored drag-over Accept ring — `SpellCastSubMenu::OnItemListDragOver` @0x004C5990 setting the per-cell authored DragAccept child (element 0x1000045A, `UIElement_UIItem::PostInit` @0x004E1870) to `ItemSlot_DragOver_Accept` (UIStateId 0x10000040 → authored art 0x060011F9) on the hovered cell while a spell drag is live, cleared on leave/drop (`UiCatalogSlot.DragOverAcceptance` → `UiItemSlot.DrawDragAcceptOverlay`), with the ring and the drop sharing `FavoriteDropIndex` so the ring cannot promise a different landing. | `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`BeginFavoriteDrag`, `EndFavoriteDrag`, `DropFavorite`, `Tick` — the `_favoriteDragActive` gate) | `UiRoot`'s subtree-removal safety net (`ClearSubtreeOwnership`, `UiRoot.cs:240-247`) cancels any in-flight drag whose source widget is destroyed, and `Rebuild()` tears down and recreates every cell in the list (`UiItemList.Flush` → `RemoveChild` per cell) rather than incrementally diffing. Left unguarded, the press-time removal's `SpellbookChanged` event would let the very next per-frame `Tick()` (production drives this unconditionally via `RetailUiRuntime.Tick`) destroy the cell driving the gesture and silently cancel the reorder before the user could complete the drop — this was the reported bug. Deferring the rebuild for the gesture's duration is the minimal fix that does not touch the shared `UiRoot` drag machinery every other panel (toolbar/inventory/vendor/paperdoll) also depends on. | A future rewrite that makes `Rebuild()` an incremental per-cell diff (add/remove/reflow one cell) instead of flush-and-recreate-all would make this deferral unnecessary and should retire this row along with it — until then, a player watching their OWN spell bar mid-drag sees the vacated slot's icon linger and siblings snap into place only on release, rather than reflowing live as retail does — and one ring consequence of that frozen bar: when dragging rightward past the source, the Accept ring's SCREEN slot sits one cell right of where the icon finally lands (retail's live-reflowed bar makes them coincide); the ring is on the correct CELL in both — the spell lands immediately before that cell's spell, retail's exact insert-before semantic. No effect on final position, the wire pair sent, or any other panel; cross-window spellbook→favorite drops are live-count-relative and untouched (the empty-tail claim this sentence used to carry was corrected 2026-08-08 — see the Divergence column). | `gmSpellcastingUI::RecvNotice_ItemListBeginDrag` @0x004C7360 (`SpellCastSubMenu::RemoveSpellFromMenu`, immediate live-list removal at lift); `SpellCastSubMenu::AddFavorite` @0x004C7060 (`RemoveSpellFromMenu`'s return value gating the `-1`-if-lifted-before-target `m_numSpells` adjustment before `ItemList_InsertSpellShortcut`); `PlayerModule::AddSpellFavorite` @0x005D43E0 (`InsertPos`); `PlayerModule::RemoveSpellFavorite` @0x005D4910 |
@@ -352,11 +352,17 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-191 | **Filed 2026-08-10 (Campaign CH round 4, user-gate items 1+2 — retail two-plane glyph outline + authored SpewBox/chat text style, `docs/research/2026-08-10-retail-ui-text-style.md`).** The chat transcript's authored BASE STYLE (`0x10000372` in layout `0x2100003F`) carries a `0x1C`/`0x1D` pair alongside its `0x1A`/`0x1B` — `0x1D` (`TagFontColor[]`) is confirmed authored `ARGB(255,0,178,0)` (green), and `0x1C` is UNVERIFIED but most likely `TagFontDID` by symmetry with `0x1D` (both are pull-based, no `OnSetAttribute` case, unlike `0x1A`/`0x1B`/`0x21`/`0x22` which this round's commit DOES import). Retail's `AppendTextWithFont` selects a font/colour PAIR per appended run via `SetFontDIDNum`/`SetFontColorNum`, so a message's `[General]`-style channel tag can render in a distinct colour/font from the rest of the line — a capability `UiText.Line` does not have (one `Color` per whole line, no sub-line run concept). Landing this needs a per-run tag boundary threaded from `ChatTranscriptRenderer.BuildLines` through `UiText`'s line model into `UiRenderContext.DrawStringDat`, deliberately out of this round's scope (Fix 5 only changed the DEFAULT/uncolored-run seed, not the run model). `src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs` (`BuildLines`); `src/AcDream.App/UI/UiText.cs` (`Line`) | The default-fill fix (this same commit) is the higher-value, lower-risk half of retail's text-style gap for the transcript; a per-run tag concept is a larger structural change (touches the line model every transcript consumer reads) better landed as its own reviewed slice than folded into a text-style bugfix commit | Retail's `[General]`/channel-name tag prefix on a chat line renders the SAME colour as the rest of the line in acdream instead of green, and any authored tag-specific font goes unused — cosmetic only, the message text itself is unaffected | `UIElement_Text::AppendTextWithFont @0x00469de0`; `UIElement_Text::SetFontColorHelper @0x00466ac0`; `docs/research/2026-08-10-retail-ui-text-style.md` §2.3/§2.6 |
| AP-192 | **Filed 2026-08-10 (Campaign CH round-5 polish, review item S2 — non-UiText outline paths).** Authored glyph outline `0x21`/outline color `0x22` now reach every text-bearing retained widget (`UiText`, `UiButton`, `UiDatElement`, `UiField`, `UiMeter`, `UiMenu`, `UiCatalogSlot` — the last two settable-only, having no authored build path), seeded ONCE from the element's effective-default state via `ElementReader.ApplyCanonicalLegacyProjection`'s `TryGetEffectiveProperty` (DirectState-then-effective-default rule). Retail instead re-resolves text properties on every UI STATE CHANGE — a button entering state `0x3` whose StateDesc authors `0x21=true` gains the outline for the duration of that state. The authored data hits this today: the dialog panel's two buttons (`0x2100003C` elements `0x17`/`0x19`), the character panel button `0x10000535`, and the combat panel button `0x100000B2` each author `0x21=true` in state `0x3` ONLY (DefaultStateId=1 → no outline at effective-default; `0x100000B2` also authors DirectState `0x21=true`, which the canonical rule DOES honor). The same seed-once shape already governs `UiText` (its `ApplyDatState` re-resolves `0x1B` FontColor per state but not `0x21`/`0x22`). `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (BuildButton/BuildCheckbox/BuildMeter/BuildText + the editable-field branch); `src/AcDream.App/UI/UiText.cs` (`ApplyDatState`) | Seed-once from the canonical effective state is strictly closer to retail than the pre-round-5 any-state first-wins scan (which lit those state-`0x3` outlines PERMANENTLY); the widening this row rides in on makes every ALWAYS-outlined authored element (DirectState/default-state authors) render retail-correct, and per-state re-resolution needs a property-application pass on the existing `TrySetRetailState` path — a reviewed slice of its own, not a polish-commit fold-in | A button that retail outlines only in a specific UI state (the four state-`0x3` authors above — state 3 is a hover/highlight-class state) never shows that transient outline in acdream; conversely nothing over-renders, since the effective-default resolution correctly yields outline-off for those elements | `UIElement_Text::SetOutline @0x0046a81c` (`m_bitField & 0x10`); `UIElement_Text::DrawSelf @0x00467aa0` (two-pass outline+fill); LayoutDesc fixtures `dialogs_2100003C.json` (`0x17`/`0x19`), `character_2100002E.json` (`0x10000535`), `combat_21000073.json` (`0x100000B2`) |
-## 4. Temporary stopgap (TS) — 41 active rows (TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
+## 4. Temporary stopgap (TS) — 47 active rows (TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
-| TS-73 | `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) covers only the two cases that mutate `PlayerModule` state itself — `case 2 IgnoreFellowshipRequests` / `case 0x12 FellowshipAutoAcceptRequests`, the fellowship mutual-exclusion pair (MF-2, OP1 review fix). The other four cases (`0x04 DisableMostWeatherEffects`, `0x05 PersistentAtDay`, `0x07 ViewCombatTarget`, `0x30 DisableDistanceFog`) are presentation bindings retail wires to `SmartBox::EnableWeather`/`LScape::SetDay`/`ClientCombatSystem::TrackTarget`/`LScape::m_fFogEnabled` — none of which this Runtime-only seam can reach today. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | Pre-anchored, not newly discovered: Campaign OP's own slice map already assigns these four options' consumers to OP4's "Group B one-line binds" (weather/daylight/fog) and OP3's mouse-turning row — the campaign plan (`docs/plans/2026-08-10-options-panel-campaign.md` §4 OP4) is the tracking mechanism, this row is the auditable pointer to it from the code that actually omits them. | Until OP4 lands, toggling `DisableMostWeatherEffects`/`PersistentAtDay`/`ViewCombatTarget`/`DisableDistanceFog` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, distance fog doesn't toggle, day/night doesn't force, combat-target tracking doesn't engage) until the next full reconnect re-derives it from some other path. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 |
+| TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 |
+| TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) |
+| TS-76 | Five Character-tab rows have no acdream consumer at all (research doc §4.2's own "state-only, no consumer" list, narrowed to the ids NOT already closed by Campaign OP's Group-C re-points): "Display 3D Tooltips" (`ShowTooltips`), "Side By Side Vitals" (`SideBySideVitals`), "Display Spell Durations" (`SpellDuration`), "Advanced Combat Interface" (`AdvancedCombatUI`), "Stay in Chat Mode After Sending a Message" (`StayInChatMode`) — retail renders 3D item tooltips, an alternate side-by-side vitals layout, remaining-duration overlays on enchantment icons, an expanded combat panel, and a chat-input-stays-open behavior respectively; acdream has none of the four rendering surfaces and no chat-input-close-on-send behavior to gate in the first place. | `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (the rows wire+store only) | Each needs a real UI/behavior feature built before the option means anything — inventing a stand-in now would be exactly the workaround CLAUDE.md forbids. | Toggling any of the five writes the bit and dirties/auto-saves it correctly, but no observable client behavior changes. | `gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004e9da0`; `EffectInfoRegion::Update @0x004f1c00`; `gmCombatUI::RecvNotice_SetCombatMode @0x004cc620`; `ChatInterface::HandleEnterKey @0x004f52d0`; `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004e5ad0` |
+| TS-77 | "Filter Language" (`PlayerOption FilterLanguage`) has no acdream consumer — retail filters profanity out of chat text against a DAT `TabooTable`/`NameFilterTable` at the SAME `ClientSystem::AddTextToScroll` chokepoint `RuntimeCommunicationState.AddText` now applies Display-Timestamps at; acdream has no profanity-filter subsystem to gate. | `src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs` (`AddText`) | A real filter needs the DAT `TabooTable`/`NameFilterTable` reader (types exist in `DatReaderWriter.DBObjs`, unread by acdream) and the actual retail filter algorithm — future scope, not invented here. | Toggling the option writes the bit and dirties/auto-saves it correctly, but no chat text is ever filtered. | `ClientSystem::AddTextToScroll @0x00563c50`; `DatReaderWriter.DBObjs.TabooTable`/`NameFilterTable` |
+| TS-78 | "Use Main Pack as Default for Picking Up Items" (`PlayerOption MainPackPreferred`) has no acdream consumer — retail's `CPlayerSystem::PlaceInBackpack @0x0055d8c0` chooses which container a picked-up item lands in client-side; acdream's pickup path (`SendPickup`) has no client-side preferred-container selection at all today. | item-pickup path (`src/AcDream.App/UI/ItemInteractionController.cs` and siblings) — no consumer wired | A real consumer needs the client-side container-preference decision retail's `PlaceInBackpack` makes, which does not exist in the current pickup flow — future scope. | Toggling the option writes the bit and dirties/auto-saves it correctly, but item pickups route exactly as before (server-decided placement). | `CPlayerSystem::PlaceInBackpack @0x0055d8c0` |
+| TS-79 | Group D (plan §4 OP4): "Salvage Multiple Materials at Once" (`SalvageMultiple`) and "Disable House Restriction Effects" (`DisableHouseRestrictionEffects`) have no acdream consumer — acdream has no salvage UI (`gmSalvageUI`) and no housing subsystem (`ACCWeenieObject::CanMoveInto`) for either option to gate. | no consumer — both are Character-tab rows, wire+store only | Both require whole unbuilt subsystems (salvage crafting UI; player housing); inventing a stand-in is out of scope for a settings-panel slice. | Toggling either option writes the bit and dirties/auto-saves it correctly, but no observable client behavior changes (both are also currently unreachable — no salvage UI, no housing). | `gmSalvageUI::IsItemSuitable @0x004cb040`; `ACCWeenieObject::CanMoveInto @0x0058da40` |
+| TS-80 | "Share Fellowship Experience and Luminance" (`PlayerOption FellowshipShareXP`) is Group D's one CLIENT-SOURCED option (character-options-map.md §3): retail's `gmFellowshipUI::CreateFellowship` reads the option value and puts it directly in the fellowship-CREATE wire action; ACE takes XP-sharing from that packet field, never from the stored `CharacterOptions1` bit (`Entity/Fellowship.cs:31,53-54`). Storing the bit alone (this slice's row) is necessary but not sufficient — acdream's own fellowship-create action does not yet read it into the create packet. | fellowship-create action (not yet located/audited in acdream's `src/AcDream.App`) | Filed rather than silently assumed correct — a bit that LOOKS wired (toggles, persists, sends `0x0005`) but is never actually consulted by fellowship creation would silently share/withhold XP incorrectly the moment a fellowship is created. | Toggling the option and then creating a fellowship may not honor the toggle — the created fellowship's actual XP-share setting depends on whatever acdream's fellowship-create action currently sends, unaudited by this slice. | `gmFellowshipUI::CreateFellowship` (address not captured this slice); ACE `Entity/Fellowship.cs:31,53-54` |
| ~~TS-1~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the row was stale, not the code.** The cited `:1254` line is unrelated stepping-loop code; the file moved substantially since the row was written. Retail's `EdgeSlide → PrecipiceSlide / CliffSlide` chain is already a real, tested port: `SpherePath.PrecipiceSlide` (`TransitionTypes.cs:943-970`, retail `SPHEREPATH::precipice_slide` pc:274316), `Transition.CliffSlide` (`:2080-2164`, retail `CTransition::cliff_slide` pc:272397, return-value mapping verified against `acclient.h:6100-6108`), and `Transition.EdgeSlideAfterStepDownFailed` (`:1907-2078`, mirrors `CTransition::edge_slide` pc:273001-273090). The one real gap (back-probe fallback skipping retail's `walkable_check_pos`/`localspace_sphere` recache, pc:274318-274326) needed no code change: acdream's `WalkableVertices`/`GlobalSphere` are populated in unified world space at assignment time (`SetWalkable`/`SetWalkableTransformed`, `SetCheckPos`/`RestoreCheckPos`), so both operands `BSPQuery.FindCrossedEdge` compares are already commensurable — retail's per-cell local-frame reprojection is a no-op correction here. Documented in-code at the back-probe site and pinned by `EdgeSlideBackProbePrecipiceSlideTests`. The chain's two acdream-only compensating branches (CliffSlide's three-source reference-normal fallback; the walkable-steepness reroute to CliffSlide before PrecipiceSlide) are real, non-retail additions — filed as AD-53 / AD-54 rather than folded into this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SpherePath.PrecipiceSlide`, `Transition.CliffSlide`, `Transition.EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/EdgeSlideBackProbePrecipiceSlideTests.cs` | — | — | `SPHEREPATH::precipice_slide` pc:274316 (0050cc80); `CTransition::cliff_slide` pc:272397 (0050a6d0); `CTransition::edge_slide` pc:273001-273090 (0050b3d0); `SPHEREPATH::get_walkable_pos`/`cache_localspace_sphere`/`set_walkable_check_pos` pc:274318-274326 (0050a8f0/0050c9d0/00509ce0); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2, §6 Step 1 |
| ~~TS-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 2B; corrective acceptance complete).** The graph and prepared-flat Path-6 implementations now match retail's exact two-sphere split: every primary/foot polygon hit calls `SetCollide`, sets `WalkableAllowance=LandingZ`, and returns `Adjusted`; only a secondary/head hit writes `CollisionNormal` and returns `Collided`. The steep tangent shortcut and every BSP-layer `SetSlidingNormal` write are deleted. Exact site tests pin all changed and preserved fields plus raw-bit graph/flat parity. A corrective 90-tick already-airborne, zero-root-motion Core suite executes acceleration, body integration, transition resolution, exact commit, and `handle_all_collisions` while retaining every behavior-bearing collision/body field used by that specialized quantum. Vertical, inward, tangential, downhill, and positive-Z uphill-jump traces match graph/flat by raw bits, reject penetration/fixed points/second launches, and pin exact terminal velocity, contact, sliding, and contact-plane state. The older resolver-only capture is explicitly historical and restored to its three-second bound. | `src/AcDream.Core/Physics/BSPQuery.cs`; `src/AcDream.Core/Physics/FlatBspQuery.cs`; `tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs` | — | — | `BSPTREE::find_collisions` 0x0053A440: head `0x0053A793..0x0053A7A4`, foot `0x0053A7B3..0x0053A7DC`; research §10 |
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
diff --git a/docs/research/2026-08-11-campaign-op-test-script.md b/docs/research/2026-08-11-campaign-op-test-script.md
index d8981521..7ba8db09 100644
--- a/docs/research/2026-08-11-campaign-op-test-script.md
+++ b/docs/research/2026-08-11-campaign-op-test-script.md
@@ -1,8 +1,8 @@
# Campaign OP connected-gate test script
-**Status:** OP3 and OP7 sections. Later slices (OP4-6, OP8) append their own
-sections here as they land; the campaign's OP9 closeout gate is this
-document complete plus every slice code-complete.
+**Status:** OP3, OP4, and OP7 sections. Later slices (OP5-6, OP8) append
+their own sections here as they land; the campaign's OP9 closeout gate is
+this document complete plus every slice code-complete.
This document is the script the user runs against the live connected
client (`ACDREAM_LIVE=1` against the local ACE server) to accept each
@@ -166,6 +166,171 @@ for this slice, not a bug.
---
+## OP4 — the Character tab
+
+The Character tab (`0x21000028`) is the biggest single tab: 6 authored
+group headers and 50 toggle rows (49 from the 2013 build + D3's "Listen to
+PK death messages"), each bound by its retail `PlayerOption` id through
+`CharacterOptionTable` and the shared `RuntimeCharacterOptionsState.
+TrySetOption` seam every other Options-panel consumer already uses.
+
+### Opening the tab and reading the rows
+
+1. **Open the Options panel (F11) and click the Character tab.** Six
+ group headers appear top-to-bottom: **User Interface Behavior** (3
+ rows), **User Interface Display** (15 rows), **Grouping** (6 rows),
+ **Other Players** (11 rows), **Character Behavior** (7 rows), **Chat**
+ (8 rows — 7 from retail plus "Listen to PK death messages"). Every row
+ has a label; hover a few and confirm a tooltip appears. If ANY row
+ shows no label at all, that is a DAT string-resolution miss worth
+ reporting (the code deliberately renders nothing rather than invented
+ English when a string fails to resolve — a blank row is the correct
+ FAILURE shape, not a crash, but still worth flagging which row).
+2. **Scroll the list** via the scrollbar. All 50+6 rows are reachable;
+ the list does not clip or overlap the Apply/Reset/Defaults buttons at
+ the bottom.
+3. **Confirm the checkboxes reflect your character's actual state** —
+ e.g. if you have `IgnoreAllegianceRequests` on from a prior session,
+ its row should show checked on open (seeded from the live server bit
+ at panel-mount time, not always-off).
+
+### Auto-save rows — immediate `0x0005`
+
+4. **Toggle "Listen to General Chat" off, then on again**, without
+ clicking Apply. Confirm the effect is IMMEDIATE and matches the
+ existing Settings-panel Hear\*Chat behavior: turning it off silently
+ drops you from general chat (no more `/g` messages appear); turning it
+ back on restores them. This id is auto-save — the wire send happens on
+ the click itself, not on Apply.
+5. **Toggle "Automatically Repeat Attacks" or "Accept Corpse Looting
+ Permissions"** (both auto-save). These previously flipped ONLY a
+ client-local flag and never reached the wire (an unfiled divergence
+ this slice closes) — verify the *behavior* actually changes in
+ combat/looting, not just the checkbox art.
+6. **Relog the same character** after toggling a couple of auto-save
+ rows. Reopen the panel — the rows should still read whatever you left
+ them at (server echo), confirming the send actually reached ACE and
+ persisted, not just the local bit.
+
+### Batched rows — the `0x01A1` blob via Apply
+
+7. **Toggle a BATCHED row** (e.g. "Side By Side Vitals" or "Display Date
+ of Birth" — anything NOT in the auto-save set) and click **Apply**.
+ Relog and reopen the panel: the row should still read your new value
+ (Apply flushed the `0x01A1` blob, ACE persisted it, the fresh
+ `PlayerDescription` echoes it back).
+8. **Toggle a batched row WITHOUT clicking Apply**, then relog. The row
+ should revert to its PRE-toggle value on reopen — an un-flushed
+ batched change never reached the wire (the 480 s auto-save timer is a
+ Runtime-level mechanism tested at OP1; do not wait 8 minutes for this
+ gate — just don't click Apply).
+
+### Apply / Reset / Defaults semantics
+
+9. **Toggle several rows (mix of auto-save and batched), then click
+ Reset.** Every row you touched reverts to its pre-edit value; rows you
+ didn't touch are unaffected. Auto-save rows you toggled will have
+ ALREADY sent their change on the click — Reset sends the REVERTED
+ value as a fresh change (a second `0x0005`), which is retail-correct
+ (Reset re-applies live, it does not "undo" a wire message that already
+ happened).
+10. **Click Defaults.** Every row jumps to its retail default value
+ LIVE — auto-save rows whose default differs from current send
+ immediately; batched rows just dirty the module. Apply/Reset stay (or
+ become) ENABLED afterward if anything actually changed — Defaults
+ does NOT commit a new baseline by itself.
+11. **After clicking Defaults, click Apply.** The defaulted batched rows'
+ values now flush via `0x01A1`; the new state becomes the baseline
+ (Reset can no longer undo past this point).
+12. **Switch to another tab (e.g. Gameplay) without clicking Apply after
+ an edit.** Uncommitted edits silently revert — switching tabs is a
+ Reset, not a save. Switch back to Character and confirm the
+ reverted state.
+
+### Group B consumers — presentation bindings
+
+13. **Toggle "Display Timestamps" on, then send/receive a new chat
+ line** (e.g. `/who` or any inbound tell/say). The NEW line should be
+ prefixed with a timestamp like `14:32:07 ` (no leading zero on the
+ hour). Lines already in the transcript before you toggled the option
+ do NOT retroactively gain a timestamp. Toggle it back off — new lines
+ stop getting the prefix.
+14. **Toggle "Disable Distance Fog" on** while outdoors somewhere with
+ visible atmospheric fog at a distance (dusk/dawn or an overcast
+ keyframe shows this most clearly). Distant terrain/objects should
+ stop fading into the fog color — toggle back off and the fog returns.
+15. **Toggle "Run as Default Movement" off.** Press W (or your bound
+ forward-movement key) alone, with no modifier held: your character
+ should now WALK by default instead of run. Hold the walk-mode
+ modifier key while this option is off: it should now temporarily make
+ you RUN (the modifier always inverts whichever default is active).
+ Toggle the option back on and confirm W-alone runs again (today's
+ pre-OP4 behavior).
+16. **"Display 3D Tooltips", "Side By Side Vitals", "Display Spell
+ Durations", "Advanced Combat Interface", "Stay in Chat Mode",
+ "Disable Most Weather Effects", "Always Daylight Outdoors", "Filter
+ Language", "Use Main Pack as Default for Picking Up Items" — verify
+ these are TOGGLEABLE and PERSIST (store+wire only, per the plan's own
+ contract), but do NOT expect any observable client behavior change
+ from them yet.** acdream has no existing consumer surface for these
+ (no side-by-side vitals layout, no 3D tooltip rendering, no discrete
+ weather-particle system to gate, no client-side pickup-container
+ preference, no profanity filter) — this is an honest, registered gap,
+ not a bug to report. "Always Daylight Outdoors" specifically does NOT
+ force daytime lighting yet; do not confuse it with the unrelated
+ weather-VARIETY forcing mechanism already in the codebase.
+
+### Group C — re-pointed to server truth
+
+17. **Toggle "Auto Target", "Automatically Repeat Attacks", "Keep Combat
+ Targets in View", "Vivid Targeting Indicator", "Show Coordinates By
+ the Radar", or "Accept Corpse Looting Permissions" and confirm the
+ EXISTING behavior for each (auto-targeting in combat, the combat
+ camera tracking your target, the vivid target overlay, radar
+ coordinate labels, corpse-loot consent) still works exactly as
+ before — these now read the SERVER bit instead of the local
+ `settings.json` copy, so the observable behavior should be unchanged
+ UNLESS your character's server-side value differs from whatever
+ `settings.json` had (in which case the NEW, server-driven behavior is
+ correct and the old local-only behavior was the bug).
+18. **Relog and confirm all six Group-C options above read their
+ server-persisted value**, not a locally-cached default.
+
+### What to report
+
+- Any row with a missing label/tooltip (note which one).
+- Any auto-save row whose behavior doesn't persist across a relog.
+- Any batched row that persists WITHOUT clicking Apply, or reverts
+ DESPITE clicking Apply.
+- Any Reset/Defaults button that stays permanently disabled, or Defaults
+ that becomes disabled (it must never be).
+- Any crash, freeze, or scroll glitch anywhere in the 56-row list.
+- Timestamps, fog toggling, or run-as-default NOT working as described in
+ 13-15.
+- Any Group-C option (17-18) behaving DIFFERENTLY than it did before this
+ slice, other than "now reads the correct server value instead of a
+ stale local one."
+
+### Explicitly NOT in scope for this gate
+
+- Chat tab / Config tab content — OP5/OP6.
+- Configure Keyboard — OP8.
+- Any observable change from Group A rows (privacy flags, fellowship
+ grouping, missile/charge-attack options, helm/cloak visibility, and
+ similar) beyond the checkbox itself toggling and persisting — retail's
+ OWN client has no local consumer for most of these either; the server
+ is the only place their effect is visible (and largely invisible to the
+ player triggering them).
+- "Always Daylight Outdoors" and "Disable Most Weather Effects" actually
+ changing the sky/weather — no acdream consumer exists yet (see item 16
+ and this slice's register rows).
+- "Use Main Pack as Default for Picking Up Items" actually changing
+ pickup routing — no client-side preferred-container consumer exists.
+- "Filter Language" actually filtering profanity — no filter subsystem
+ exists.
+
+---
+
## OP7 — headless `characterOptions`
Unlike OP3-OP6, this is not a graphical-client gate: no window is launched.
diff --git a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs
index 1fe592f8..d2afcd23 100644
--- a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs
+++ b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs
@@ -1,6 +1,7 @@
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.Core.Combat;
+using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Panels.Settings;
@@ -27,6 +28,42 @@ internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource
public bool ViewCombatTarget => Value.ViewCombatTarget;
}
+///
+/// D7 Group-C re-point (Campaign OP slice OP4, 2026-08-11):
+/// AutoTarget/AutoRepeatAttack/ViewCombatTarget move
+/// from the client-local GameplaySettings record (what
+/// and the legacy
+/// RuntimeSettingsController read) to the canonical
+/// server-authoritative — the
+/// CH3 precedent (server bit is authoritative; the Character-tab panel
+/// row's LED click writes THROUGH RuntimeCharacterOptionsState.
+/// TrySetOption 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):
+/// AutoRepeatAttack and (via ClientCommandController's
+/// /consent re-point, same commit) AcceptCorpseLootingPermissions
+/// were client-local and never reached the wire even though retail
+/// auto-saves both (0x0005 immediately).
+///
+internal sealed class CharacterOptionCombatSettingsSource : ICombatGameplaySettingsSource
+{
+ private readonly RuntimeCharacterOptionsState _options;
+
+ public CharacterOptionCombatSettingsSource(RuntimeCharacterOptionsState options)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ public bool AutoTarget =>
+ _options.GetOptionBit(CharacterOptionId.AutoTarget);
+
+ public bool AutoRepeatAttack =>
+ _options.GetOptionBit(CharacterOptionId.AutoRepeatAttack);
+
+ public bool ViewCombatTarget =>
+ _options.GetOptionBit(CharacterOptionId.ViewCombatTarget);
+}
+
internal interface ICombatFeedbackSink
{
void Show(string message);
diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs
index d661dc3a..95ade180 100644
--- a/src/AcDream.App/Composition/FrameRootComposition.cs
+++ b/src/AcDream.App/Composition/FrameRootComposition.cs
@@ -596,7 +596,11 @@ internal sealed class FrameRootCompositionPhase
session.LocalPlayerFrame,
session.LiveSpatialReconciler,
new AcDream.App.Combat.CombatCameraTargetSource(
- d.Settings,
+ // D7 Group-C re-point (Campaign OP OP4): server bit, not
+ // the client-local GameplaySettings record — see
+ // CharacterOptionCombatSettingsSource's doc comment.
+ new AcDream.App.Combat.CharacterOptionCombatSettingsSource(
+ d.Runtime.CharacterOwner.Options),
d.Combat,
d.Selection,
live.SelectionQuery));
diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
index e97f72e9..3c8dd92c 100644
--- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
+++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
@@ -283,7 +283,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
InteractionRetainedUiDependencies d,
DeferredSelectionUiAuthority selection) =>
d.CombatTargetOperations.BindOwned(new LiveCombatTargetOperations(
- autoTarget: () => d.Settings.Gameplay.AutoTarget,
+ // D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server
+ // bit — see CharacterOptionCombatSettingsSource's doc comment.
+ autoTarget: () => d.Character.Options.GetOptionBit(
+ CharacterOptionId.AutoTarget),
selectClosestTarget: () =>
selection.SelectClosestCombatTarget(showToast: false)));
@@ -474,7 +477,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
checkpoint(InteractionRetainedUiCompositionPoint.UiHostAcquired);
inputCapture = d.RetainedInputCapture.Bind(host.Root);
checkpoint(InteractionRetainedUiCompositionPoint.InputCaptureBound);
- host.Root.UiLocked = d.Settings.Gameplay.LockUI;
+ // D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server
+ // bit at mount time — see CharacterOptionCombatSettingsSource's
+ // doc comment. Live toggling afterward still flows through
+ // RuntimeSettingsController.SetUiLocked's existing
+ // _runtimeTargets?.ApplyUiLock push (ToggleUiLock's Bindings
+ // site, LiveSessionRuntimeFactory.cs, now also sends the wire
+ // bit — see its own comment).
+ host.Root.UiLocked = d.Character.Options.GetOptionBit(
+ CharacterOptionId.LockUI);
var cursorFeedback = new CursorFeedbackController(
itemInteraction,
@@ -673,7 +684,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
VividTarget: new VividTargetRuntimeBindings(
d.Actions.Selection,
() => d.PlayerIdentity.ServerGuid,
- () => d.Settings.Gameplay.VividTargetingIndicator,
+ // D7 Group-C re-point (Campaign OP OP4, 2026-08-11):
+ // server bit, not the client-local GameplaySettings
+ // record — CH3 precedent (server-authoritative,
+ // local-write-then-send; the Character-tab panel row
+ // writes through RuntimeCharacterOptionsState.
+ // TrySetOption before this read can observe it).
+ () => d.Character.Options.GetOptionBit(
+ CharacterOptionId.VividTargetingIndicator),
late.Selection.ResolveVividTargetInfo,
late.SelectionCamera.UiSnapshot),
Indicators: new IndicatorRuntimeBindings(
@@ -838,7 +856,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
DisplayMouseTurningMacroLine: text =>
d.Communication.AddText(text, RetailLogTextType.Magic),
LoadCameraTurning: d.Settings.LoadCameraTurning,
- SaveCameraTurning: d.Settings.SaveCameraTurning),
+ SaveCameraTurning: d.Settings.SaveCameraTurning,
+ // Campaign OP slice OP4 (2026-08-11): the Character-tab
+ // panel's row-seed source.
+ CurrentCharacterOption: id => d.Character.Options.GetOptionBit(id)),
StackSplitQuantity: d.StackSplitQuantity,
Plugins: d.UiRegistry,
Persistence: persistence,
diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs
index 55617bb2..6c481669 100644
--- a/src/AcDream.App/Composition/LivePresentationComposition.cs
+++ b/src/AcDream.App/Composition/LivePresentationComposition.cs
@@ -19,6 +19,7 @@ using AcDream.App.World;
using AcDream.Core.Audio;
using AcDream.Core.Items;
using AcDream.Core.Lighting;
+using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Plugins;
using AcDream.Core.Rendering;
@@ -27,6 +28,7 @@ using AcDream.Core.Vfx;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
+using AcDream.Runtime.Gameplay;
using AcDream.Runtime.World;
using AcDream.UI.Abstractions.Panels.SpewBox;
using DatReaderWriter;
@@ -83,6 +85,8 @@ internal sealed record LivePresentationDependencies(
public RuntimeLocalPlayerMovementState PlayerController =>
Runtime.MovementOwner;
+
+ public RuntimeCharacterState Character => Runtime.CharacterOwner;
}
internal sealed record LivePresentationResult(
@@ -833,8 +837,14 @@ internal sealed class LivePresentationCompositionPhase
playerYawRadians: () => d.PlayerController.Controller?.Yaw ?? 0f,
playerCellId: () => d.PlayerController.Controller?.CellId ?? 0u,
selectedGuid: () => d.Selection.SelectedObjectId,
- coordinatesOnRadar: () => d.Settings.Gameplay.CoordinatesOnRadar,
- uiLocked: () => d.Settings.Gameplay.LockUI,
+ // D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server
+ // bit, not the client-local GameplaySettings record — see
+ // CharacterOptionCombatSettingsSource's doc comment
+ // (AcDream.App.Combat).
+ coordinatesOnRadar: () => d.Character.Options.GetOptionBit(
+ CharacterOptionId.CoordinatesOnRadar),
+ uiLocked: () => d.Character.Options.GetOptionBit(
+ CharacterOptionId.LockUI),
spatialQuery: () => worldState);
bindings.Adopt(
"radar snapshot",
diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs
index e11a412c..25c532f2 100644
--- a/src/AcDream.App/Composition/SessionPlayerComposition.cs
+++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs
@@ -767,7 +767,10 @@ internal sealed class SessionPlayerCompositionPhase
live.LiveEntities,
d.EntityObjects.Objects,
d.PlayerIdentity),
- d.Settings,
+ // D7 Group-C re-point (Campaign OP OP4): server bit,
+ // not the client-local GameplaySettings record — see
+ // CharacterOptionCombatSettingsSource's doc comment.
+ new CharacterOptionCombatSettingsSource(d.Character.Options),
d.PlayerController,
d.PlayerOutbound,
liveSessionSource,
diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs
index 977102b4..2ef5eb2c 100644
--- a/src/AcDream.App/Input/DispatcherMovementInputSource.cs
+++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs
@@ -74,7 +74,14 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
// applies to ordinary held-key movement. Recomputing `!walking`
// unconditionally let a walk-mode toggle demote an active autorun
// to walking — impossible in retail.
- Run: !walking || AutoRunActive,
+ //
+ // Campaign OP slice OP4 (2026-08-11): `!walking` was a hardcoded
+ // "run by default" assumption. PlayerOption RunAsDefaultMovement
+ // (retail default ON, matching the prior hardcoded behavior
+ // exactly) now supplies the default; the walk-mode modifier
+ // still temporarily INVERTS whichever default is active, same
+ // as before.
+ Run: (_movement.RunAsDefaultMovement != walking) || AutoRunActive,
Jump: dispatcher.IsActionHeld(InputAction.MovementJump));
}
diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
index 71d3ab60..48cc1a73 100644
--- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
+++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs
@@ -382,9 +382,23 @@ internal sealed class LiveSessionRuntimeFactory
QueryAge: session.SendQueryAge,
QueryBirth: session.SendQueryBirth,
ToggleFrameRate: _interaction.Settings.ToggleFrameRate,
+ // D7 Group-C re-point (Campaign OP OP4, 2026-08-11): flips the
+ // SERVER bit through the shared write-then-send/dirty seam
+ // (SendSingleCharacterOption above, itself
+ // RuntimeCharacterOptionsState.TrySetOption) — LockUI is
+ // auto-save (CharacterOptionTable), so this sends 0x0005
+ // immediately, exactly like the Character-tab panel row.
+ // SetUiLocked still runs too, for its existing
+ // _runtimeTargets?.ApplyUiLock(locked) immediate visual push
+ // (host.Root.UiLocked) and its legacy GameplaySettings mirror
+ // (retired in OP9).
ToggleUiLock: () =>
- _interaction.Settings.SetUiLocked(
- !_interaction.Settings.Gameplay.LockUI),
+ {
+ bool locked = !_domain.Character.Options.GetOptionBit(
+ CharacterOptionId.LockUI);
+ SendSingleCharacterOption((uint)CharacterOptionId.LockUI, locked);
+ _interaction.Settings.SetUiLocked(locked);
+ },
// ClientCommandController's informational output sink — 0x00
// Default, NOT 0x1A (corrected 2026-08-09, Opus review of
// 172c6f9a). Retail types command output like @version/@loc
@@ -449,9 +463,23 @@ internal sealed class LiveSessionRuntimeFactory
.Properties.GetBool(0x6Eu) == true,
SetAway: session.SendSetAfkMode,
SetAwayMessage: session.SendSetAfkMessage,
+ // D7 Group-C re-point (Campaign OP OP4, 2026-08-11): closes the
+ // unfiled divergence character-options-map.md §0 names —
+ // `/consent on|off` flipped only the client-local
+ // GameplaySettings.AcceptLootPermits bool and never reached the
+ // wire, even though retail auto-saves this id (0x0005
+ // immediately). Now routes through the SAME
+ // SendSingleCharacterOption seam the Character-tab panel row
+ // uses.
AcceptLootPermits: () =>
- _interaction.Settings.Gameplay.AcceptLootPermits,
- SetAcceptLootPermits: _interaction.Settings.SetAcceptLootPermits,
+ _domain.Character.Options.GetOptionBit(
+ CharacterOptionId.AcceptLootPermits),
+ SetAcceptLootPermits: value =>
+ {
+ SendSingleCharacterOption(
+ (uint)CharacterOptionId.AcceptLootPermits, value);
+ _interaction.Settings.SetAcceptLootPermits(value);
+ },
DisplayConsent: session.SendDisplayConsent,
ClearConsent: session.SendClearConsent,
RemoveConsent: session.SendRemoveConsent,
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 949cc94d..9d688d4a 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -646,6 +646,22 @@ public sealed class GameWindow :
alphaScratchBudgets.QueueBytes);
WorldTime = _worldEnvironment.WorldTime;
Weather = _worldEnvironment.Weather;
+ // Campaign OP slice OP4 (2026-08-11): PlayerOption
+ // DisableDistanceFog, polled — see WeatherSystem.
+ // DisableDistanceFogSource's doc comment. Bound once here (not
+ // per-session): _runtime.CharacterOwner is the ONE canonical
+ // RuntimeCharacterOptionsState instance for this GameRuntime's
+ // whole lifetime, reused (not replaced) across reconnects.
+ Weather.DisableDistanceFogSource = () =>
+ _runtime.CharacterOwner.Options.GetOptionBit(
+ AcDream.Core.Net.Messages.CharacterOptionId.DisableDistanceFog);
+ // Campaign OP slice OP4 (2026-08-11): PlayerOption
+ // DisplayTimeStamps, polled — see RuntimeCommunicationState.
+ // DisplayTimestampsSource's doc comment. Same one-time,
+ // whole-lifetime bind as the two above.
+ _runtime.CommunicationOwner.DisplayTimestampsSource = () =>
+ _runtime.CharacterOwner.Options.GetOptionBit(
+ AcDream.Core.Net.Messages.CharacterOptionId.DisplayTimeStamps);
_retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot();
_inputCapture = new AcDream.App.Input.CompositeInputCaptureSource(
new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools),
@@ -653,6 +669,13 @@ public sealed class GameWindow :
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
_playerControllerSlot,
_inputCapture);
+ // Campaign OP slice OP4 (2026-08-11): PlayerOption
+ // RunAsDefaultMovement, polled — see RuntimeLocalPlayerMovementState.
+ // RunAsDefaultMovementSource's doc comment. Same one-time,
+ // whole-lifetime bind as DisableDistanceFogSource above.
+ _playerControllerSlot.RunAsDefaultMovementSource = () =>
+ _runtime.CharacterOwner.Options.GetOptionBit(
+ AcDream.Core.Net.Messages.CharacterOptionId.ToggleRun);
_framebufferResize = new FramebufferResizeController(_viewportAspect);
_datDir = options.DatDir;
_worldGameState = worldGameState;
diff --git a/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs
new file mode 100644
index 00000000..06e8dbe4
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs
@@ -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;
+
+///
+/// Campaign OP slice OP4 (2026-08-11): binds the Character tab
+/// (LayoutDesc 0x21000028, root 0x100001F9, ListBox
+/// 0x100001FA) — retail's gmCharacterSettingsUI::InitOptions
+/// @0x004A02F0 — through OP2's template-list mechanism
+/// () and OP3's per-page
+/// model. Six authored group headers, 49 toggle
+/// rows from the 2013 build plus D3's 50th ("Listen to PK death
+/// messages", —
+/// wire+store only, register row AP-193), in
+/// docs/research/2026-08-10-character-options-map.md §2's authored
+/// order (byte-verified against the same header-boundary decomp §7 of
+/// docs/research/2026-08-10-options-panel-structure.md cites).
+///
+///
+/// Defaults (U1). Retail's UIOption_Checkbox::SetPlayerOption
+/// @0x00486e80 — the constructor path every AddToggleOption(enum
+/// PlayerOption) call reaches — sets this->m_default =
+/// PlayerModule::GetDefaultOptionValue(playerModule, this->m_playerOption)
+/// (pseudo-C line 147375) as a DIRECT side effect of construction; retail
+/// never calls the separate SetDefaultValue API for this tab (which
+/// is why InitOptions itself shows no such call — options-panel-
+/// structure.md §7 flagged this as the tab's biggest open unknown). This
+/// IS CharacterOptionTable.ClientDefault — OP1 already ported the
+/// exact same byte-verified GetDefaultOptionValue @0x005D2A30
+/// table. Coordinator correction: the plan's own "U1 closed" note
+/// (§1) points at UIOption::InqDefaultGameplayOptionProperty
+/// @0x004ef8d0 reading a DAT DBPropertyCollection at
+/// DBCache::GetDIDFromEnumStatic(0x16, 2) — that mechanism is real
+/// (empirically confirmed against the installed DATs: DID
+/// 0x78000001, three entries — 0x1000007F the Chat tab's
+/// filter-bitfield property, 0x10000080/0x10000081 = 0.5/1.0
+/// the Chat tab's two opacity-slider defaults) but it is
+/// UIOption_Slider::SetGameplayOptionProperty @0x00485030's
+/// m_propName-bound path — the Chat/Config tabs' PREFERENCE rows,
+/// which the structure doc's own §1.2 already established have ZERO
+/// PlayerOption 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.
+///
+///
+///
+/// Labels/tooltips (U2). Retail hashes ID_PlayerOption_<Name>
+/// / ID_PlayerOption_<Name>_Help against string table
+/// 0x23000003 at row-build time (SetToggleLabel) — never
+/// hard-coded English. <Name> is retail's OWN enum
+/// PlayerOption member name (acclient.h:4162-4218), which for
+/// the six Hear*Chat ids and D3's HearPKDeaths differs from
+/// acdream's own spelling (ACE's
+/// ListenTo*Chat convention) —
+/// carries the correct retail token for hashing, kept deliberately
+/// separate from the wire/storage id.
+///
+///
+public static class CharacterOptionsPageController
+{
+ /// Character page root — gmCharacterSettingsUI.
+ public const uint RootElementId = 0x100001F9u;
+
+ /// The row ListBox (dat Type 5) — m_pOptionBox.
+ public const uint ListBoxElementId = 0x100001FAu;
+
+ /// The ListBox's linked scrollbar.
+ public const uint ScrollbarElementId = 0x100001FBu;
+
+ /// Template-list index of the header row (Type 12 text).
+ private const int HeaderTemplateIndex = 0;
+
+ /// Template-list index of the separator row (Type 3 image).
+ private const int SeparatorTemplateIndex = 1;
+
+ /// Template-list index of the toggle-option row.
+ private const int ToggleTemplateIndex = 2;
+
+ private const uint StringTableId = 0x23000003u;
+
+ /// One authored Character-tab row: the wire/storage id plus
+ /// retail's OWN PlayerOption enum member name (for the
+ /// ID_PlayerOption_<Name>/_Help string hash — see
+ /// the U2 note above).
+ public readonly record struct RowSpec(CharacterOptionId Id, string RetailName);
+
+ /// One authored header group: its section string key plus
+ /// authored-order rows.
+ public readonly record struct GroupSpec(string HeaderKey, RowSpec[] Rows);
+
+ ///
+ /// The complete 6-group / 50-row authored table — research doc §2 /
+ /// §7, id-for-id, in EXACT authored order. RetailName values
+ /// verified against acclient.h:4162-4218's literal enumerator
+ /// spelling.
+ ///
+ 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"),
+ }),
+ };
+
+ /// Total authored row count across every group — 50 (49 from
+ /// the 2013 build + D3's HearPKDeaths). Exposed for conformance
+ /// tests.
+ public static int TotalRowCount
+ {
+ get
+ {
+ int count = 0;
+ foreach (GroupSpec group in Groups) count += group.Rows.Length;
+ return count;
+ }
+ }
+
+ /// The seam this page writes/reads live character-option
+ /// state through — via the
+ /// shared Runtime command surface, exactly like OP3's mouse-turning
+ /// macro and every other Options-panel consumer.
+ public sealed record Bindings(
+ Func CurrentValue,
+ Action SetOption);
+
+ ///
+ /// Builds all 6 headers + 50 toggle rows + 6 separators (5 interior +
+ /// 1 trailing, matching retail's own AddSeperator call pattern
+ /// — structure doc §7) into 's Character
+ /// ListBox, links its scrollbar, seeds every row's current/default
+ /// triple, and registers each row into .
+ ///
+ /// The already-built Options panel tree (the
+ /// SAME import used) — the
+ /// Character page's ListBox is a descendant of it via the tab host's
+ /// page-slot base-merge.
+ /// The Character tab's
+ /// model () — every
+ /// row registers into this SAME instance.
+ /// Builds one row template's subtree —
+ /// wired directly onto the ListBox's (property 0x64). The production caller
+ /// (RetailUiRuntime.MountOptionsPanel) closes over live DAT
+ /// access exactly like 's shared
+ /// dialog catalog; a hermetic test closes over a committed fixture's
+ /// already-resolved tree instead — this
+ /// controller has no DAT dependency of its own.
+ /// Resolves ONE ID_PlayerOption_*/
+ /// ID_CharacterOption_*_Section string by (table id, hash) —
+ /// never hard-coded English. Production passes
+ /// DatStringResolver.Resolve; tests can inject a small fake
+ /// table or a constant-null resolver to exercise the "string absent"
+ /// no-invented-text path.
+ /// The live read/write seam for every row.
+ /// if the Character page's ListBox
+ /// did not resolve as a (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.
+ public static bool Bind(
+ ImportedLayout layout,
+ OptionPage page,
+ Func templateResolver,
+ Func 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 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 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;
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/OptionsPanelController.cs b/src/AcDream.App/UI/Layout/OptionsPanelController.cs
index 62effb6e..c900b621 100644
--- a/src/AcDream.App/UI/Layout/OptionsPanelController.cs
+++ b/src/AcDream.App/UI/Layout/OptionsPanelController.cs
@@ -67,6 +67,17 @@ public sealed class OptionsPanelController : IRetainedPanelController
private const uint UseMouseTurningSettingsId = 0x100005CCu;
private const uint ExitGameId = 0x10000617u;
+ // Apply/Reset/Defaults — research doc §3.1/§10.1: identical geometry
+ // AND identical element ids on every page that has them (Character,
+ // Chat, Config — NOT Gameplay). Each page's own LayoutDesc authors its
+ // OWN physical button instances under these SAME numeric ids, so a
+ // flat layout.FindElement lookup cannot reliably pick one page's
+ // instance — Campaign OP OP4 wires each page's copy from a SCOPED
+ // search rooted at that page's own slot (UiElement.FindDescendant).
+ private const uint ApplyButtonId = 0x100001FCu;
+ private const uint ResetButtonId = 0x100001FDu;
+ private const uint DefaultsButtonId = 0x100001FEu;
+
/// Callback delegates this controller wires the seven Gameplay
/// buttons and the close button to. Every field maps to exactly one
/// button; a null field leaves that button INERT (authored, clickable,
@@ -191,9 +202,39 @@ public sealed class OptionsPanelController : IRetainedPanelController
BindButton(layout, ReportAbuseId,
() => callbacks.DisplaySystemMessage(callbacks.ReportAbuseMessage));
+ // Apply/Reset/Defaults — retail's gmCharacterSettingsUI /
+ // gmChatOptionsUI / gmConfigUI ::ListenToElementMessage
+ // @0x0049E3A0 (COMDAT-folded — literally the SAME handler body on
+ // all three pages, structure doc §3.1): idElement == 0x100001FC ->
+ // SaveCurrentValues (Apply); 0x100001FD -> RestoreSavedValues
+ // (Reset); 0x100001FE -> RestoreDefaultValues (Defaults). Each
+ // page's OWN OptionPage model owns the actual semantics
+ // (OptionPageModel.cs); this loop only wires each page's physical
+ // button instances to its own model.
+ foreach (uint pageId in new[] { CharacterPageId, ChatPageId, ConfigPageId })
+ {
+ OptionPage page = controller._pages[pageId];
+ UiElement? pageRoot = UiElement.FindDescendant(tabPanel, pageId);
+ if (pageRoot is null) continue;
+
+ BindPageButton(pageRoot, ApplyButtonId, page.Apply);
+ BindPageButton(pageRoot, ResetButtonId, page.Reset);
+ BindPageButton(pageRoot, DefaultsButtonId, page.Defaults);
+ }
+
return controller;
}
+ private static void BindPageButton(UiElement pageRoot, uint elementId, Action onClick)
+ {
+ if (UiElement.FindDescendant(pageRoot, elementId) is UiButton button)
+ button.OnClick = onClick;
+ else
+ Console.WriteLine(
+ $"[D.2b] OptionsPanelController: page 0x{pageRoot.DatElementId:X8}'s button "
+ + $"0x{elementId:X8} not found — its handler was not wired.");
+ }
+
///
/// Activates the tab-switching behavior (idempotent — safe even if
/// already active). Must run AFTER so this
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 817ef468..9110cbcf 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -195,7 +195,11 @@ public sealed record OptionsRuntimeBindings(
Action DisplaySystemMessage,
Action DisplayMouseTurningMacroLine,
Func LoadCameraTurning,
- Action SaveCameraTurning);
+ Action 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 CurrentCharacterOption);
public sealed record InventoryRuntimeBindings(
ClientObjectTable Objects,
@@ -1992,6 +1996,42 @@ public sealed class RetailUiRuntime : IDisposable
}
OptionsPanelController = controller;
+
+ // Campaign OP slice OP4 (2026-08-11): the Character tab's 6
+ // headers + 50 rows. Runs BEFORE ActivateTabs (below) so the
+ // Character page's model is fully populated before the default
+ // tab's OnShown fires. Needs a SECOND dat-lock scope — resolving
+ // each row's template (property 0x64 on the ListBox) is a live
+ // DAT read per row, unlike the already-built tree the block above
+ // only wires callbacks into.
+ lock (_bindings.Assets.DatLock)
+ {
+ var strings = new DatStringResolver(_bindings.Assets.Dats);
+ bool bound = Layout.CharacterOptionsPageController.Bind(
+ layout,
+ controller.CharacterPage,
+ templateResolver: (templateLayoutId, templateElementId) =>
+ {
+ ElementInfo? info = LayoutImporter.ImportInfos(
+ _bindings.Assets.Dats, templateLayoutId, templateElementId);
+ return info is null
+ ? null
+ : LayoutImporter.Build(
+ info,
+ _bindings.Assets.ResolveSprite,
+ _bindings.Assets.DefaultFont,
+ _bindings.Assets.ResolveFont,
+ strings.Resolve).Root;
+ },
+ resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
+ new Layout.CharacterOptionsPageController.Bindings(
+ CurrentValue: id => _bindings.Options.CurrentCharacterOption((uint)id),
+ SetOption: (id, value) => _bindings.Options.CommandBus().Publish(
+ new SetSingleCharacterOptionRuntimeCmd((uint)id, value))));
+ if (!bound)
+ Console.WriteLine("[UI] options panel: Character tab rows did not bind.");
+ }
+
controller.ActivateTabs();
RetailWindowHandle handle = RetailWindowFrame.Mount(
diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index 4af90e90..cc7fcbfe 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -309,6 +309,32 @@ public abstract class UiElement
InvalidateChildOrder();
}
+ ///
+ /// Retail's GetChildRecursive: a depth-first search of
+ /// and every descendant (not just direct
+ /// children) for a widget carrying .
+ /// Shared by '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
+ /// 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.
+ ///
+ public static UiElement? FindDescendant(UiElement root, uint datElementId)
+ {
+ ArgumentNullException.ThrowIfNull(root);
+ if (root.DatElementId == datElementId) return root;
+ foreach (UiElement child in root.Children)
+ {
+ UiElement? found = FindDescendant(child, datElementId);
+ if (found is not null) return found;
+ }
+ return null;
+ }
+
public virtual bool RemoveChild(UiElement child)
{
if (!_children.Contains(child)) return false;
diff --git a/src/AcDream.App/UI/UiTabPanel.cs b/src/AcDream.App/UI/UiTabPanel.cs
index 02aad6ab..44715691 100644
--- a/src/AcDream.App/UI/UiTabPanel.cs
+++ b/src/AcDream.App/UI/UiTabPanel.cs
@@ -212,24 +212,4 @@ public sealed class UiTabPanel : UiDatElement, IUiChildrenAttachedListener
ActivePageElementId = pageElementId;
ActivePageChanged?.Invoke(previousPageElementId, pageElementId);
}
-
- ///
- /// Retail GetChildRecursive: a depth-first search of every descendant (not
- /// just direct children) for a widget carrying .
- /// Direct-children-only search (the pre-rework FindDirectChild) missed
- /// cross-layout mounts where the tab table's ids name elements nested below an
- /// intermediate incorporated container.
- ///
- 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;
- }
}
diff --git a/src/AcDream.Core/World/WeatherState.cs b/src/AcDream.Core/World/WeatherState.cs
index 4117ef1d..53a8a463 100644
--- a/src/AcDream.Core/World/WeatherState.cs
+++ b/src/AcDream.Core/World/WeatherState.cs
@@ -125,6 +125,19 @@ public sealed class WeatherSystem
// SetKindFromDayGroupName, the internal RollKind hash is disabled.
private bool _externallyDriven;
+ ///
+ /// Campaign OP slice OP4 (2026-08-11): retail PlayerOption
+ /// DisableDistanceFog — CPlayerModule::OnChanged @0x0059A8E0
+ /// case 0x30 writes LScape::m_fFogEnabled = !value. Polled
+ /// (not pushed) so the App composition layer only needs to bind this
+ /// ONCE per session to a live RuntimeCharacterOptionsState.GetOptionBit
+ /// 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 m_fFogEnabled ctor default.
+ ///
+ public Func? DisableDistanceFogSource { get; set; }
+
public WeatherSystem(Random? rng = null)
{
// The random-seed ctor argument remains for test API compat,
@@ -295,6 +308,7 @@ public sealed class WeatherSystem
Vector3 fogColor = kf.FogColor;
float fogStart = kf.FogStart;
float fogEnd = kf.FogEnd;
+ FogMode fogMode = kf.FogMode;
// AdminEnvirons server override: replace fog COLOR only.
// Keyframe distances unchanged until we find evidence retail
@@ -304,13 +318,21 @@ public sealed class WeatherSystem
if (_override != EnvironOverride.None)
fogColor = EnvironOverrideColor(_override);
+ // OP4 (2026-08-11): PlayerOption DisableDistanceFog ->
+ // LScape::m_fFogEnabled = !value (see DisableDistanceFogSource's
+ // doc comment). Forces FogMode.Off regardless of the keyframe's
+ // own authored mode; distances are left alone since the shader
+ // never reads them once fog is off.
+ if (DisableDistanceFogSource?.Invoke() == true)
+ fogMode = FogMode.Off;
+
return new AtmosphereSnapshot(
Kind: _kind, // informational
Intensity: 1f, // no per-Kind easing in retail
FogColor: fogColor,
FogStart: fogStart,
FogEnd: fogEnd,
- FogMode: kf.FogMode,
+ FogMode: fogMode,
LightningFlash: _flashLevel, // 0 in production; TriggerFlash hook for tests
Override: _override);
}
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs
index dad4faf0..a3a4850b 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs
@@ -841,6 +841,33 @@ public sealed class RuntimeCharacterOptionsState
return true;
}
+ ///
+ /// Read ONE character-option bit, by its linear CharacterOptionId,
+ /// resolved through (Campaign OP slice
+ /// OP4, 2026-08-11) — the read counterpart to .
+ /// 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, ICombatGameplaySettingsSource'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 (matches retail's own
+ /// unmodeled-id-is-off default — there is no bit to test).
+ ///
+ 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;
+ }
+
+ /// Typed overload of .
+ public bool GetOptionBit(CharacterOptionId characterOptionId) =>
+ GetOptionBit((uint)characterOptionId);
+
///
/// Set ONE character-option bit locally, by its linear
/// CharacterOptionId (the same id carried on the wire by
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs
index c6cdcccd..5010d24f 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs
@@ -95,6 +95,22 @@ public sealed class RuntimeCommunicationState : IDisposable
///
public SpewBoxState SpewBox { get; }
+ ///
+ /// Campaign OP slice OP4 (2026-08-11): retail PlayerOption
+ /// DisplayTimeStamps — ClientSystem::AddTextToScroll
+ /// @0x00563C50 (character-options-map.md §2.2, "Display
+ /// Timestamps") prefixes each transcript line with
+ /// PlayerModule::m_TimeStampFormat (ctor default the byte-
+ /// verified CRT strftime string "%#H:%M:%S " — non-zero-padded
+ /// 24h hour, then zero-padded minute:second, trailing space) when the
+ /// option is on. Polled — see for why. Applied only to
+ /// the persisted transcript (matching retail's own
+ /// ClientLocal exemption in below — the
+ /// transient is never timestamped).
+ ///
+ public Func? DisplayTimestampsSource { get; set; }
+
public ChatCommandTargetState CommandTargets { get; }
public TurbineChatState TurbineChat { get; }
public FriendsState Friends { get; }
@@ -200,6 +216,14 @@ public sealed class RuntimeCommunicationState : IDisposable
return;
}
+ // OP4: DisplayTimeStamps — see DisplayTimestampsSource's doc
+ // comment. Prefixed AFTER the trim above (retail's own order:
+ // AddTextToScroll trims first, then ClientSystem's caller-side
+ // timestamp prepend applies to the transcript line, never to the
+ // ClientLocal/SpewBox branch already returned above).
+ if (DisplayTimestampsSource?.Invoke() == true)
+ text = DateTime.Now.ToString("H:mm:ss ") + text;
+
Chat.OnSystemMessage(text, (uint)type);
}
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
index 0b55e575..ac8d8edd 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs
@@ -143,6 +143,26 @@ public sealed class RuntimeLocalPlayerMovementState
}
public bool AutoRunActive => _autoRunActive;
+
+ ///
+ /// Campaign OP slice OP4 (2026-08-11): retail PlayerOption
+ /// RunAsDefaultMovement (ACCmdInterp::UITogglesRun) —
+ /// whether an ordinary held movement key runs by default (retail's own
+ /// client default: ) or walks by default,
+ /// requiring the walk-mode modifier to be held to invert. Distinct
+ /// from , which is the ToggleRunLock
+ /// KEYBIND LATCH (auto-walk-forward-without-holding-W), an unrelated
+ /// acdream feature with no retail PlayerOption behind it. Polled
+ /// (not pushed) — see for why. Null (the default) means the
+ /// option was never bound; then
+ /// reports retail's own default (), matching
+ /// acdream's pre-OP4 hardcoded behavior exactly.
+ ///
+ public Func? RunAsDefaultMovementSource { get; set; }
+
+ public bool RunAsDefaultMovement => RunAsDefaultMovementSource?.Invoke() ?? true;
+
public bool HasCommandInput => _hasCommandInput;
public MovementInput CommandInput => _commandInput;
public long Revision => Interlocked.Read(ref _revision);
diff --git a/tests/AcDream.App.Tests/Combat/CharacterOptionCombatSettingsSourceTests.cs b/tests/AcDream.App.Tests/Combat/CharacterOptionCombatSettingsSourceTests.cs
new file mode 100644
index 00000000..d60fdf70
--- /dev/null
+++ b/tests/AcDream.App.Tests/Combat/CharacterOptionCombatSettingsSourceTests.cs
@@ -0,0 +1,62 @@
+using AcDream.App.Combat;
+using AcDream.Core.Net.Messages;
+using AcDream.Runtime.Gameplay;
+
+namespace AcDream.App.Tests.Combat;
+
+///
+/// Campaign OP slice OP4 (2026-08-11) — D7 Group-C re-point:
+/// reads
+/// AutoTarget/AutoRepeatAttack/ViewCombatTarget from the canonical
+/// instead of the client-local
+/// GameplaySettings record used
+/// to be the only implementation of.
+///
+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(
+ static () => new CharacterOptionCombatSettingsSource(null!));
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs
new file mode 100644
index 00000000..e20ef89b
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs
@@ -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;
+
+///
+/// Campaign OP slice OP4 (2026-08-11) conformance + behavior tests for
+/// — the CH4 registry-
+/// conformance pattern: every one of the 50 authored rows pinned against
+/// 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 options_panel_2100006E_1000018D.json fixture — no
+/// live DAT access, following the same hermetic pattern
+/// OptionsPanelControllerTests/OptionsPanelLayoutConformanceTests
+/// already established.
+///
+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 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