acdream/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCommunicationStateTests.cs
Erik 77c8296e3f feat(chat): Campaign CH slice CH2 — retail SpewBox interface text
Retail routes on-screen refusals ("You can't jump while in the air",
"You are too encumbered to carry that!") through a SEPARATE transient
screen surface (gmSpewBoxUI, ClientSystem::AddTextToScroll @0x00563C50)
that never touches the chat scroll — type 0x1A is exactly the bit every
ChatInterface window's default filter excludes
(ChatInterface::ChatInterface @0x004F4550). acdream had no such split:
every WeenieError rendered in chat at a single stand-in LogTextType
0x00 (CH1-era approximation, register AP-176), and locally-detected
jump refusals were silently discarded.

This slice ports the full mechanism per
docs/research/2026-08-09-chat-retail-interface-text.md:

CORE (AcDream.Core/Chat):
- WeenieErrorMessages.Resolve now returns (text, RetailLogTextType) from
  a 338-row transcription of ClientCommunicationSystem::HandleFailureEvent
  @0x00571990 (Appendix A's 339 cases minus one, 0x4F8, deliberately
  excluded — its case body is a tangled decompiler artifact, not
  resolvable with confidence). Spot-checked ~20 rows directly against
  the raw decomp (case 0x2b/0x36/0x3a/0x4e/0x4ec/0x4f3/0x4f4 and the
  jump family), beyond the ~10 the brief asked for, because the first
  pass surfaced two transcription classes the research doc's markdown
  silently ate: (1) 7 ids marked "shared string global" resolved by
  reading the case bodies directly (0x24/0x48/0x49 reuse the jump-
  refusal globals; 0x4DE/0x4DF/0x55A/0x55E are pure param passthrough);
  (2) 19 "arg3 + literal" CONCATENATION ids whose leading space (and
  therefore their %s marker) the markdown table's cell-trimming ate —
  fixed by re-reading each case body, several requiring a SECOND
  non-truncated data_XXXXXXXX dump elsewhere in the same oracle file to
  recover text the ~33-char inline preview cut off. One retail typo is
  preserved verbatim: 0x4F4's second placeholder is literal "$s", not
  "%s" — only the first substitutes.
- ClientTextRefusals: the 11 process-lifetime string globals, all
  byte-recovered from the PDB-paired C:\Users\erikn\Downloads\acclient.exe
  (MATCH verified via check_exe_pdb.py) via raw UTF-16LE prefix search —
  5 were truncated in the research doc's own transcription and all 5
  turned out to end "...combat mode"/"...this position", not the
  shorter "...combat" a truncated read would suggest.
- SpewBoxState: the gmSpewBoxUI pending/visible queue port (insert-at-0,
  dedupe-against-index-0-only, MaxConcurrentItems overflow, per-entry
  expiry, one-frame enqueue/drain decoupling). Placed in Core (not
  Runtime as the brief's default) because AcDream.UI.Abstractions
  references Core but not Runtime, and SpewBoxVM needs to wrap it
  directly — the same constraint ChatVM already satisfies against
  ChatLog.
- Folded the 4-entry WeenieErrorText.cs into the full table; deleted it.

RUNTIME (AcDream.Runtime):
- RuntimeCommunicationState.AddText(text, type, windowId): the
  AddTextToScroll chokepoint. type == ClientLocal -> SpewBox only, never
  chat; everything else -> the existing transcript, tagged with type.
- GameEventWiring gains an `onInterfaceText` delegate hole (Core.Net
  cannot reference Runtime, so this follows the file's own established
  pattern for every other Runtime-owned sink). Rewires 0x028A/0x028B/
  UseDone through the full table + router; fixes 0x02EB
  CommunicationTransientString's routing type from a CH1-era 0x00
  guess to retail's hardcoded ClientLocal (Handle_Communication__
  TransientString @0x0057D460).
- LiveSessionEventRouter's 0xF7E0 ServerMessage handler now routes
  through AddText with the wire chatType verbatim instead of always
  writing ChatLog directly.
- PlayerMovementController gains OnInterfaceText, applied by
  RuntimeLocalPlayerMovementState to every controller it installs.
  Reports ChargeJump/jump refusals exactly as ClientCombatSystem::
  CommenceJump @0x0056AF90 / DoJump @0x0056B110 do — confirmed via
  their compiled dispatch that ONLY 0x24/0x48/0x49 produce text;
  0x47 (GeneralMovementFailure, fully-constrained/no-stamina) and any
  other code are retail-SILENT (DoJump's jump table has exactly 4 real
  targets), which contradicts this task's brief ("0x47 -> the
  constrained/stamina row per §4.2") — the brief's reading of §4.2
  described what jump_is_allowed COMPUTES, not what CommenceJump/DoJump
  DISPLAY for it. Implemented the decomp-verified silent behavior.

APP (AcDream.App / AcDream.UI.Abstractions):
- The 5 composition sites that already used RetailLogTextType.ClientLocal
  now call Communication.AddText instead of Chat.OnSystemMessage
  directly, so they reach the SpewBox instead of the transcript.
- SpewBoxVM (UI.Abstractions) + SpewBoxController (App), modeled
  directly on PortalWaitNoticeController. Position/font/colour/
  MaxConcurrentItems are placeholders: SpewBoxLayoutDumpDiagnostic
  exhaustively swept the installed client_portal.dat's entire LayoutDesc
  id range (0x21000000-0x21000075, 101/118 ids populated, sanity-checked
  against 3 known ids) and found ZERO elements of class 0x10000016 —
  gmSpewBoxUI is mounted from C++ code, not any authored LayoutDesc, so
  the dump cannot recover these values.

REGISTER: AP-176 retired (its WeenieError half is now the full table
port); its OnCombatLine half was never in this slice's scope and is
split out to AP-179 so that divergence keeps a row. AP-177 (invented
line lifetime) and AP-178 (invented position/font/colour/max-items)
filed for the presentation placeholders above. AP-175 (PopUpString ->
chat instead of modal) is untouched, not duplicated.

Suite: 11,890 passed / 4 skipped / 0 failed (was 11,835/4/0; +55 net
new tests, 0 regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:04:02 +02:00

272 lines
9.9 KiB
C#

using AcDream.Core.Chat;
using AcDream.Core.Social;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeCommunicationStateTests
{
[Fact]
public void SocialViewBorrowsFriendsAndSquelchOwners()
{
using var state = new RuntimeCommunicationState();
state.Friends.Apply(new AcDream.Core.Social.FriendsUpdate(
AcDream.Core.Social.FriendsUpdateType.Full,
[
new AcDream.Core.Social.FriendEntry(
0x50000001u,
"Friend",
Online: true,
AppearOffline: false,
Friends: [],
FriendOf: []),
]));
state.Squelch.Replace(new AcDream.Core.Social.SquelchDatabase(
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
{
["account"] = 1u,
},
new Dictionary<uint, AcDream.Core.Social.SquelchInfo>(),
new AcDream.Core.Social.SquelchInfo(
string.Empty,
false,
new HashSet<uint> { 3u })));
RuntimeSocialSnapshot snapshot = state.SocialView.Snapshot;
Assert.Equal(1, snapshot.FriendCount);
Assert.Equal(1, snapshot.SquelchedAccountCount);
Assert.Equal(1, snapshot.GlobalSquelchTypeCount);
Assert.True(state.SocialView.TryGetFriend(
0x50000001u,
out RuntimeFriendSnapshot friend));
Assert.Equal("Friend", friend.Name);
Assert.True(friend.Online);
}
[Fact]
public void OwnsOneExactCommunicationGraphAndPublishesCommittedEntries()
{
using var state = new RuntimeCommunicationState();
var observer = new RecordingObserver();
using IDisposable subscription = state.Events.Subscribe(observer);
state.Chat.OnTellReceived("Bestie", "hello", 0x50000001u, logTextType: 0x03u);
RuntimeCommunicationEvent delta = Assert.Single(observer.Events);
Assert.Equal(1UL, delta.Sequence);
Assert.Equal(state.Chat.Revision, delta.Entry.Revision);
Assert.Equal(1, state.View.Count);
Assert.Equal(state.Chat.Revision, state.View.Revision);
Assert.Equal(1UL, state.LastSequence);
Assert.Equal(0, state.PendingDispatchCount);
Assert.False(state.IsDispatching);
Assert.Equal("Bestie", state.CommandTargets.LastIncomingTellSender);
Assert.Equal("hello", delta.Entry.Text);
}
[Fact]
public void ReentrantAppendPreservesSequenceForEveryObserver()
{
using var state = new RuntimeCommunicationState();
var reentrant = new ReentrantObserver(state.Chat);
var trailing = new RecordingObserver();
using IDisposable first = state.Events.Subscribe(reentrant);
using IDisposable second = state.Events.Subscribe(trailing);
state.Chat.OnSystemMessage("first", 0u);
Assert.Equal([1UL, 2UL], reentrant.Sequences);
Assert.Equal(
[1UL, 2UL],
trailing.Events.Select(item => item.Sequence).ToArray());
Assert.Equal(["first", "second"], trailing.Events
.Select(item => item.Entry.Text)
.ToArray());
Assert.Equal(0, state.PendingDispatchCount);
Assert.False(state.IsDispatching);
}
[Fact]
public void ObserverFailureDoesNotStarveLaterObserverOrOwner()
{
using var state = new RuntimeCommunicationState();
var recording = new RecordingObserver();
using IDisposable throwing =
state.Events.Subscribe(new ThrowingObserver());
using IDisposable trailing = state.Events.Subscribe(recording);
state.Chat.OnSystemMessage("still committed", 0u);
Assert.Equal(1, state.Chat.Count);
Assert.Single(recording.Events);
Assert.Equal(1, state.DispatchFailureCount);
Assert.IsType<InvalidOperationException>(state.LastDispatchFailure);
}
[Fact]
public void SessionResetsClearScopedStateWithoutClearingTranscript()
{
using var state = new RuntimeCommunicationState();
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(
FriendsUpdateType.Full,
[new FriendEntry(1u, "Friend", true, false, [], [])]));
state.Squelch.Replace(new SquelchDatabase(
new Dictionary<string, uint> { ["account"] = 1u },
new Dictionary<uint, SquelchInfo>(),
new SquelchInfo(string.Empty, false, new HashSet<uint>())));
state.ResetCommandTargets();
state.ResetChatIdentity();
state.ResetNegotiatedChannels();
state.ResetFriends();
state.ResetSquelch();
Assert.Equal(2, state.Chat.Count);
Assert.Null(state.CommandTargets.LastIncomingTellSender);
Assert.Null(state.CommandTargets.LastOutgoingTellTarget);
Assert.False(state.TurbineChat.Enabled);
Assert.Empty(state.Friends.Snapshot());
Assert.Empty(state.Squelch.Snapshot().Accounts);
}
[Fact]
public void DisposeDetachesStreamAndAllBorrowedState()
{
var state = new RuntimeCommunicationState();
var observer = new RecordingObserver();
IDisposable subscription = state.Events.Subscribe(observer);
state.Dispose();
state.Dispose();
state.Chat.OnSystemMessage("after dispose", 0u);
Assert.True(state.IsDisposed);
Assert.Equal(0, state.SubscriberCount);
Assert.Empty(observer.Events);
Assert.Throws<ObjectDisposedException>(
() => state.Events.Subscribe(new RecordingObserver()));
subscription.Dispose();
}
[Fact]
public void IndependentRuntimeInstancesNeverShareState()
{
using var first = new RuntimeCommunicationState();
using var second = new RuntimeCommunicationState();
first.Chat.OnTellReceived("OnlyFirst", "hello", 0x50000001u, logTextType: 0x03u);
Assert.Equal(1, first.View.Count);
Assert.Equal(0, second.View.Count);
Assert.Equal("OnlyFirst", first.CommandTargets.LastIncomingTellSender);
Assert.Null(second.CommandTargets.LastIncomingTellSender);
}
// ── Campaign CH slice CH2: AddText routing chokepoint ────────────────
[Fact]
public void AddText_ClientLocal_RoutesToSpewBoxOnly_NeverChatTranscript()
{
// Retail: type == 0x1A (ClientLocal) is exactly the bit every
// ChatInterface window's default filter excludes
// (ChatInterface::ChatInterface @0x004F4550,
// m_llTextTypeFilter &= 0xFBFFFFFF). The CH1-era ChatLog.OnWeenieError
// path put EVERY WeenieError line in chat, including 0x1A ones —
// this is the fix: a ClientLocal line must reach SpewBox and never
// touch the chat transcript at all.
using var state = new RuntimeCommunicationState();
state.AddText("You can't jump while in the air", RetailLogTextType.ClientLocal);
Assert.Equal(0, state.Chat.Count);
state.SpewBox.Tick(0d);
Assert.Equal(1, state.SpewBox.Count);
Assert.Equal("You can't jump while in the air", state.SpewBox.Snapshot()[0].Text);
}
[Theory]
[InlineData(RetailLogTextType.Default)]
[InlineData(RetailLogTextType.Magic)]
[InlineData(RetailLogTextType.System)]
public void AddText_NonClientLocalTypes_RouteToChatOnly_NeverSpewBox(RetailLogTextType type)
{
using var state = new RuntimeCommunicationState();
state.AddText("Your spell fizzled.", type);
Assert.Equal(1, state.Chat.Count);
Assert.Equal("Your spell fizzled.", state.Chat.Snapshot()[0].Text);
Assert.Equal((uint)type, state.Chat.Snapshot()[0].LogTextType);
state.SpewBox.Tick(0d);
Assert.Equal(0, state.SpewBox.Count);
}
[Fact]
public void AddText_TrimsTrailingWhitespace_LikeRetailAddTextToScroll()
{
using var state = new RuntimeCommunicationState();
state.AddText("Out of Range! ", RetailLogTextType.ClientLocal);
state.SpewBox.Tick(0d);
Assert.Equal("Out of Range!", state.SpewBox.Snapshot()[0].Text);
}
[Fact]
public void AddText_EmptyAfterTrim_IsDropped()
{
using var state = new RuntimeCommunicationState();
state.AddText(" ", RetailLogTextType.ClientLocal);
state.AddText(" ", RetailLogTextType.Default);
state.SpewBox.Tick(0d);
Assert.Equal(0, state.SpewBox.Count);
Assert.Equal(0, state.Chat.Count);
}
[Fact]
public void Dispose_ResetsSpewBox()
{
var state = new RuntimeCommunicationState();
state.AddText("about to be torn down", RetailLogTextType.ClientLocal);
state.SpewBox.Tick(0d);
Assert.Equal(1, state.SpewBox.Count);
state.Dispose();
Assert.Equal(0, state.SpewBox.Count);
}
private sealed class RecordingObserver : IRuntimeCommunicationObserver
{
public List<RuntimeCommunicationEvent> Events { get; } = [];
public void OnChat(in RuntimeCommunicationEvent delta) =>
Events.Add(delta);
}
private sealed class ReentrantObserver(ChatLog chat)
: IRuntimeCommunicationObserver
{
public List<ulong> Sequences { get; } = [];
public void OnChat(in RuntimeCommunicationEvent delta)
{
Sequences.Add(delta.Sequence);
if (delta.Sequence == 1UL)
chat.OnSystemMessage("second", 1u);
}
}
private sealed class ThrowingObserver : IRuntimeCommunicationObserver
{
public void OnChat(in RuntimeCommunicationEvent delta) =>
throw new InvalidOperationException("observer");
}
}