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>
306 lines
13 KiB
C#
306 lines
13 KiB
C#
using AcDream.Core.Chat;
|
|
|
|
namespace AcDream.Core.Tests.Chat;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="WeenieErrorMessages"/>. The retail client showed
|
|
/// these as plain-language strings; we mirror that via templated lookup.
|
|
/// Filed after the 2026-04-25 live launch where the user saw cryptic
|
|
/// "WeenieError 0x051B" in chat for what was actually a friendly login
|
|
/// notification.
|
|
/// </summary>
|
|
public sealed class WeenieErrorMessagesTests
|
|
{
|
|
// ── known codes — informational, parameterised ───────────────────
|
|
|
|
[Fact]
|
|
public void Format_YouHaveEnteredChannel_SubstitutesParam()
|
|
{
|
|
// 0x051B = WeenieErrorWithString.YouHaveEnteredThe_Channel.
|
|
// Template "You have entered the _ channel." with `_` placeholder.
|
|
Assert.Equal(
|
|
"You have entered the General channel.",
|
|
WeenieErrorMessages.Format(0x051B, "General"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_YouHaveEnteredChannel_WorksForEachChannelName()
|
|
{
|
|
Assert.Equal("You have entered the Trade channel.", WeenieErrorMessages.Format(0x051B, "Trade"));
|
|
Assert.Equal("You have entered the LFG channel.", WeenieErrorMessages.Format(0x051B, "LFG"));
|
|
Assert.Equal("You have entered the Roleplay channel.",WeenieErrorMessages.Format(0x051B, "Roleplay"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_YouHaveLeftChannel_SubstitutesParam()
|
|
{
|
|
Assert.Equal(
|
|
"You have left the General channel.",
|
|
WeenieErrorMessages.Format(0x051C, "General"));
|
|
}
|
|
|
|
// ── known codes — informational, no parameter ────────────────────
|
|
|
|
[Fact]
|
|
public void Format_0x051D_FallsBackToHex_NoRetailCaseExists()
|
|
{
|
|
// Campaign CH slice CH2: the pre-CH2 "Turbine Chat is enabled."
|
|
// text for 0x051D was an ACE-derived guess, never decomp-confirmed.
|
|
// The full HandleFailureEvent port found NO case for 0x51D anywhere
|
|
// in the switch (only 0x51C has one — case 0x51c: at raw line
|
|
// 383115-383118 of acclient_2013_pseudo_c.txt) — retail's own
|
|
// client simply has no display text for this id. Falling back to
|
|
// the generic form is now the retail-faithful answer, not a gap.
|
|
Assert.Equal(
|
|
"WeenieError 0x051D",
|
|
WeenieErrorMessages.Format(0x051D, param: null));
|
|
}
|
|
|
|
// ── known codes — error-level ────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Format_CharacterNotAvailable_NoParam()
|
|
{
|
|
// 0x052B fired by the server when a Tell target lookup fails
|
|
// (e.g. the user typed "/t je, hello" → server got "je," → no
|
|
// character). Should read like the retail message.
|
|
Assert.Equal(
|
|
"That person is not available now.",
|
|
WeenieErrorMessages.Format(0x052B, param: null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_TradeComplete()
|
|
{
|
|
Assert.Equal("Trade Complete!", WeenieErrorMessages.Format(0x0529, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_ThatIsNotAValidCommand()
|
|
{
|
|
// 0x0026 fires on /-prefixed text that ACE's command parser
|
|
// can't resolve. Filed after a 2026-04-25 trace where /help
|
|
// produced cryptic "WeenieError 0x0026" lines.
|
|
Assert.Equal(
|
|
"That is not a valid command.",
|
|
WeenieErrorMessages.Format(0x0026, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_YouAreNotInAllegiance()
|
|
{
|
|
Assert.Equal(
|
|
"You are not in an allegiance!",
|
|
WeenieErrorMessages.Format(0x0414, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_YouDoNotBelongToAFellowship()
|
|
{
|
|
Assert.Equal(
|
|
"You do not belong to a Fellowship.",
|
|
WeenieErrorMessages.Format(0x050F, null));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0x0036u, "Action cancelled!")]
|
|
[InlineData(0x003Du, "You charged too far!")]
|
|
[InlineData(0x004Au, "Ack! You killed yourself!")]
|
|
[InlineData(0x0550u, "Out of Range!")]
|
|
public void Format_CombatMovementErrors(uint code, string expected)
|
|
=> Assert.Equal(expected, WeenieErrorMessages.Format(code, null));
|
|
|
|
// ── PK status codes ───────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Format_YouAreNonPKAgain_ExactRetailText()
|
|
{
|
|
// 0x0504 = WeenieError.YouAreNonPKAgain. Filed after the user saw
|
|
// "WeenieError 0x0504" on login following a PK Lite status
|
|
// reversion. Retail: ClientCommunicationSystem::HandleFailureEvent
|
|
// @0x00571990 case 0x504 @0x005745cd; string recovered byte-exact
|
|
// from data_7d32c0 in the PDB-paired binary (the pseudo-C dump
|
|
// truncates at the declared 0x5f-wchar16 array bound, mid-sentence
|
|
// at "...protection of the Lig").
|
|
Assert.Equal(
|
|
"You are enveloped in a feeling of warmth as you are brought back into the protection of the Light. You are once again a Non-Player Killer.",
|
|
WeenieErrorMessages.Format(0x0504, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_YoureTooCloseToYourSanctuary()
|
|
{
|
|
// 0x0505, case 0x505 @0x00574c65, data_7d2640.
|
|
Assert.Equal(
|
|
"You're too close to your sanctuary!",
|
|
WeenieErrorMessages.Format(0x0505, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_CannotChangePKStatusWhileRecovering()
|
|
{
|
|
// 0x04EC, case 0x4ec @0x0057446f, data_7d3820.
|
|
Assert.Equal(
|
|
"You cannot modify your player killer status while you are recovering from a PK death.",
|
|
WeenieErrorMessages.Format(0x04EC, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_AdvocatesCannotChangePKStatus()
|
|
{
|
|
// 0x04ED, case 0x4ed @0x005744a1, data_7d37b0.
|
|
Assert.Equal(
|
|
"Advocates may not change their player killer status!",
|
|
WeenieErrorMessages.Format(0x04ED, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_LevelTooLowToChangePKStatus_NowResolvedByCH2()
|
|
{
|
|
// 0x04EE (LevelTooLowToChangePKStatusWithObject) sits right next to
|
|
// the PK-status codes above; the pre-CH2 test asserted the
|
|
// fallback because only a curated ~60-entry subset was ported then.
|
|
// Campaign CH slice CH2's full 338-row HandleFailureEvent port
|
|
// (Appendix A row 0x4EE, Type 0x00) resolves it for real.
|
|
Assert.Equal(
|
|
"Your level is too low to change your player killer status with this object.",
|
|
WeenieErrorMessages.Format(0x04EE, null));
|
|
}
|
|
|
|
// ── unknown codes — graceful fallback preserves debug info ───────
|
|
|
|
[Fact]
|
|
public void Format_UnknownCode_NoParam_FallsBackToHexForm()
|
|
{
|
|
Assert.Equal("WeenieError 0xABCD", WeenieErrorMessages.Format(0xABCD, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_UnknownCode_WithParam_FallsBackToColonForm()
|
|
{
|
|
Assert.Equal(
|
|
"WeenieError 0xDEAD: Mana Stone",
|
|
WeenieErrorMessages.Format(0xDEAD, "Mana Stone"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_UnknownCode_EmptyParam_StaysAsHexOnly()
|
|
{
|
|
// Empty string param shouldn't add a stray colon.
|
|
Assert.Equal("WeenieError 0xCAFE", WeenieErrorMessages.Format(0xCAFE, ""));
|
|
}
|
|
|
|
// ── parameterised templates with non-trivial params ──────────────
|
|
|
|
[Fact]
|
|
public void Format_HearListAdded_SubstitutesParam()
|
|
{
|
|
Assert.Equal(
|
|
"Caith has been added to the list of people you can hear.",
|
|
WeenieErrorMessages.Format(0x0521, "Caith"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_0x004F_FallsBackToHex_NoRetailCaseExists()
|
|
{
|
|
// Campaign CH slice CH2: the pre-CH2 "You fail to affect _ because
|
|
// they cannot be harmed!" text for 0x004F was an ACE-derived guess.
|
|
// Direct decomp verification (grepping every "case 0x4f:" in
|
|
// ClientCommunicationSystem::HandleFailureEvent's whole body) found
|
|
// NONE — only 0x4E, 0x50, 0x51, 0x52, 0x53, 0x54 have cases; 0x4F is
|
|
// skipped entirely, same as the many other gaps in that switch's
|
|
// sparse jump table. Retail has no display text for this id.
|
|
Assert.Equal(
|
|
"WeenieError 0x004F: Drudge",
|
|
WeenieErrorMessages.Format(0x004F, "Drudge"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_HealingTargetAlreadyFull_SubstitutesParam()
|
|
{
|
|
Assert.Equal(
|
|
"+Acdream is already at full health!",
|
|
WeenieErrorMessages.Format(0x04FF, "+Acdream"));
|
|
}
|
|
|
|
// ── Campaign CH slice CH2: the full HandleFailureEvent table port ────
|
|
|
|
/// <summary>
|
|
/// Pins the table's size: 338 rows (Appendix A's 339 minus the one
|
|
/// deliberately-excluded 0x4F8, see the class doc comment on
|
|
/// <see cref="WeenieErrorMessages"/>). A change to this number without
|
|
/// a matching research/commit citation is a red flag, not a routine
|
|
/// edit.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Resolve_FullTable_HasExactly338Rows()
|
|
{
|
|
int count = 0;
|
|
for (uint id = 0; id <= 0x600u; id++)
|
|
{
|
|
var (text, _) = WeenieErrorMessages.Resolve(id, null);
|
|
if (!text.StartsWith("WeenieError 0x", StringComparison.Ordinal))
|
|
count++;
|
|
}
|
|
Assert.Equal(338, count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_0x4F8_IsDeliberatelyExcluded_FallsBackToHex()
|
|
{
|
|
// See the class doc comment: 0x4F8's case body is a tangled
|
|
// multi-operator+ decompiler artifact that could not be resolved
|
|
// with confidence — excluded rather than guessed.
|
|
var (text, type) = WeenieErrorMessages.Resolve(0x4F8, "Someone");
|
|
Assert.Equal("WeenieError 0x04F8: Someone", text);
|
|
Assert.Equal(RetailLogTextType.Default, type);
|
|
}
|
|
|
|
// ── spot pins across all three retail routing destinations ──────────
|
|
|
|
[Theory]
|
|
// ClientLocal (0x1A) — the SpewBox destination.
|
|
[InlineData(0x017u, "You failed to go to non-combat mode.", RetailLogTextType.ClientLocal)]
|
|
[InlineData(0x02Au, "You are too encumbered to carry that!", RetailLogTextType.ClientLocal)]
|
|
[InlineData(0x04EBu, "You can't do that while in the air!", RetailLogTextType.ClientLocal)]
|
|
[InlineData(0x550u, "Out of Range!", RetailLogTextType.ClientLocal)]
|
|
// Magic (0x07) — the light-blue spell/portal-failure channel.
|
|
[InlineData(0x402u, "Your spell fizzled.", RetailLogTextType.Magic)]
|
|
[InlineData(0x49Bu, "You fail to link with the lifestone!", RetailLogTextType.Magic)]
|
|
[InlineData(0x593u, "Olthoi characters can only use Lifestone and PK Arena recalls!", RetailLogTextType.Magic)]
|
|
// Default (0x00) — the ordinary broadcast/green channel.
|
|
[InlineData(0x4A, "Ack! You killed yourself!", RetailLogTextType.Default)]
|
|
[InlineData(0x50Cu, "%s is now a closed fellowship.", RetailLogTextType.Default)]
|
|
[InlineData(0x55Fu, "Only Player Killer characters may use this command!", RetailLogTextType.Default)]
|
|
public void Resolve_SpotPins_TextAndTypeMatchAppendixA(uint id, string expectedTemplate, RetailLogTextType expectedType)
|
|
{
|
|
var (text, type) = WeenieErrorMessages.Resolve(id, param: null);
|
|
Assert.Equal(expectedTemplate, text);
|
|
Assert.Equal(expectedType, type);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_JumpFamily_SharesClientTextRefusalsConstantsVerbatim()
|
|
{
|
|
// HandleFailureEvent's 0x24/0x48/0x49 cases reuse the SAME string
|
|
// globals as the local jump-refusal sites — assert byte-identity,
|
|
// not just similar wording.
|
|
Assert.Equal(ClientTextRefusals.CantJumpInAir, WeenieErrorMessages.Resolve(0x024u, null).Text);
|
|
Assert.Equal(ClientTextRefusals.CantJumpPosition, WeenieErrorMessages.Resolve(0x048u, null).Text);
|
|
Assert.Equal(ClientTextRefusals.CantJumpLoad, WeenieErrorMessages.Resolve(0x049u, null).Text);
|
|
Assert.Equal(RetailLogTextType.ClientLocal, WeenieErrorMessages.Resolve(0x024u, null).Type);
|
|
Assert.Equal(RetailLogTextType.ClientLocal, WeenieErrorMessages.Resolve(0x048u, null).Type);
|
|
Assert.Equal(RetailLogTextType.ClientLocal, WeenieErrorMessages.Resolve(0x049u, null).Type);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_0x4F4_PreservesRetailDollarSTypo()
|
|
{
|
|
// Retail's own literal is "...because $s cannot affect anyone!" —
|
|
// a genuine retail typo (should have been %s). Only the FIRST %s
|
|
// substitutes; the literal "$s" must NOT be replaced.
|
|
var (text, type) = WeenieErrorMessages.Resolve(0x4F4u, "A drudge");
|
|
Assert.Equal("A drudge fails to affect you because $s cannot affect anyone!", text);
|
|
Assert.Equal(RetailLogTextType.Magic, type);
|
|
}
|
|
}
|