fix(chat): CH1 review fixes — sbb-idiom channel catch-all, command-output typing

Applies the Opus review findings on CH1 (172c6f9a), the exact retail chat
color table. Two blockers plus should-fixes/nits, one commit:

BLOCKER 1 — LegacyChannelChatType.Resolve's channel-bit table was wrong.
Binary Ninja renders retail's `neg esi; sbb esi, esi` idiom (a branchless
select between Channel 0x08 and Channel_Send 0x09) as the trivial pseudo-C
`esi - esi` (always 0), hiding the real values. Corrected by decoding the
raw bytes at the PDB-paired binary: HEAR sbb site VA 0x00570F0A (mask -6 ->
0x08), SEND sbb site VA 0x00570D4F (mask -5 -> 0x09). The generic
admin/audit/sentinel catch-all is Channel/Channel_Send, NOT Abuse (0x0E) —
Abuse is retail's ONLY 0x0E producer (bit 0x0001). The unnamed
FellowBroadcast bit (0x4000000) is hear=Channel(0x08)/send=Fellowship(0x13),
not a flat 0x13. ACE's PDB-sourced Channel enum corroborates. Introduces
`RetailLogTextType`, the 34-value named enum for the wire LogTextType space
(values only, no color — Core stays presentation-free).

BLOCKER 2 — three ChatLog.OnSystemMessage sinks (ChatVM.ShowSystemMessage,
LiveSessionRuntimeFactory's ShowSystemMessage delegate,
HeadlessGameplayOperations.DisplayMessage) were typing ALL
ClientCommandController output 0x1A (bright red), including informational
command output (@version, /loc, friends list, usage lines). Retail types
the great majority of that output 0x00 Default (green) and reserves 0x1A
for genuine refusals/errors. Reverted to 0x00 with a comment noting the
refusal-vs-info split lands with CH2's SpewBox producer rewiring. The five
App composition sites that pass 0x1A for actual refusal text
(InteractionRetainedUiComposition, SessionPlayerComposition) were already
correct and are untouched (aside from converting the literal to the new
enum).

Also: AP-176 divergence-register row for OnWeenieError/OnCombatLine's
single-type approximation of retail's per-code/per-message dispatch; a
carry-forward test for the out-of-range LogTextType color fallback in
ChatWindowController; decomp-confirmed anchors replacing ACE-inferred
citations in CombatChatTranslator and ChatLog.OnPlayerKilled; required
(non-optional) logTextType parameters on OnLocalSpeech/OnTellReceived/
OnCombatLine/OnSelfSent since no production caller relied on a default;
LegacyChannelChatType.Resolve's parameter renamed channelBit -> channelId
with a doc note on multi-bit ids; corrections to the color-table research
doc's §3.3 wire tables; and issue #359 for the pre-existing (not
CH1-introduced) 0x019E PlayerKilled participant-suppression gap retail has
and acdream lacks.

dotnet build clean; full Release suite 11,835 passed / 4 skipped / 0 failed
(11,839 total), up from the CH1 baseline of 11,833/4/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 16:02:59 +02:00
parent e306c979ae
commit 34d8a3c0e7
31 changed files with 481 additions and 213 deletions

View file

@ -45,7 +45,8 @@ public class ChatWindowControllerTests
/// send (Type-3) [0x10000019]
/// maxmin (Type-3) [0x1000046F]
/// </summary>
private static (ElementInfo rootInfo, ImportedLayout layout, ChatVM vm) BuildTestTree()
private static (ElementInfo rootInfo, ImportedLayout layout, ChatVM vm) BuildTestTree(
ChatLog? log = null)
{
var transcriptNode = new ElementInfo
{
@ -116,7 +117,7 @@ public class ChatWindowControllerTests
root.Children.Add(maxMinNode);
var layout = LayoutImporter.Build(root, NoTex, null);
var vm = new ChatVM(new ChatLog());
var vm = new ChatVM(log ?? new ChatLog());
return (root, layout, vm);
}
@ -209,6 +210,34 @@ public class ChatWindowControllerTests
Assert.Equal(4, ctrl.TranscriptLayoutBuildCount);
}
[Fact]
public void TranscriptLines_OutOfRangeLogTextType_CarriesPreviousLinesColor()
{
// Retail's SetFontColorHelper leaves m_curFontColor UNCHANGED for an
// out-of-range index instead of reverting to a default (research
// doc §3.2) — RetailChatColorTable.TryGetColor returns false for
// any index >= 0x22 and ChatWindowController.GetTranscriptLines
// carries the prior line's resolved color forward. Three entries;
// the middle one uses 0x22 (one past the last real retail slot,
// 0x21) so its rendered color must equal the first line's, not
// the third's.
var log = new ChatLog();
var (rootInfo, layout, vm) = BuildTestTree(log);
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, null, null, NoTex)!;
log.OnSystemMessage("first", chatType: 0x05u); // System, colorBrightPurple
log.OnSystemMessage("middle", chatType: 0x22u); // out of range — carries 0x05's color
log.OnSystemMessage("third", chatType: 0x00u); // Default, colorGreen
IReadOnlyList<UiText.Line> lines = ctrl.Transcript.LinesProvider();
Assert.Equal(3, lines.Count);
Assert.Equal(lines[0].Color, lines[1].Color);
Assert.NotEqual(lines[0].Color, lines[2].Color);
}
// ── Test 4: Input.OnSubmit publishes SendChatCmd via the capture bus ─────
[Fact]

View file

@ -155,8 +155,10 @@ public sealed class GameEventWiringTests
Assert.Equal(ChatKind.Channel, entry.Kind);
Assert.Equal("Alice", entry.Sender);
// channelId 42 (0x2A) matches no named legacy bit — generic
// admin/audit catch-all (LegacyChannelChatType.Resolve → 0x0E).
Assert.Equal(0x0Eu, entry.LogTextType);
// admin/audit catch-all, hear branch (LegacyChannelChatType.Resolve
// → 0x08 Channel; corrected 2026-08-09, Opus review of 172c6f9a —
// was wrongly 0x0E Abuse, retail's ONLY 0x0E producer is bit 0x0001).
Assert.Equal(0x08u, entry.LogTextType);
}
[Fact]

View file

@ -10,8 +10,8 @@ public sealed class ChatCommandTargetStateTests
var chat = new ChatLog();
using var targets = new ChatCommandTargetState(chat);
chat.OnTellReceived("Bestie", "incoming", 0x50000001u);
chat.OnSelfSent(ChatKind.Tell, "outgoing", "Caith");
chat.OnTellReceived("Bestie", "incoming", 0x50000001u, logTextType: 0x03u);
chat.OnSelfSent(ChatKind.Tell, "outgoing", logTextType: 0x04u, targetOrChannel: "Caith");
Assert.Equal("Bestie", targets.LastIncomingTellSender);
Assert.Equal("Caith", targets.LastOutgoingTellTarget);
@ -22,8 +22,8 @@ public sealed class ChatCommandTargetStateTests
{
var chat = new ChatLog();
using var targets = new ChatCommandTargetState(chat);
chat.OnTellReceived("Bestie", "incoming", 0x50000001u);
chat.OnSelfSent(ChatKind.Tell, "outgoing", "Caith");
chat.OnTellReceived("Bestie", "incoming", 0x50000001u, logTextType: 0x03u);
chat.OnSelfSent(ChatKind.Tell, "outgoing", logTextType: 0x04u, targetOrChannel: "Caith");
targets.ResetSession();
@ -40,7 +40,7 @@ public sealed class ChatCommandTargetStateTests
targets.Dispose();
targets.Dispose();
chat.OnTellReceived("After", "ignored", 0x50000002u);
chat.OnTellReceived("After", "ignored", 0x50000002u, logTextType: 0x03u);
Assert.Null(targets.LastIncomingTellSender);
Assert.Null(targets.LastOutgoingTellTarget);

View file

@ -20,7 +20,7 @@ public sealed class ChatLogLocalGuidTests
log.SetLocalPlayerGuid(0x5000_000A);
log.OnLocalSpeech("+Acdream", "hello world",
senderGuid: 0x5000_000A, isRanged: false);
senderGuid: 0x5000_000A, isRanged: false, logTextType: 0x02u);
var entry = log.Snapshot()[0];
Assert.Equal(ChatKind.LocalSpeech, entry.Kind);
@ -35,7 +35,7 @@ public sealed class ChatLogLocalGuidTests
log.SetLocalPlayerGuid(0x5000_000A);
log.OnLocalSpeech("Caith", "hi",
senderGuid: 0x5000_0042, isRanged: false);
senderGuid: 0x5000_0042, isRanged: false, logTextType: 0x02u);
Assert.Equal("Caith", log.Snapshot()[0].Sender);
}
@ -48,7 +48,7 @@ public sealed class ChatLogLocalGuidTests
// arrive with sender="" before the player has a guid.
var log = new ChatLog();
log.OnLocalSpeech("", "anyone home?",
senderGuid: 0u, isRanged: true);
senderGuid: 0u, isRanged: true, logTextType: 0x02u);
Assert.Equal("You", log.Snapshot()[0].Sender);
Assert.Equal(ChatKind.RangedSpeech, log.Snapshot()[0].Kind);
@ -62,13 +62,13 @@ public sealed class ChatLogLocalGuidTests
const uint newGuid = 0x50000002u;
log.SetLocalPlayerGuid(oldGuid);
log.OnSystemMessage("session boundary", 1u);
log.OnLocalSpeech("Old", "before", oldGuid, isRanged: false);
log.OnLocalSpeech("Old", "before", oldGuid, isRanged: false, logTextType: 0x02u);
log.ResetSessionIdentity();
log.OnSystemMessage("session boundary", 1u);
log.OnLocalSpeech("Old", "after", oldGuid, isRanged: false);
log.OnLocalSpeech("Old", "after", oldGuid, isRanged: false, logTextType: 0x02u);
log.SetLocalPlayerGuid(newGuid);
log.OnLocalSpeech("New", "new", newGuid, isRanged: false);
log.OnLocalSpeech("New", "new", newGuid, isRanged: false, logTextType: 0x02u);
ChatEntry[] entries = log.Snapshot();
Assert.Equal(5, entries.Length);

View file

@ -13,7 +13,7 @@ public sealed class ChatLogTests
ChatEntry? seen = null;
log.EntryAppended += e => seen = e;
log.OnLocalSpeech("Alice", "hi", 0xAA, isRanged: false);
log.OnLocalSpeech("Alice", "hi", 0xAA, isRanged: false, logTextType: 0x02u);
Assert.Equal(1, log.Count);
Assert.NotNull(seen);
@ -26,7 +26,7 @@ public sealed class ChatLogTests
public void OnLocalSpeech_Ranged_SetsRangedKind()
{
var log = new ChatLog();
log.OnLocalSpeech("Bob", "SHOUT", 0xBB, isRanged: true);
log.OnLocalSpeech("Bob", "SHOUT", 0xBB, isRanged: true, logTextType: 0x02u);
Assert.Equal(ChatKind.RangedSpeech, log.Snapshot()[0].Kind);
}
@ -44,7 +44,7 @@ public sealed class ChatLogTests
public void OnTellReceived_SetsTellKind()
{
var log = new ChatLog();
log.OnTellReceived("Alice", "psst", 0xAA);
log.OnTellReceived("Alice", "psst", 0xAA, logTextType: 0x03u);
Assert.Equal(ChatKind.Tell, log.Snapshot()[0].Kind);
}
@ -62,7 +62,7 @@ public sealed class ChatLogTests
public void OnSelfSent_EchoesOutbound()
{
var log = new ChatLog();
log.OnSelfSent(ChatKind.Tell, "hey", targetOrChannel: "Alice");
log.OnSelfSent(ChatKind.Tell, "hey", logTextType: 0x04u, targetOrChannel: "Alice");
var e = log.Snapshot()[0];
Assert.Equal("Alice", e.Sender);
Assert.Equal("hey", e.Text);
@ -72,10 +72,10 @@ public sealed class ChatLogTests
public void RingBuffer_DropsOldestBeyondCapacity()
{
var log = new ChatLog(maxEntries: 3);
log.OnLocalSpeech("A", "1", 0, false);
log.OnLocalSpeech("B", "2", 0, false);
log.OnLocalSpeech("C", "3", 0, false);
log.OnLocalSpeech("D", "4", 0, false);
log.OnLocalSpeech("A", "1", 0, false, logTextType: 0x02u);
log.OnLocalSpeech("B", "2", 0, false, logTextType: 0x02u);
log.OnLocalSpeech("C", "3", 0, false, logTextType: 0x02u);
log.OnLocalSpeech("D", "4", 0, false, logTextType: 0x02u);
var snap = log.Snapshot();
Assert.Equal(3, snap.Length);
@ -87,7 +87,7 @@ public sealed class ChatLogTests
public void Clear_EmptiesBuffer()
{
var log = new ChatLog();
log.OnLocalSpeech("A", "1", 0, false);
log.OnLocalSpeech("A", "1", 0, false, logTextType: 0x02u);
log.Clear();
Assert.Equal(0, log.Count);
}
@ -172,7 +172,7 @@ public sealed class ChatLogTests
// ranged shout). Substitute "You" so the chat line reads
// "You: hello" instead of ": hello".
var log = new ChatLog();
log.OnLocalSpeech(sender: "", text: "hello", senderGuid: 0, isRanged: false);
log.OnLocalSpeech(sender: "", text: "hello", senderGuid: 0, isRanged: false, logTextType: 0x02u);
var e = log.Snapshot()[0];
Assert.Equal("You", e.Sender);
Assert.Equal("hello", e.Text);
@ -182,7 +182,7 @@ public sealed class ChatLogTests
public void OnLocalSpeech_NonEmptySender_KeepsAsIs()
{
var log = new ChatLog();
log.OnLocalSpeech(sender: "Alice", text: "hi", senderGuid: 0xAA, isRanged: false);
log.OnLocalSpeech(sender: "Alice", text: "hi", senderGuid: 0xAA, isRanged: false, logTextType: 0x02u);
Assert.Equal("Alice", log.Snapshot()[0].Sender);
}
@ -192,7 +192,7 @@ public sealed class ChatLogTests
public void OnCombatLine_DefaultsInfoKind_TagsEntryAsCombat()
{
var log = new ChatLog();
log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).");
log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).", logTextType: 0x06u);
var e = log.Snapshot()[0];
Assert.Equal(ChatKind.Combat, e.Kind);
Assert.Equal(CombatLineKind.Info, e.CombatKind);
@ -204,21 +204,21 @@ public sealed class ChatLogTests
{
var log = new ChatLog();
log.OnCombatLine("Mosswart hit you for 8 fire damage to your chest.",
CombatLineKind.Warning);
logTextType: 0x06u, kind: CombatLineKind.Warning);
Assert.Equal(CombatLineKind.Warning, log.Snapshot()[0].CombatKind);
log.OnCombatLine("Attack sequence finished with WeenieError 0x1234.",
CombatLineKind.Error);
logTextType: 0x06u, kind: CombatLineKind.Error);
Assert.Equal(CombatLineKind.Error, log.Snapshot()[1].CombatKind);
}
// ── Campaign CH slice CH1: LogTextType ingestion-site mapping ──────────
[Fact]
public void OnLocalSpeech_DefaultsLogTextType_ToSpeech()
public void OnLocalSpeech_ExplicitLogTextType_PinsSpeechValue()
{
var log = new ChatLog();
log.OnLocalSpeech("Alice", "hi", 0xAA, isRanged: false);
log.OnLocalSpeech("Alice", "hi", 0xAA, isRanged: false, logTextType: 0x02u);
Assert.Equal(0x02u, log.Snapshot()[0].LogTextType);
}
@ -275,10 +275,10 @@ public sealed class ChatLogTests
}
[Fact]
public void OnTellReceived_DefaultsLogTextType_ToTell()
public void OnTellReceived_ExplicitLogTextType_PinsTellValue()
{
var log = new ChatLog();
log.OnTellReceived("Alice", "psst", 0xAA);
log.OnTellReceived("Alice", "psst", 0xAA, logTextType: 0x03u);
Assert.Equal(0x03u, log.Snapshot()[0].LogTextType);
}
@ -291,18 +291,18 @@ public sealed class ChatLogTests
}
[Fact]
public void OnSelfSent_Tell_DefaultsLogTextType_ToSpeechDirectSend()
public void OnSelfSent_Tell_ExplicitLogTextType_PinsSpeechDirectSendValue()
{
var log = new ChatLog();
log.OnSelfSent(ChatKind.Tell, "hey", targetOrChannel: "Alice");
log.OnSelfSent(ChatKind.Tell, "hey", logTextType: 0x04u, targetOrChannel: "Alice");
Assert.Equal(0x04u, log.Snapshot()[0].LogTextType);
}
[Fact]
public void OnSelfSent_Channel_DefaultsLogTextType_ToSocialSend()
public void OnSelfSent_Channel_ExplicitLogTextType_PinsSocialSendValue()
{
var log = new ChatLog();
log.OnSelfSent(ChatKind.Channel, "hi all", targetOrChannel: "Fellowship");
log.OnSelfSent(ChatKind.Channel, "hi all", logTextType: 0x0Bu, targetOrChannel: "Fellowship");
Assert.Equal(0x0Bu, log.Snapshot()[0].LogTextType);
}
@ -310,7 +310,7 @@ public sealed class ChatLogTests
public void OnSelfSent_ExplicitLogTextType_Overrides()
{
var log = new ChatLog();
log.OnSelfSent(ChatKind.Channel, "hi all", targetOrChannel: "Fellowship", logTextType: 0x13u);
log.OnSelfSent(ChatKind.Channel, "hi all", logTextType: 0x13u, targetOrChannel: "Fellowship");
Assert.Equal(0x13u, log.Snapshot()[0].LogTextType);
}
@ -324,12 +324,15 @@ public sealed class ChatLogTests
// Co-Vassals / Allegiance Broadcast.
[InlineData(0x1000000u, "Co-Vassals", 0x0Au)]
[InlineData(0x2000000u, "Allegiance Broadcast", 0x0Au)]
// Unnamed bit reuses Fellowship's slot.
[InlineData(0x4000000u, "?", 0x13u)]
// FellowBroadcast — hear is the plain Channel slot (corrected
// 2026-08-09, Opus review of 172c6f9a — was wrongly 0x13).
[InlineData(0x4000000u, "?", 0x08u)]
// The one named non-family bit inside the generic bucket (Help).
[InlineData(0x0400u, "Help", 0x0Fu)]
// Generic admin/audit/sentinel catch-all.
[InlineData(0x0900u, "Audit", 0x0Eu)]
// Generic admin/audit/sentinel catch-all — Channel (hear), NOT Abuse
// (corrected 2026-08-09, Opus review of 172c6f9a — Abuse is retail's
// ONLY 0x0E producer, bit 0x0001, and this test's 0x0900 doesn't hit it).
[InlineData(0x0900u, "Audit", 0x08u)]
public void OnChannelBroadcast_DerivesLogTextType_FromLegacyChannelBit(
uint channelBit, string channelName, uint expectedLogTextType)
{
@ -359,10 +362,10 @@ public sealed class ChatLogTests
}
[Fact]
public void OnCombatLine_DefaultLogTextType_IsGenericCombat()
public void OnCombatLine_ExplicitLogTextType_PinsGenericCombatValue()
{
var log = new ChatLog();
log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).");
log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).", logTextType: 0x06u);
Assert.Equal(0x06u, log.Snapshot()[0].LogTextType);
}
}

View file

@ -9,7 +9,15 @@ namespace AcDream.Core.Tests.Chat;
/// <c>ClientCommunicationSystem::Handle_Communication__ChannelBroadcast
/// @0x00570B90</c> — both the HEAR (someone else's message) and OWN-SEND
/// (the local player's own outgoing message) branches, which diverge for
/// exactly three bits (Patron/Vassal/Follower).
/// Patron/Vassal/Follower and the unnamed FellowBroadcast bit.
///
/// <para>
/// Opus review, 2026-08-09: the catch-all and the Abuse/Help/FellowBroadcast
/// cases were corrected after decoding the raw <c>sbb</c>-idiom bytes at VA
/// <c>0x00570F0A</c> (hear) / <c>0x00570D4F</c> (send) — Binary Ninja's
/// pseudo-C had rendered the idiom as the trivial <c>esi - esi</c> (always
/// 0), hiding the real Channel (0x08) / Channel_Send (0x09) values.
/// </para>
/// </summary>
public sealed class LegacyChannelChatTypeTests
{
@ -17,23 +25,51 @@ public sealed class LegacyChannelChatTypeTests
[InlineData(0x0800u, 0x13u)] // Fellowship
[InlineData(0x1000000u, 0x0Au)] // Co-Vassals
[InlineData(0x2000000u, 0x0Au)] // Allegiance Broadcast
[InlineData(0x4000000u, 0x13u)] // unnamed bit
[InlineData(0x0400u, 0x0Fu)] // Help
[InlineData(0x0100u, 0x0Eu)] // generic/admin catch-all
[InlineData(0xDEADu, 0x0Eu)] // any other unmatched bit
public void Resolve_SameForHearAndSend(uint channelBit, uint expected)
public void Resolve_SameForHearAndSend(uint channelId, uint expected)
{
Assert.Equal(expected, LegacyChannelChatType.Resolve(channelBit, ownSend: false));
Assert.Equal(expected, LegacyChannelChatType.Resolve(channelBit, ownSend: true));
Assert.Equal(expected, LegacyChannelChatType.Resolve(channelId, ownSend: false));
Assert.Equal(expected, LegacyChannelChatType.Resolve(channelId, ownSend: true));
}
[Theory]
[InlineData(0x1000u)] // Patron
[InlineData(0x2000u)] // Vassal
[InlineData(0x4000u)] // Follower / Monarch
public void Resolve_HearIsSocial_SendIsSocialSend(uint channelBit)
public void Resolve_HearIsSocial_SendIsSocialSend(uint channelId)
{
Assert.Equal(0x0Au, LegacyChannelChatType.Resolve(channelBit, ownSend: false));
Assert.Equal(0x0Bu, LegacyChannelChatType.Resolve(channelBit, ownSend: true));
Assert.Equal(0x0Au, LegacyChannelChatType.Resolve(channelId, ownSend: false));
Assert.Equal(0x0Bu, LegacyChannelChatType.Resolve(channelId, ownSend: true));
}
[Fact]
public void Resolve_Abuse_IsRetailsOnly0xEProducer()
{
// Bit 0x0001 — the ONLY channel bit that resolves to Abuse (0x0E)
// for either hear or send.
Assert.Equal(0x0Eu, LegacyChannelChatType.Resolve(0x0001u, ownSend: false));
Assert.Equal(0x0Eu, LegacyChannelChatType.Resolve(0x0001u, ownSend: true));
}
[Fact]
public void Resolve_Help_IsSameForHearAndSend()
{
Assert.Equal(0x0Fu, LegacyChannelChatType.Resolve(0x0400u, ownSend: false));
Assert.Equal(0x0Fu, LegacyChannelChatType.Resolve(0x0400u, ownSend: true));
}
[Fact]
public void Resolve_FellowBroadcast_HearIsChannel_SendIsFellowship()
{
Assert.Equal(0x08u, LegacyChannelChatType.Resolve(0x4000000u, ownSend: false));
Assert.Equal(0x13u, LegacyChannelChatType.Resolve(0x4000000u, ownSend: true));
}
[Theory]
[InlineData(0x0100u)] // generic/admin catch-all
[InlineData(0xDEADu)] // any other unmatched bit
public void Resolve_CatchAll_HearIsChannel_SendIsChannelSend(uint channelId)
{
Assert.Equal(0x08u, LegacyChannelChatType.Resolve(channelId, ownSend: false));
Assert.Equal(0x09u, LegacyChannelChatType.Resolve(channelId, ownSend: true));
}
}

View file

@ -51,7 +51,7 @@ public sealed class RuntimeCommunicationStateTests
var observer = new RecordingObserver();
using IDisposable subscription = state.Events.Subscribe(observer);
state.Chat.OnTellReceived("Bestie", "hello", 0x50000001u);
state.Chat.OnTellReceived("Bestie", "hello", 0x50000001u, logTextType: 0x03u);
RuntimeCommunicationEvent delta = Assert.Single(observer.Events);
Assert.Equal(1UL, delta.Sequence);
@ -108,8 +108,8 @@ public sealed class RuntimeCommunicationStateTests
public void SessionResetsClearScopedStateWithoutClearingTranscript()
{
using var state = new RuntimeCommunicationState();
state.Chat.OnTellReceived("Bestie", "hello", 0x50000001u);
state.Chat.OnSelfSent(ChatKind.Tell, "outgoing", "Caith");
state.Chat.OnTellReceived("Bestie", "hello", 0x50000001u, logTextType: 0x03u);
state.Chat.OnSelfSent(ChatKind.Tell, "outgoing", logTextType: 0x04u, targetOrChannel: "Caith");
state.TurbineChat.OnChannelsReceived(
1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u);
state.Friends.Apply(new FriendsUpdate(
@ -159,7 +159,7 @@ public sealed class RuntimeCommunicationStateTests
using var first = new RuntimeCommunicationState();
using var second = new RuntimeCommunicationState();
first.Chat.OnTellReceived("OnlyFirst", "hello", 0x50000001u);
first.Chat.OnTellReceived("OnlyFirst", "hello", 0x50000001u, logTextType: 0x03u);
Assert.Equal(1, first.View.Count);
Assert.Equal(0, second.View.Count);

View file

@ -87,11 +87,13 @@ public sealed class RuntimeGameplayOwnershipTests
communication.Chat.OnTellReceived(
"Sender",
"hello",
0x50000001u);
0x50000001u,
logTextType: 0x03u);
communication.Chat.OnSelfSent(
ChatKind.Tell,
"reply",
"Recipient");
logTextType: 0x04u,
targetOrChannel: "Recipient");
communication.TurbineChat.OnChannelsReceived(
1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u);
communication.Friends.Apply(new FriendsUpdate(

View file

@ -180,7 +180,8 @@ public sealed class NoWindowGameRuntimeHostTests
"Runtime",
"fixture speech",
localGuid,
isRanged: false);
isRanged: false,
logTextType: 0x02u);
runtime.CharacterOwner.LocalPlayer.OnVitalUpdate(
vitalId: 1u,
ranks: 100u,

View file

@ -18,8 +18,8 @@ public sealed class ChatVMTests
public void RecentLines_ReturnsAllEntries_WhenBelowLimit()
{
var log = new ChatLog();
log.OnLocalSpeech(sender: "Caith", text: "hello", senderGuid: 0x5000_0001u, isRanged: false);
log.OnLocalSpeech(sender: "Regal", text: "world", senderGuid: 0x5000_0002u, isRanged: false);
log.OnLocalSpeech(sender: "Caith", text: "hello", senderGuid: 0x5000_0001u, isRanged: false, logTextType: 0x02u);
log.OnLocalSpeech(sender: "Regal", text: "world", senderGuid: 0x5000_0002u, isRanged: false, logTextType: 0x02u);
var vm = new ChatVM(log, displayLimit: 20);
var lines = vm.RecentLines();
@ -34,7 +34,7 @@ public sealed class ChatVMTests
{
var log = new ChatLog();
for (int i = 0; i < 30; i++)
log.OnLocalSpeech(sender: "A", text: $"msg{i}", senderGuid: 0x5000_0001u, isRanged: false);
log.OnLocalSpeech(sender: "A", text: $"msg{i}", senderGuid: 0x5000_0001u, isRanged: false, logTextType: 0x02u);
var vm = new ChatVM(log, displayLimit: 5);
var lines = vm.RecentLines();
@ -136,24 +136,27 @@ public sealed class ChatVMTests
Assert.Empty(vm.RecentLines());
log.OnLocalSpeech("Caith", "hello", 0x5000_0001u, false);
log.OnLocalSpeech("Caith", "hello", 0x5000_0001u, false, logTextType: 0x02u);
var after = vm.RecentLines();
Assert.Single(after);
Assert.Equal("Caith says, \"hello\"", after[0]);
}
[Fact]
public void ShowSystemMessage_UsesClientLocalLogTextType()
public void ShowSystemMessage_UsesDefaultLogTextType()
{
// Campaign CH slice CH1: client-local command output (/help, /clear,
// /framerate, /loc, ...) never reaches the wire — retail's own
// client-local text is LogTextType 0x1A (bright red).
// Corrected 2026-08-09, Opus review of 172c6f9a: ShowSystemMessage
// is ClientCommandController's general-purpose informational sink
// (/help, /clear, /framerate, /loc, @version, friends list, ...).
// Retail types the great majority of that command output 0x00
// Default (green); 0x1A (bright red) is reserved for genuine
// refusals, which land separately with CH2's SpewBox routing.
var log = new ChatLog();
var vm = new ChatVM(log);
vm.ShowSystemMessage("Unknown command: foo");
var entry = Assert.Single(log.Snapshot());
Assert.Equal(0x1Au, entry.LogTextType);
Assert.Equal(0x00u, entry.LogTextType);
}
}

View file

@ -280,7 +280,7 @@ public sealed class ChatPanelInputTests
{
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnTellReceived("Bestie", "ping", senderGuid: 0x5000_00AAu);
log.OnTellReceived("Bestie", "ping", senderGuid: 0x5000_00AAu, logTextType: 0x03u);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();

View file

@ -99,7 +99,7 @@ public sealed class ChatPanelLayoutTests
Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetScrollHereY");
// Append a new entry, render again — auto-scroll should fire.
log.OnLocalSpeech("Caith", "hello", senderGuid: 0xAA, isRanged: false);
log.OnLocalSpeech("Caith", "hello", senderGuid: 0xAA, isRanged: false, logTextType: 0x02u);
renderer.Calls.Clear();
panel.Render(ctx, renderer);

View file

@ -34,7 +34,7 @@ public sealed class ChatVMCombatTests
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnCombatLine("Mosswart hit you for 8 fire damage to your chest.",
CombatLineKind.Warning);
logTextType: 0x06u, kind: CombatLineKind.Warning);
var lines = vm.RecentLinesDetailed();
var line = Assert.Single(lines);
@ -48,7 +48,7 @@ public sealed class ChatVMCombatTests
{
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnLocalSpeech("Alice", "hi", senderGuid: 0xAA, isRanged: false);
log.OnLocalSpeech("Alice", "hi", senderGuid: 0xAA, isRanged: false, logTextType: 0x02u);
var line = Assert.Single(vm.RecentLinesDetailed());
Assert.Equal(ChatKind.LocalSpeech, line.Kind);
@ -61,9 +61,9 @@ public sealed class ChatVMCombatTests
{
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnLocalSpeech("Alice", "hi", senderGuid: 0xAA, isRanged: false);
log.OnLocalSpeech("Alice", "hi", senderGuid: 0xAA, isRanged: false, logTextType: 0x02u);
log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).",
CombatLineKind.Info);
logTextType: 0x06u, kind: CombatLineKind.Info);
var panel = new ChatPanel(vm);
var bus = new RecordingChatBus();
@ -74,8 +74,9 @@ public sealed class ChatVMCombatTests
// Plain LocalSpeech entry → Text; combat entry → TextColored, now
// sourced from RetailChatColorTable (Campaign CH slice CH1) keyed
// by LogTextType, not ChatPanel.ColorForCombat's severity bucket.
// OnCombatLine's default logTextType (0x06, generic Combat slot,
// colorDarkRed) applies here since no explicit type was passed.
// The 0x06 generic Combat slot (colorDarkRed) is passed explicitly
// above — a registered approximation of retail's per-message
// dispatch (register row AP-176), not a ChatLog default.
Assert.Contains(renderer.Calls, c =>
c.Method == "Text" && (string?)c.Args[0] == "Alice says, \"hi\"");
var coloredCall = Assert.Single(

View file

@ -17,11 +17,11 @@ public sealed class ChatVMLastTellSenderTests
{
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnTellReceived("Before", "ping", 0x5000_0001u);
log.OnTellReceived("Before", "ping", 0x5000_0001u, logTextType: 0x03u);
vm.Dispose();
vm.Dispose();
log.OnTellReceived("After", "pong", 0x5000_0002u);
log.OnTellReceived("After", "pong", 0x5000_0002u, logTextType: 0x03u);
Assert.Equal("Before", vm.LastIncomingTellSender);
}
@ -41,7 +41,7 @@ public sealed class ChatVMLastTellSenderTests
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnTellReceived(sender: "Bestie", text: "ping", senderGuid: 0x5000_00AAu);
log.OnTellReceived(sender: "Bestie", text: "ping", senderGuid: 0x5000_00AAu, logTextType: 0x03u);
Assert.Equal("Bestie", vm.LastIncomingTellSender);
}
@ -52,8 +52,8 @@ public sealed class ChatVMLastTellSenderTests
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnTellReceived("Bestie", "ping", 0x5000_00AAu);
log.OnTellReceived("Regal", "yo", 0x5000_00BBu);
log.OnTellReceived("Bestie", "ping", 0x5000_00AAu, logTextType: 0x03u);
log.OnTellReceived("Regal", "yo", 0x5000_00BBu, logTextType: 0x03u);
Assert.Equal("Regal", vm.LastIncomingTellSender);
}
@ -66,8 +66,8 @@ public sealed class ChatVMLastTellSenderTests
// /r reply echo: SenderGuid = 0 (no real GUID for ourselves).
// Must NOT clobber the captured sender.
log.OnTellReceived("Bestie", "ping", 0x5000_00AAu);
log.OnSelfSent(ChatKind.Tell, "back at you", targetOrChannel: "Bestie");
log.OnTellReceived("Bestie", "ping", 0x5000_00AAu, logTextType: 0x03u);
log.OnSelfSent(ChatKind.Tell, "back at you", logTextType: 0x04u, targetOrChannel: "Bestie");
Assert.Equal("Bestie", vm.LastIncomingTellSender);
}
@ -78,7 +78,7 @@ public sealed class ChatVMLastTellSenderTests
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnLocalSpeech("Caith", "hello", 0x5000_00CCu, isRanged: false);
log.OnLocalSpeech("Caith", "hello", 0x5000_00CCu, isRanged: false, logTextType: 0x02u);
Assert.Null(vm.LastIncomingTellSender);
}

View file

@ -22,7 +22,7 @@ public sealed class ChatVMRetellAndProvidersTests
using var second = new ChatVM(log, commandTargets: targets);
first.Dispose();
log.OnTellReceived("Bestie", "incoming", 0x50000001u);
log.OnTellReceived("Bestie", "incoming", 0x50000001u, logTextType: 0x03u);
Assert.Equal("Bestie", second.LastIncomingTellSender);
}
@ -32,8 +32,8 @@ public sealed class ChatVMRetellAndProvidersTests
{
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnTellReceived("Bestie", "incoming", 0x50000001u);
log.OnSelfSent(ChatKind.Tell, "outgoing", targetOrChannel: "Caith");
log.OnTellReceived("Bestie", "incoming", 0x50000001u, logTextType: 0x03u);
log.OnSelfSent(ChatKind.Tell, "outgoing", logTextType: 0x04u, targetOrChannel: "Caith");
Assert.NotNull(vm.LastIncomingTellSender);
Assert.NotNull(vm.LastOutgoingTellTarget);
@ -58,7 +58,7 @@ public sealed class ChatVMRetellAndProvidersTests
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnSelfSent(ChatKind.Tell, "hi there", targetOrChannel: "Caith");
log.OnSelfSent(ChatKind.Tell, "hi there", logTextType: 0x04u, targetOrChannel: "Caith");
Assert.Equal("Caith", vm.LastOutgoingTellTarget);
// Inbound-tell tracker must NOT pick up an outgoing echo —
@ -72,7 +72,7 @@ public sealed class ChatVMRetellAndProvidersTests
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnTellReceived("Bestie", "psst", senderGuid: 0x5000_0042);
log.OnTellReceived("Bestie", "psst", senderGuid: 0x5000_0042, logTextType: 0x03u);
Assert.Equal("Bestie", vm.LastIncomingTellSender);
Assert.Null(vm.LastOutgoingTellTarget);
@ -84,8 +84,8 @@ public sealed class ChatVMRetellAndProvidersTests
var log = new ChatLog();
var vm = new ChatVM(log);
log.OnSelfSent(ChatKind.Tell, "hi", targetOrChannel: "Caith");
log.OnTellReceived("Bestie", "psst", senderGuid: 0x5000_0042);
log.OnSelfSent(ChatKind.Tell, "hi", logTextType: 0x04u, targetOrChannel: "Caith");
log.OnTellReceived("Bestie", "psst", senderGuid: 0x5000_0042, logTextType: 0x03u);
Assert.Equal("Caith", vm.LastOutgoingTellTarget);
Assert.Equal("Bestie", vm.LastIncomingTellSender);