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

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