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:
parent
a485425743
commit
c1f1582576
14 changed files with 788 additions and 254 deletions
|
|
@ -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__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue