fix(ui,runtime): OP4 re-review residuals R1-R4 (coordinator pass) — OP4 CLOSED

R1: the timestamp prefix moves from ChatLog.Append (which stamped the
stored BODY, rendering 'Alice says, "13:05:09 hi"') to ChatVM's display
composition — FormatTimestampPrefix(entry.Received) prepends the COMPOSED
line, matching retail's separate-leading-string model (fprintf("%ls%ls",
ts, text) @0x00563e5b; AddTextToScroll receives composed lines). The
prefix renders entry.Received in LOCAL time (retail strftime), invariant
literal colons. The ten defect-pinning test cases across
ChatLogTests/RuntimeCommunicationStateTests are rewritten to pin the
corrected contract (stored bodies stay clean; the composed line carries
the stamp outside the quotes — ChatVMTests).

R2: open option-bearing panels converge on every PlayerDescription seed:
OptionPage.ReloadFromLive (per-row live re-read + gating re-eval, NO
AfterApply flush — the seed just cleared the dirty module),
OptionsPanelController.OnServerOptionsSeeded (active page),
CombatUiController.OnServerOptionsSeeded (SyncControls), wired through
RuntimeSettingsController.ServerOptionsSeeded from the same factory hook
LockUI already uses. Retail cannot reach this state (its panels close
across login); the adaptation exists because retained panels survive the
session boundary — documented at the seam.

R3: tests drive the refresh widget push (model AND checkbox converge) and
ReloadFromLive's no-flush contract. R4: AP-196 addendum names the
headless AutoRepeatAttack false->true effective-default flip and the
characterOptions escape hatch.

Full Release suite: 13,083 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 06:36:14 +02:00
parent e71e5a9614
commit ac0304dcf0
13 changed files with 216 additions and 114 deletions

File diff suppressed because one or more lines are too long

View file

@ -873,6 +873,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
RetailUiRuntime runtime = lease.Mount( RetailUiRuntime runtime = lease.Mount(
() => RetailUiRuntime.CreateUninitialized(bindings)); () => RetailUiRuntime.CreateUninitialized(bindings));
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted); checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
// OP4 re-review R2: open option-bearing panels converge on every
// PlayerDescription seed (login + reconnect), closing the
// stale-rows/stale-baseline window a retained panel left open
// across the session boundary would otherwise hold.
d.Settings.ServerOptionsSeeded = () =>
{
runtime.OptionsPanelController?.OnServerOptionsSeeded();
runtime.CombatUiController?.OnServerOptionsSeeded();
};
inventoryContainer = late.InventoryContainer.Bind(runtime); inventoryContainer = late.InventoryContainer.Bind(runtime);
checkpoint(InteractionRetainedUiCompositionPoint.InventoryContainerBound); checkpoint(InteractionRetainedUiCompositionPoint.InventoryContainerBound);

View file

@ -338,6 +338,10 @@ internal sealed class LiveSessionRuntimeFactory
_interaction.Settings.SyncChatFromServerOptions(options2); _interaction.Settings.SyncChatFromServerOptions(options2);
_interaction.Settings.SetUiLocked( _interaction.Settings.SetUiLocked(
_domain.Character.Options.GetOptionBit(CharacterOptionId.LockUI)); _domain.Character.Options.GetOptionBit(CharacterOptionId.LockUI));
// OP4 re-review R2: open option-bearing panels re-read live
// bits at every seed (login + reconnect) — see
// RuntimeSettingsController.ServerOptionsSeeded.
_interaction.Settings.NotifyServerOptionsSeeded();
}); });
} }

View file

@ -633,6 +633,21 @@ internal sealed class RuntimeSettingsController :
} }
} }
/// <summary>
/// OP4 re-review R2 (2026-08-11): assigned by the retained-UI
/// composition; fired (via <see cref="NotifyServerOptionsSeeded"/>) from
/// the same PlayerDescription seed hook that drives
/// <see cref="SyncChatFromServerOptions"/>/<c>SetUiLocked</c>, so OPEN
/// option-bearing panels (the Options panel's active page, the Combat
/// panel's three LEDs) re-read live bits at every seed — login AND
/// reconnect — instead of holding stale rows and a stale undo baseline
/// until their next show.
/// </summary>
public Action? ServerOptionsSeeded { get; set; }
/// <inheritdoc cref="ServerOptionsSeeded"/>
public void NotifyServerOptionsSeeded() => ServerOptionsSeeded?.Invoke();
private void SaveCharacter(CharacterSettings character) private void SaveCharacter(CharacterSettings character)
{ {
try try

View file

@ -200,6 +200,12 @@ public sealed class CombatUiController : IRetainedPanelController
private void OnAttackStateChanged() => SyncControls(); private void OnAttackStateChanged() => SyncControls();
/// <summary>OP4 re-review R2: a fresh PlayerDescription seed landed —
/// the three option LEDs re-read the live bits so an OPEN combat panel
/// converges with the Character tab instead of waiting for the next
/// show/mode/attack-state change.</summary>
public void OnServerOptionsSeeded() => SyncControls();
private void SyncControls() private void SyncControls()
{ {
_powerControl.SetScalarPosition(_attacks.DesiredPower); _powerControl.SetScalarPosition(_attacks.DesiredPower);

View file

@ -494,6 +494,25 @@ public sealed class OptionPage
/// (re)opening): applies + commits, same as <see cref="Apply"/>.</summary> /// (re)opening): applies + commits, same as <see cref="Apply"/>.</summary>
public void OnShown() => Apply(); public void OnShown() => Apply();
/// <summary>
/// OP4 re-review R2 (2026-08-11): a fresh <c>PlayerDescription</c> seed
/// replaced the live option words while this page may be VISIBLE —
/// re-read every row's (current, saved) from the live source WITHOUT
/// <see cref="Apply"/>'s flush (the seed just cleared the dirty module;
/// there is nothing to flush, and an <see cref="AfterApply"/> publication
/// here would be spurious). Retail cannot reach this state — its panels
/// are closed during login/reconnect — so this adaptation exists only
/// because acdream's retained panels survive the session boundary; the
/// stale (current, saved) it clears would otherwise let Reset restore
/// pre-reconnect values over the new character's server truth.
/// </summary>
public void ReloadFromLive()
{
foreach (IOptionRow row in _rows)
row.SaveCurrentValue();
OnOptionChanged?.Invoke();
}
/// <summary><c>PlayerOptionPage::OnVisibilityChanged(false)</c> — the page /// <summary><c>PlayerOptionPage::OnVisibilityChanged(false)</c> — the page
/// became hidden (a tab switch away, or the window closing): reverts /// became hidden (a tab switch away, or the window closing): reverts
/// uncommitted edits, same as <see cref="Reset"/>.</summary> /// uncommitted edits, same as <see cref="Reset"/>.</summary>

View file

@ -310,6 +310,16 @@ public sealed class OptionsPanelController : IRetainedPanelController
page.OnShown(); page.OnShown();
} }
/// <summary>OP4 re-review R2: a fresh PlayerDescription seed landed —
/// re-read the ACTIVE page's rows from live state (flush-free; see
/// <see cref="OptionPage.ReloadFromLive"/>). Hidden pages re-read on
/// their next <see cref="OptionPage.OnShown"/> as always.</summary>
public void OnServerOptionsSeeded()
{
if (_pages.TryGetValue(_tabPanel.ActivePageElementId, out OptionPage? page))
page.ReloadFromLive();
}
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (_disposed) return;

View file

@ -435,14 +435,14 @@ public sealed class ChatLog
private void Append(ChatEntry entry) private void Append(ChatEntry entry)
{ {
if (DisplayTimestampsSource?.Invoke() == true) // OP4 re-review R1 (2026-08-11): the timestamp prefix does NOT touch
{ // entry.Text here. Retail composes the display line FIRST and carries
entry = entry with // the timestamp as a SEPARATE leading string at display time
{ // (AddTextToScroll @0x00563C50 receives already-composed lines;
Text = FormatTimestampPrefix() + entry.Text, // fprintf("%ls%ls\n", ts, text) @0x00563e5b) — prefixing the raw body
}; // put the stamp INSIDE the quotes of composed kinds
} // ('Alice says, "13:05:09 hi"'). ChatVM's display composition applies
// FormatTimestampPrefix(entry.Received) around FormatEntry instead.
_buffer.Enqueue(entry); _buffer.Enqueue(entry);
while (_buffer.Count > _maxEntries) while (_buffer.Count > _maxEntries)
_buffer.TryDequeue(out _); _buffer.TryDequeue(out _);
@ -465,8 +465,9 @@ public sealed class ChatLog
/// <c>GenericQualitiesData::InqString(m_pPlayerOptionsData, 1, ...)</c> /// <c>GenericQualitiesData::InqString(m_pPlayerOptionsData, 1, ...)</c>
/// — see register row AP-197. /// — see register row AP-197.
/// </summary> /// </summary>
private static string FormatTimestampPrefix() => public static string FormatTimestampPrefix(DateTime receivedUtc) =>
DateTime.Now.ToString(@"H\:mm\:ss ", CultureInfo.InvariantCulture); receivedUtc.ToLocalTime().ToString(
@"H\:mm\:ss ", CultureInfo.InvariantCulture);
public void Clear() public void Clear()
{ {

View file

@ -236,10 +236,16 @@ public sealed class ChatVM : IDisposable
int count = snap.Length - start; int count = snap.Length - start;
if (count <= 0) return Array.Empty<string>(); if (count <= 0) return Array.Empty<string>();
// OP4 re-review R1: read the option once per snapshot so every line
// in one frame renders consistently.
bool timestamps = _log.DisplayTimestampsSource?.Invoke() == true;
var lines = new string[count]; var lines = new string[count];
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
lines[i] = FormatEntry(snap[start + i]); var entry = snap[start + i];
lines[i] = timestamps
? ChatLog.FormatTimestampPrefix(entry.Received) + FormatEntry(entry)
: FormatEntry(entry);
} }
return lines; return lines;
} }
@ -338,12 +344,20 @@ public sealed class ChatVM : IDisposable
int count = snap.Length - start; int count = snap.Length - start;
if (count <= 0) return Array.Empty<FormattedLine>(); if (count <= 0) return Array.Empty<FormattedLine>();
// OP4 re-review R1: retail prepends the timestamp to the COMPOSED
// display line (a separate leading string — fprintf("%ls%ls", ts,
// text) @0x00563e5b), never to the message body, so tells/says render
// '13:05:09 Alice says, "hi"' and not 'Alice says, "13:05:09 hi"'.
bool timestamps = _log.DisplayTimestampsSource?.Invoke() == true;
var lines = new FormattedLine[count]; var lines = new FormattedLine[count];
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
var entry = snap[start + i]; var entry = snap[start + i];
string text = FormatEntry(entry);
if (timestamps)
text = ChatLog.FormatTimestampPrefix(entry.Received) + text;
lines[i] = new FormattedLine( lines[i] = new FormattedLine(
Text: FormatEntry(entry), Text: text,
Kind: entry.Kind, Kind: entry.Kind,
CombatKind: entry.CombatKind, CombatKind: entry.CombatKind,
LogTextType: entry.LogTextType); LogTextType: entry.LogTextType);

View file

@ -372,4 +372,63 @@ public sealed class OptionPageModelTests
Assert.Equal(1, notifyCount); Assert.Equal(1, notifyCount);
} }
// ── OP4 re-review R3: SaveCurrentValue's live re-read pushes the WIDGET
// too (the refresh delegate), so a re-seed converges both the row's
// model state AND the visible checkbox. ──────────────────────────────
[Fact]
public void SaveCurrentValue_ReReadsLiveSource_AndPushesTheWidgetRefresh()
{
bool live = false;
bool widgetChecked = true; // deliberately out of sync with live
var row = new BoolOptionRow(
initial: true,
defaultValue: false,
read: () => live,
refresh: value => widgetChecked = value);
live = false;
row.SaveCurrentValue();
// Model AND widget both converge on the live source.
Assert.False(row.Current);
Assert.False(row.Changed);
Assert.False(widgetChecked);
live = true;
row.SaveCurrentValue();
Assert.True(row.Current);
Assert.True(widgetChecked);
}
// ── OP4 re-review R2: ReloadFromLive = per-row live re-read + gating
// re-eval, WITHOUT Apply's AfterApply flush (a seed just cleared the
// dirty module — a flush publication here would be spurious). ────────
[Fact]
public void ReloadFromLive_ReReadsRows_WithoutFiringAfterApply()
{
bool live = false;
bool widgetChecked = false;
int flushCount = 0;
int gatingCount = 0;
var page = new OptionPage { AfterApply = () => flushCount++ };
var row = new BoolOptionRow(
initial: false,
defaultValue: false,
read: () => live,
refresh: value => widgetChecked = value);
page.Register(row);
page.OnOptionChanged = () => gatingCount++;
live = true;
page.ReloadFromLive();
Assert.True(row.Current);
Assert.True(widgetChecked);
Assert.False(row.Changed); // (current, saved) both re-read — no phantom dirt
Assert.Equal(0, flushCount); // NO AfterApply publication on a seed
Assert.Equal(1, gatingCount); // Apply/Reset ghosting re-evaluated
}
} }

View file

@ -339,78 +339,39 @@ public sealed class ChatLogTests
Assert.Equal(0x06u, log.Snapshot()[0].LogTextType); Assert.Equal(0x06u, log.Snapshot()[0].LogTextType);
} }
// ── SF-1/S1 (OP4 review-fix round, 2026-08-11): DisplayTimestampsSource // ── OP4 re-review R1 (2026-08-11): the timestamp NEVER touches the stored
// prefixes EVERY producer through the shared Append seam, not just // body — retail composes the display line first and prepends the stamp
// RuntimeCommunicationState.AddText's own subset of callers. ──────── // as a separate leading string at display time (fprintf("%ls%ls", ts,
// text) @0x00563e5b). ChatVM's display composition owns the prefix;
[Fact] // these tests pin that the LOG stays clean and the format is invariant.
public void DisplayTimestampsSource_Unbound_NoPrefix()
{
var log = new ChatLog();
log.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
Assert.Equal("hi", log.Snapshot()[0].Text);
}
[Theory] [Theory]
[InlineData(false)] [InlineData(false)]
[InlineData(true)] [InlineData(true)]
public void DisplayTimestampsSource_GatesThePrefix(bool timestampsOn) public void StoredEntryText_NeverCarriesTheTimestampPrefix(bool timestampsOn)
{ {
var log = new ChatLog { DisplayTimestampsSource = () => timestampsOn }; var log = new ChatLog { DisplayTimestampsSource = () => timestampsOn };
log.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u); log.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
string text = log.Snapshot()[0].Text; // The stored body is ALWAYS the raw message — the option gates the
if (timestampsOn) // display composition (ChatVM), never the log content.
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} hi$", text); Assert.Equal("hi", log.Snapshot()[0].Text);
else
Assert.Equal("hi", text);
}
[Theory]
// Every public ingestion method — proving the prefix applies at the
// ONE shared Append seam, not per-caller.
[InlineData("OnEmote")]
[InlineData("OnSoulEmote")]
[InlineData("OnChannelBroadcast")]
[InlineData("OnTellReceived")]
[InlineData("OnSystemMessage")]
[InlineData("OnPopup")]
[InlineData("OnCombatLine")]
[InlineData("OnSelfSent")]
[InlineData("OnPlayerKilled")]
public void DisplayTimestampsSource_AppliesToEveryProducer(string method)
{
var log = new ChatLog { DisplayTimestampsSource = () => true };
switch (method)
{
case "OnEmote": log.OnEmote("Caith", "waves", 0xCAFEu); break;
case "OnSoulEmote": log.OnSoulEmote("Bob", "dances", 0xBEEFu); break;
case "OnChannelBroadcast": log.OnChannelBroadcast(42u, "Alice", "motd"); break;
case "OnTellReceived": log.OnTellReceived("Alice", "psst", 0xAAu, logTextType: 0x03u); break;
case "OnSystemMessage": log.OnSystemMessage("fizzled", chatType: 5); break;
case "OnPopup": log.OnPopup("modal"); break;
case "OnCombatLine": log.OnCombatLine("hit", logTextType: 0x06u); break;
case "OnSelfSent": log.OnSelfSent(ChatKind.Tell, "hey", logTextType: 0x04u, targetOrChannel: "Alice"); break;
case "OnPlayerKilled": log.OnPlayerKilled("died", 0x1u, 0x2u); break;
}
string text = log.Snapshot()[0].Text;
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} .+$", text);
} }
[Fact] [Fact]
public void DisplayTimestampsSource_UsesLiteralColons_RegardlessOfCurrentCulture() public void FormatTimestampPrefix_UsesLiteralColons_RegardlessOfCurrentCulture()
{ {
CultureInfo original = Thread.CurrentThread.CurrentCulture; CultureInfo original = Thread.CurrentThread.CurrentCulture;
try try
{ {
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fi-FI"); Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fi-FI");
var log = new ChatLog { DisplayTimestampsSource = () => true };
log.OnSystemMessage("fizzled", chatType: 0); string prefix = ChatLog.FormatTimestampPrefix(
new DateTime(2026, 8, 11, 13, 5, 9, DateTimeKind.Utc));
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} fizzled$", log.Snapshot()[0].Text); // fi-FI's time separator is '.', so a culture-dependent format
// would emit "16.05.09 " here; retail's strftime is a literal ':'.
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} $", prefix);
} }
finally finally
{ {

View file

@ -267,65 +267,36 @@ public sealed class RuntimeCommunicationStateTests
} }
[Fact] [Fact]
public void AddText_TimestampsTrue_PrefixesTranscriptLine() public void AddText_TimestampsTrue_StoredBodyStaysClean()
{ {
// OP4 re-review R1 (2026-08-11): the prefix belongs to the DISPLAY
// composition (ChatVM prepends FormatTimestampPrefix(entry.Received)
// to the COMPOSED line — retail fprintf("%ls%ls", ts, text)
// @0x00563e5b), never to the stored body. The earlier fix round
// prefixed entry.Text here, which put the stamp INSIDE the quotes of
// composed kinds ('Alice says, "13:05:09 hi"').
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true }; using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
state.AddText("Your spell fizzled.", RetailLogTextType.Default); state.AddText("Your spell fizzled.", RetailLogTextType.Default);
string text = state.Chat.Snapshot()[0].Text; Assert.Equal("Your spell fizzled.", 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 " (colons ESCAPED, not the
// culture-dependent TimeSeparator placeholder) is the exact
// equivalent (SF-1/S4, OP4 review-fix round, 2026-08-11).
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", text);
} }
[Fact] [Fact]
public void AddText_TimestampsTrue_UsesLiteralColons_RegardlessOfCurrentCulture() public void DisplayTimestampsSource_ForwardsToChat_SoTheDisplaySeamSeesOneSource()
{ {
// SF-1/S4 (OP4 review-fix round, 2026-08-11): an unescaped "H:mm:ss" // R1: this class still owns the ONE forwarding seam — ChatVM reads
// format string renders ':' as CurrentCulture.DateTimeFormat. // ChatLog.DisplayTimestampsSource at display composition, so setting
// TimeSeparator, which is NOT ':' on cultures like fi-FI ("."). The // it HERE must reach the log's property (every producer's entries
// fix escapes the colons and forces InvariantCulture — prove the // then render prefixed, ChatVMTests pins the composed-line shape).
// output stays colon-separated even under a culture that would
// otherwise substitute a different separator.
CultureInfo original = Thread.CurrentThread.CurrentCulture;
try
{
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fi-FI");
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
state.AddText("Your spell fizzled.", RetailLogTextType.Default);
string text = state.Chat.Snapshot()[0].Text;
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Your spell fizzled\.$", text);
}
finally
{
Thread.CurrentThread.CurrentCulture = original;
}
}
[Fact]
public void DisplayTimestampsSource_ForwardsToChat_TimestampingEveryProducer_NotJustAddText()
{
// S1 (OP4 review-fix round, 2026-08-11, blast lens): AddText's own
// callers (ServerMessage/WeenieError) were a strict SUBSET of
// every chat producer — heard speech, emotes, Turbine channels,
// and combat text all bypassed it by calling ChatLog's own OnXxx
// methods directly. Setting DisplayTimestampsSource on this class
// must timestamp lines that never go through AddText at all.
using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true }; using var state = new RuntimeCommunicationState { DisplayTimestampsSource = () => true };
state.Chat.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u); Assert.NotNull(state.Chat.DisplayTimestampsSource);
Assert.True(state.Chat.DisplayTimestampsSource!());
string text = state.Chat.Snapshot()[0].Text; // And the stored body of a non-AddText producer stays clean too.
Assert.EndsWith("hi", text); state.Chat.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} hi$", text); Assert.Equal("hi", state.Chat.Snapshot()[0].Text);
} }
[Fact] [Fact]

View file

@ -161,4 +161,37 @@ public sealed class ChatVMTests
var entry = Assert.Single(log.Snapshot()); var entry = Assert.Single(log.Snapshot());
Assert.Equal(0x00u, entry.LogTextType); Assert.Equal(0x00u, entry.LogTextType);
} }
// ── OP4 re-review R1 (2026-08-11): the timestamp prefixes the COMPOSED
// display line, never the body — '13:05:09 Alice says, "hi"', not
// 'Alice says, "13:05:09 hi"' (retail fprintf("%ls%ls", ts, text)
// @0x00563e5b). ─────────────────────────────────────────────────────
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DisplayTimestamps_PrefixTheComposedLine_NotTheBody(bool timestampsOn)
{
var log = new ChatLog { DisplayTimestampsSource = () => timestampsOn };
log.OnLocalSpeech("Alice", "hi", 0xAAu, isRanged: false, logTextType: 0x02u);
var vm = new ChatVM(log);
string plain = Assert.Single(vm.RecentLines());
var detailed = Assert.Single(vm.RecentLinesDetailed());
if (timestampsOn)
{
// The stamp leads the whole composed line, OUTSIDE the quotes.
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Alice says, ""hi""$", plain);
Assert.Matches(@"^\d{1,2}:\d{2}:\d{2} Alice says, ""hi""$", detailed.Text);
}
else
{
Assert.Equal("Alice says, \"hi\"", plain);
Assert.Equal("Alice says, \"hi\"", detailed.Text);
}
// The stored body never carries the stamp in either state.
Assert.Equal("hi", log.Snapshot()[0].Text);
}
} }