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>
371 lines
13 KiB
C#
371 lines
13 KiB
C#
using AcDream.Core.Chat;
|
|
using AcDream.Core.Combat;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Chat;
|
|
|
|
public sealed class ChatLogTests
|
|
{
|
|
[Fact]
|
|
public void OnLocalSpeech_AppendsEntry_FiresEvent()
|
|
{
|
|
var log = new ChatLog();
|
|
ChatEntry? seen = null;
|
|
log.EntryAppended += e => seen = e;
|
|
|
|
log.OnLocalSpeech("Alice", "hi", 0xAA, isRanged: false, logTextType: 0x02u);
|
|
|
|
Assert.Equal(1, log.Count);
|
|
Assert.NotNull(seen);
|
|
Assert.Equal(ChatKind.LocalSpeech, seen!.Value.Kind);
|
|
Assert.Equal("Alice", seen.Value.Sender);
|
|
Assert.Equal("hi", seen.Value.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnLocalSpeech_Ranged_SetsRangedKind()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnLocalSpeech("Bob", "SHOUT", 0xBB, isRanged: true, logTextType: 0x02u);
|
|
Assert.Equal(ChatKind.RangedSpeech, log.Snapshot()[0].Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnChannelBroadcast_SetsChannelId()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnChannelBroadcast(channelId: 42, sender: "Alice", text: "allegiance motd");
|
|
var e = log.Snapshot()[0];
|
|
Assert.Equal(42u, e.ChannelId);
|
|
Assert.Equal(ChatKind.Channel, e.Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnTellReceived_SetsTellKind()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnTellReceived("Alice", "psst", 0xAA, logTextType: 0x03u);
|
|
Assert.Equal(ChatKind.Tell, log.Snapshot()[0].Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnSystemMessage_EncodesChatType_AsChannelId()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSystemMessage("Your spell fizzled!", chatType: 5);
|
|
var e = log.Snapshot()[0];
|
|
Assert.Equal(ChatKind.System, e.Kind);
|
|
Assert.Equal(5u, e.ChannelId);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnSelfSent_EchoesOutbound()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSelfSent(ChatKind.Tell, "hey", logTextType: 0x04u, targetOrChannel: "Alice");
|
|
var e = log.Snapshot()[0];
|
|
Assert.Equal("Alice", e.Sender);
|
|
Assert.Equal("hey", e.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void RingBuffer_DropsOldestBeyondCapacity()
|
|
{
|
|
var log = new ChatLog(maxEntries: 3);
|
|
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);
|
|
Assert.Equal("2", snap[0].Text); // "1" was dropped
|
|
Assert.Equal("4", snap[2].Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Clear_EmptiesBuffer()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnLocalSpeech("A", "1", 0, false, logTextType: 0x02u);
|
|
log.Clear();
|
|
Assert.Equal(0, log.Count);
|
|
}
|
|
|
|
// ── Phase I.5: emote / soul-emote / killed / weenie-error adapters ──
|
|
|
|
[Fact]
|
|
public void OnEmote_AppendsEmoteEntry()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnEmote("Caith", "waves at you", 0xCAFE);
|
|
var e = log.Snapshot()[0];
|
|
Assert.Equal(ChatKind.Emote, e.Kind);
|
|
Assert.Equal("Caith", e.Sender);
|
|
Assert.Equal("waves at you", e.Text);
|
|
Assert.Equal(0xCAFEu, e.SenderGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnSoulEmote_AppendsSoulEmoteEntry()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSoulEmote("Bob", "dances", 0xBEEF);
|
|
var e = log.Snapshot()[0];
|
|
Assert.Equal(ChatKind.SoulEmote, e.Kind);
|
|
Assert.Equal("Bob", e.Sender);
|
|
Assert.Equal("dances", e.Text);
|
|
Assert.Equal(0xBEEFu, e.SenderGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnPlayerKilled_AppendsSystemEntry_StoresGuids()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnPlayerKilled("Caith was killed by a Drudge.",
|
|
victimGuid: 0x12345678u, killerGuid: 0x90ABCDEFu);
|
|
var e = log.Snapshot()[0];
|
|
Assert.Equal(ChatKind.System, e.Kind);
|
|
Assert.Equal("Caith was killed by a Drudge.", e.Text);
|
|
Assert.Equal(0x12345678u, e.SenderGuid);
|
|
Assert.Equal(0x90ABCDEFu, e.ChannelId); // killer guid stashed here
|
|
}
|
|
|
|
[Fact]
|
|
public void OnWeenieError_PlainCode_AppendsSystemEntry()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnWeenieError(errorId: 0x1234, param: null);
|
|
var e = log.Snapshot()[0];
|
|
Assert.Equal(ChatKind.System, e.Kind);
|
|
Assert.Contains("0x1234", e.Text);
|
|
Assert.Equal(0x1234u, e.ChannelId);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnWeenieError_WithString_AppendsInterpolation()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnWeenieError(errorId: 0x5678, param: "Mana Stone");
|
|
var e = log.Snapshot()[0];
|
|
Assert.Equal(ChatKind.System, e.Kind);
|
|
Assert.Contains("Mana Stone", e.Text);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0x003Bu)] // ILeftTheWorld
|
|
[InlineData(0x003Cu)] // ITeleported
|
|
public void OnWeenieError_RetailSilentClientControlStatus_DoesNotAppend(uint code)
|
|
{
|
|
var log = new ChatLog();
|
|
|
|
log.OnWeenieError(code, param: null);
|
|
|
|
Assert.Empty(log.Snapshot());
|
|
}
|
|
|
|
[Fact]
|
|
public void OnLocalSpeech_EmptySender_SubstitutesYou()
|
|
{
|
|
// Holtburger client/messages.rs lines 476-487 — empty sender
|
|
// means the player is the speaker (echo back of their own
|
|
// 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, logTextType: 0x02u);
|
|
var e = log.Snapshot()[0];
|
|
Assert.Equal("You", e.Sender);
|
|
Assert.Equal("hello", e.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnLocalSpeech_NonEmptySender_KeepsAsIs()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnLocalSpeech(sender: "Alice", text: "hi", senderGuid: 0xAA, isRanged: false, logTextType: 0x02u);
|
|
Assert.Equal("Alice", log.Snapshot()[0].Sender);
|
|
}
|
|
|
|
// ── Phase I.7: combat-line adapter ────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void OnCombatLine_DefaultsInfoKind_TagsEntryAsCombat()
|
|
{
|
|
var log = new ChatLog();
|
|
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);
|
|
Assert.Equal("You hit Mosswart for 5 slashing damage (50.0%).", e.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnCombatLine_PreservesExplicitKind()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnCombatLine("Mosswart hit you for 8 fire damage to your chest.",
|
|
logTextType: 0x06u, kind: CombatLineKind.Warning);
|
|
Assert.Equal(CombatLineKind.Warning, log.Snapshot()[0].CombatKind);
|
|
|
|
log.OnCombatLine("Attack sequence finished with WeenieError 0x1234.",
|
|
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_ExplicitLogTextType_PinsSpeechValue()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnLocalSpeech("Alice", "hi", 0xAA, isRanged: false, logTextType: 0x02u);
|
|
Assert.Equal(0x02u, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnLocalSpeech_PassesWireChatTypeVerbatim()
|
|
{
|
|
// HearSpeech/HearRangedSpeech carry the LogTextType on the wire —
|
|
// zero remapping (research doc §3.3 / HearSpeech.cs doc comment).
|
|
var log = new ChatLog();
|
|
log.OnLocalSpeech("Mosswart", "grumble", 0x5000_1234u, isRanged: false, logTextType: 0x0Cu);
|
|
Assert.Equal(0x0Cu, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnEmote_HardCodesLogTextType_0x0C()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnEmote("Caith", "waves at you", 0xCAFE);
|
|
Assert.Equal(0x0Cu, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnSoulEmote_HardCodesLogTextType_0x0C()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSoulEmote("Bob", "dances", 0xBEEF);
|
|
Assert.Equal(0x0Cu, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnPlayerKilled_LogTextType_IsDefault()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnPlayerKilled("Caith was killed by a Drudge.", 0x1u, 0x2u);
|
|
Assert.Equal(0x00u, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnWeenieError_LogTextType_IsDefault()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnWeenieError(errorId: 0x1234, param: null);
|
|
Assert.Equal(0x00u, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnPopup_LogTextType_IsDefault()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnPopup("A modal message.");
|
|
var e = log.Snapshot()[0];
|
|
Assert.Equal(ChatKind.Popup, e.Kind);
|
|
Assert.Equal(0x00u, e.LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnTellReceived_ExplicitLogTextType_PinsTellValue()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnTellReceived("Alice", "psst", 0xAA, logTextType: 0x03u);
|
|
Assert.Equal(0x03u, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnTellReceived_PassesWireChatTypeVerbatim()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnTellReceived("Alice", "psst", 0xAA, logTextType: 0x1Fu);
|
|
Assert.Equal(0x1Fu, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnSelfSent_Tell_ExplicitLogTextType_PinsSpeechDirectSendValue()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSelfSent(ChatKind.Tell, "hey", logTextType: 0x04u, targetOrChannel: "Alice");
|
|
Assert.Equal(0x04u, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnSelfSent_Channel_ExplicitLogTextType_PinsSocialSendValue()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSelfSent(ChatKind.Channel, "hi all", logTextType: 0x0Bu, targetOrChannel: "Fellowship");
|
|
Assert.Equal(0x0Bu, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnSelfSent_ExplicitLogTextType_Overrides()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSelfSent(ChatKind.Channel, "hi all", logTextType: 0x13u, targetOrChannel: "Fellowship");
|
|
Assert.Equal(0x13u, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Theory]
|
|
// Fellowship — same type hear + send.
|
|
[InlineData(0x0800u, "Fellowship", 0x13u)]
|
|
// Patron/Vassal/Follower — hear is Social (0xA).
|
|
[InlineData(0x1000u, "Patron", 0x0Au)]
|
|
[InlineData(0x2000u, "Vassal", 0x0Au)]
|
|
[InlineData(0x4000u, "Follower", 0x0Au)]
|
|
// Co-Vassals / Allegiance Broadcast.
|
|
[InlineData(0x1000000u, "Co-Vassals", 0x0Au)]
|
|
[InlineData(0x2000000u, "Allegiance Broadcast", 0x0Au)]
|
|
// 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 — 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)
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnChannelBroadcast(channelBit, sender: "Someone", text: "hi", channelName: channelName);
|
|
Assert.Equal(expectedLogTextType, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnChannelBroadcast_ExplicitLogTextType_OverridesLegacyDerivation()
|
|
{
|
|
// TurbineChat rooms share the ChannelId slot with an opaque room
|
|
// GUID (not a legacy bitflag) — callers MUST override.
|
|
var log = new ChatLog();
|
|
log.OnChannelBroadcast(
|
|
channelId: 0x7000_0001u, sender: "Someone", text: "hi",
|
|
logTextType: 0x1Bu, channelName: "General");
|
|
Assert.Equal(0x1Bu, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnSystemMessage_LogTextType_MatchesChatType()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnSystemMessage("Your spell fizzled!", chatType: 5);
|
|
Assert.Equal(5u, log.Snapshot()[0].LogTextType);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnCombatLine_ExplicitLogTextType_PinsGenericCombatValue()
|
|
{
|
|
var log = new ChatLog();
|
|
log.OnCombatLine("You hit Mosswart for 5 slashing damage (50.0%).", logTextType: 0x06u);
|
|
Assert.Equal(0x06u, log.Snapshot()[0].LogTextType);
|
|
}
|
|
}
|