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 + /// DisableDistanceFogCPlayerModule::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 + /// DisplayTimeStampsClientSystem::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 RetailEnumNameCases() + { + yield return [CharacterOptionId.ListenToAllegianceChat, "HearAllegianceChat"]; + yield return [CharacterOptionId.ListenToGeneralChat, "HearGeneralChat"]; + yield return [CharacterOptionId.ListenToTradeChat, "HearTradeChat"]; + yield return [CharacterOptionId.ListenToLFGChat, "HearLFGChat"]; + yield return [CharacterOptionId.ListenToRoleplayChat, "HearRoleplayChat"]; + yield return [CharacterOptionId.ListenToSocietyChat, "HearSocietyChat"]; + yield return [CharacterOptionId.HearPkDeathMessages, "HearPKDeaths"]; + // Spot-check a few that DON'T differ from CharacterOptionId's own name. + yield return [CharacterOptionId.ViewCombatTarget, "ViewCombatTarget"]; + yield return [CharacterOptionId.MainPackPreferred, "MainPackPreferred"]; + yield return [CharacterOptionId.AutoRepeatAttack, "AutoRepeatAttack"]; + } + + [Fact] + public void AuthoredOrder_MatchesResearchDocRowByRow() + { + // docs/research/2026-08-10-options-panel-structure.md §7's exact + // authored PlayerOption enumerator sequence, transcribed + // independently of CharacterOptionsPageController.cs. + CharacterOptionId[][] expectedGroups = + [ + [ + CharacterOptionId.ViewCombatTarget, + CharacterOptionId.SalvageMultiple, + CharacterOptionId.MainPackPreferred, + ], + [ + CharacterOptionId.VividTargetingIndicator, + CharacterOptionId.ShowTooltips, + CharacterOptionId.CoordinatesOnRadar, + CharacterOptionId.SideBySideVitals, + CharacterOptionId.SpellDuration, + CharacterOptionId.DisableMostWeatherEffects, + CharacterOptionId.DisableDistanceFog, + CharacterOptionId.PersistentAtDay, + CharacterOptionId.DisableHouseRestrictionEffects, + CharacterOptionId.UseCraftSuccessDialog, + CharacterOptionId.ConfirmVolatileRareUse, + CharacterOptionId.DisplayTimeStamps, + CharacterOptionId.FilterLanguage, + CharacterOptionId.ShowHelm, + CharacterOptionId.ShowCloak, + ], + [ + CharacterOptionId.IgnoreAllegianceRequests, + CharacterOptionId.IgnoreFellowshipRequests, + CharacterOptionId.DisplayAllegianceLogonNotifications, + CharacterOptionId.FellowshipShareXP, + CharacterOptionId.FellowshipShareLoot, + CharacterOptionId.FellowshipAutoAcceptRequests, + ], + [ + CharacterOptionId.AcceptLootPermits, + CharacterOptionId.UseDeception, + CharacterOptionId.AllowGive, + CharacterOptionId.IgnoreTradeRequests, + CharacterOptionId.DragItemOnPlayerOpensSecureTrade, + CharacterOptionId.DisplayDateOfBirth, + CharacterOptionId.DisplayAge, + CharacterOptionId.DisplayChessRank, + CharacterOptionId.DisplayFishingSkill, + CharacterOptionId.DisplayNumberDeaths, + CharacterOptionId.DisplayNumberCharacterTitles, + ], + [ + CharacterOptionId.ToggleRun, + CharacterOptionId.AdvancedCombatUI, + CharacterOptionId.AutoTarget, + CharacterOptionId.AutoRepeatAttack, + CharacterOptionId.UseChargeAttack, + CharacterOptionId.LeadMissileTargets, + CharacterOptionId.UseFastMissiles, + ], + [ + CharacterOptionId.StayInChatMode, + CharacterOptionId.ListenToAllegianceChat, + CharacterOptionId.ListenToGeneralChat, + CharacterOptionId.ListenToTradeChat, + CharacterOptionId.ListenToLFGChat, + CharacterOptionId.ListenToRoleplayChat, + CharacterOptionId.ListenToSocietyChat, + CharacterOptionId.HearPkDeathMessages, + ], + ]; + + for (int g = 0; g < expectedGroups.Length; g++) + { + CharacterOptionId[] actual = CharacterOptionsPageController.Groups[g].Rows + .Select(static r => r.Id).ToArray(); + Assert.Equal(expectedGroups[g], actual); + } + } + + // ── Behavioral: built against the committed fixture ───────────────────── + + private static (uint, int, int) NoTex(uint _) => (0, 0, 0); + + private static ElementInfo? Find(ElementInfo n, uint id) + { + if (n.Id == id) return n; + foreach (ElementInfo c in n.Children) + { + ElementInfo? f = Find(c, id); + if (f is not null) return f; + } + return null; + } + + /// The SAME "resolve a row template from the standalone + /// 0x2100002B fixture" resolver OptionsPanelLayoutConformanceTests + /// uses for its own end-to-end template-mechanism test. + private static Func MakeTemplateResolver() + { + ElementInfo panelRoot = FixtureLoader.LoadOptionsPanelInfos(); + return (layoutId, elementId) => + { + if (layoutId != 0x2100002Bu) return null; + ElementInfo? templateInfo = Find(panelRoot, elementId); + return templateInfo is null ? null : LayoutImporter.Build(templateInfo, NoTex, null).Root; + }; + } + + private sealed class FakeBindings + { + public Dictionary Values { get; } = new(); + public List<(CharacterOptionId Id, bool Value)> Sets { get; } = new(); + + public CharacterOptionsPageController.Bindings ToBindings() => new( + CurrentValue: id => Values.TryGetValue(id, out bool v) && v, + SetOption: (id, value) => + { + Values[id] = value; + Sets.Add((id, value)); + }); + } + + private static (OptionsPanelController Panel, FakeBindings Bindings, bool Bound) BindReal( + Func? resolveString = null) + { + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + var calls = new List(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, + new OptionsPanelController.Callbacks( + Toggle: () => calls.Add("toggle"), + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }))!; + + var fakeBindings = new FakeBindings(); + bool bound = CharacterOptionsPageController.Bind( + layout, + controller.CharacterPage, + MakeTemplateResolver(), + resolveString ?? ((_, _) => null), + fakeBindings.ToBindings()); + + return (controller, fakeBindings, bound); + } + + [Fact] + public void Bind_Succeeds_AndRegistersExactly50Rows() + { + (OptionsPanelController controller, _, bool bound) = BindReal(); + + Assert.True(bound); + Assert.Equal(50, controller.CharacterPage.Rows.Count); + } + + [Fact] + public void Bind_SeedsEveryRowFromCurrentValue() + { + var fakeBindings = new FakeBindings(); + // Seed a handful of ids ON; everything else defaults to off in the + // fake's dictionary lookup. + fakeBindings.Values[CharacterOptionId.ViewCombatTarget] = true; + fakeBindings.Values[CharacterOptionId.IgnoreAllegianceRequests] = true; + + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, + new OptionsPanelController.Callbacks( + Toggle: () => { }, + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }))!; + bool bound = CharacterOptionsPageController.Bind( + layout, + controller.CharacterPage, + MakeTemplateResolver(), + (_, _) => null, + fakeBindings.ToBindings()); + Assert.True(bound); + + // No SetOption calls yet — seeding reads CurrentValue, it never + // writes back through the bindings. + Assert.Empty(fakeBindings.Sets); + } + + [Fact] + public void ClickingARow_PublishesSetOption_WithTheAuthoredId() + { + (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); + + // AutoRepeatAttack (0x00) is the FIRST row's checkbox + // (0x10000219) instantiated for the Character page — resolve it by + // walking the built tree rather than layout.FindElement (which + // collides across the Chat/Config pages' own same-id copies, per + // OptionsPanelController's Apply/Reset/Defaults comment). + IOptionRow row = Assert.Single( + controller.CharacterPage.Rows.Skip(0).Take(1)); + var boolRow = Assert.IsType(row); + + boolRow.SetCurrentValue(true); + + Assert.Single(bindings.Sets); + } + + [Fact] + public void Apply_CommitsBaseline_AndReset_NoLongerReverts() + { + (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); + var row = Assert.IsType(controller.CharacterPage.Rows[0]); + bool initial = row.Current; + + row.SetCurrentValue(!initial); + Assert.True(controller.CharacterPage.Changed); + + controller.CharacterPage.Apply(); + Assert.False(controller.CharacterPage.Changed); + Assert.Equal(!initial, row.Saved); + + controller.CharacterPage.Reset(); // nothing changed since Apply -> no-op + Assert.Equal(!initial, row.Current); + } + + [Fact] + public void Reset_RevertsToSavedBaseline_AndRePublishesTheRevertedValue() + { + (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); + var row = Assert.IsType(controller.CharacterPage.Rows[0]); + bool initial = row.Current; + row.SetCurrentValue(!initial); + bindings.Sets.Clear(); + + controller.CharacterPage.Reset(); + + Assert.Equal(initial, row.Current); + Assert.Contains(bindings.Sets, s => s.Value == initial); + } + + [Fact] + public void Defaults_AppliesClientDefault_ForEveryRow_WithoutCommitting() + { + (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); + // Drive every row to the OPPOSITE of its own default first. + foreach (IOptionRow r in controller.CharacterPage.Rows) + { + var b = (BoolOptionRow)r; + b.SetCurrentValue(!b.DefaultValue); + } + bindings.Sets.Clear(); + + controller.CharacterPage.Defaults(); + + foreach (IOptionRow r in controller.CharacterPage.Rows) + { + var b = (BoolOptionRow)r; + Assert.Equal(b.DefaultValue, b.Current); + } + // Defaults applies live but does NOT commit — Changed stays true + // for every row that isn't already equal to its own saved value. + Assert.True(controller.CharacterPage.Changed); + } + + [Fact] + public void EveryRow_DefaultValue_MatchesCharacterOptionTableClientDefault() + { + // U1: the Defaults button restores PlayerModule::GetDefaultOptionValue + // (CharacterOptionTable.ClientDefault), NOT a DAT DBPropertyCollection + // read — see CharacterOptionsPageController's own type doc for the + // full trace. This is the direct pin. + (OptionsPanelController controller, _, _) = BindReal(); + + var rowsById = new Dictionary(); + int i = 0; + foreach (CharacterOptionsPageController.RowSpec spec in AllRows()) + rowsById[spec.Id] = (BoolOptionRow)controller.CharacterPage.Rows[i++]; + + foreach ((CharacterOptionId id, BoolOptionRow row) in rowsById) + { + Assert.True(CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry)); + Assert.Equal(entry.ClientDefault, row.DefaultValue); + } + } + + [Fact] + public void TabHide_RevertsUncommittedCharacterEdits_ViaOnVisibilityChanged() + { + // Structure doc §3.6: switching tabs away reverts uncommitted edits + // on the page you left — exercised through the SAME + // OnActivePageChanged wiring OptionsPanelControllerTests already + // covers for the (then-empty) Character page; this proves it still + // holds once the page has REAL rows. + (OptionsPanelController controller, FakeBindings bindings, _) = BindReal(); + controller.ActivateTabs(); + controller.TabPanel.SwitchTo(0x10000211u); // Character page slot + var row = Assert.IsType(controller.CharacterPage.Rows[0]); + bool initial = row.Current; + row.SetCurrentValue(!initial); + Assert.True(controller.CharacterPage.Changed); + + controller.TabPanel.SwitchTo(0x10000212u); // Gameplay page slot + + Assert.Equal(initial, row.Current); + Assert.False(controller.CharacterPage.Changed); + } + + [Fact] + public void ScrollbarLinkage_ModelPointsAtTheListBoxScroll() + { + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, + new OptionsPanelController.Callbacks( + Toggle: () => { }, + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }))!; + var fakeBindings = new FakeBindings(); + CharacterOptionsPageController.Bind( + layout, controller.CharacterPage, MakeTemplateResolver(), (_, _) => null, + fakeBindings.ToBindings()); + + var listBox = Assert.IsType( + layout.FindElement(CharacterOptionsPageController.ListBoxElementId)); + var scrollbar = Assert.IsType( + layout.FindElement(CharacterOptionsPageController.ScrollbarElementId)); + + Assert.Same(listBox.Scroll, scrollbar.Model); + } + + [Fact] + public void Bind_MissingListBox_ReturnsFalse_AndDoesNotThrow() + { + // A layout whose root did not build the Character ListBox at all — + // exercises the degrade-gracefully path. + var emptyRoot = new ElementInfo { Id = 0, Type = 3 }; + ImportedLayout emptyLayout = LayoutImporter.Build(emptyRoot, NoTex, null); + var page = new OptionPage(); + + bool bound = CharacterOptionsPageController.Bind( + emptyLayout, page, MakeTemplateResolver(), (_, _) => null, + new CharacterOptionsPageController.Bindings( + CurrentValue: _ => false, SetOption: (_, _) => { })); + + Assert.False(bound); + Assert.Empty(page.Rows); + } + + [Fact] + public void LabelResolutionFailure_LeavesCheckboxLabelNull_NeverInventsEnglish() + { + (OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => null); + + Assert.True(bound); + // Every row still registers (structural build succeeds) even + // though every string lookup returns null — "no invented text" + // degrades to "no text", never a fabricated label. + Assert.Equal(50, controller.CharacterPage.Rows.Count); + } +} diff --git a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs index 20d490b3..1f81c38d 100644 --- a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs +++ b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs @@ -64,6 +64,46 @@ public sealed class WeatherSystemTests } } + // ── OP4 (Campaign OP, 2026-08-11): DisableDistanceFogSource — + // PlayerOption DisableDistanceFog -> LScape::m_fFogEnabled = !value. + + [Fact] + public void DisableDistanceFogSource_Unbound_LeavesKeyframeFogModeUnchanged() + { + var sys = new WeatherSystem(); + var kf = SkyStateProvider.Default().Interpolate(0.5f); + + var snap = sys.Snapshot(in kf); + + Assert.Equal(kf.FogMode, snap.FogMode); + } + + [Fact] + public void DisableDistanceFogSource_True_ForcesFogModeOff() + { + var sys = new WeatherSystem { DisableDistanceFogSource = () => true }; + var kf = SkyStateProvider.Default().Interpolate(0.5f); + Assert.NotEqual(FogMode.Off, kf.FogMode); // sanity: the keyframe itself authors real fog + + var snap = sys.Snapshot(in kf); + + Assert.Equal(FogMode.Off, snap.FogMode); + // Distances are left alone (the shader never reads them once off). + Assert.Equal(kf.FogStart, snap.FogStart, precision: 2); + Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2); + } + + [Fact] + public void DisableDistanceFogSource_False_LeavesFogModeAlone() + { + var sys = new WeatherSystem { DisableDistanceFogSource = () => false }; + var kf = SkyStateProvider.Default().Interpolate(0.5f); + + var snap = sys.Snapshot(in kf); + + Assert.Equal(kf.FogMode, snap.FogMode); + } + [Fact] public void EnvironOverride_ForcesTintedFog() { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index 9a2b13de..ad9c4c9b 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -286,6 +286,45 @@ public sealed class RuntimeCharacterStateTests Assert.Equal(beforeRevision, options.Revision); } + // ── OP4 (Campaign OP, 2026-08-11): GetOptionBit — the read + // counterpart every Character-tab row seed and every re-pointed + // Group-C consumer polls. + + [Theory] + [InlineData(CharacterOptionId.AutoTarget)] + [InlineData(CharacterOptionId.ViewCombatTarget)] + [InlineData(CharacterOptionId.ListenToGeneralChat)] + [InlineData(CharacterOptionId.DisableDistanceFog)] + public void GetOptionBit_RoundTripsWithSetOptionBit(CharacterOptionId id) + { + var options = new RuntimeCharacterOptionsState(); + + options.SetOptionBit((uint)id, true); + Assert.True(options.GetOptionBit(id)); + Assert.True(options.GetOptionBit((uint)id)); + + options.SetOptionBit((uint)id, false); + Assert.False(options.GetOptionBit(id)); + } + + [Fact] + public void GetOptionBit_UnrecognizedId_ReturnsFalse() + { + var options = new RuntimeCharacterOptionsState(); + Assert.False(options.GetOptionBit(0xFFFFu)); + } + + [Fact] + public void GetOptionBit_ReflectsReplace_NotJustSetOptionBit() + { + var options = new RuntimeCharacterOptionsState(); + Assert.True(options.GetOptionBit(CharacterOptionId.ListenToGeneralChat)); // default ON + + options.Replace(options.Options1, 0u); // every Options2 bit off, incl. ListenToGeneralChat + + Assert.False(options.GetOptionBit(CharacterOptionId.ListenToGeneralChat)); + } + // ── OP1 (Campaign OP, 2026-08-10): TrySetOption — the shared // local-write-then-send/dirty seam, + the dirty/flush state machine ──── diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs index e8c46338..1f070f9d 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs @@ -241,6 +241,59 @@ public sealed class RuntimeCommunicationStateTests Assert.Equal("", state.Chat.Snapshot()[0].Text); } + // ── OP4 (Campaign OP, 2026-08-11): DisplayTimestampsSource — + // PlayerOption DisplayTimeStamps prefix. + + [Fact] + public void AddText_TimestampsUnbound_NoPrefix() + { + using var state = new RuntimeCommunicationState(); + + state.AddText("Your spell fizzled.", RetailLogTextType.Default); + + Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text); + } + + [Fact] + public void AddText_TimestampsFalse_NoPrefix() + { + using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => false }; + + state.AddText("Your spell fizzled.", RetailLogTextType.Default); + + Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text); + } + + [Fact] + public void AddText_TimestampsTrue_PrefixesTranscriptLine() + { + using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true }; + + state.AddText("Your spell fizzled.", RetailLogTextType.Default); + + string text = state.Chat.Snapshot()[0].Text; + Assert.EndsWith("Your spell fizzled.", text); + Assert.NotEqual("Your spell fizzled.", text); + // Retail ctor default format "%#H:%M:%S " (non-zero-padded 24h + // hour, zero-padded minute:second, trailing space before the + // text) — .NET "H:mm:ss " is the exact equivalent. + Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", text); + } + + [Fact] + public void AddText_TimestampsTrue_NeverAppliedToClientLocalSpewBox() + { + // Retail's own ClientLocal (0x1A) exemption already skips the + // whole AddTextToScroll destination — timestamps are a transcript + // concept, never applied to the transient SpewBox line. + using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true }; + + state.AddText("Out of Range!", RetailLogTextType.ClientLocal); + + state.SpewBox.Tick(0d); + Assert.Equal("Out of Range!", state.SpewBox.Snapshot()[0].Text); + } + [Fact] public void Dispose_ResetsSpewBox() { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs index e64009b8..59ca1e5f 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs @@ -574,4 +574,31 @@ public sealed class RuntimeLocalPlayerMovementStateTests controller.ApplyServerPhysicsState(initial)); Assert.Equal(pushed, body.State); } + + // ── OP4 (Campaign OP, 2026-08-11): RunAsDefaultMovementSource — + // PlayerOption RunAsDefaultMovement, polled. + + [Fact] + public void RunAsDefaultMovement_Unbound_DefaultsToTrue() + { + var movement = new RuntimeLocalPlayerMovementState(); + + Assert.Null(movement.RunAsDefaultMovementSource); + Assert.True(movement.RunAsDefaultMovement); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void RunAsDefaultMovement_ReflectsBoundSourceLive(bool value) + { + var movement = new RuntimeLocalPlayerMovementState(); + bool current = value; + movement.RunAsDefaultMovementSource = () => current; + + Assert.Equal(value, movement.RunAsDefaultMovement); + + current = !value; + Assert.Equal(!value, movement.RunAsDefaultMovement); // polled, not cached + } }