fix(ui): OP3 review fixes — byte-verified Magic chat lines, Gameplay/OptionPage shape, mid-air tri-state, shared geometry

Consolidated fix round for the two OP3 dual-lens reviews
(docs/research/2026-08-11-op3-review-{mechanism,blast}.md), both
APPROVE-WITH-FIXES.

MUST-FIX:
- The six "Use Mouse Turning Settings" chat lines were typed
  RetailLogTextType.ClientLocal (0x1A); retail types them 0x07 (Magic).
  BYTE-VERIFIED against the PDB-paired binary at all six
  gmConfigUI::SetMouseTurningDefaults call sites (0x0049E972/E9E2/EA52/
  EAA4/EAF6/EB48): every site pushes `6a 07` (type=7) immediately before
  the text-pointer push and the AddTextToScroll call. Added a dedicated
  OptionsRuntimeBindings.DisplayMouseTurningMacroLine seam routed at
  Magic (scrolling chat transcript, light blue, timestamped) instead of
  the 4-slot SpewBox ClientLocal uses; the mid-air refusal and UA/RA
  keep ClientLocal (both independently confirmed correct).
- Filed AD-77: the client-wide floating-only gmPanelUI host divergence
  (retail also exposes a docked 0x21000017 host) the plan §5 delegated
  to this review, scoped to every main panel, not just Options.

SHOULD-FIX:
- gmGameplayOptionsUI is not an OptionPage in retail (acclient.h:55857,
  UIElement_Field). OptionsPanelController now constructs the Gameplay
  slot's OptionPage with AfterApply deliberately null, so entering/
  leaving that tab never publishes SaveCharacterOptionsRuntimeCmd.
  Corrected OptionPageModel's doc comment and rewrote the two tests
  that pinned the wrong (Gameplay-flushes) shape.
- Added the OptionPage.OnOptionChanged seam (PlayerOptionPage::
  OnOptionChanged @0x004F27D0) — fires as the last step of Apply/
  Reset/Defaults, plus once per live LED edit via a new
  IOptionRow.AttachPageNotify hook (BoolOptionRow wires it into
  SetCurrentValue only, matching retail's Apply(1)-only
  HandleDialogAndNotices path). OP4-6 will bind Apply/Reset enable
  state to this.
- Exit to Character Selection's mid-air refusal is now tri-state
  (Func<bool?> IsGrounded): retail's UseTime only reaches the airborne
  test inside `else if (smartbox->player)`, so outside player mode (or
  with no live controller) the button is a SILENT no-op, not a
  refusal. Fixed the inverted comment at both call sites.
- Options panel geometry now matches its nine gmPanelUI siblings
  sharing RetailPanelUiController's one main-panel rectangle
  (ResizeX=false, bottom-edge-only resize, no invented Min/MaxWidth/
  Height) instead of being the only all-four-edge/horizontal-resize
  outlier whose width silently reverted whenever a sibling was shown.
- Added the three missing test pins: Options/Character mutual
  exclusion through a REAL RetailPanelUiController registration,
  RetailDialogFactory.MakeConfirmation's omitted-queueKey overload
  sharing DefaultQueueKey, and UiTabPanel.ActivePageChanged never
  firing on a dormant (non-activated) host.
- TS-74's What/Where now names the five store-only CameraTurning
  preferences explicitly instead of only mentioning them in Risk.
- Test script gains the toolbar-button ghosted->enabled+highlight
  check, UseMouseTurning-survives-relogin and the five prefs-survive-
  relaunch steps, a UA/RA legibility eye-item, and the corrected
  bottom-edge-only geometry description for step 5.

One-liners fixed in files already touched: symmetric close-button
resolve-failure logging in OptionsPanelController.Bind (blast NOTE 8).

Full Release suite: 12,947 passed / 4 skipped / 0 failed (baseline
12,935/4/0 post-OP7 — 12 net new tests; the two OptionPageModelTests
"wrong-shape" tests were renamed/rewritten in place, not removed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 03:05:27 +02:00
parent 64898f1301
commit 386076af0f
11 changed files with 505 additions and 72 deletions

View file

@ -156,10 +156,14 @@ public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider);
/// already uses, since the live session's bus is not yet constructed at the
/// point this binding record is built (composition order).</param>
/// <param name="IsGrounded">Retail's mid-air logout-refusal check
/// (<c>transient_state &amp; 1</c>) — mirrors the SAME
/// <c>d.PlayerMode.IsPlayerMode &amp;&amp; d.PlayerController.Controller is
/// { IsAirborne: false }</c> pattern already used for
/// <c>InteractionRetainedUiComposition</c>'s own <c>playerOnGround</c> read.</param>
/// (<c>transient_state &amp; 1</c>), tri-state per
/// <c>gmGamePlayUI::UseTime @0x004EA3A0</c>'s exact branch structure:
/// <c>else if (smartbox-&gt;player) { if (!grounded) refuse; else proceed; }</c>
/// — the airborne test is reached ONLY when a player object exists at all.
/// <c>null</c> means "no player" (acdream: <c>!d.PlayerMode.IsPlayerMode ||
/// d.PlayerController.Controller is null</c>), which retail's own structure
/// reaches neither branch for — no refusal, no proceed (mechanism review S3
/// / blast review SHOULD-FIX 1, 2026-08-11).</param>
/// <param name="IsUseMouseTurningEnabled">Live read of
/// <c>PlayerOption.UseMouseTurning</c> (<c>CharacterOptions2</c> bit
/// <c>0x00400000</c>) from the canonical <c>RuntimeCharacterOptionsState</c> —
@ -168,12 +172,28 @@ public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider);
/// (<c>RetailLogTextType.ClientLocal</c>, SpewBox-only) — the SAME
/// <c>text =&gt; d.Communication.AddText(text, RetailLogTextType.ClientLocal)</c>
/// delegate shape <see cref="VendorRuntimeBindings.DisplaySystemMessage"/>/
/// <see cref="AppraisalRuntimeBindings"/> already use.</param>
/// <see cref="AppraisalRuntimeBindings"/> already use. Used for the mid-air
/// refusal (byte-verified <c>ClientLocal 0x1A</c>) and the Urgent
/// Assistance/Report Abuse short-circuit (AD-75) — NOT the mouse-turning
/// macro's own six lines, which use <see cref="DisplayMouseTurningMacroLine"/>
/// instead.</param>
/// <param name="DisplayMouseTurningMacroLine">The mouse-turning macro's own
/// six retail chat lines — routed at <c>RetailLogTextType.Magic (0x07)</c>,
/// BYTE-VERIFIED at all six <c>ClientSystem::AddTextToScroll</c> call sites
/// inside <c>gmConfigUI::SetMouseTurningDefaults</c> (raw bytes: <c>6a 07</c>
/// immediately before the text-pointer push and the call, at every one of
/// <c>0x0049E972</c>/<c>0x0049E9E2</c>/<c>0x0049EA52</c>/<c>0x0049EAA4</c>/
/// <c>0x0049EAF6</c>/<c>0x0049EB48</c>, byte-verified against the PDB-paired
/// binary 2026-08-11 — the OP3 review-fix round). Magic routes through
/// <c>RuntimeCommunicationState.AddText</c> to the scrolling chat transcript
/// (light blue, timestamped, logged), NOT the 4-slot SpewBox
/// <see cref="DisplaySystemMessage"/>'s <c>ClientLocal</c> uses.</param>
public sealed record OptionsRuntimeBindings(
Func<ICommandBus> CommandBus,
Func<bool> IsGrounded,
Func<bool?> IsGrounded,
Func<bool> IsUseMouseTurningEnabled,
Action<string> DisplaySystemMessage,
Action<string> DisplayMouseTurningMacroLine,
Func<CameraTurningSettings> LoadCameraTurning,
Action<CameraTurningSettings> SaveCameraTurning);
@ -1855,11 +1875,20 @@ public sealed class RetailUiRuntime : IDisposable
/// Selection button (element <c>0x10000203</c>) — D6's "behaves as Exit
/// Game" adaptation (register row, same commit), PLUS retail's
/// confirmation dialog and mid-air refusal, which DO port exactly.
/// <c>gmGamePlayUI::UseTime @0x004EA3A0</c>'s drain: on confirmation
/// accept, refuse with <see cref="ClientTextRefusals.CantLogOffMidAir"/>
/// via the interface-text seam while airborne
/// (<c>transient_state &amp; 1</c>); otherwise proceed exactly like Exit
/// Game.
/// <c>gmGamePlayUI::UseTime @0x004EA3A0</c>'s drain, its EXACT three-way
/// branch (review-fix round, 2026-08-11 — the original port collapsed
/// this to a two-way `if/else` that fired the refusal outside player
/// mode too):
/// <code>
/// if (m_bLogoutConfirmed) {
/// if (m_shouldQuitOnLogout) { QueueUIMode(0x10000009); }
/// else if (smartbox-&gt;player) {
/// if (!grounded) refuse "Cannot log off while in mid-air.";
/// else LogOffCharacter();
/// }
/// // no player at all: neither branch runs — SILENT no-op.
/// }
/// </code>
/// </summary>
private void RequestExitToCharacterSelection()
{
@ -1867,13 +1896,20 @@ public sealed class RetailUiRuntime : IDisposable
{
if (!accepted) return;
if (_bindings.Options.IsGrounded())
switch (_bindings.Options.IsGrounded())
{
_bindings.Indicators.EndCharacterSession();
}
else
{
_bindings.Options.DisplaySystemMessage(ClientTextRefusals.CantLogOffMidAir);
case true:
_bindings.Indicators.EndCharacterSession();
break;
case false:
_bindings.Options.DisplaySystemMessage(ClientTextRefusals.CantLogOffMidAir);
break;
case null:
// Retail's `else if (smartbox->player)` gate: outside
// player mode (or with no live controller) there is no
// player object for UseTime to test at all, so neither
// the airborne refusal nor the logoff itself ever runs.
break;
}
});
}
@ -1883,7 +1919,9 @@ public sealed class RetailUiRuntime : IDisposable
/// button (element <c>0x100005CC</c>) — the pure
/// <see cref="MouseTurningSettingsMacro"/> orchestrated against live
/// bindings: load current values, compute the macro, persist + apply
/// live, send the wire bit if it changed, emit every retail chat line,
/// live, send the wire bit if it changed, emit every retail chat line
/// (byte-verified <c>Magic 0x07</c>, review-fix round 2026-08-11 — see
/// <see cref="OptionsRuntimeBindings.DisplayMouseTurningMacroLine"/>),
/// then commit the (currently empty) Config page exactly like retail's
/// own trailing <c>SaveCurrentValues()</c>
/// (<c>gmConfigUI::SetMouseTurningDefaults @0x0049EB57</c>).
@ -1905,7 +1943,7 @@ public sealed class RetailUiRuntime : IDisposable
}
foreach (string line in result.ChatLines)
_bindings.Options.DisplaySystemMessage(line);
_bindings.Options.DisplayMouseTurningMacroLine(line);
OptionsPanelController?.ConfigPage.Apply();
}
@ -1967,17 +2005,27 @@ public sealed class RetailUiRuntime : IDisposable
Left = 150f,
Top = 80f,
Visible = false,
ResizeX = true,
// Blast review SHOULD-FIX 2 (2026-08-11): match every OTHER
// gmPanelUI sibling sharing RetailPanelUiController's ONE
// main-panel geometry rectangle (Character/Inventory/
// Spellbook/Effects/the four indicator-detail panels) —
// ResizeX=false, bottom-edge-only resize, no invented
// Min/MaxWidth/Height. Options previously authored the ONLY
// divergent policy in that shared group (ResizeX=true,
// all-four-edge resize, literal 300x200 minimums); since
// every sibling forces `width = frame.Width` on the shared
// rect (RetailWindowManager.ResizeTo), a user-widened
// Options window silently reverted to 300 the next time ANY
// sibling was shown — this makes the whole cohort consistent
// again instead of one outlier with its own rules.
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right
| ResizeEdges.Top | ResizeEdges.Bottom,
ResizableEdges = ResizeEdges.Bottom,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
| AnchorEdges.Right | AnchorEdges.Bottom,
ContentClickThrough = false,
MinWidth = 300f,
MinHeight = 200f,
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
Controller = controller,
});