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

@ -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