Reworks Campaign CH slice CH2 per the REJECT-review findings doc (docs/research/2026-08-09-ch2-review-findings.md). BLOCKER 1 — SpewBoxController never rendered a line and leaked its pending queue. LinesProvider only ran through UiText.OnDraw, which gates on Visible — and the box started invisible, so the provider (the sole caller of SpewBoxState.Tick) never ran. Gave the controller an explicit per-frame Tick(now) driven by UiRoot's global-message-3 broadcast (a zero-size GlobalTimeSink child, the same pattern VendorUiController.DragOverGlobalTimeSink already uses), matching retail's gmSpewBoxUI::Update. LinesProvider now only returns the cache. Tests rewritten to drive root.Tick(...) instead of calling the provider directly, plus new coverage for visibility-without-a-draw, queue-drain-without-a-draw, and bounded-queue-across-many-ticks. BLOCKER 2 — re-derived the HandleFailureEvent routing table from the PDB-paired binary instead of the pseudo-C's ~33-char string previews. tools/pdb-extract/sweep_weenie_strings.py sweeps every push imm32 in VA 0x571990-0x575480, dereferences into .rdata/.data, and decodes the full UTF-16LE literal. Added the 5 ids dispatched via else-if (missed by case-label enumeration), resolved 0x4F8 (previously excluded), fixed 18 wrong strings (16 the review flagged + 2 more — 0x4E9 and 0x518 — an automated diff between every swept literal and the landed table found). Every changed row cross-checked against ACE's WeenieError/WeenieErrorWithString enum doc comments; both oracles agreed on every row, including a case where the review's own proposed text for the new 0x4E8 row was itself wrong (it was 0x4E9's text) — corrected via the else-if block's own instruction address plus the ACE cross-check. Pinned table count: 344 (338 + 5 + 0x4F8). SHOULD-FIX 1 — RuntimeCommunicationState.ResetSpewBox was dead code; folded into the ChatIdentity generation-reset stage (same lifetime boundary), with a reset assertion added to the existing populated-reset test. SHOULD-FIX 2 — AddText trimmed only the trailing end and invented an empty-string early return; retail's AddTextToScroll trims both ends (trim(&str, 1, 1, ws)) and has no empty guard. Both retired. SHOULD-FIX 3 — ShowWeenieError bypassed the AddText chokepoint via ChatLog.OnWeenieError (hardcoded LogTextType 0x00); routed through Communication.AddText(Resolve(code, param)) instead, and ChatLog.OnWeenieError is deleted — GameEventWiring's legacy no-router fallback now resolves + calls OnSystemMessage directly. SHOULD-FIX 4 — retail's HandleFailureEvent switch has no default case; an unmapped id now resolves to a null Text (silence toward the player) instead of the invented "WeenieError 0xNNNN" hex fallback, with a diagnostics-only console log line for the id. NITs — AP-TBD placeholders corrected to their real register rows (AP-178, not the unrelated AP-177 lifetime row); filed AP-180 for the windowId dual-destination gap and corrected three stale "lands with CH2" comments; extended SpewBoxLayoutDumpDiagnostic from dats.Portal to dats.Local and found the SpewBox element for real — LayoutDesc 0x21000011, element 0x10000048, size 450x72, MaxConcurrentItems (ListBox property 0x10000028) = 4, not retail's code default of 1. AP-178 narrowed accordingly; SpewBoxState.MaxConcurrentItems and SpewBoxController's extent/anchor/OneLine are now authored rather than placeholder (absolute screen position and colour remain open); fixed the "19 ids... lists 18" miscount by retiring the stale paragraph in the class doc rewrite; aligned the UseDone handler's silent-status check with the other two WeenieError handlers. Full Release suite: 11,914 passed / 4 skipped / 0 failed (build 0 errors). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
368 lines
18 KiB
C#
368 lines
18 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_ReturnsNull_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.
|
|
//
|
|
// REJECT-review rework (SHOULD-FIX 4,
|
|
// docs/research/2026-08-09-ch2-review-findings.md): retail's switch
|
|
// has no default case — an unhandled id produces NO text, silently,
|
|
// toward the player. Format now returns null rather than inventing
|
|
// a "WeenieError 0xNNNN" hex fallback that has no retail
|
|
// counterpart.
|
|
Assert.Null(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 — retail-faithful silence (SHOULD-FIX 4) ───────
|
|
//
|
|
// docs/research/2026-08-09-ch2-review-findings.md SHOULD-FIX 4: retail's
|
|
// HandleFailureEvent switch has no default case — an id it does not
|
|
// recognize produces NO text at all, toward the player. The prior
|
|
// "WeenieError 0xNNNN[: param]" hex fallback was acdream's own
|
|
// invention with no retail counterpart. These three tests are flipped
|
|
// (not deleted) to pin the new null-means-silence contract.
|
|
|
|
[Fact]
|
|
public void Format_UnknownCode_NoParam_ReturnsNull()
|
|
{
|
|
Assert.Null(WeenieErrorMessages.Format(0xABCD, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_UnknownCode_WithParam_ReturnsNull()
|
|
{
|
|
Assert.Null(WeenieErrorMessages.Format(0xDEAD, "Mana Stone"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Format_UnknownCode_EmptyParam_ReturnsNull()
|
|
{
|
|
Assert.Null(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_ResolvesToRetailText()
|
|
{
|
|
// REJECT-review rework (BLOCKER 2,
|
|
// docs/research/2026-08-09-ch2-review-findings.md): the prior
|
|
// "grep for a case label" transcription missed 0x04F because it
|
|
// dispatches via `else if (arg2 == 0x4f)`, not a switch case label.
|
|
// The binary sweep found its sprintf format string directly
|
|
// (VA 0x00571e23, in ClientCommunicationSystem::HandleFailureEvent's
|
|
// else-if chain). Retail preserves its own $s typo (only the first
|
|
// %s substitutes), same pattern as 0x4F4.
|
|
Assert.Equal(
|
|
"You fail to affect Drudge because $s cannot be harmed!",
|
|
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: 344 rows. REJECT-review rework (BLOCKER 2,
|
|
/// docs/research/2026-08-09-ch2-review-findings.md) added the 5 ids the
|
|
/// prior pass's case-label enumeration missed (dispatched via
|
|
/// <c>else if</c> chains, not switch cases) plus <c>0x4F8</c>, which now
|
|
/// resolves for real instead of being deliberately excluded: 338 + 5 + 1
|
|
/// = 344. 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_HasExactly344Rows()
|
|
{
|
|
int count = 0;
|
|
for (uint id = 0; id <= 0x600u; id++)
|
|
{
|
|
var (text, _) = WeenieErrorMessages.Resolve(id, null);
|
|
if (text is not null)
|
|
count++;
|
|
}
|
|
Assert.Equal(344, count);
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolve_0x4F8_NowResolvesForReal()
|
|
{
|
|
// REJECT-review rework (BLOCKER 2): the prior pass excluded 0x4F8
|
|
// because its case body's 3-operator+ concatenation chain has
|
|
// confusing BN-generated self-referential operand names. The binary
|
|
// sweep dereferenced both literal data pointers directly
|
|
// (data_7d2ee8, data_7d2f80), sidestepping the naming confusion.
|
|
// Both %s placeholders substitute the SAME parameter (retail only
|
|
// has one arg3 to concatenate twice).
|
|
var (text, type) = WeenieErrorMessages.Resolve(0x4F8, "Someone");
|
|
Assert.Equal(
|
|
"Someone fails to affect you because you are not the same sort of player killer as Someone!",
|
|
text);
|
|
Assert.Equal(RetailLogTextType.Magic, type);
|
|
}
|
|
|
|
// ── REJECT-review rework (BLOCKER 2): every corrected/added row ──────
|
|
//
|
|
// docs/research/2026-08-09-ch2-review-findings.md — pins the exact text
|
|
// for every id the binary sweep + ACE cross-check corrected or added
|
|
// this pass, so a future regression to the wrong (previously-landed)
|
|
// text fails loudly instead of silently.
|
|
|
|
[Theory]
|
|
// 5 ids added — missed by the prior case-label enumeration because
|
|
// they dispatch via `else if (arg2 == N)`, not a switch case.
|
|
[InlineData(0x04Fu, "You fail to affect %s because $s cannot be harmed!", RetailLogTextType.Magic)]
|
|
[InlineData(0x3EEu, "The container is closed!", RetailLogTextType.ClientLocal)]
|
|
[InlineData(0x408u, "Your spell cannot be cast inside", RetailLogTextType.ClientLocal)]
|
|
[InlineData(0x48Au, "You must be a monarch to purchase this dwelling.", RetailLogTextType.Default)]
|
|
[InlineData(0x4E8u, "The %s cannot be used while on a hook and only the owner may open the hook.", RetailLogTextType.Default)]
|
|
// 16 ids corrected per the review's own flagged list.
|
|
[InlineData(0x051u, "You fail to affect %s because you are not a player killer!", RetailLogTextType.Magic)]
|
|
[InlineData(0x053u, "You fail to affect %s because you are not the same sort of player killer as %s!", RetailLogTextType.Magic)]
|
|
[InlineData(0x054u, "You fail to affect %s because you are acting across a house boundary!", RetailLogTextType.Magic)]
|
|
[InlineData(0x466u, "You must purchase Asheron's Call: Dark Majesty to interact with that portal.", RetailLogTextType.Magic)]
|
|
[InlineData(0x4A3u, "You must have linked with a portal in order to recall to it!", RetailLogTextType.Magic)]
|
|
[InlineData(0x4B5u, "You must specify a character to query.", RetailLogTextType.ClientLocal)]
|
|
[InlineData(0x4E0u, "You are currently wielding items which require a certain level of skill. Your attributes cannot be transferred while you are wielding these items. Please remove these items and try again.", RetailLogTextType.Default)]
|
|
[InlineData(0x4F7u, "%s fails to affect you because you are not a player killer!", RetailLogTextType.Magic)]
|
|
[InlineData(0x544u, "An unspecified error occurred while attempting to remove %s as an allegiance officer.", RetailLogTextType.Default)]
|
|
[InlineData(0x54Eu, "The hook does not contain a usable item. You cannot open the hook because you do not own the house to which it belongs.", RetailLogTextType.Default)]
|
|
[InlineData(0x552u, "You must purchase Asheron's Call -- Throne of Destiny to use this function.", RetailLogTextType.ClientLocal)]
|
|
[InlineData(0x553u, "You must purchase Asheron's Call -- Throne of Destiny to use this item.", RetailLogTextType.ClientLocal)]
|
|
[InlineData(0x554u, "You must purchase Asheron's Call -- Throne of Destiny to use this portal.", RetailLogTextType.ClientLocal)]
|
|
[InlineData(0x555u, "You must purchase Asheron's Call -- Throne of Destiny to access this quest.", RetailLogTextType.ClientLocal)]
|
|
[InlineData(0x57Fu, "Your allegiance chat privileges have been temporarily removed by %s. Until they are restored, you may not view or speak in the allegiance chat channel.", RetailLogTextType.Default)]
|
|
[InlineData(0x582u, "Your allegiance chat privileges have been restored by %s.", RetailLogTextType.Default)]
|
|
// 2 ids corrected that were NOT in the review's flagged list — found by
|
|
// an automated diff between the swept binary literals and the landed
|
|
// table (the review's own "sweep may find more" prediction).
|
|
[InlineData(0x4E9u, "The %s cannot be used while on a hook, use the '@house hooks on' command to make the hook openable.", RetailLogTextType.Default)]
|
|
[InlineData(0x518u, "This fellowship is locked; %s cannot be recruited into the fellowship.", RetailLogTextType.Default)]
|
|
public void Resolve_Blocker2CorrectedRows_MatchTheSweptBinaryLiteral(
|
|
uint id, string expectedTemplate, RetailLogTextType expectedType)
|
|
{
|
|
var (text, type) = WeenieErrorMessages.Resolve(id, param: null);
|
|
Assert.Equal(expectedTemplate, text);
|
|
Assert.Equal(expectedType, 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);
|
|
}
|
|
}
|