89da7b7609
Three things: the envelope rule was wrong and is corrected, /hub is settled, and two
documented response shapes that would freeze the client are fixed.
THE CORRECTION. The previous commit concluded that a three-token root consumes `{`, the
first field name and that field's value without dispatching them, so the first key of a
flat body was silently eaten, and that `login` had therefore never been delivered on
POST /user. That is WRONG and is withdrawn, along with the claim that the key order of
the auth dict is load-bearing.
The first call to FUN_1801c7f10 returns token 7 and consumes NO input. It is a
once-only start-of-document token, guarded by the flag at parser+0xda together with the
zero character counter at parser+0x30. So the three tokens are BOF, `{`, and the FIRST
FIELD NAME, and the key loop dispatches from that first key onward. The `== 10` test on
the third token is not an envelope check, it is the empty-object early-out: for `{}` the
third token is END_OBJECT and the root exits with its constructor defaults intact, which
is why answering `{}` has always been safe.
Corrected enum: 7=BOF 9=START_OBJECT 10=END_OBJECT 11=FIELD_NAME 12=START_ARRAY
13=END_ARRAY. The enum itself was right before; the inference from it was not.
HOW IT WAS CAUGHT, which is the part worth keeping. Not by more decompiling. /hub is
served flat and the wrong model predicted its first key would be discarded, so the
prediction was checked against the client's own memory: clubPlayers read back as 205,
the value the server sent, at model+0x1fd70+0x3c with the slide proven against the FNV
prologue first. One live read refuted a chain of otherwise sound static reasoning in
about a minute. tools/hub_counter_probe.py keeps it repeatable.
Consequence worth flagging: a wrapper is not just unnecessary for these roots, it would
be harmful, since a wrapper key hashes to an atom with no arm and the whole object is
skipped. That makes the createPackResponse envelope DOUBTFUL rather than confirmed.
Atom 0xbe has no arm in FUN_180162880. There is no live evidence either way because
nothing has ever parsed that body, so the buy path is left exactly as it is.
TWO ERRORS OF MINE ON THE WAY, both recorded in the doc because both are cheap to
repeat. I searched for RS4:FutGetHubServerResponse, found nothing and reported that no
hub class existed; the class is FutGetHubDataServerResponse (literal 0x18022ce40,
vtable 0x18022cd48, deser 0x1801738b0, control FutSquadSave -> 0x180171a60 matched in
the same run). Then I scanned 152 deserializers for clubPlayers, got zero hits and a
passing control, because the guard is `!= 0x90` and my pattern only matched `== 0x`.
The control passed only because auctionCount happens to use `==`. A control that does
not exercise the same code shape as the target is not a control. The comment already at
utas_server.py:1076 had the hub chain right the whole time.
ENDPOINT_MAP corrections, both freeze-risky as written, neither affecting what we serve
today:
* duplicateItemIdList is an ARRAY OF OBJECTS (element deser 0x180138e10), not the int
list at :1095. Bare ints where the element parser expects objects is a tokenizer
desync, i.e. a hard freeze at 0x1801c7f1a. Control that this is not a misread:
dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array.
* FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
top-level id.
No behaviour change. utas_server.py is comment-only. 439 contract checks pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
114 lines
4.8 KiB
Python
114 lines
4.8 KiB
Python
"""The hub response class exists after all. Resolve it and settle the clubPlayers question.
|
|
|
|
q_hub_1.py searched for RS4:FutGetHubServerResponse and found nothing, and the
|
|
pack-opening doc recorded that as "there is no hub class". Wrong search, not absent
|
|
class: the literal scan turned up `tHubDataServerResponse` at 0x18022ce61, i.e. the
|
|
class is FutGetHubData..., not FutGetHub.... This is the same absence-from-a-bad-search
|
|
trap the project has hit before, so this file re-derives it from the literal outward.
|
|
|
|
q_hub_1 also found ZERO direct `== 0x90` comparisons for clubPlayers across 152
|
|
deserializers, with a passing control (itemList in 0x180162880). That is suggestive but
|
|
it is NOT a proven absence, because dispatch here is frequently a running-sum sub/dec
|
|
ladder, which a source grep for `== 0x90` cannot see. Settle it by reading the actual
|
|
hub deserializer instead of by scanning.
|
|
|
|
QUESTIONS
|
|
Q1 exact class literal, its vtable, its deserializer (RS4 rule: the literal is
|
|
"RS4:<Name>", so search the full string and take vtable slot +0x08)
|
|
Q2 how many tokens the deserializer spends before its first key read, which decides
|
|
whether the flat two-key body we serve loses its first pair
|
|
Q3 which atoms its ladder actually handles, read from the decompile IN FULL rather
|
|
than grepped, so clubPlayers 0x90 and auctionCount 0x33 get a real answer
|
|
|
|
CONTROL: resolve FutSquadSave the same way in the same run. Its deserializer is known
|
|
to be 0x180171a60. If that does not come back correct, nothing else here is trustworthy.
|
|
"""
|
|
import traceback
|
|
|
|
CANDIDATES = [b"RS4:FutGetHubDataServerResponse", b"RS4:FutSquadSaveServerResponse"]
|
|
KNOWN = {"RS4:FutSquadSaveServerResponse": 0x180171A60}
|
|
|
|
|
|
def resolve(lit):
|
|
"""literal -> [(deser, vtable, factory)] via the RS4 rule."""
|
|
out = []
|
|
for a in find_all(lit):
|
|
print(" literal at %#x: %r" % (a, rd_str(a)))
|
|
for frm, typ, fn, ent in xrefs_to(a):
|
|
if not ent:
|
|
continue
|
|
f = func(ent)
|
|
if f is None:
|
|
continue
|
|
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())
|
|
if not (0x1801E5000 <= t <= 0x1802891FF):
|
|
continue
|
|
try:
|
|
v0, v1 = qword(t), qword(t + 8)
|
|
except Exception:
|
|
continue
|
|
if fm.getFunctionAt(addr(v0)) and fm.getFunctionAt(addr(v1)):
|
|
out.append((v1, t, ent))
|
|
return out
|
|
|
|
|
|
try:
|
|
found = {}
|
|
for lit in CANDIDATES:
|
|
name = lit.decode()
|
|
print("=" * 78)
|
|
print(name)
|
|
print("=" * 78)
|
|
res = resolve(lit)
|
|
uniq = sorted({d for d, _, _ in res})
|
|
for d, vt, ent in res:
|
|
print(" deser %#x vtable %#x factory %#x" % (d, vt, ent))
|
|
print(" -> distinct deserializers: %s"
|
|
% (" ".join("%#x" % d for d in uniq) or "NONE"))
|
|
found[name] = uniq
|
|
if name in KNOWN:
|
|
exp = KNOWN[name]
|
|
print(" CONTROL: expected %#x, %s"
|
|
% (exp, "MATCH" if exp in uniq else "*** MISMATCH, results are void ***"))
|
|
print()
|
|
|
|
for d in found.get("RS4:FutGetHubDataServerResponse", []):
|
|
src = dec(d)
|
|
f = func(d)
|
|
print("=" * 78)
|
|
print("HUB DESERIALIZER %#x body %d bytes / decompile %d chars (IN FULL)"
|
|
% (d, f.getBody().getNumAddresses() if f else -1, len(src)))
|
|
print("=" * 78)
|
|
print(src)
|
|
|
|
toks, begin, keys = [], [], []
|
|
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 == 0x1801C8270:
|
|
begin.append(a)
|
|
elif t == 0x180141EE0:
|
|
keys.append(a)
|
|
print("\n tokenizer calls : %d at %s" % (len(toks), " ".join("%#x" % a for a in toks)))
|
|
print(" begin-object : %s" % (" ".join("%#x" % a for a in begin) or "NONE"))
|
|
print(" key-reader calls: %s" % (" ".join("%#x" % a for a in keys) or "NONE"))
|
|
if begin and keys:
|
|
b, k = min(begin), min(keys)
|
|
print(" tokens before first key read: %d"
|
|
% len([a for a in toks if b < a < k]))
|
|
print("\n callers: %s" % " ".join("%#x %s" % (a, n) for a, n in callers(d)[:10]))
|
|
|
|
except Exception:
|
|
traceback.print_exc()
|