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

@ -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__":