Campaign CT Group D, closing the campaign. D1. The ImGui-era ChatPanel has not been constructed anywhere in src/ since Campaign V deleted AcDream.UI.ImGui. Verified that directly rather than on the audit's word, then removed it with its three panel-only test files. Those tests passed, which is exactly the problem: they made the real input surface look better covered than it is. ChatVMCombatTests was KEPT — three of its four tests are genuine ChatVM coverage and only one exercised ChatPanel, so just that method went. Deleting the file would have quietly dropped real coverage along with the dead kind. Three doc comments referencing the deleted type were rewritten rather than left as dangling crefs. D2. docs/ISSUES.md turned out to be ACCURATE already — #358 and #363 are recorded CLOSED there, contrary to the audit's summary. What was stale was the chat DIGEST's "Open" section, which still named four closed issues and claimed Campaign CH's connected gate was owed. Corrected against ISSUES: genuinely open are #359, #360, #361 and #366. The digest also gained a Campaign CT section (the tag mechanism, the MEASURED tag colour, and what shipped) and three DO-NOT-RETRY rows earned this session: - Do not model authored state media with one image per state — the unseen indicator's Normal state carries SIX frames and that IS retail's blink. - Do not read an element's role from a Binary Ninja field NAME — the names in ChatInterface's binder are shifted badly enough to assign a UIElement* into a float field. - Do not assume our side has a gap because retail has a mechanism. That cost this campaign twice in one session: the transcript was claimed unbounded when ChatLog has always capped at 500 entries, and C1's auto-scroll was planned as a port when UiScrollable already did it. CT-C4 is deferred and marked so: pure test coverage over behaviour the audit confirmed already works, changing nothing a user can see. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
537 lines
23 KiB
C#
537 lines
23 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Concurrent;
|
|
using System.Globalization;
|
|
using System.Threading;
|
|
|
|
namespace AcDream.Core.Chat;
|
|
|
|
/// <summary>
|
|
/// Unified chat log — mirrors every chat-bearing message the server
|
|
/// sends (local HearSpeech, broadcast ChannelBroadcast, whispered
|
|
/// Tell, system TransientMessage, PopupString).
|
|
///
|
|
/// <para>
|
|
/// Sits behind the UI chat panel (Phase D.2) and the scripting
|
|
/// plugin API so plugins can react to chat (e.g. auto-reply, loot
|
|
/// logging).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Retail keeps ~200 lines of scrollback. Our ring buffer defaults to
|
|
/// 500 and is configurable.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class ChatLog
|
|
{
|
|
private readonly ConcurrentQueue<ChatEntry> _buffer = new();
|
|
private readonly int _maxEntries;
|
|
private uint _localPlayerGuid;
|
|
private long _revision;
|
|
|
|
// Phase J follow-up: ACE often sends the same system text via two
|
|
// wire paths (GameMessageSystemChat 0xF7E0 + GameEventCommunication-
|
|
// TransientString 0x02EB) for back-compat — we wired both to
|
|
// OnSystemMessage in I.5/J, so the user saw lines like "Unknown
|
|
// command: help" twice. Dedupe within a short window: track the
|
|
// last system text + arrival time; if a second identical text
|
|
// shows up within one second, skip.
|
|
private string _lastSystemText = "";
|
|
private DateTime _lastSystemAt = DateTime.MinValue;
|
|
private static readonly TimeSpan SystemDedupWindow = TimeSpan.FromSeconds(1);
|
|
|
|
public ChatLog(int maxEntries = 500)
|
|
{
|
|
if (maxEntries < 1) throw new ArgumentOutOfRangeException(nameof(maxEntries));
|
|
_maxEntries = maxEntries;
|
|
}
|
|
|
|
/// <summary>
|
|
/// OP4 review-fix round (2026-08-11, SHOULD-FIX S1): retail
|
|
/// <c>PlayerOption DisplayTimeStamps</c> — <c>ClientSystem::
|
|
/// AddTextToScroll @0x00563C50</c> — prefixes EVERY transcript line
|
|
/// with the timestamp, not just the subset that happens to route
|
|
/// through <c>RuntimeCommunicationState.AddText</c>. Moved HERE
|
|
/// (from AddText) because <see cref="Append"/> is the ONE seam every
|
|
/// chat producer (<see cref="OnLocalSpeech"/>, <see cref="OnEmote"/>,
|
|
/// <see cref="OnSoulEmote"/>, <see cref="OnPlayerKilled"/>,
|
|
/// <see cref="OnChannelBroadcast"/>, <see cref="OnTellReceived"/>,
|
|
/// <see cref="OnSystemMessage"/>, <see cref="OnPopup"/>,
|
|
/// <see cref="OnCombatLine"/>, <see cref="OnSelfSent"/>) funnels
|
|
/// through — AddText's own callers (ServerMessage/WeenieError) were a
|
|
/// strict subset, so heard speech, emotes, Turbine channels, and
|
|
/// combat text never gained the prefix. The transient SpewBox
|
|
/// (<c>RetailLogTextType.ClientLocal</c>) never touches this class at
|
|
/// all, so it stays exempt automatically — matching retail's own
|
|
/// exemption without a special case here.
|
|
/// </summary>
|
|
public Func<bool>? DisplayTimestampsSource { get; set; }
|
|
|
|
/// <summary>Fires every time a new entry is appended.</summary>
|
|
public event Action<ChatEntry>? EntryAppended;
|
|
|
|
/// <summary>Snapshot of all current entries, oldest first.</summary>
|
|
public ChatEntry[] Snapshot() => _buffer.ToArray();
|
|
|
|
public int Count => _buffer.Count;
|
|
|
|
/// <summary>
|
|
/// Monotonic content revision. It advances after every successful append and
|
|
/// every explicit clear, allowing retained UI consumers to cache formatted
|
|
/// transcript layout without snapshotting the concurrent queue every frame.
|
|
/// </summary>
|
|
public long Revision => Interlocked.Read(ref _revision);
|
|
|
|
/// <summary>
|
|
/// Push the authoritative local-player GUID from <c>WorldSession</c>.
|
|
/// The host sets it after character selection and resets it at session
|
|
/// teardown. Used by <see cref="OnLocalSpeech"/> to
|
|
/// recognize ACE's HearSpeech echo of our own /say (server's
|
|
/// HandleActionTalk broadcasts to all in range INCLUDING the
|
|
/// sender) and re-render it as <c>"You say, ..."</c>.
|
|
/// </summary>
|
|
public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid;
|
|
|
|
/// <summary>
|
|
/// Reset per-session speaker classification and duplicate suppression
|
|
/// while preserving the visible transcript. Retail has an explicit
|
|
/// <c>ChatInterface::RecvNotice_ClearChatBuffer @ 0x004F2F60</c> path;
|
|
/// character-session teardown does not use that clear-buffer notice.
|
|
/// </summary>
|
|
public void ResetSessionIdentity()
|
|
{
|
|
_localPlayerGuid = 0u;
|
|
_lastSystemText = string.Empty;
|
|
_lastSystemAt = DateTime.MinValue;
|
|
}
|
|
|
|
// ── Inbound adapters ─────────────────────────────────────────────────────
|
|
|
|
/// <summary>Local or ranged HearSpeech (0x02BB / 0x02BC).</summary>
|
|
/// <remarks>
|
|
/// Phase I.5: an empty <paramref name="sender"/> is substituted with
|
|
/// "You" — the server uses this convention to indicate that the
|
|
/// player is the speaker (e.g. their own ranged shouts echo back).
|
|
/// Port from holtburger
|
|
/// <c>references/holtburger/.../client/messages.rs</c> lines 476-487.
|
|
/// </remarks>
|
|
/// <param name="logTextType">
|
|
/// The wire <c>chatType</c> carried by HearSpeech/HearRangedSpeech
|
|
/// (<c>speech.ChatType</c>) — passed through VERBATIM, with zero
|
|
/// remapping, matching retail's <c>Handle_Communication__HearSpeech
|
|
/// @0x005712A0</c> (the raw <c>arg5</c> feeds <c>AddTextToScroll</c>
|
|
/// directly). Required — no production caller relies on a default;
|
|
/// retail's normal value is <c>0x02</c> (Speech) for callers that
|
|
/// don't have a wire value in hand.
|
|
/// </param>
|
|
public void OnLocalSpeech(string sender, string text, uint senderGuid, bool isRanged, uint logTextType)
|
|
{
|
|
// Phase J: ACE's HandleActionTalk broadcasts a HearSpeech echo
|
|
// back to the sender too. Detect own echo by guid match and
|
|
// substitute "" so the formatter renders "You say, ..." (single
|
|
// first-person echo) instead of "+Acdream says, ..."
|
|
// (third-person duplicate of our optimistic, already dropped).
|
|
bool isOwnEcho = _localPlayerGuid != 0 && senderGuid == _localPlayerGuid;
|
|
string effectiveSender = (isOwnEcho || string.IsNullOrEmpty(sender)) ? "You" : sender;
|
|
Append(new ChatEntry(
|
|
Kind: isRanged ? ChatKind.RangedSpeech : ChatKind.LocalSpeech,
|
|
Sender: effectiveSender,
|
|
Text: text,
|
|
SenderGuid: senderGuid,
|
|
ChannelId: 0)
|
|
{
|
|
LogTextType = logTextType,
|
|
});
|
|
}
|
|
|
|
/// <summary>EmoteText (0x01E0) — server-driven third-person emote.</summary>
|
|
public void OnEmote(string senderName, string text, uint senderGuid)
|
|
{
|
|
Append(new ChatEntry(
|
|
Kind: ChatKind.Emote,
|
|
Sender: senderName,
|
|
Text: text,
|
|
SenderGuid: senderGuid,
|
|
ChannelId: 0)
|
|
{
|
|
// Retail hard-codes Emote (0x0C) for every HearEmote line —
|
|
// ClientCommunicationSystem::HearEmote @0x0057CBE0, the
|
|
// literal constant at 0x0057CF94. Not a wire value.
|
|
LogTextType = (uint)RetailLogTextType.Emote,
|
|
});
|
|
}
|
|
|
|
/// <summary>SoulEmote (0x01E2) — complex emote (chat + paired animation).</summary>
|
|
public void OnSoulEmote(string senderName, string text, uint senderGuid)
|
|
{
|
|
Append(new ChatEntry(
|
|
Kind: ChatKind.SoulEmote,
|
|
Sender: senderName,
|
|
Text: text,
|
|
SenderGuid: senderGuid,
|
|
ChannelId: 0)
|
|
{
|
|
// HearSoulEmote tail-calls HearEmote @0x0057D096 — same
|
|
// hard-coded 0x0C.
|
|
LogTextType = (uint)RetailLogTextType.Emote,
|
|
});
|
|
}
|
|
|
|
/// <summary>PlayerKilled (0x019E) — death announcement.</summary>
|
|
/// <remarks>
|
|
/// Death messages are routed as <see cref="ChatKind.System"/> so they
|
|
/// share styling with other server announcements. The
|
|
/// <c>SenderGuid</c> field carries the victim guid; the
|
|
/// <c>ChannelId</c> field carries the killer guid (a small misuse
|
|
/// of the field but avoids a schema change).
|
|
/// </remarks>
|
|
public void OnPlayerKilled(string deathMessage, uint victimGuid, uint killerGuid)
|
|
{
|
|
Append(new ChatEntry(
|
|
Kind: ChatKind.System,
|
|
Sender: "",
|
|
Text: deathMessage,
|
|
SenderGuid: victimGuid,
|
|
ChannelId: killerGuid)
|
|
{
|
|
// Direct anchor (corrected 2026-08-09, Opus review of
|
|
// 172c6f9a — replaces the earlier sibling-opcode analogy):
|
|
// opcode 0x019E dispatches via UIQueueManager::ProcessNet
|
|
// BlobData's byte table @0x55CB07 -> case 7 ->
|
|
// ClientCombatSystem::HandlePlayerDeathEvent @0x0056C320 ->
|
|
// AddTextToScroll(..., 0, 1, 0) @0x0056C3D8. Type 0x00 Default
|
|
// confirmed.
|
|
LogTextType = 0x00u,
|
|
});
|
|
}
|
|
|
|
// WeenieError (0x028A) / WeenieErrorWithString (0x028B) used to have a
|
|
// dedicated OnWeenieError entry point here (Phase I.5, hardcoded at
|
|
// LogTextType 0x00 pending register row AP-176). REJECT-review rework
|
|
// (SHOULD-FIX 3, docs/research/2026-08-09-ch2-review-findings.md):
|
|
// AP-176 retired at Campaign CH slice CH2 — WeenieErrorMessages.Resolve
|
|
// now resolves BOTH the display text AND the real per-code retail
|
|
// RetailLogTextType (chat vs SpewBox) from the full 344-row
|
|
// HandleFailureEvent port. Every producer of WeenieError text — the
|
|
// inbound GameEventWiring handlers AND the client-command
|
|
// ShowWeenieError sink — now resolves through WeenieErrorMessages and
|
|
// calls the AddText chokepoint (RuntimeCommunicationState.AddText /
|
|
// ChatLog.OnSystemMessage) directly instead of through a dedicated
|
|
// ChatLog method, so the single-fixed-color OnWeenieError entry point
|
|
// is deleted rather than kept as a second, narrower routing path.
|
|
|
|
/// <summary>
|
|
/// Channel broadcast — legacy <c>ChatChannel (0x0147)</c> or the
|
|
/// TurbineChat (<c>0xF7DE</c>) global community channels (General,
|
|
/// Trade, LFG, Roleplay, Society, Olthoi). Pass
|
|
/// <paramref name="channelName"/> when the caller knows the
|
|
/// friendly room name (TurbineChat dispatch always does); the
|
|
/// <c>ChatVM</c> formatter renders entries as
|
|
/// <c>"[ChannelName] Sender says, \"text\""</c> when set.
|
|
/// </summary>
|
|
/// <param name="logTextType">
|
|
/// The retail <c>LogTextType</c> for this line. When
|
|
/// <see langword="null"/> (the legacy 0x0147 default), it is derived
|
|
/// from <paramref name="channelId"/> via
|
|
/// <see cref="LegacyChannelChatType.Resolve"/> as a HEARD (not
|
|
/// own-send) message — correct for this method's only production
|
|
/// caller, the inbound <c>ChannelBroadcast</c> GameEvent handler.
|
|
/// TurbineChat-sourced calls MUST pass an explicit value computed
|
|
/// from the room's <c>TurbineChat.ChatType</c> instead — the wire
|
|
/// <paramref name="channelId"/> there is an opaque per-session room
|
|
/// GUID, not a legacy channel bitflag, and the two id spaces must
|
|
/// never be conflated.
|
|
/// </param>
|
|
public void OnChannelBroadcast(
|
|
uint channelId, string sender, string text, uint? logTextType = null, string channelName = "")
|
|
{
|
|
Append(new ChatEntry(
|
|
Kind: ChatKind.Channel,
|
|
Sender: sender,
|
|
Text: text,
|
|
SenderGuid: 0,
|
|
ChannelId: channelId)
|
|
{
|
|
ChannelName = channelName,
|
|
LogTextType = logTextType ?? LegacyChannelChatType.Resolve(channelId, ownSend: false),
|
|
});
|
|
}
|
|
|
|
/// <summary>GameEvent Tell (0x02BD) — whisper received.</summary>
|
|
/// <param name="logTextType">
|
|
/// The wire <c>chatType</c> from the Tell GameEvent payload
|
|
/// (<c>GameEvents.Tell.ChatType</c>) — required; no production caller
|
|
/// relies on a default. Retail's normal value is <c>0x03</c> (Tell)
|
|
/// for callers without a wire value in hand.
|
|
/// </param>
|
|
public void OnTellReceived(string sender, string text, uint senderGuid, uint logTextType)
|
|
{
|
|
Append(new ChatEntry(
|
|
Kind: ChatKind.Tell,
|
|
Sender: sender,
|
|
Text: text,
|
|
SenderGuid: senderGuid,
|
|
ChannelId: 0)
|
|
{
|
|
LogTextType = logTextType,
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// System chat — covers GameMessageSystemChat (0xF7E0
|
|
/// ServerMessage) and GameEventCommunicationTransientString
|
|
/// (0x02EB). Phase J follow-up: dedupe identical text arriving
|
|
/// within <see cref="SystemDedupWindow"/> so flows that fire on
|
|
/// both opcodes (e.g. "Unknown command: help" via help-command
|
|
/// failure path) only show once.
|
|
/// </summary>
|
|
public void OnSystemMessage(string text, uint chatType)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
if (text == _lastSystemText && (now - _lastSystemAt) < SystemDedupWindow)
|
|
{
|
|
// Suppress the dup — the wire-level duplicate isn't a
|
|
// user-meaningful signal. Reset the timer so a long burst
|
|
// of the same text still skips.
|
|
_lastSystemAt = now;
|
|
return;
|
|
}
|
|
_lastSystemText = text;
|
|
_lastSystemAt = now;
|
|
|
|
Append(new ChatEntry(
|
|
Kind: ChatKind.System,
|
|
Sender: "",
|
|
Text: text,
|
|
SenderGuid: 0,
|
|
ChannelId: chatType)
|
|
{
|
|
// `chatType` IS the retail LogTextType here — every caller
|
|
// (ServerMessage.ChatType, GameEventWiring's transient/
|
|
// query-age/use-done sites, App's client-command echoes)
|
|
// already passes the wire/retail-correct value.
|
|
LogTextType = chatType,
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// GameEvent PopupString (0x0004) — modal dialog text.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Retail shows PopUpString as a MODAL DIALOG
|
|
/// (<c>Handle_Communication__PopUpString @0x0057FE80</c>), never as a
|
|
/// chat-log line — acdream's choice to render it in chat at all is a
|
|
/// registered divergence (register row AP-175). Fixed at LogTextType
|
|
/// <c>0x00</c> (Default/green) to preserve the color this entry has
|
|
/// always rendered with; retail has no chat color for this type since
|
|
/// it never reaches the chat log.
|
|
/// </remarks>
|
|
public void OnPopup(string text)
|
|
{
|
|
Append(new ChatEntry(
|
|
Kind: ChatKind.Popup,
|
|
Sender: "",
|
|
Text: text,
|
|
SenderGuid: 0,
|
|
ChannelId: 0)
|
|
{
|
|
LogTextType = 0x00u,
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Phase I.7: combat-translator emits a pre-formatted line. The
|
|
/// translator (<see cref="CombatChatTranslator"/>) subscribes to
|
|
/// <see cref="Combat.CombatState"/> events and renders the retail
|
|
/// template (e.g. "You hit Mosswart for 12 slashing damage (54.0%)
|
|
/// Critical hit.") and decorates the entry with a
|
|
/// <see cref="Combat.CombatLineKind"/> so the panel can color the
|
|
/// line. Maps to holtburger's <c>info().combat()</c> /
|
|
/// <c>warning().combat()</c> / <c>error().combat()</c> tag flow at
|
|
/// <c>chat.rs:221-308</c>.
|
|
/// </summary>
|
|
/// <param name="text">The pre-formatted combat line text.</param>
|
|
/// <param name="logTextType">
|
|
/// The retail <c>LogTextType</c> for this combat line — required; no
|
|
/// production caller relies on a default. Callers should pass one of
|
|
/// the ACE-cited combat types (<c>0x16</c> Combat_Self for lines about
|
|
/// the local player's OWN offensive action, <c>0x15</c> Combat_Enemy
|
|
/// for lines about an enemy's action against the local player — see
|
|
/// <see cref="CombatChatTranslator"/>), <c>0x00</c> Default for
|
|
/// retail's decompiled kill/death-notification color
|
|
/// (<c>HandleKillerNotificationEvent @0x0056C410</c>), or <c>0x06</c>
|
|
/// (the generic Combat slot) when no more specific classification is
|
|
/// in hand — the generic-slot choice is a registered approximation of
|
|
/// retail's per-message dispatch (register row AP-176).
|
|
/// </param>
|
|
/// <param name="kind">
|
|
/// Severity bucket for panel coloring; defaults to
|
|
/// <see cref="Combat.CombatLineKind.Info"/>.
|
|
/// </param>
|
|
public void OnCombatLine(
|
|
string text, uint logTextType, Combat.CombatLineKind kind = Combat.CombatLineKind.Info)
|
|
{
|
|
Append(new ChatEntry(
|
|
Kind: ChatKind.Combat,
|
|
Sender: "",
|
|
Text: text,
|
|
SenderGuid: 0,
|
|
ChannelId: 0)
|
|
{
|
|
CombatKind = kind,
|
|
LogTextType = logTextType,
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Echo the player's own outbound message after local send.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <list type="bullet">
|
|
/// <item><b>Say</b>: pass <paramref name="targetOrChannel"/> as
|
|
/// empty; the formatter renders <c>"You say, \"text\""</c>.</item>
|
|
/// <item><b>Tell</b>: pass the target name; the formatter
|
|
/// renders <c>"You tell {target}, \"text\""</c>.</item>
|
|
/// <item><b>Channel</b>: <i>do not call</i> for global community
|
|
/// channels — the server (ACE TurbineChatHandler) echoes the
|
|
/// broadcast back to the sender already, so optimistic-echoing
|
|
/// would double-print. For legacy channels (Fellowship,
|
|
/// Allegiance) where the server may not echo, pass the
|
|
/// channel name as <paramref name="targetOrChannel"/>; it
|
|
/// becomes the entry's <c>ChannelName</c>.</item>
|
|
/// </list>
|
|
/// <c>SenderGuid == 0</c> on the resulting entry is the
|
|
/// discriminator the formatter uses to render outgoing-vs-incoming
|
|
/// (a real incoming Tell carries the sender's player guid).
|
|
/// </remarks>
|
|
/// <param name="logTextType">
|
|
/// The retail <c>LogTextType</c> for this self-sent line — required;
|
|
/// no production caller relies on a default (the old null-fallback
|
|
/// ternary is retired). Pass <c>0x04</c> Speech_Direct_Send for Tell
|
|
/// (retail's own-echo "You tell ..." type — cross-check ACE's
|
|
/// <c>ChatMessageType.OutgoingTell</c> comment "You tell ...") or, for
|
|
/// Channel, the precise per-channel-bit value from
|
|
/// <see cref="LegacyChannelChatType.Resolve"/> — the
|
|
/// LiveSessionCommandRouter production caller does exactly this.
|
|
/// </param>
|
|
public void OnSelfSent(ChatKind kind, string text, uint logTextType, string targetOrChannel = "")
|
|
{
|
|
Append(new ChatEntry(
|
|
Kind: kind,
|
|
// For Tell, Sender carries the target name (so the formatter
|
|
// can render "You tell {Sender}..."). For LocalSpeech, we
|
|
// leave Sender empty and let the formatter substitute "You".
|
|
// For Channel callers, Sender stays empty too — ChannelName
|
|
// (below) carries the friendly name.
|
|
Sender: kind == ChatKind.Tell ? targetOrChannel : "",
|
|
Text: text,
|
|
SenderGuid: 0,
|
|
ChannelId: 0)
|
|
{
|
|
ChannelName = kind == ChatKind.Channel ? targetOrChannel : "",
|
|
LogTextType = logTextType,
|
|
});
|
|
}
|
|
|
|
private void Append(ChatEntry entry)
|
|
{
|
|
// OP4 re-review R1 (2026-08-11): the timestamp prefix does NOT touch
|
|
// entry.Text here. Retail composes the display line FIRST and carries
|
|
// the timestamp as a SEPARATE leading string at display time
|
|
// (AddTextToScroll @0x00563C50 receives already-composed lines;
|
|
// 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);
|
|
while (_buffer.Count > _maxEntries)
|
|
_buffer.TryDequeue(out _);
|
|
Interlocked.Increment(ref _revision);
|
|
EntryAppended?.Invoke(entry);
|
|
}
|
|
|
|
/// <summary>
|
|
/// SF-1/S4 (OP4 review-fix round, 2026-08-11): retail's constructor
|
|
/// default <c>"%#H:%M:%S "</c> (<c>PlayerModule::PlayerModule
|
|
/// @0x005D51F0</c>, BN-sourced string literal — wire research doc U6,
|
|
/// NOT byte-verified) is non-zero-padded 24h hour, then zero-padded
|
|
/// minute:second, trailing space. <c>H\:mm\:ss </c> with the colons
|
|
/// ESCAPED (not the culture-dependent <c>DateTimeFormatInfo.
|
|
/// TimeSeparator</c> placeholder) plus <see cref="CultureInfo.
|
|
/// InvariantCulture"/> is the exact .NET equivalent — the CRT's
|
|
/// <c>strftime</c> always emits a literal colon regardless of locale.
|
|
/// acdream hardcodes this constructor default rather than reading the
|
|
/// per-character override retail sources from
|
|
/// <c>GenericQualitiesData::InqString(m_pPlayerOptionsData, 1, ...)</c>
|
|
/// — see register row AP-197.
|
|
/// </summary>
|
|
public static string FormatTimestampPrefix(DateTime receivedUtc) =>
|
|
receivedUtc.ToLocalTime().ToString(
|
|
@"H\:mm\:ss ", CultureInfo.InvariantCulture);
|
|
|
|
public void Clear()
|
|
{
|
|
while (_buffer.TryDequeue(out _)) { /* drain */ }
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
}
|
|
|
|
public enum ChatKind
|
|
{
|
|
LocalSpeech,
|
|
RangedSpeech,
|
|
Channel,
|
|
Tell,
|
|
System,
|
|
Popup,
|
|
Emote,
|
|
SoulEmote,
|
|
/// <summary>
|
|
/// Phase I.7: a combat feedback line emitted by
|
|
/// <see cref="CombatChatTranslator"/> from <see cref="Combat.CombatState"/>
|
|
/// events. The accompanying <see cref="ChatEntry.CombatKind"/> field
|
|
/// drives panel coloring (info / warning / error per holtburger
|
|
/// <c>chat.rs:221-308</c>).
|
|
/// </summary>
|
|
Combat,
|
|
}
|
|
|
|
public readonly record struct ChatEntry(
|
|
ChatKind Kind,
|
|
string Sender,
|
|
string Text,
|
|
uint SenderGuid,
|
|
uint ChannelId)
|
|
{
|
|
public DateTime Received { get; init; } = DateTime.UtcNow;
|
|
|
|
/// <summary>
|
|
/// Phase I.7: severity bucket for <see cref="ChatKind.Combat"/>
|
|
/// entries. Null for every other kind. Carried through to the transcript
|
|
/// on <c>FormattedLine</c>; the per-message colour itself now comes from
|
|
/// the retail colour table keyed by <c>LogTextType</c>.
|
|
/// </summary>
|
|
public Combat.CombatLineKind? CombatKind { get; init; }
|
|
|
|
/// <summary>
|
|
/// Friendly name of the channel for <see cref="ChatKind.Channel"/>
|
|
/// entries (e.g. "General", "Trade", "LFG", "Fellowship"). Empty
|
|
/// for non-Channel kinds. Used by <c>ChatVM.FormatEntry</c> to
|
|
/// render lines as <c>"[ChannelName] Sender says, \"text\""</c>.
|
|
/// Falls back to <c>"ch {ChannelId}"</c> if not populated.
|
|
/// </summary>
|
|
public string ChannelName { get; init; } = "";
|
|
|
|
/// <summary>
|
|
/// Campaign CH slice CH1: the retail wire <c>LogTextType</c>
|
|
/// (<c>0x00</c>-<c>0x21</c>) that keys
|
|
/// <c>RetailChatColorTable</c>/<c>ChatInterface::BuildChatColorLookupTable
|
|
/// @0x004F31C0</c>. This is the FULL 34-value retail index space, NOT
|
|
/// <see cref="Kind"/> — retail colors by this integer, never by our
|
|
/// synthetic 9-value <see cref="ChatKind"/>. Populated by every
|
|
/// <c>OnXxx</c> ingestion method above; defaults to <c>0x00</c>
|
|
/// (Default/green, retail's own unfilled-slot default) for any entry
|
|
/// constructed without setting it explicitly.
|
|
/// </summary>
|
|
public uint LogTextType { get; init; } = 0x00u;
|
|
}
|