fix(chat): Campaign CH user-gate round 2 -- portal notice rerouted to SpewBox, verbatim /help extraction, jump-in-air evidence

Item 2: retail's portal-space "In Portal Space..." notice is the SpewBox
(ECM_UI::SendNotice_DisplayStringInfo(0x1A,...) -> AddTextToScroll(str,
0x1A, 1, 0), hardcoded to the SpewBox per the decomp), not a dedicated
centered overlay. PortalWaitNoticeController and its lease are deleted;
PortalTunnelPresentation's per-rotation-segment cadence now writes
straight into RuntimeCommunicationState.AddText(ClientLocal) -- the
SpewBox's own dedupe-at-index-0 handles the repetition exactly as
retail's does. Register row AP-184 records the surface fix and the AP-178
scope extension.

Items 4+5: /help text was partially fabricated -- the user caught the
"/help death" meta-message. Generalized
tools/pdb-extract/sweep_weenie_strings.py to decode narrow
PStringBase<char> literals (the ClientCommunicationSystem::Help* family's
shape) alongside its original UTF-16LE support, then swept every
HelpXxxGroup function's exact byte extent against the PDB-paired
acclient.exe. 4 of 7 group topics (death/status/text/allegiances) are now
complete verbatim listings; the other 3 (channels/chatting/commands) keep
an honest UNVERIFIED note citing HelpStupidChannelHack @0x0056f290 (a
genuinely undecodable BN-mislabeled-fragment mechanism) instead of the
old fabricated sentinel. 7 of ~35 channel one-liners are also now
verbatim. ISSUES.md #364 tracks the remainder;
RetailCommandHelpTableTests.cs pins every result byte-exact.

Item 1: jump-in-air refusal still silent live is NOT reproduced and NOT
speculatively fixed. Exhaustive static re-audit found the mechanism
correct by construction (single-writer OnWalkable, exactly-once-per-frame
Update()/Capture(), no interfering edge-history resets). A live headless
repro (new jump-probe bot policy, real ACE connect) was blocked --
probeaccount2 has no character, and the graphical client already owned
testaccount this session so the task's own fallback rule forbade using
it. Two temporary probes are left behind ACDREAM_PROBE_JUMP=1 (blocked
entirely in Headless by the existing multi-session static-state guard --
graphical-only for the next round).

Item 3 confirmed fixed, no regression. Item 6 (resize: no diagonal
cursors, cannot grow Y from bottom-right) folded into CH6a's existing
scope.

Full Release suite: 12,267 passed / 4 skipped / 0 failed (up from
12,221/4/0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 08:40:24 +02:00
parent a485425743
commit c1f1582576
14 changed files with 788 additions and 254 deletions

View file

@ -121,6 +121,38 @@ than a REJECT-review fix batch; CH5-or-later.
**Campaign:** `docs/plans/2026-08-09-chat-parity-campaign.md` (Campaign CH,
CH4 REJECT-review).
## #364 — Three `/help` group topics still partial: HelpStupidChannelHack unresolved
**Status:** OPEN — filed 2026-08-09, Campaign CH user-gate round 2, item 3.
The user caught `/help death` printing an acdream meta-message instead of
retail's real listing; all 7 `ClientCommunicationSystem::HelpXxxGroup`
nodes were re-extracted verbatim from the PDB-paired binary via a
generalized `tools/pdb-extract/sweep_weenie_strings.py --ascii-only`.
4 of 7 (death/status/text/allegiances) are now COMPLETE verbatim listings
(`RetailCommandHelpTable.DeathGroupDetail` etc.). 3 remain PARTIAL
(`ChannelsGroupDetail`, `ChattingGroupDetail`, `CommandsGroupDetail`):
their detail text is built (in full or in part) by
`ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which
constructs its output from three string fragments Binary Ninja
mis-attributes to unrelated vtable slots (the classic BN pooled/
mislabeled-data artifact — `&ClientCommunicationSystem::\`vftable'.
RecvNotice_StartBarberNotice` etc. are NOT real vtable dispatch)
concatenated around a live `ChannelSystem::GetChannelName` call —
genuinely not decodable from a static string sweep. Each partial group
keeps its own summary line (fully verbatim) and an explicit UNVERIFIED
note citing the address; `chatting` additionally resolves 7 of its 8
detail entries (only the 5 HelpStupidChannelHack-delegated channel-alias
lines are missing). The same mechanism also blocks 5 of ~35 channel
one-liners in `ByVerb` (fellowship/monarch/patron/vassals/covassal
family), which stay acdream-authored summaries. Resolving
HelpStupidChannelHack requires reading the three mislabeled string
fragments directly out of `.rdata` by address (not by BN symbol name) and
confirming the channel-name substring logic — a focused follow-up, not
guessed.
**Campaign:** `docs/plans/2026-08-09-chat-parity-campaign.md` (Campaign CH,
user gate round 2).
## Note — six invented chat verbs removed for registry parity (2026-08-09)
Campaign CH slice CH4 deleted `/gen`, `/cv`, `/lookingforgroup`, `/tr`,

File diff suppressed because one or more lines are too long

View file

@ -18,11 +18,21 @@ rows TS-68/TS-69/TS-70), and 9 are retail's own null-handler help-only
nodes. CH5 (this closeout sweep) is COMPLETE — plan/register/ISSUES/
CLAUDE.md/roadmap ledger flip plus the chat memory digest. **User gate
round 1 ran 2026-08-09 and found ten live defects; see "User gate —
round 1" below.** Items AG are fixed in this round's commit; the three
round 1" below.** Items AG are fixed in round 1's commit; the three
remaining findings (extra chat windows on 1/2/3/4, resize only working in
one corner, transparency/artifacts) are out of this round's scope and
filed as a new slice, CH6. Status stays CODE-COMPLETE pending the next
user gate round.
one corner, transparency/artifacts) are out of round 1's scope and filed
as a new slice, CH6. **User gate round 2 ran 2026-08-09 and found six
more findings; see "User gate — round 2" below.** Items 2 ("In Portal
Space…" on the wrong surface) and 4+5 (`/help` text fabricated/misaligned)
are fixed in this round's commit; item 3 is confirmed-fixed with no
regression; item 6 is folded into CH6a's existing scope. Item 1
(jump-in-air still silent live) is NOT reproduced and NOT speculatively
fixed — round 1's press-edge logic is provably correct by construction
from source, a live headless repro was attempted and blocked (no
character on the probe account), and two temporary graphical-only probes
are left behind for the next round. Status stays CODE-COMPLETE pending
the next user gate round (still needed for item 1, CH6, and a final
in-client visual pass on everything fixed so far).
**Why now:** first track of the alpha-release program (chat is the most
visible daily surface for the friend-alpha). User-directed 2026-08-09.
@ -117,7 +127,14 @@ implementer per slice against a pinned contract (per
teach `LayoutImporter` element type 9 (`UIElement_Resizebar`, 8
authored grips: 4 edges + 4 corners via bools 0x2A0x2D); fix the
resize mask that excludes Top. Expected to also clear the round-1
artifact report.
artifact report. **User gate round 2 (2026-08-09) added two more
symptoms to fold into this same slice:** resize has no diagonal
(corner) cursor feedback — only edge cursors show, the 4 corner grips
from the 8-grip authored set above have no matching cursor affordance
yet; and the window cannot grow in the Y axis when dragging from the
bottom-right corner (a corner-grip axis-composition bug, likely the
same resize-mask gap already scoped for the Top edge above, now
confirmed to also affect corner grips specifically).
- **CH6b — floating windows 14.** Mount `0x2100005B` ×4 as
always-resident children per `gmGamePlayUI::SetupChildren
@0x004E9EC0` (ids 0x10000505/0x1000050E/0x1000050F/0x10000510);
@ -158,6 +175,7 @@ implementer per slice against a pinned contract (per
| CH5 closeout | (this commit) | — (docs/memory only, no build) | — | pending (connected gate — see test script) |
| User gate round 1 | (this commit) | 12,221 passed / 4 skipped / 0 failed (baseline; items AG fixed this commit) | — | items AG user-gate round 1 fixed; ten findings total, see "User gate — round 1" below |
| CH6 chat-window shell parity | not started | — | — | filed 2026-08-09 at user gate round 1; research first |
| User gate round 2 | (this commit) | 12,267 passed / 4 skipped / 0 failed | — | items 2/4/5 fixed this commit, item 3 confirmed-fixed, item 6 folded into CH6a's spec, item 1 NOT reproduced (see "User gate — round 2" below) |
### CH4 closeout (2026-08-09)
@ -512,3 +530,26 @@ Release suite green (see the commit message for the exact count). Items
H/I/J need the next visual round once CH6 lands; AG still want a final
in-client eyes-on pass to confirm the fix reads correctly on screen (build
+ test green is necessary, not sufficient, for a presentation change).
## User gate — round 2 (2026-08-09)
The user tested round 1's fixes live and reported six more findings.
| # | User finding (condensed) | Disposition |
|---|---|---|
| 1 | Jumping while already airborne is STILL silent live — round 1's press-edge branch (`PlayerMovementController._prevJumpHeld`) has a passing unit test but never visibly fires in the running client. | **NOT REPRODUCED; NO SPECULATIVE FIX SHIPPED.** Exhaustive static re-audit of the whole live path (branch logic, `_body.OnWalkable`'s single writer — the quantum-loop resolve, driven only by real physics results, never anything else — the exact-once-per-frame `Update()`/`Capture()` call site, `TakeControlFromServer`'s edge-history reset scope, mouse-look's extra `Capture()` calls) found no bug: the mechanism is provably correct BY CONSTRUCTION from source. Attempted a live headless repro (new `jump-probe` bot policy exercising the SAME typed `commands.Movement.SetIntent` surface, real ACE connect at 127.0.0.1:9000) — blocked: `probeaccount2` connects but has NO CHARACTER ("no available characters on account"), and per the task's own constraint the fallback to `testaccount` is refused because the graphical client (PID confirmed running this session) owns that account. Left TWO temporary probes behind a `PhysicsDiagnostics`-family flag (`ACDREAM_PROBE_JUMP=1`, blocked entirely in Headless by the existing multi-session static-state guard, so this is a GRAPHICAL-client-only diagnostic for the next round): `[jump]` in `ReportJumpRefusal` (prints unconditionally, even when `OnInterfaceText` is null, to separate "branch never evaluated true" from "callback dropped it"), and `[jump-tick]` (per-tick trace bracketing every frame where Jump is/was held). Next round: launch with `ACDREAM_PROBE_JUMP=1`, reproduce, and read the console trace — it will show exactly which of the three jump branches fires and what `OnWalkable`/`_prevJumpHeld` were at that instant. |
| 2 | Retail shows "In Portal Space…" at the TOP of the screen, SMALL font, tell-yellow (the SpewBox) — acdream renders a big centered white/yellow overlay instead. | **FIXED this SHA.** Verified in the decomp: `gmSmartBoxUI::UseTime`'s notice emits via `ECM_UI::SendNotice_DisplayStringInfo(0x1A, ...)`, which forwards to `AddTextToScroll(str, 0x1A, 1, 0)` — type `0x1A` is HARDCODED to the SpewBox (`docs/research/2026-08-09-chat-retail-interface-text.md` §1.1/§4.2), the same surface every other `ClientLocal` refusal (jump-in-air, etc.) already uses. `PortalWaitNoticeController` (the dedicated centered-overlay presentation) and its lease are DELETED outright; `PortalTunnelPresentation`'s round-1 per-rotation-segment emission cadence is unchanged, now writing straight into `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)` — the SpewBox's own dedupe-at-index-0 (`SpewBoxState.Tick`) collapses the per-segment repetition exactly as retail's `gmSpewBoxUI::Update` does. No conflict with the decomp — the fix and the user's report agree exactly. |
| 3 | (Round 1 item B, `[System] ` prefix) | **CONFIRMED FIXED** — no regression, no further action. |
| 4+5 | `/help` output differs from retail; `/help death` prints an acdream META-MESSAGE ("This is a retail help-topic group; acdream has not yet extracted its exact retail listing text…"); spacing/alignment is off. | **FIXED this SHA via verbatim extraction, not authorship.** `tools/pdb-extract/sweep_weenie_strings.py` generalized to decode narrow `PStringBase<char>` literals (the Help* family's shape) alongside its original UTF-16LE support, then swept every `ClientCommunicationSystem::HelpXxxGroup` function's exact byte extent (read from the pseudo-C's own function-header addresses) against the PDB-paired `C:\Users\erikn\Downloads\acclient.exe` (verified MATCH). 4 of 7 groups (death/status/text/allegiances) are now COMPLETE verbatim listings — `/help death` now prints retail's real 8-line text, byte-exact including the retail-authentic trailing space on the `@day` line. The other 3 (channels/chatting/commands) delegate part or all of their detail text to `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which builds its output from BN-mislabeled data fragments around a live channel-name lookup — genuinely not decodable with confidence; each keeps its own verbatim summary line plus an explicit UNVERIFIED note citing the address, never a fabricated meta-message. 7 of the ~35 channel one-liners are also now verbatim (a/guild/gu, general/cg, trade/ct, lfg/clfg, roleplay/crp, society/soc, olthoi/o), each including retail's own "Also: @alias" text — this is also the alignment/spacing fix, since the fabricated summaries never matched retail's exact wording. Register row AP-184 filed; ISSUES.md #364 tracks the remaining 3-group gap. `RetailCommandHelpTableTests.cs` (new) pins every complete listing and the partial/UNVERIFIED shape byte-exact. |
| 6 | Resize is still buggy: missing diagonal (corner) cursor feedback, and the window cannot grow in the Y axis when dragging from the bottom-right corner. | **FOLDED into CH6a's spec** (not fixed this SHA — CH6a itself has not started; see the slice list above, now carrying these two symptoms alongside the pre-existing resize-mask/LayoutDesc-import scope). |
Evidence for items 2, 4, and 5: this commit's diff + `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailCommandHelpTableTests.cs` (new) + the deletion of
`tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs` (its subject
class no longer exists) + `PortalTunnelAssetTests.cs` (unchanged; verified by
inspection — its `CreateRequired` call never passed the removed
`displayNoticeLifetime` parameter, so it needed no update). Item 1's evidence
is negative: no code changed in the jump branch logic itself, only
diagnostics; the two temporary probes and the `jump-probe` headless bot
policy are the round's deliverable for item 1, pending either a successful
next-round repro or a character created on `probeaccount2`. Full Release
suite green (see the commit message for the exact count).

View file

@ -1061,25 +1061,30 @@ internal sealed class LivePresentationCompositionPhase
?? throw new InvalidOperationException(
"The graphics backend must publish a world pass scope.")),
static value => value.Dispose());
CompositionAcquisitionScope.CompositionAcquisitionLease<
PortalWaitNoticeController>? portalWaitNoticeLease = null;
if (interaction.RetainedUi is { } portalRetainedUi)
{
portalWaitNoticeLease = scope.Acquire(
"portal wait notice",
() => new PortalWaitNoticeController(
portalRetainedUi.Host.Root),
static value => value.Dispose());
}
Action<string?>? displayPortalWaitNotice =
portalWaitNoticeLease is { } waitNoticeLease
? waitNoticeLease.Resource.Set
: null;
// Campaign CH user-gate round 2, item 2: the user reported retail's
// portal-space notice at the TOP of the screen in SMALL tell-yellow
// text — the SpewBox — while the former PortalWaitNoticeController
// rendered a big centered white/yellow overlay of its own. The
// decomp confirms the user's report: gmSmartBoxUI::UseTime
// @0x004D6E30 emits via ECM_UI::SendNotice_DisplayStringInfo(0x1A,
// ...), which forwards to AddTextToScroll(str, 0x1A, 1, 0) — type
// 0x1A is HARDCODED to the SpewBox
// (docs/research/2026-08-09-chat-retail-interface-text.md
// §1.1/§4.2), the SAME surface every other ClientLocal refusal uses
// (PlayerMovementController.ReportJumpRefusal, etc.), not a
// dedicated overlay. PortalWaitNoticeController is deleted along
// with its lease; this delegate now writes straight into the
// canonical AddText router every other on-screen interface-text
// site already uses. Unlike the deleted controller, this has no
// retained-UI dependency (Runtime state, not a UI element), so it
// is always wired, not gated on interaction.RetainedUi.
Action<string> displayPortalWaitNotice = text =>
d.Runtime.CommunicationOwner.AddText(
text,
AcDream.Core.Chat.RetailLogTextType.ClientLocal);
// Campaign CH slice CH2: the SpewBox is retail's OTHER on-screen
// interface-text surface (research doc §1.1/§7.3/§7.4) — modeled
// directly on the PortalWaitNoticeController lease immediately
// above, wired into the same retained-UI host. Like the wait notice
// in its no-tunnel arm, the transferred controller is reclaimed by
// interface-text surface (research doc §1.1/§7.3/§7.4), wired into
// the retained-UI host. The transferred controller is reclaimed by
// the retained-UI root's own teardown (its Dispose only detaches
// children from that root).
CompositionAcquisitionScope.CompositionAcquisitionLease<
@ -1120,8 +1125,7 @@ internal sealed class LivePresentationCompositionPhase
portalDispatcher,
foundation.SceneLighting!,
foundation.MeshAdapter!,
displayPortalWaitNotice,
portalWaitNoticeLease?.Resource),
displayPortalWaitNotice),
static tunnel => tunnel.PrepareResources());
}
catch (Exception acquisitionFailure)
@ -1304,7 +1308,6 @@ internal sealed class LivePresentationCompositionPhase
clipFrameLease.Transfer();
portalDepthLease.Transfer();
portalTunnelLease?.Transfer();
portalWaitNoticeLease?.Transfer();
spewBoxLease?.Transfer();
skyLease.Transfer();
particleLease.Transfer();

View file

@ -86,8 +86,7 @@ public sealed class PortalTunnelPresentation : IDisposable
private readonly WorldEntity _entity;
private readonly PortalTunnelCamera _camera = new();
private readonly Random _random;
private readonly Action<string?>? _displayNotice;
private IDisposable? _displayNoticeLifetime;
private readonly Action<string>? _displayNotice;
private readonly SyntheticEntityMeshReferenceOwner _meshReferences;
private bool _visible;
@ -113,8 +112,7 @@ public sealed class PortalTunnelPresentation : IDisposable
IAnimationLoader animationLoader,
IAnimationHookSink hookSink,
Random random,
Action<string?>? displayNotice,
IDisposable? displayNoticeLifetime)
Action<string>? displayNotice)
{
_scope = scope;
_frames = frames;
@ -128,7 +126,6 @@ public sealed class PortalTunnelPresentation : IDisposable
_sequence.HookObj = _animationHooks;
_random = random;
_displayNotice = displayNotice;
_displayNoticeLifetime = displayNoticeLifetime;
_entity = new WorldEntity
{
@ -164,8 +161,7 @@ public sealed class PortalTunnelPresentation : IDisposable
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IWbMeshAdapter meshAdapter,
Action<string?>? displayNotice = null,
IDisposable? displayNoticeLifetime = null,
Action<string>? displayNotice = null,
Random? random = null)
{
ArgumentNullException.ThrowIfNull(scope);
@ -202,8 +198,7 @@ public sealed class PortalTunnelPresentation : IDisposable
animationLoader,
hookSink,
random ?? Random.Shared,
displayNotice,
displayNoticeLifetime);
displayNotice);
}
internal static void EnsureRequiredAssets(
@ -260,7 +255,7 @@ public sealed class PortalTunnelPresentation : IDisposable
_rotationEndAngle = 0f;
_rotationCurrentAngle = 0f;
_camera.DirectionDegrees = 0f;
ClearWaitCueNotice();
_waitCueVisible = false;
_visible = true;
RebuildPose();
}
@ -271,7 +266,7 @@ public sealed class PortalTunnelPresentation : IDisposable
if (_disposed)
return;
_visible = false;
ClearWaitCueNotice();
_waitCueVisible = false;
_animationHooks.Clear();
_sequence.ClearAnimations();
}
@ -291,38 +286,19 @@ public sealed class PortalTunnelPresentation : IDisposable
/// The hold-delay-gated arm/disarm <c>LocalPlayerTeleportController</c>
/// still drives every frame from <c>RuntimeWorldTransitState.ObserveWait</c>
/// (own telemetry: <c>RuntimePortalSnapshot.WaitCueShown</c>). This is
/// deliberately NOT the retail cue-emission path any more — see
/// deliberately NOT the retail cue-emission path — see
/// <see cref="TickRotation"/>'s unconditional per-segment write (item D,
/// #329). Kept only so the controller's own hold bookkeeping still has
/// somewhere to land; because <c>visible</c> is false for the entire
/// common case (a transit that never crosses the invented 5-second
/// hold), this is a same-value no-op there and never contends with the
/// per-segment write above.
/// somewhere to land; it is pure bookkeeping now (Campaign CH user-gate
/// round 2, item 2). The former text-clear invoke made sense only for
/// the deleted <c>PortalWaitNoticeController</c>'s overwrite-only slot —
/// the SpewBox <see cref="_displayNotice"/> now targets has no "hide"
/// concept; a line disappears when its own timeout elapses
/// (<c>SpewBoxState.DefaultLifetime</c>), exactly like retail's
/// <c>gmSpewBoxUI</c>. <see cref="Enter"/>/<see cref="Exit"/> reset this
/// same bookkeeping flag directly.
/// </summary>
public void SetWaitCue(bool visible)
{
if (_waitCueVisible == visible)
return;
_waitCueVisible = visible;
_displayNotice?.Invoke(
visible ? "In Portal Space - Please Wait..." : null);
}
/// <summary>
/// Unconditionally hides any wait-cue notice text and resets the
/// hold-delay dedup state, independent of <see cref="_waitCueVisible"/>'s
/// current value. <see cref="TickRotation"/> now writes the notice text
/// directly (bypassing <see cref="SetWaitCue"/>'s dedup), so the old
/// `SetWaitCue(false)` calls at Enter/Exit/Dispose could no-op and leave
/// a stale "In Portal Space..." line on screen after the presentation
/// went invisible — this always clears it.
/// </summary>
private void ClearWaitCueNotice()
{
_waitCueVisible = false;
_displayNotice?.Invoke(null);
}
public void SetWaitCue(bool visible) => _waitCueVisible = visible;
/// <summary>
/// Draw retail portal space into the active viewport. The caller suppresses
@ -420,12 +396,21 @@ public sealed class PortalTunnelPresentation : IDisposable
// independent of `_waitCueVisible`/SetWaitCue (see that
// method's own doc comment): LocalPlayerTeleportController
// still drives SetWaitCue every frame from its own hold-delay
// bookkeeping, but because that call is a same-value no-op for
// the entire common case (a transit that never crosses the 5s
// hold), it never fights this unconditional per-segment write.
// Enter/Exit/Dispose clear the notice directly (not through
// SetWaitCue's dedup) so a stale line can never survive past
// this presentation going invisible.
// bookkeeping, but that call is pure bookkeeping now, so it
// never fights this unconditional per-segment write.
// Campaign CH user-gate round 2, item 2: this now targets the
// SpewBox (RuntimeCommunicationState.AddText, ClientLocal),
// retail's real destination for this notice
// (ECM_UI::SendNotice_DisplayStringInfo(0x1A, ...) ->
// AddTextToScroll(str, 0x1A, 1, 0), hardcoded to the SpewBox —
// docs/research/2026-08-09-chat-retail-interface-text.md
// §1.1/§4.2), not the former dedicated centered-overlay
// controller. Neither Enter/Exit/Dispose nor SetWaitCue clears
// anything any more — the SpewBox has no "hide" concept, a line
// simply times out (SpewBoxState.DefaultLifetime) exactly like
// retail's gmSpewBoxUI, and its own dedupe-at-index-0
// (SpewBoxState.Tick) collapses this call's per-segment
// repetition into one refreshed line, same as retail.
_displayNotice?.Invoke("In Portal Space - Please Wait...");
}
else
@ -517,14 +502,12 @@ public sealed class PortalTunnelPresentation : IDisposable
try
{
_visible = false;
ClearWaitCueNotice();
_waitCueVisible = false;
_animationHooks.Clear();
_sequence.ClearAnimations();
_meshReferences.Dispose();
if (_meshReferences.IsDisposed)
{
_displayNoticeLifetime?.Dispose();
_displayNoticeLifetime = null;
_disposed = true;
}
}

View file

@ -1,74 +0,0 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Retained presentation of retail's ECM_UI display-string notice emitted by
/// <c>gmSmartBoxUI::UseTime @ 0x004D6E30</c> while portal-space cell blocking
/// continues. It is a centered overlay, not a chat message.
/// </summary>
internal sealed class PortalWaitNoticeController : IDisposable
{
/// <summary>
/// Register row AP-150/AP-178: CH user-gate round 1 (2026-08-09) PINNED
/// this — the user confirmed live, side-by-side against retail, that
/// this notice renders in the same bright yellow as an incoming Tell
/// (<c>0x81C4C8</c>, <c>RetailChatColorTable.Yellow</c> =
/// <c>(1, 1, 0.247, 1)</c>), not white. Same exact value as the
/// SpewBox's pinned colour (<see cref="AcDream.App.UI.SpewBoxController"/>).
/// </summary>
private static readonly Vector4 RetailWaitCueColor = new(1f, 1f, 0.247f, 1f);
private readonly UiRoot _root;
private readonly UiText _text;
private UiText.Line[] _lines = [];
private bool _disposed;
public PortalWaitNoticeController(UiRoot root)
{
_root = root ?? throw new ArgumentNullException(nameof(root));
_text = new UiText
{
Name = "PortalSpaceWaitNotice",
Left = 0f,
Top = 0f,
Width = root.Width,
Height = root.Height,
Anchors = AnchorEdges.Left
| AnchorEdges.Top
| AnchorEdges.Right
| AnchorEdges.Bottom,
Centered = true,
OneLine = true,
ClickThrough = true,
ZOrder = int.MaxValue,
DefaultColor = RetailWaitCueColor,
Visible = false,
};
_text.LinesProvider = () => _lines;
_root.AddChild(_text);
}
public void Set(string? message)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (string.IsNullOrEmpty(message))
{
_text.Visible = false;
_lines = [];
return;
}
_lines = [new UiText.Line(message, RetailWaitCueColor)];
_text.Visible = true;
}
public void Dispose()
{
if (_disposed)
return;
_root.RemoveChild(_text);
_disposed = true;
}
}

View file

@ -7,11 +7,15 @@ namespace AcDream.App.UI;
/// <summary>
/// Retained presentation of retail's <c>gmSpewBoxUI</c> (research doc
/// §1.1/§7.3/§7.4) — the transient top-of-viewport interface-text queue.
/// Modeled directly on <see cref="PortalWaitNoticeController"/>: a single
/// <c>ClickThrough</c> <see cref="UiText"/> block at a high
/// <see cref="UiElement.ZOrder"/>. Unlike that controller's single
/// overwrite-only slot, this reads <see cref="SpewBoxVM"/>'s bounded,
/// newest-on-top, per-entry-expiring queue every frame.
/// A single <c>ClickThrough</c> <see cref="UiText"/> block at a high
/// <see cref="UiElement.ZOrder"/>. Campaign CH user-gate round 2, item 2:
/// this is now the ONLY on-screen interface-text presentation surface —
/// the former <c>PortalWaitNoticeController</c> (a dedicated centered
/// overlay with a single overwrite-only slot) is deleted; the portal-space
/// wait notice routes here too, through the same
/// <c>RuntimeCommunicationState.AddText</c> chokepoint every other
/// <c>ClientLocal</c> refusal uses. This reads <see cref="SpewBoxVM"/>'s
/// bounded, newest-on-top, per-entry-expiring queue every frame.
/// </summary>
/// <remarks>
/// <b>CH2 REJECT-review rework, BLOCKER 1

View file

@ -1434,6 +1434,19 @@ public static class PhysicsDiagnostics
public static bool ProbeSupportEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_SUPPORT") == "1";
/// <summary>
/// Initial state from <c>ACDREAM_PROBE_JUMP=1</c>. Campaign CH user-gate
/// round 2, item 1 (jump-in-air refusal reported STILL silent live after
/// round 1's press-edge branch landed with a passing unit test). Enables
/// the <c>[jump]</c> line in <c>PlayerMovementController.ReportJumpRefusal</c>
/// — printed UNCONDITIONALLY, even when <c>OnInterfaceText</c> is null, so
/// the probe can distinguish "the branch never evaluated true" from "the
/// branch fired but the callback dropped it." TEMPORARY — strip once the
/// live mechanism is confirmed and fixed.
/// </summary>
public static bool ProbeJumpEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_JUMP") == "1";
/// <summary>Vertical agreement window, in metres, inside which the contact
/// plane's height and the terrain's height at the same XY are called the
/// same surface.</summary>

View file

@ -20,6 +20,7 @@ internal static class HeadlessBotPolicyFactory
"lifecycle-smoke" => new LifecycleSmokeHeadlessBotPolicy(),
"observer-movement" => new ObserverMovementHeadlessBotPolicy(),
"portal-route-smoke" => new PortalRouteSmokeHeadlessBotPolicy(),
"jump-probe" => new JumpProbeHeadlessBotPolicy(),
_ => throw new HeadlessConfigurationException(
$"Unknown headless bot policy '{id}'."),
};
@ -573,3 +574,167 @@ internal sealed class PortalRouteSmokeHeadlessBotPolicy
ForwardTwo,
}
}
/// <summary>
/// Campaign CH user-gate round 2, item 1 (TEMPORARY, evidence-gathering
/// only — not a shipped bot behavior). Round 1 added a press-edge branch in
/// <c>PlayerMovementController.Update</c>
/// (<c>input.Jump &amp;&amp; !_prevJumpHeld &amp;&amp; !_body.OnWalkable</c>
/// → <c>ReportJumpRefusal(NotGrounded)</c>) with a passing unit test, but the
/// user reported it STILL silent live. This policy drives the SAME typed
/// <c>commands.Movement.SetIntent</c> surface a headless bot uses (the
/// <c>RuntimeLocalPlayerMovementState.HasCommandInput</c> route — distinct
/// from the graphical dispatcher's held-key polling path) to charge and fire
/// a jump, wait for <see cref="RuntimeMovementSnapshot.IsAirborne"/> to go
/// true under REAL physics against a live ACE connection, then press jump
/// again mid-air. Pair with <c>ACDREAM_PROBE_JUMP=1</c> (prints
/// <c>[jump]</c>/<c>[jump-tick]</c> lines from <c>PlayerMovementController</c>
/// itself) to see whether OnWalkable actually clears while genuinely
/// airborne and whether <c>ReportJumpRefusal</c> is reached.
/// </summary>
internal sealed class JumpProbeHeadlessBotPolicy : IHeadlessBotPolicy
{
private enum Stage
{
WaitForPlayer,
Charging,
WaitReleaseAirborne,
WaitMidAirPress,
Done,
}
private Stage _stage = Stage.WaitForPlayer;
private double _stageDeadline;
private bool _wasAirborne;
public bool IsComplete => _stage == Stage.Done;
public void Tick(IGameRuntimeView view, IGameRuntimeCommands commands)
{
ArgumentNullException.ThrowIfNull(view);
ArgumentNullException.ThrowIfNull(commands);
RuntimeMovementSnapshot movement = view.Movement.Snapshot;
if (movement.IsAirborne != _wasAirborne)
{
Console.WriteLine(
$"[jump-probe] airborne-transition {_wasAirborne} -> "
+ $"{movement.IsAirborne} stage={_stage} "
+ $"simTime={view.Clock.SimulationTimeSeconds:F3} "
+ $"hasCommandInput={movement.HasCommandInput} "
+ $"commandInput.Jump={movement.CommandInput.Jump}");
_wasAirborne = movement.IsAirborne;
}
switch (_stage)
{
case Stage.WaitForPlayer:
if (!HasLocalPlayer(view))
return;
Console.WriteLine("[jump-probe] local player present; charging jump");
Require(commands.Movement.SetIntent(
view.Generation,
new MovementInput(Jump: true)));
_stageDeadline = view.Clock.SimulationTimeSeconds + 0.6;
_stage = Stage.Charging;
break;
case Stage.Charging:
if (view.Clock.SimulationTimeSeconds < _stageDeadline)
return;
Console.WriteLine("[jump-probe] releasing jump (fire)");
Require(commands.Movement.SetIntent(
view.Generation,
new MovementInput(Jump: false)));
_stageDeadline = view.Clock.SimulationTimeSeconds + 3.0;
_stage = Stage.WaitReleaseAirborne;
break;
case Stage.WaitReleaseAirborne:
if (movement.IsAirborne)
{
Console.WriteLine(
"[jump-probe] airborne confirmed; pressing jump mid-air "
+ $"simTime={view.Clock.SimulationTimeSeconds:F3}");
Require(commands.Movement.SetIntent(
view.Generation,
new MovementInput(Jump: true)));
_stageDeadline = view.Clock.SimulationTimeSeconds + 1.0;
_stage = Stage.WaitMidAirPress;
return;
}
if (view.Clock.SimulationTimeSeconds >= _stageDeadline)
{
Console.WriteLine(
"[jump-probe] TIMEOUT waiting for airborne after jump "
+ "fire -- the released jump never registered as "
+ "airborne.");
Require(commands.Movement.ClearIntent(view.Generation));
_stage = Stage.Done;
}
return;
case Stage.WaitMidAirPress:
if (view.Clock.SimulationTimeSeconds < _stageDeadline)
return;
Console.WriteLine(
"[jump-probe] probe complete; final airborne="
+ $"{movement.IsAirborne}");
Require(commands.Movement.ClearIntent(view.Generation));
_stage = Stage.Done;
return;
}
}
public void OnLifecycle(in RuntimeLifecycleDelta delta)
{
}
public void OnCommand(in RuntimeCommandDelta delta)
{
}
public void OnEntity(in RuntimeEntityDelta delta)
{
}
public void OnInventory(in RuntimeInventoryDelta delta)
{
}
public void OnChat(in RuntimeChatDelta delta)
{
}
public void OnMovement(in RuntimeMovementDelta delta)
{
}
public void OnPortal(in RuntimePortalDelta delta)
{
}
public void OnCombat(in RuntimeCombatDelta delta)
{
}
public void Dispose()
{
}
private static bool HasLocalPlayer(IGameRuntimeView view) =>
view.Lifecycle.State is RuntimeLifecycleState.InWorld
&& view.Lifecycle.PlayerGuid != 0u
&& view.Entities.TryGet(
view.Lifecycle.PlayerGuid,
out _);
private static void Require(in RuntimeCommandResult result)
{
if (!result.Accepted)
{
throw new InvalidOperationException(
$"Jump probe command was rejected with {result.Status}.");
}
}
}

View file

@ -967,6 +967,20 @@ public sealed class PlayerMovementController
/// </summary>
private void ReportJumpRefusal(WeenieError result)
{
// Campaign CH user-gate round 2, item 1 (TEMPORARY): print
// UNCONDITIONALLY, before the OnInterfaceText null-check, so the
// probe distinguishes "ReportJumpRefusal was never called with a
// reportable result" from "it was called but OnInterfaceText was
// null" from "the callback fired but downstream dropped the line."
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeJumpEnabled)
{
Console.WriteLine(
$"[jump] ReportJumpRefusal result={result} "
+ $"hasCallback={OnInterfaceText is not null} "
+ $"onWalkable={_body.OnWalkable} "
+ $"prevJumpHeld={_prevJumpHeld}");
}
if (OnInterfaceText is null)
return;
@ -2595,6 +2609,19 @@ public sealed class PlayerMovementController
// in-air raises exactly one report, not one per frame.
ReportJumpRefusal(WeenieError.NotGrounded);
}
// Campaign CH user-gate round 2, item 1 (TEMPORARY): per-tick trace
// bracketing every frame where jump is (or was) held, so the probe
// can see the OnWalkable transition around a live jump attempt
// without spamming every ordinary frame.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeJumpEnabled
&& (input.Jump || _prevJumpHeld))
{
Console.WriteLine(
$"[jump-tick] input.Jump={input.Jump} prevJumpHeld={_prevJumpHeld} "
+ $"onWalkable={_body.OnWalkable} jumpCharging={_jumpCharging} "
+ $"jumpExtent={_jumpExtent:F2}");
}
_prevJumpHeld = input.Jump;
// ── 2. Run admitted complete-object quanta ────────────────────────────

View file

@ -22,10 +22,9 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c> by the
/// recipe in the command-registry doc §5 (scan each <c>Help*</c>
/// function's byte extent for <c>push imm32</c> into <c>.rdata</c>). Entries
/// that could not be recovered this way (the 7 group-index headers'
/// summary text, @day's confirmation lines, @render's option list) are
/// NOT fabricated — they are simply absent from this table; the lookup
/// falls through to a generic "no detailed help" line rather than guess.
/// that could not be recovered this way (@render's option list) are NOT
/// fabricated — they are simply absent from this table; the lookup falls
/// through to a generic "no detailed help" line rather than guess.
/// </para>
///
/// <para>
@ -36,9 +35,28 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
/// one-liners in <see cref="ByVerb"/> ("Sends text to your Fellowship
/// channel.", etc.) are acdream-authored SUMMARIES, not individually
/// hand-extracted retail strings — retail's own per-channel help text was
/// not recovered this slice. Recovering them (or deleting the class-doc
/// overclaim) is future work; this comment now says so honestly instead
/// of leaving the contradiction standing.
/// not recovered this slice.
/// </para>
///
/// <para>
/// <b>Campaign CH user-gate round 2, item 3 (2026-08-09):</b> the user
/// caught that <c>/help death</c> printed an acdream META-MESSAGE ("This is
/// a retail help-topic group; acdream has not yet extracted its exact
/// retail listing text…") instead of retail's real listing — this was the
/// class-doc overclaim's SHARP end. All 7 <c>HelpXxxGroup</c> nodes are now
/// resolved: <see cref="DeathGroupDetail"/>, <see cref="StatusGroupDetail"/>,
/// <see cref="TextGroupDetail"/>, and <see cref="AllegiancesGroupDetail"/>
/// are COMPLETE verbatim listings; <see cref="ChannelsGroupDetail"/>,
/// <see cref="ChattingGroupDetail"/>, and <see cref="CommandsGroupDetail"/>
/// are PARTIAL (their own summary line, plus — for chatting — 7 of 8
/// entries) with an explicit UNVERIFIED note citing
/// <c>ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290</c> for
/// the un-decodable remainder, never a guess. 7 of the ~35 channel
/// one-liners (a/guild/gu, general/cg, trade/ct, lfg/clfg, roleplay/crp,
/// society/soc, olthoi/o) are now verbatim too; the rest (fellowship/
/// monarch/patron/vassals/covassal family) route through the same
/// unresolved mechanism and remain acdream summaries. See the remarks on
/// <see cref="ChannelsGroupSummary"/> for the full extraction method.
/// </para>
/// </summary>
public static class RetailCommandHelpTable
@ -154,11 +172,138 @@ public static class RetailCommandHelpTable
// The 7 retail-registered "group index" nodes — retail registers them
// with a NULL func (like @mr/@pr); typing one bare reaches the server.
// Only @help <group> shows anything, and only this generic pointer —
// the exact per-group summary/listing text (retail's HelpXxxGroup
// functions) was not extracted this slice; see ISSUES.md.
private const string GroupNodeNotExtracted =
"This is a retail help-topic group; acdream has not yet extracted its exact retail listing text. Typing this verb alone (without @help) reaches the server as literal text, matching retail's null-handler registration.";
// Only @help <group> shows anything.
//
// Campaign CH user-gate round 2, item 3 (2026-08-09): the user caught
// that "/help death" printed an acdream META-MESSAGE instead of
// retail's real listing. Extracted verbatim via
// tools/pdb-extract/sweep_weenie_strings.py (generalized this round for
// narrow PStringBase<char> literals — the Help* family uses 8-bit ASCII,
// not the UTF-16LE ECM_UI notice shape the script originally targeted)
// against the PDB-paired C:\Users\erikn\Downloads\acclient.exe. Method:
// each HelpXxxGroup function (ClientCommunicationSystem::HelpXxxGroup,
// decomp §5) is a single `if (arg2 != Summary_HelpType) { <DETAIL:
// straight-line concatenation of subcommand summaries> } else { <the
// group's own one-line summary> }`; the exact byte extent (this
// function's start VA to the next function's start VA) was read from
// the pseudo-C's own function-header addresses, and every extent was
// swept with --ascii-only. Four groups decode COMPLETELY (every
// subcommand summary is either an inline literal in the group function
// itself or a direct `HelpXxx(this, Summary_HelpType, "@tag", arg4)`
// call whose OWN summary text was independently swept from HelpXxx's
// own extent): death, status, text, allegiances. Three groups
// (commands, channels, chatting) delegate part or all of their detail
// text to ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290,
// which builds its output from THREE string fragments Binary Ninja
// mis-attributes to unrelated vtable slots
// (`ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice`
// etc. — the classic BN pooled/mislabeled-data artifact, not real
// vtable dispatch) concatenated around a live channel-name lookup
// (`ChannelSystem::GetChannelName`) — genuinely not decodable via this
// sweep with confidence. Those three keep an honest UNVERIFIED fallback
// (still real retail text where available — each group's own one-line
// summary IS fully decoded and included) rather than inventing the
// missing portion. Tracked as ISSUES.md #364.
private const string ChannelsGroupSummary =
"@help channels - How to communicate with people in your allegiance or fellowship.";
// acclient_2013_pseudo_c.txt:391002 (0x0057b1b0). HelpChattingGroup's
// own one-liner — not used standalone below (its Detail branch does
// NOT prepend it, matching every other group), kept for documentation
// parity with the other two Summary consts above/below.
private const string ChattingGroupSummaryVerbatim =
"@help chatting - How to chat publically and privately.";
private const string CommandsGroupSummary =
"@help commands - Lists all commands.";
private const string GroupDetailUnverifiedSuffix =
" UNVERIFIED: the rest of this group's retail listing routes through ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290, which builds its text from BN-mislabeled data fragments around a live channel-name lookup — not decodable with confidence from a static string sweep. See docs/research/2026-08-09-chat-retail-command-registry.md §5 and ISSUES.md #364.";
// acclient_2013_pseudo_c.txt:391365-391436 (HelpDeathGroup, decomp
// §5 sweep, addresses 0x57b913-0x57ba56). The Detail branch is a
// straight-line concatenation with NO leading group-summary line (that
// string is exclusive to the Summary_HelpType branch, i.e. what a
// PARENT listing shows about "death" as one entry — @help death itself
// never shows it).
public const string DeathGroupDetail =
"@permit - Commands to give or revoke permission for others to loot your corpse.\n"
+ "@consent - Commands to help you manage the corpse-looting permissions that others give you.\n"
+ "@corpse - Displays the location of your last outdoor death.\n"
+ "@die - Kills your character and leaves a corpse, returning you to your lifestone.\n"
+ "@lifestone - Returns you to the last lifestone you used without killing you.\n"
+ "@marketplace - Teleports you to the Marketplace of Dereth.\n"
+ "@pkarena - Teleports you to the PK Arena. You must be PK to use this command.\n"
+ "@pklarena - Teleports you to the PKL Arena. You must be PKL to use this command.\n";
// acclient_2013_pseudo_c.txt:392030-392093 (HelpStatusGroup). Same
// shape as HelpDeathGroup — Detail branch, no leading group-summary
// line. The trailing space before "\n" on the @day line is retail's
// own byte content (data_0x7de280), not an acdream typo.
public const string StatusGroupDetail =
"@age - Displays your total gameplay time.\n"
+ "@birth - Displays when your character was created.\n"
+ "@day - A toggle that lightens the outdoor landscape. Note that this command may take several seconds to take effect. \n"
+ "@endurance - Explains how endurance affects your character.\n"
+ "@framerate - Toggles the framerate display.\n"
+ "@loc - Displays your current position.\n"
+ "@pklite - Sets your status to Player Killer Lite. Type @help pklite for more details.\n"
+ "@version - Tells you what version of the software you are using.\n";
// acclient_2013_pseudo_c.txt:392209-392279 (HelpTextGroup).
public const string TextGroupDetail =
"@clear - Clears the chat box of all text.\n"
+ "@filter - Commands to filter out incoming messages.\n"
+ "@unfilter - Commands to remove filters from incoming messages.\n"
+ "@loadfile - Reads in the given text file and executes each line in the chat entry field.\n"
+ "@log - Commands to echo chat text to a logfile.\n"
+ "@title <new title> - Sets the title of the popup chat window.\n";
// acclient_2013_pseudo_c.txt:393033-393059 (HelpAllegiancesGroup) —
// the shortest group, only 2 lines.
public const string AllegiancesGroupDetail =
"@allegiance - Commands to help manage your allegiance.\n"
+ "@allegiance motd - Displays or sets the message of the day for your allegiance, see @help motd for more information.\n";
// Partial: own summary (fully verbatim, swept) + the honest UNVERIFIED
// note for the HelpStupidChannelHack-delegated remainder. HelpChannelsGroup's
// Detail branch (acclient_2013_pseudo_c.txt:387166-387194) is ENTIRELY
// 6 HelpStupidChannelHack calls -- none of its own text is decodable.
public const string ChannelsGroupDetail =
ChannelsGroupSummary + GroupDetailUnverifiedSuffix;
// Partial: HelpChattingGroup's Detail branch
// (acclient_2013_pseudo_c.txt:390909-390984) resolves 7 of 8 entries
// verbatim (chat/notell/reply/retell/say/tell/afk, each an inline
// literal or a direct HelpXxx(Summary_HelpType,...) call whose own text
// was independently swept) -- only the 5 HelpStupidChannelHack calls in
// the middle (channel-alias entries) are unresolved.
public const string ChattingGroupDetail =
"@chat - Sets whether or not you receive normal chat.\n"
+ "@notell - Sets whether or not you receive @tell's.\n"
+ "@reply - Sends some text to the last person who @tell'd you.\n"
+ "@retell - Sends some text to the last person you @tell'd.\n"
+ "@say - Says some text to everyone around you.\n"
+ "@tell - Sends a private message to another character.\n"
+ "[5 more retail channel-alias one-liners not yet extracted verbatim -- ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290, UNVERIFIED; see the class remarks above.]\n"
+ "@afk - Set your away-from-keyboard status.\n";
// HelpAllGroup's Detail branch (acclient_2013_pseudo_c.txt:394006-394176)
// recursively calls HelpAllegiancesGroup AND HelpChattingGroup in Detail
// mode (not Summary), plus the same 5 HelpStupidChannelHack calls
// directly, plus HelpDeathGroup in Detail mode, plus several more direct
// entries, plus a CONFIRMED retail duplicate (the saveui/loadui pair
// appears twice in the straight-line decompiled body with no
// conditional between the two occurrences -- verified at
// acclient_2013_pseudo_c.txt:394089-394137, not an acdream artifact).
// The nesting compounds two independent HelpStupidChannelHack gaps
// (its own 5 calls + HelpChattingGroup's 5) with enough structural
// uncertainty (unconfirmed HelpType arg on the trailing @status/@text
// calls) that a full reconstruction risks presenting an inaccurate
// listing as verbatim. Kept UNVERIFIED with its own summary (fully
// decoded) rather than risk that.
public const string CommandsGroupDetail =
CommandsGroupSummary + GroupDetailUnverifiedSuffix;
private static readonly FrozenDictionary<string, string> ByVerb =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
@ -181,16 +326,24 @@ public static class RetailCommandHelpTable
["log"] = Log,
["render"] = Render,
["motd"] = Motd,
["commands"] = GroupNodeNotExtracted,
["allegiances"] = GroupNodeNotExtracted,
["channels"] = GroupNodeNotExtracted,
["chatting"] = GroupNodeNotExtracted,
["death"] = GroupNodeNotExtracted,
["status"] = GroupNodeNotExtracted,
["text"] = GroupNodeNotExtracted,
// Channel verbs — one line each, generated rather than
// hand-extracted (retail's per-channel help strings were not
// individually recovered this slice).
["commands"] = CommandsGroupDetail,
["allegiances"] = AllegiancesGroupDetail,
["channels"] = ChannelsGroupDetail,
["chatting"] = ChattingGroupDetail,
["death"] = DeathGroupDetail,
["status"] = StatusGroupDetail,
["text"] = TextGroupDetail,
// Channel verbs. Campaign CH user-gate round 2, item 3: 7 of
// these are now VERBATIM retail text (each its own
// HelpTurbineChat_Xxx function, decomp §5 sweep,
// acclient_2013_pseudo_c.txt:387294-387411/387410 area,
// addresses 0x577620-0x577850) — the "Also: @alias" suffix is
// retail's own text, not an acdream addition. The remaining
// ones (f/fellow(s)/fellowship/g/group/party, m/monarch,
// p/patron, v/vassal(s), c/covassal(s)/co-vassals) still route
// through the unresolved HelpStupidChannelHack pooled-string
// mechanism (see the class remarks above) and stay
// acdream-authored summaries — an honest gap, not a silent one.
["f"] = "Sends text to your Fellowship channel.",
["fellow"] = "Sends text to your Fellowship channel.",
["fellows"] = "Sends text to your Fellowship channel.",
@ -198,22 +351,22 @@ public static class RetailCommandHelpTable
["g"] = "Sends text to your Fellowship channel.",
["group"] = "Sends text to your Fellowship channel.",
["party"] = "Sends text to your Fellowship channel.",
["a"] = "Sends text to your Allegiance chat room.",
["guild"] = "Sends text to your Allegiance chat room.",
["gu"] = "Sends text to your Allegiance chat room.",
["a"] = "@a - Sends a message to your Allegiance. Also: @guild, @gu",
["guild"] = "@a - Sends a message to your Allegiance. Also: @guild, @gu",
["gu"] = "@a - Sends a message to your Allegiance. Also: @guild, @gu",
["ab"] = "Broadcasts text to your entire allegiance (monarch/speaker permission). Also @allegiance broadcast.",
["general"] = "Sends text to the General chat room.",
["cg"] = "Sends text to the General chat room.",
["trade"] = "Sends text to the Trade chat room.",
["ct"] = "Sends text to the Trade chat room.",
["lfg"] = "Sends text to the Looking-For-Group chat room.",
["clfg"] = "Sends text to the Looking-For-Group chat room.",
["roleplay"] = "Sends text to the Roleplay chat room.",
["crp"] = "Sends text to the Roleplay chat room.",
["society"] = "Sends text to your Society chat room.",
["soc"] = "Sends text to your Society chat room.",
["olthoi"] = "Sends text to the Olthoi Player Killer chat room.",
["o"] = "Sends text to the Olthoi Player Killer chat room.",
["general"] = "@general - Sends a message to the global General chat channel. Also: @cg",
["cg"] = "@general - Sends a message to the global General chat channel. Also: @cg",
["trade"] = "@trade - Sends a message to the global Trade chat channel. Also: @ct",
["ct"] = "@trade - Sends a message to the global Trade chat channel. Also: @ct",
["lfg"] = "@lfg - Sends a message to the global Looking For Group (LFG) chat channel. Also: @clfg",
["clfg"] = "@lfg - Sends a message to the global Looking For Group (LFG) chat channel. Also: @clfg",
["roleplay"] = "@roleplay - Sends a message to the global Roleplay chat channel. Also: @crp",
["crp"] = "@roleplay - Sends a message to the global Roleplay chat channel. Also: @crp",
["society"] = "@society - Sends a message to the your Society chat channel. Also: @soc",
["soc"] = "@society - Sends a message to the your Society chat channel. Also: @soc",
["olthoi"] = "@olthoi - If you are an Olthoi, sends a message to the global Olthoi chat channel. Also: @o",
["o"] = "@olthoi - If you are an Olthoi, sends a message to the global Olthoi chat channel. Also: @o",
["m"] = "Sends text to your Monarch.",
["monarch"] = "Sends text to your Monarch.",
["p"] = "Sends text to your Patron.",

View file

@ -1,45 +0,0 @@
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class PortalWaitNoticeControllerTests
{
// Campaign CH user-gate round 1 (item D): the user confirmed live,
// side-by-side against retail, that this notice renders in the same
// bright yellow as an incoming Tell (RetailChatColorTable.Yellow).
private static readonly Vector4 RetailWaitCueColor = new(1f, 1f, 0.247f, 1f);
[Fact]
public void Notice_IsCenteredOverlayAndDisposesFromRetainedRoot()
{
var root = new UiRoot
{
Width = 1280f,
Height = 720f,
};
using (var controller = new PortalWaitNoticeController(root))
{
UiText text = Assert.IsType<UiText>(Assert.Single(root.Children));
Assert.False(text.Visible);
Assert.True(text.Centered);
Assert.True(text.ClickThrough);
Assert.Equal(root.Width, text.Width);
Assert.Equal(root.Height, text.Height);
Assert.Equal(RetailWaitCueColor, text.DefaultColor);
controller.Set("In Portal Space - Please Wait...");
Assert.True(text.Visible);
UiText.Line line = Assert.Single(text.LinesProvider!());
Assert.Equal("In Portal Space - Please Wait...", line.Text);
Assert.Equal(RetailWaitCueColor, line.Color);
controller.Set(null);
Assert.False(text.Visible);
}
Assert.Empty(root.Children);
}
}

View file

@ -0,0 +1,168 @@
using AcDream.UI.Abstractions.Panels.Chat;
using Xunit;
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// <summary>
/// Campaign CH user-gate round 2, item 3 (2026-08-09): pins a sample of
/// <see cref="RetailCommandHelpTable"/>'s extracted strings byte-exact
/// against the values recovered from the PDB-paired
/// <c>C:\Users\erikn\Downloads\acclient.exe</c> via
/// <c>tools/pdb-extract/sweep_weenie_strings.py --ascii-only</c> (generalized
/// this round for narrow <c>PStringBase&lt;char&gt;</c> literals — see the
/// script's own docstring). Covers all four COMPLETE group listings
/// (death/status/text/allegiances — <c>/help death</c> was the user's own
/// example of the fabricated meta-message this round replaces), the three
/// PARTIAL groups' honest UNVERIFIED fallback, and a sample of the newly
/// verbatim channel one-liners.
/// </summary>
public sealed class RetailCommandHelpTableTests
{
[Fact]
public void DeathGroup_IsCompleteVerbatimListing_NoLeadingSummaryLine()
{
// acclient_2013_pseudo_c.txt:391365-391436 (HelpDeathGroup Detail
// branch) -- straight-line concatenation, no "@help death - ..."
// prefix (that string is exclusive to the Summary_HelpType branch).
Assert.Equal(
"@permit - Commands to give or revoke permission for others to loot your corpse.\n"
+ "@consent - Commands to help you manage the corpse-looting permissions that others give you.\n"
+ "@corpse - Displays the location of your last outdoor death.\n"
+ "@die - Kills your character and leaves a corpse, returning you to your lifestone.\n"
+ "@lifestone - Returns you to the last lifestone you used without killing you.\n"
+ "@marketplace - Teleports you to the Marketplace of Dereth.\n"
+ "@pkarena - Teleports you to the PK Arena. You must be PK to use this command.\n"
+ "@pklarena - Teleports you to the PKL Arena. You must be PKL to use this command.\n",
RetailCommandHelpTable.DeathGroupDetail);
Assert.True(RetailCommandHelpTable.TryGetHelpText("death", out string viaLookup));
Assert.Equal(RetailCommandHelpTable.DeathGroupDetail, viaLookup);
Assert.DoesNotContain("has not yet extracted", viaLookup);
}
[Fact]
public void StatusGroup_IsCompleteVerbatimListing_PreservesRetailTrailingSpace()
{
// The trailing space before "\n" on the @day line is retail's own
// byte content (data_0x7de280) -- not an acdream typo.
Assert.Equal(
"@age - Displays your total gameplay time.\n"
+ "@birth - Displays when your character was created.\n"
+ "@day - A toggle that lightens the outdoor landscape. Note that this command may take several seconds to take effect. \n"
+ "@endurance - Explains how endurance affects your character.\n"
+ "@framerate - Toggles the framerate display.\n"
+ "@loc - Displays your current position.\n"
+ "@pklite - Sets your status to Player Killer Lite. Type @help pklite for more details.\n"
+ "@version - Tells you what version of the software you are using.\n",
RetailCommandHelpTable.StatusGroupDetail);
}
[Fact]
public void TextGroup_IsCompleteVerbatimListing()
{
Assert.Equal(
"@clear - Clears the chat box of all text.\n"
+ "@filter - Commands to filter out incoming messages.\n"
+ "@unfilter - Commands to remove filters from incoming messages.\n"
+ "@loadfile - Reads in the given text file and executes each line in the chat entry field.\n"
+ "@log - Commands to echo chat text to a logfile.\n"
+ "@title <new title> - Sets the title of the popup chat window.\n",
RetailCommandHelpTable.TextGroupDetail);
}
[Fact]
public void AllegiancesGroup_IsCompleteVerbatimListing_TwoLines()
{
Assert.Equal(
"@allegiance - Commands to help manage your allegiance.\n"
+ "@allegiance motd - Displays or sets the message of the day for your allegiance, see @help motd for more information.\n",
RetailCommandHelpTable.AllegiancesGroupDetail);
}
[Theory]
[InlineData("channels")]
[InlineData("commands")]
public void UnresolvedGroups_ContainVerbatimSummaryPlusHonestUnverifiedNote_NoMetaFabrication(
string verb)
{
Assert.True(RetailCommandHelpTable.TryGetHelpText(verb, out string text));
// The old sentinel this round retired -- must never come back.
Assert.DoesNotContain("has not yet extracted", text);
Assert.Contains("UNVERIFIED", text);
Assert.Contains("HelpStupidChannelHack @0x0056f290", text);
}
[Fact]
public void ChannelsGroup_SummaryLineIsVerbatim()
{
Assert.True(RetailCommandHelpTable.TryGetHelpText("channels", out string text));
Assert.StartsWith(
"@help channels - How to communicate with people in your allegiance or fellowship.",
text);
}
[Fact]
public void ChattingGroup_SevenOfEightEntriesAreVerbatim_FifthEntryMarkedUnverified()
{
Assert.True(RetailCommandHelpTable.TryGetHelpText("chatting", out string text));
Assert.Contains(
"@chat - Sets whether or not you receive normal chat.\n",
text);
Assert.Contains(
"@notell - Sets whether or not you receive @tell's.\n",
text);
Assert.Contains(
"@reply - Sends some text to the last person who @tell'd you.\n",
text);
Assert.Contains(
"@retell - Sends some text to the last person you @tell'd.\n",
text);
Assert.Contains(
"@say - Says some text to everyone around you.\n",
text);
Assert.Contains(
"@tell - Sends a private message to another character.\n",
text);
Assert.Contains(
"@afk - Set your away-from-keyboard status.\n",
text);
Assert.Contains("HelpStupidChannelHack @0x0056f290", text);
}
// Campaign CH user-gate round 2, item 3: 7 of the ~35 channel
// one-liners are now verbatim retail text (HelpTurbineChat_Xxx,
// acclient_2013_pseudo_c.txt:387294-387411, addresses
// 0x577620-0x577850) -- the "Also: @alias" suffix is retail's own text.
[Theory]
[InlineData("a", "@a - Sends a message to your Allegiance. Also: @guild, @gu")]
[InlineData("guild", "@a - Sends a message to your Allegiance. Also: @guild, @gu")]
[InlineData("general", "@general - Sends a message to the global General chat channel. Also: @cg")]
[InlineData("trade", "@trade - Sends a message to the global Trade chat channel. Also: @ct")]
[InlineData("lfg", "@lfg - Sends a message to the global Looking For Group (LFG) chat channel. Also: @clfg")]
[InlineData("roleplay", "@roleplay - Sends a message to the global Roleplay chat channel. Also: @crp")]
[InlineData("society", "@society - Sends a message to the your Society chat channel. Also: @soc")]
[InlineData("olthoi", "@olthoi - If you are an Olthoi, sends a message to the global Olthoi chat channel. Also: @o")]
public void ChannelVerb_VerbatimOneLiner_MatchesExactly(string verb, string expected)
{
Assert.True(RetailCommandHelpTable.TryGetHelpText(verb, out string text));
Assert.Equal(expected, text);
}
// The fellowship/monarch/patron/vassals/covassal family still routes
// through the unresolved HelpStupidChannelHack pooled-string mechanism
// (the "fvpca" BN artifact) and remains an honest acdream summary, not
// a claimed-verbatim string. Pinned so a future accidental "upgrade" to
// a fabricated retail-looking string doesn't slip in unnoticed.
[Theory]
[InlineData("f", "Sends text to your Fellowship channel.")]
[InlineData("monarch", "Sends text to your Monarch.")]
[InlineData("patron", "Sends text to your Patron.")]
[InlineData("vassal", "Sends text to your Vassals.")]
[InlineData("covassal", "Sends text to your Co-vassals.")]
public void ChannelVerb_StillAcdreamSummary_UnresolvedChannelHackFamily(
string verb, string expected)
{
Assert.True(RetailCommandHelpTable.TryGetHelpText(verb, out string text));
Assert.Equal(expected, text);
}
}

View file

@ -1,6 +1,8 @@
"""Sweep `push imm32` (0x68) operands inside a VA range of a PE binary,
dereference each into a data section (.rdata/.data), and decode any that
resolve to a printable UTF-16LE literal.
resolve to a printable string literal -- UTF-16LE (PStringBase<unsigned
short>, e.g. ECM_UI notice text) or narrow ASCII (PStringBase<char>, e.g.
the ClientCommunicationSystem::Help* command-help family) alike.
Built for the CH2 REJECT-review rework (BLOCKER 2,
docs/research/2026-08-09-ch2-review-findings.md) to re-derive
@ -8,9 +10,22 @@ ClientCommunicationSystem::HandleFailureEvent (@0x00571990)'s 344-row
display-string table from ground truth instead of the Binary Ninja
pseudo-C's ~33-char inline preview -- the same class of problem
check_exe_pdb.py and dump_pdb_info.py solve for PDB metadata, applied to
wide-string literal recovery. Not tied to WeenieError specifically: any VA
string literal recovery. Not tied to WeenieError specifically: any VA
range in any PDB-paired PE binary works.
Generalized for Campaign CH user-gate round 2, item 3 (2026-08-09): the
retail help command family (ClientCommunicationSystem::Help*, e.g.
HelpAllGroup/HelpAllegiancesGroup/HelpChannelsGroup/HelpChattingGroup/
HelpDeathGroup/HelpStatusGroup/HelpTextGroup) constructs its strings via
PStringBase<char> (narrow 8-bit ASCII), NOT PStringBase<unsigned short>
(wide UTF-16LE) like the WeenieError table or ECM_UI notices. Both
encodings now share one sweep: each push-imm32 hit is decoded as UTF-16LE
first (a real wide string reads back false as ASCII almost immediately --
every other byte is 0x00, which read_ascii_cstr rejects as a control
character), then as ASCII if that fails. The reported tuple carries which
encoding matched so callers can tell narrow help text apart from wide
notice text at a glance.
ALWAYS run check_exe_pdb.py first to confirm the candidate .exe pairs with
the PDB you're cross-referencing addresses against -- a mismatched binary
will produce confident-looking garbage.
@ -30,6 +45,8 @@ Usage:
directly encodes its own VA in hex)
--min-len N minimum decoded string length to report (default 3);
raise this to cut noise from short accidental hits
--ascii-only skip the UTF-16LE attempt entirely (narrow-string ranges
run faster and cannot false-positive against wide data)
"""
import argparse
import struct
@ -109,14 +126,44 @@ class PeImage:
out.append(chr(code))
return None # ran off the end without a NUL -- not a bounded literal
def read_ascii_cstr(self, va, max_chars=800):
"""Decode a narrow (8-bit) NUL-terminated C string -- the
PStringBase<char> literal shape the Help* command family uses,
distinct from read_utf16_cstr's PStringBase<unsigned short>
shape."""
off = self.va_to_off(va)
if off is None:
return None
out = []
for i in range(max_chars):
chunk = self.data[off + i: off + i + 1]
if len(chunk) < 1:
break
code = chunk[0]
if code == 0:
return "".join(out)
# Same control-char allowance as read_utf16_cstr (\n, \t only);
# anything else (including high bytes outside printable ASCII)
# means this isn't a real narrow literal.
if code < 0x20 and code not in (0x0A, 0x09):
return None
if code > 0x7E:
return None
out.append(chr(code))
return None # ran off the end without a NUL -- not a bounded literal
def is_data_section(self, va):
s = self.section_for_va(va)
return s is not None and s["name"] in (".rdata", ".data")
def sweep_push_imm32(self, lo, hi, min_len=3):
def sweep_push_imm32(self, lo, hi, min_len=3, ascii_only=False):
"""Scan [lo, hi) for `push imm32` (opcode 0x68) whose operand VA
dereferences to a UTF-16LE string in .rdata/.data. Returns a list
of (instr_va, target_va, text)."""
dereferences to a printable string literal in .rdata/.data --
UTF-16LE tried first (unless ascii_only), narrow ASCII as the
fallback (a real wide string's alternating 0x00 bytes make the
ASCII decode reject it as a control character almost immediately,
so the two encodings do not cross-contaminate each other's hits).
Returns a list of (instr_va, target_va, text, encoding)."""
hits = []
off_lo = self.va_to_off(lo)
off_hi = self.va_to_off(hi)
@ -127,19 +174,27 @@ class PeImage:
if self.data[i] == 0x68:
operand = struct.unpack_from("<I", self.data, i + 1)[0]
if self.is_data_section(operand):
text = self.read_utf16_cstr(operand)
text = None
encoding = None
if not ascii_only:
text = self.read_utf16_cstr(operand)
encoding = "utf16" if text is not None else None
if text is None:
text = self.read_ascii_cstr(operand)
encoding = "ascii" if text is not None else None
if text is not None and len(text) >= min_len:
instr_va = lo + (i - off_lo)
hits.append((instr_va, operand, text))
hits.append((instr_va, operand, text, encoding))
i += 1
return hits
def find_push_before(self, anchor_va, window=64, min_len=3):
def find_push_before(self, anchor_va, window=64, min_len=3, ascii_only=False):
"""Search backward from anchor_va (a call-site VA taken from the
pseudo-C) for the nearest preceding `push imm32` whose operand
dereferences to a printable UTF-16LE string."""
dereferences to a printable string literal."""
lo = anchor_va - window
return self.sweep_push_imm32(lo, anchor_va + 2, min_len=min_len)
return self.sweep_push_imm32(
lo, anchor_va + 2, min_len=min_len, ascii_only=ascii_only)
def main():
@ -150,6 +205,7 @@ def main():
ap.add_argument("--window", type=int, default=64)
ap.add_argument("--deref", action="append", default=[])
ap.add_argument("--min-len", type=int, default=3)
ap.add_argument("--ascii-only", action="store_true")
args = ap.parse_args()
pe = PeImage(args.exe)
@ -162,22 +218,30 @@ def main():
if args.range:
lo = int(args.range[0], 16)
hi = int(args.range[1], 16)
hits = pe.sweep_push_imm32(lo, hi, min_len=args.min_len)
hits = pe.sweep_push_imm32(
lo, hi, min_len=args.min_len, ascii_only=args.ascii_only)
print(f"# sweep 0x{lo:08x}-0x{hi:08x}: {len(hits)} string-valued push imm32 sites")
for instr_va, target_va, text in hits:
print(f"0x{instr_va:08x} -> data_0x{target_va:08x} {text!r}")
for instr_va, target_va, text, encoding in hits:
print(f"0x{instr_va:08x} -> data_0x{target_va:08x} [{encoding}] {text!r}")
for a in args.anchor:
anchor = int(a, 16)
hits = pe.find_push_before(anchor, window=args.window, min_len=args.min_len)
hits = pe.find_push_before(
anchor, window=args.window, min_len=args.min_len,
ascii_only=args.ascii_only)
print(f"\n# anchor 0x{anchor:08x} (window={args.window}): {len(hits)} hits")
for instr_va, target_va, text in hits:
print(f"0x{instr_va:08x} -> data_0x{target_va:08x} {text!r}")
for instr_va, target_va, text, encoding in hits:
print(f"0x{instr_va:08x} -> data_0x{target_va:08x} [{encoding}] {text!r}")
for d in args.deref:
target = int(d, 16)
text = pe.read_utf16_cstr(target)
print(f"\n# deref 0x{target:08x}: {text!r}")
if text is None:
text = pe.read_ascii_cstr(target)
kind = "ascii"
else:
kind = "utf16"
print(f"\n# deref 0x{target:08x}: [{kind}] {text!r}")
if __name__ == "__main__":