1605e6effd
The open question was whether a response deserializer DESCENDS a wrapper or PROBES for
one. Three roots spend an identical three tokenizer calls before dispatch, yet we serve
some bodies wrapped and some flat, and not all of those could be right.
TOKEN ENUM, decoded from the class table at DAT_18023dd40 and the switch in the
classifier FUN_1801c67a0 (the push/pop arms key off container state 2 = object,
3 = array):
9 START_OBJECT case 0x64, pushes state 2
10 END_OBJECT case 0x65, pops state 2
11 FIELD_NAME confirmed independently: FUN_18013bd40 tests +0xd0 == 0xb then
atom-hashes the string at +0xf8
12 START_ARRAY case 0x66, pushes state 3
13 END_ARRAY case 0x67, pops state 3
1 error the caseD_78 sink
So the three tokens are `{`, the first FIELD_NAME, and the token opening that field's
value. The envelope is structurally required and its name is NEVER hashed, which is why
FutCreatePack's ladder has no arm for createPackResponse (0xbe) and does not need one.
Coverage for that absence: the ladder has exactly four arms (0xec, 0x16e, 0x1dd, 0x264)
and 0xbe does not occur anywhere in the full 4702-char decompile, printed in full.
The competing reading rested on a factual error. It claimed the /purchased root spends
the same three tokens. FUN_180124ee0 spends TWO and hands off to FUN_18013bd40, which
spends the third. Same total, split across two functions. /purchased never was a
counterexample.
THE BUG THIS FOUND IS NOT THE ONE THAT WAS PREDICTED. The doc expected starterPack,
squad and userData to be swallowed on POST /user. They are not. FutCreateUser
(0x18014cc60) has ladder arms for exactly the five keys we send, and four of them
dispatch correctly at the outer level. The one that does not is `login`: its name is
eaten as the anonymous envelope and its value as the third token. It has an arm, so the
client wants it, and it has never once been delivered.
The second-order consequence matters more than the first. The key order of that dict is
load-bearing and nothing said so. Put userData first and the client loses the entire
user record, silently, with no error and no log line. That warning now sits in the code
next to the dict, which is the only place someone about to reorder it would look.
No behaviour change here. The utas_server.py edit is a comment. 439 contract checks
still pass. The probable proper fix, wrapping all five keys one level down inside a
single envelope key, is a hypothesis with a mechanism rather than a proven fix, and it
touches the login path, so it is not made here and would go behind a flag defaulting
off.
Writeup is section 2 of docs/plan-2026-08-05-pack-opening.md, added by the previous
commit. Opened by this and still UNKNOWN: GET /hub is answered with a flat two-key
body, which under this rule a three-token root would silently truncate, but there is no
FutGetHubServerResponse class and neither atom has a code xref, so /hub may not go
through a generated root at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
92 lines
3.9 KiB
Python
92 lines
3.9 KiB
Python
"""Pin the token enum, so the three-token pattern can be read rather than guessed.
|
|
|
|
Query 1 established the shapes but not the vocabulary:
|
|
FUN_180162880 FutCreatePack 3 tokenizer calls, then a key loop, exits on token 10
|
|
FUN_18014cc60 FutCreateUser 3 tokenizer calls, then a key loop
|
|
FUN_180124ee0 /purchased root TWO tokenizer calls, then FUN_18013bd40, no loop at all
|
|
That two-versus-three difference is the whole answer, but only if we know what a token IS.
|
|
Note it already refutes the claim in plan-2026-08-05-pack-opening.md section 2 that the
|
|
/purchased root "spends the same three tokens". It spends two.
|
|
|
|
FUN_1801c7f10 is only a pushback wrapper: it returns param_1[0x35] if a token was pushed
|
|
back, else classifies one byte via FUN_1801c67a0. So FUN_1801c67a0 holds the enum.
|
|
Known so far, from usage rather than from the enum: 10 ends the key loop, 0xd ends an array
|
|
(`while (iVar7 != 0xd)` around the itemList element parser), 8 is set when the input is
|
|
exhausted cleanly, 1 on error, 7 on a first-call special case.
|
|
|
|
WHAT WOULD FALSIFY THE DESCEND READING: if token 2 in the /purchased root is a
|
|
START_ARRAY rather than a key or a START_OBJECT, then `itemData` is not being consumed as a
|
|
wrapper and the two-call pattern means something else entirely.
|
|
"""
|
|
import traceback
|
|
|
|
TARGETS = [
|
|
(0x1801C67A0, "token CLASSIFIER -- the enum lives here"),
|
|
(0x18013BD40, "/purchased body sub-parser (what the 2-token root hands off to)"),
|
|
(0x180141EE0, "key reader used by the CreatePack/CreateUser loops"),
|
|
(0x1801C63E0, "parser init (called before begin-object in every root)"),
|
|
]
|
|
|
|
|
|
def dump(va, title):
|
|
try:
|
|
f = func(va)
|
|
src = dec(va)
|
|
n = f.getBody().getNumAddresses() if f else -1
|
|
print("\n" + "=" * 78)
|
|
print("%#x %s" % (va, title))
|
|
print("body %d bytes / decompile %d chars (PRINTED IN FULL)" % (n, len(src)))
|
|
print("=" * 78)
|
|
print(src)
|
|
except Exception:
|
|
print("!! failed on %#x" % va)
|
|
traceback.print_exc()
|
|
|
|
|
|
try:
|
|
for va, name in TARGETS:
|
|
dump(va, name)
|
|
|
|
# Cross-check the two-versus-three count mechanically over every response root we can
|
|
# name, rather than trusting three hand-picked examples. A root is recognised by calling
|
|
# the parser-init, begin-object and the tokenizer.
|
|
print("\n" + "=" * 78)
|
|
print("TOKEN CALLS BEFORE THE FIRST KEY READ, across all begin-object callers")
|
|
print("=" * 78)
|
|
roots = sorted({e for _, _, _, e in xrefs_to(0x1801C8270) if e})
|
|
print("begin-object callers: %d" % len(roots))
|
|
for ent in roots:
|
|
try:
|
|
f = func(ent)
|
|
if f is None:
|
|
continue
|
|
# order the call sites by address and count tokenizer calls that precede the
|
|
# first key-reader call, which is what "descend depth" actually means here
|
|
toks, keys, begin = [], [], []
|
|
for ad in f.getBody().getAddresses(True):
|
|
ins = listing.getInstructionAt(ad)
|
|
if ins is None:
|
|
continue
|
|
for r in ins.getReferencesFrom():
|
|
t = int(r.getToAddress().getOffset())
|
|
a = int(ad.getOffset())
|
|
if t == 0x1801C7F10:
|
|
toks.append(a)
|
|
elif t in (0x180141EE0,):
|
|
keys.append(a)
|
|
elif t == 0x1801C8270:
|
|
begin.append(a)
|
|
if not begin:
|
|
continue
|
|
b = min(begin)
|
|
first_key = min(keys) if keys else None
|
|
pre = [a for a in toks if a > b and (first_key is None or a < first_key)]
|
|
print(" %#x %-22s begin@%#x tokens_before_first_key=%d keyreads=%d"
|
|
% (ent, f.getName(), b, len(pre), len(keys)))
|
|
except Exception:
|
|
print(" %#x <failed>" % ent)
|
|
traceback.print_exc()
|
|
|
|
except Exception:
|
|
traceback.print_exc()
|