fifa17-recon: /hub refutes yesterday's envelope conclusion, and two ENDPOINT_MAP freezes
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>
This commit is contained in:
@@ -215,7 +215,11 @@ Path template `%s = "game/fifa17"`. Methods inferred from struct verb + endpoint
|
||||
### Freeze-risk summary (type fidelity is mandatory)
|
||||
- `auctionInfo` → **array** (never object/scalar).
|
||||
- `itemData` inside each record → **object** (the card; reuse `item_def`).
|
||||
- `duplicateItemIdList` → **array**.
|
||||
- `duplicateItemIdList` → **array of objects** (element deser `0x180138e10`: `itemId` 0x16d,
|
||||
`duplicateItemId` 0xeb, `itemLoans` 0x16f, `duplicateItemLoans` 0xed). Not an int list.
|
||||
`[]` is safe; a list of bare ints is a freeze. Control that this is not a misread:
|
||||
`dreamSquads` 0xe9 in FutMoveCard genuinely IS a bare int array, parsed by a
|
||||
`while (tok != 0xd)` loop calling the int getter with no inner object loop.
|
||||
- `bidState`, `tradeState`, `sellerName` → **strings**.
|
||||
- `credits`, `total`, `count`, `*Price`, `*Bid`, `expires`, `tradeId` → **numbers**.
|
||||
- `watched` → **bool**.
|
||||
@@ -966,7 +970,11 @@ Notes:
|
||||
{ "itemData": [ /* the single updated card item */ ] }
|
||||
|
||||
// 8 DiscardCard — DELETE ut/delete/game/fifa17/item
|
||||
{ "items": [ 123456789 ], "totalCredits": 15000, "id": 123456789 }
|
||||
// CORRECTED 2026-08-05: `items` is an array of OBJECTS and there is no top-level `id`.
|
||||
// The previous shape, { "items": [ 123456789 ], ..., "id": 123456789 }, was wrong twice
|
||||
// over, and feeding a bare int where the element parser expects an object is a tokenizer
|
||||
// desync, i.e. a hard freeze at 0x1801c7f1a, not a soft failure.
|
||||
{ "items": [ { "id": 123456789 } ], "totalCredits": 15000 }
|
||||
|
||||
// 9 DiscardCardByRes — DELETE ut/delete/game/fifa17/item
|
||||
{ "totalCredits": 15000 }
|
||||
@@ -1092,7 +1100,7 @@ desyncs the SAX reader → tokenizer freeze at `0x1801c7f1a`.
|
||||
| `itemList` | 0x16e | **ARRAY** of items (element deser `0x18013fe00`) | freeze-risk |
|
||||
| `numberItems` | 0x1dd | INT | `[rsi+0x28]` |
|
||||
| `purchasedPackId` | 0x264 | INT | `[rsi+0x70]` |
|
||||
| `duplicateItemIdList` | 0xec | **ARRAY** (int list) | freeze-risk |
|
||||
| `duplicateItemIdList` | 0xec | **ARRAY of OBJECTS** (element deser `0x180138e10`) | freeze-risk |
|
||||
|
||||
- **Status: already handled — VERIFIED byte-exact** against `store_buy()`.
|
||||
- **Minimal known-good**:
|
||||
|
||||
@@ -263,6 +263,65 @@ fills it and `FutGetPurchasedItems` fills it, and we happen to be using the seco
|
||||
byte-exact". It is verified against `store_buy()`, not against the client. Nothing
|
||||
has ever read it.
|
||||
|
||||
> **SUPERSEDED. Read the correction immediately below this box before using anything
|
||||
> in it. The token enum in the table is right; the conclusion drawn from it is wrong.**
|
||||
>
|
||||
> ---
|
||||
>
|
||||
> ### CORRECTION, same evening, after a live test
|
||||
>
|
||||
> **The three tokens are `BOF`, `{`, and the FIRST FIELD NAME. Every key of a flat body
|
||||
> is dispatched, including the first. Nothing is silently eaten, and key order is not
|
||||
> load-bearing.**
|
||||
>
|
||||
> The first call to `FUN_1801c7f10` returns token **7** and consumes **no input**. It is
|
||||
> a once-only start-of-document freebie, guarded by the flag at `parser+0xda` together
|
||||
> with the zero character counter at `parser+0x30`:
|
||||
>
|
||||
> ```c
|
||||
> if ((*(longlong *)(param_1 + 0x30) == 0) && (*(char *)((longlong)param_1 + 0xda) == '\0')) {
|
||||
> *(undefined1 *)((longlong)param_1 + 0xda) = 1;
|
||||
> param_1[0x34] = 7;
|
||||
> return 7;
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> So the sequence is 7 (BOF, nothing consumed), 9 (`{`), 11 (the first FIELD_NAME), and
|
||||
> the key loop then dispatches starting from that first key. The `== 10` test on the
|
||||
> third token is not an envelope check at all, it is the empty-object early-out: for a
|
||||
> body of `{}` the third token is END_OBJECT and the function exits cleanly with its
|
||||
> constructor defaults intact, which is exactly why answering `{}` has always been safe.
|
||||
>
|
||||
> **How it was caught.** Not by more decompiling. `GET /hub` is served as a flat two-key
|
||||
> body and the superseded reading predicted its first key would be discarded. Reading
|
||||
> the client's own memory settled it: `clubPlayers` came back as **205**, the value the
|
||||
> server sent, at `model+0x1fd70+0x3c` (slide proven against the FNV prologue first).
|
||||
> It reached its arm. The model was wrong.
|
||||
>
|
||||
> **Consequences of the correction.**
|
||||
> * `POST /user` is fine as it stands. `login` is NOT being discarded. The claim that it
|
||||
> was, and the claim that the key order of that dict is load-bearing, are both
|
||||
> withdrawn.
|
||||
> * A wrapper is not merely unnecessary for these roots, it would be actively harmful.
|
||||
> `FutCreateUser`'s ladder has arms for exactly its five atoms, so a wrapper key would
|
||||
> hash to an atom with no arm and the entire object would be skipped.
|
||||
> * `createPackResponse` is now DOUBTFUL rather than confirmed. Atom `0xbe` has no arm
|
||||
> in `FUN_180162880`, and under the corrected rule the first FIELD_NAME is dispatched,
|
||||
> so wrapping would get the whole payload skipped. There is no live evidence either
|
||||
> way, because nothing has ever parsed that body (the coin buy goes through
|
||||
> `POST /purchased`). Do not "fix" the buy path on the strength of this until it can
|
||||
> be tested.
|
||||
>
|
||||
> **The methodological lesson, which is the reusable part.** The superseded reading was
|
||||
> derived from a correct token enum and a correct call census, and it was still wrong,
|
||||
> because it assumed the first tokenizer call consumed input. One live read refuted a
|
||||
> chain of otherwise sound static reasoning in about a minute. Static structure tells
|
||||
> you what the code can do; only the running client tells you what it did.
|
||||
>
|
||||
> ---
|
||||
>
|
||||
> **SUPERSEDED TEXT FOLLOWS, kept for the record:**
|
||||
>
|
||||
> **RESOLVED, 2026-08-05 evening, by `tools/ghidra_queries/q_envelope_{1,2,3}.py`.
|
||||
> Reading A is correct. The envelope is structurally required. Its name is never
|
||||
> checked. Reading B rested on a factual error, corrected below.**
|
||||
@@ -1021,10 +1080,26 @@ off per the house rule. If it is right, `login` starts being read and nothing el
|
||||
changes. If it is wrong, login desyncs and the game cannot enter FUT, so it is not a
|
||||
change to make casually or to bundle with anything else.
|
||||
|
||||
**The actual next thing to read is `GET /hub`.** We answer it with a flat two-key body,
|
||||
`{"clubPlayers":205,"auctionCount":0}`, and under the confirmed rule a three-token root
|
||||
would silently eat `clubPlayers`. I could not settle it here: there is no
|
||||
`RS4:FutGetHubServerResponse` literal in the image and both atoms appear only as
|
||||
atom-table entries with no code xref, so `/hub` may not be parsed by a generated root at
|
||||
all. One query, no launch, no risk, and it is the same class of silent-discard bug that
|
||||
this pass just found in the auth body.
|
||||
**`GET /hub`: DONE, and it is the thing that overturned the section above.** It is
|
||||
served flat and it parses correctly. `clubPlayers` reached its arm, read as 205 out of
|
||||
the running client at `model+0x1fd70+0x3c`. See the correction box in section 2.
|
||||
|
||||
Two errors of mine were exposed getting there, both cheap to repeat and worth recording:
|
||||
|
||||
1. I searched for `RS4:FutGetHubServerResponse` and reported that no hub class existed.
|
||||
The class is `FutGetHubDataServerResponse` (literal `0x18022ce40`, vtable
|
||||
`0x18022cd48`, deserializer `0x1801738b0`, resolved with `FutSquadSave ->
|
||||
0x180171a60` matching as the control in the same run). An absence found by guessing
|
||||
a name is not an absence.
|
||||
2. I then scanned 152 deserializers for `clubPlayers`, found zero hits, and had a
|
||||
control pass. The guard is `if (iVar6 != 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 pre-existing comment at `utas_server.py:1076` had the hub chain right all along, and
|
||||
reading it first would have saved both queries.
|
||||
|
||||
**The next thing worth doing is the one-launch test in section 7**, now that the
|
||||
no-launch work is exhausted. The remaining static questions in this list are small; the
|
||||
open items with real value all need either the game in front of a human, or a live probe
|
||||
of one specific field in the pattern that just worked here.
|
||||
|
||||
Reference in New Issue
Block a user