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

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.",