diff --git a/fifa17-recon/docs/plan-2026-08-06-transfer-market.md b/fifa17-recon/docs/plan-2026-08-06-transfer-market.md new file mode 100644 index 0000000..ddd503a --- /dev/null +++ b/fifa17-recon/docs/plan-2026-08-06-transfer-market.md @@ -0,0 +1,1158 @@ +# The transfer market: why it refuses, the gate-byte block, and the market wire protocol + +Written 2026-08-06. Six parallel reversing passes over the trading gate, the +trade-pile capacity, the listing predicate, the market wire protocol, the online +state machine and the live gate-byte block, plus three adversarial verification +rounds that refuted six claims and corrected a dozen more. FIFA 17 was running +throughout as pid 260692, in Ultimate Team, and was read strictly read-only. No +server was restarted, no server code was changed, and `tools/fifa17_profile.json` +was never opened for write. + +Slide for every live read: `live = static - 0x180000000 + 0x6ffffc140000`, +re-derived from `/proc/260692/maps` by six agents independently and proved each +time against bytes read from the on-disk PE in two different sections. The +controls used while writing this document were the FNV hasher prologue at +`0x180180d00` (`.text`, 32 bytes, disk == live), the single-occurrence `.rdata` +literal `RS4:FutSquadSaveServerResponse` at `0x18022c618` (48 bytes, disk == +live), and a negative control one page high that mismatches. All three passed at +the moment of writing. + +Two premises the brief carried into this run are wrong and are not inherited +below. The trading gate byte is **not** 1, it is 0, on four independent +measurements across two pids. And the settings applier is **not** unreachable: it +is virtual method `+0x988` of the `FutDataManagerImpl` vtable, it has two callers, +and it has been running on every session for the whole history of this project. +"Ghidra shows no caller" was a search-form miss, the seventh instance of the +absence trap on this project, and this time the dispatch form was a virtual call. + +--- + +## 1. What we now know that we did not know this morning + +**The transfer market is switched off by our own server, by one JSON member, and +the member is inverted so that sending it as `true` is what disables trading.** +`tools/utas_server.py` line 255 puts `"feature": {"trade": True}` inside +`userInfo`. The FIFA 17 `userMassInfo` deserializer reads that as a trade **ban**. +The chain is six hops and every one is an instruction I read out of the on-disk +PE rather than a decompile: + +``` +18013f016: cmp esi,0x330 ; atom 0x330 = "trade", inside 0x11c = "feature" +18013f01c: je 18013f02a +18013f02a: mov rcx,rbx +18013f02d: call 1801c79d0 ; the INT primitive +18013f032: cmp eax,0x1 +18013f035: jne 18013f03d +18013f037: mov BYTE PTR [rdi+0xa4],al ; userInfo+0xa4 == response+0x17c +... +180174f07: cmp eax,0xa ; top-level END_OBJECT of the whole body +180174f0a: jne 180174720 +180174f10: cmp BYTE PTR [rsi+0x17c],0x0 +180174f17: je 180174f20 +180174f19: mov DWORD PTR [rsi+0x50],0x0 ; force settings.tradingEnabled to 0 +... +18011dc8a: cmp DWORD PTR [rbx+0x28],0x1 ; rbx = copy of response+0x28, so +0x28 == response+0x50 +18011dc8e: sete al +18011dc91: mov BYTE PTR [rdi+0x1fd2e],al ; the IS_TRADING_ENABLED gate byte +``` + +`userInfo` is parsed at `response+0xd8` (`lea rcx,[rsi+0xd8]; call 0x18013ec10` +at `0x180174b63`), so `[rdi+0xa4]` is `response+0x17c`. The settings sub-struct is +at `response+0x28` (`lea rdx,[rsi+0x28]; call 0x18013c6d0` at `0x180174ab2`), so +`response+0x50` is settings field index 10, which is `tradingEnabled`, atom +`0x336`. Live right now: `model+0x1fd2e = 0`, `model+0x1fd2f = 1`. + +**The gap every previous agent left open is closed: JSON `true` reads as 1 through +the INT primitive.** This mattered because the whole finding depends on +`cmp eax,1` passing when we send a boolean, and three agents flagged it as +unverified while ranking an action that rests on it. `FUN_1801c79d0` is a +sub/dec ladder on the token at `reader+0xd0`, and the token-4 arm is: + +``` +1801c7a38: xor eax,eax +1801c7a3a: cmp BYTE PTR [rbx+0x108],al +1801c7a40: setne al +1801c7a43: ret +``` + +Token 4 is the boolean token and the INT getter returns it as 0 or 1. `true` +arrives at `cmp eax,1` as 1. The kill switch fires. Note the elegance of the bug: +`mov [rdi+0xa4],al` stores `al`, which is the getter's return value, and the +branch above guarantees it is exactly 1. + +**The kill switch runs last, unconditionally, so serving `tradingEnabled: 1` in +the configs array cannot beat it.** The test at `0x180174f07` is on the top-level +`END_OBJECT` of the entire massinfo body, and the `jne` at `0x180174f0a` loops +back to `0x180174720`, the member-dispatch loop. Every member, `settings` +included, is fully parsed before the tail runs. So a `tradingEnabled` arm would +write `response+0x50` and the tail would then zero it. Removing `feature.trade` +is not the cleanest fix, it is the **only** fix that works through +`/userMassInfo`. This point was made by exactly one agent and it is the difference +between a working experiment and a wasted relaunch. + +**This resolves a contradiction that an adversarial pass had correctly identified +and could not explain.** That verifier mechanically parsed the applier and the +settings-struct constructor `FUN_18014e320` and built an expected-from-defaults +table: 37 of 37 determinable fields match the live model exactly, including three +unique values at three unrelated offsets, and `tradingEnabled` alone deviates. I +re-read the constructor head myself: + +``` +18014e344: mov QWORD PTR [rcx+0x10],0x1e0 ; -> model+0x1fd54, live 480 +18014e353: mov DWORD PTR [rcx+0x1c],0x3c ; -> model+0x1fd28, live 60 +18014e35a: mov DWORD PTR [rcx+0x20],0x4ec0 ; -> model+0x1fd60, live 20160 +18014e361: mov DWORD PTR [rcx+0x28],0x1 ; tradingEnabled default is ONE +18014e368: mov DWORD PTR [rcx+0x2c],0x1 ; storeEnabled, identical form +``` + +There is no default asymmetry between store and trading. One agent claimed at +HIGH confidence that "the response object defaults storeEnabled to 1 but +tradingEnabled to 0, and that single asymmetry is the entire bug." That is +refuted by the instruction at `0x18014e361`, and that agent's own gap list +concedes it never read the constructor and inferred the default from the live +byte it was trying to explain. The verifier who caught the circularity was right +to reopen it, and could not close it because it did not look at `feature.trade`. +The kill switch closes it: 37 fields keep their constructor defaults because our +configs array is empty, and the 38th is zeroed by our own `userInfo` member after +the fact. Every measured number in the block is now accounted for. + +**"/settings cannot reach the gate bytes" is dead, and so is the measurement that +seemed to prove it.** The applier is virtual slot `+0x988`, called from +`0x180173f0b` (the `userMassInfo` completion callback `FUN_180173e00`) and from +`0x18011e21a` (the standalone `/settings` callback `FUN_18011e180`, which I +confirmed: `cmp DWORD PTR [rdx+0x1c],0x0` at `0x18011e19e`, then a `movups` copy +of `rdx+0x28`, then the call). Both endpoints apply. The historical probe that +served `maximumTradePileSize=77` and found no gate field carrying 77 was aimed at +the wrong destination: atom `0x1c0` lands in settings field `[0]`, and the +applier's first act is `add rcx,0x15f00; mov edx,[rdx]; call 0x18011f380`, a slot +vector at `model+0x15f00`. It was never going to appear in the `0x1fd` block. The +brief's proposed "clean falsifier" was a false falsifier and would have produced +a null result read as a second confirmation of a wrong conclusion. + +**`TRANSFER LIST 0/0` is a separate, fully decoded path, and it is not fed by +`maximumTradePileSize`.** `model+0x1fd1c` is published as `TRADE_PILE_SIZE` and +has exactly one writer, `FUN_18011dbf0` at `0x18011dc05`, reached only through +virtual slot `+0x998`, called from exactly one site, `0x18017405b`, with a copy of +`response+0xc8`. That block is written only by the `pileSizeClientData` parser +`FUN_18013adb0`, whose entire key vocabulary is two constants: + +``` +18013aeaf: cmp esi,0x2 +18013aeb2: je 18013aec7 ; -> mov [r14+0x8],eax -> model+0x1fd1c +18013aeb4: cmp esi,0x4 +18013aeb7: jne 18013aed3 ; -> mov [r14+0xc],eax -> model+0x1fd20 +``` + +Key 2 is the trade pile, key 4 is the watch list, every other key is read and +discarded. `FUT_PILESIZES=probe` should not be run: it would burn a relaunch to +rediscover a two-value enum that is now decoded, and its fallback would write the +club item count (99) into the transfer-list capacity. + +**The Blaze client-config store is not a lever for any of this.** The client +demonstrably received `IS_TRADING_ENABLED=1` and `tradingEnabled=1` this session +(the merged config blob is readable in the client's heap at `0x43c50aa7` and +contains both), and the gate byte is still 0. `IS_TRADING_ENABLED` is an **output +name**: `FUN_18006cc60` calls the accessor at `vt+0x270`, takes the byte, and +hands it out under that literal. The only rip-relative reference to `0x1801fc118` +in all of `.text` is that `lea`. `FUT_TRADING` in `blaze_responder_v3b.py` is +inert with respect to these bytes. This kills the brief's premise that we have "a +PROVEN way to set the gate bytes via the Blaze client-config store." We do not. +The proven way is the settings sub-struct, and its constructor already sets most +of them. + +**Every market route currently implemented in `utas_server.py` is at a path the +client never requests.** The route table at `.rdata 0x18021df80` (45 rows) and the +action table at `.data 0x1802caa20` (125 rows of 0x30) resolve twelve market +operations to twelve path templates, none of which are the ones we serve. Section +5 has the full spec. One route is empirically confirmed rather than inferred: the +game itself issued `GET /ut/game/fifa17/tradePile` at 10:34:24 and 10:42:35 with +its own `ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)` user agent. + +**The reconnect banner is a red herring and the market refusal is not a +connectivity decision.** `PRESS q TO RE-CONNECT` belongs to +`powdll_Win64_retail.dll` and the EASFC widget. CardsDLL does not import powdll +(its import directory names exactly four DLLs: sysdll, MSVCR120, KERNEL32, +USER32) and contains no POW or EASFC connection literal. The Blaze session is +alive and ping-replying. Section 4 has what this does and does not mean for +Seasons. + +Be precise about what this document claims. It explains, in instructions, why +`IS_TRADING_ENABLED` publishes 0. It does **not** prove that flipping it opens the +Transfer Market screen. That link is inference from the published input set plus a +same-mechanism control, and section 2 grades it honestly. + +--- + +## 2. Why the market refuses + +### The refusal is client-side UI logic, and CardsDLL only supplies its inputs + +No CardsDLL code path raises the caption. The full text "The Transfer Market is +currently unavailable. Please try again later." exists in memory only as EA string +objects in the Wine heap, preceded by a three-`u16` header and followed by `0xcd` +fill, with no adjacent identifier and no loc key. `MARKET_UNAVAILABLE`, +`TRANSFERMARKET` and `TRANSFER_HUB` return zero hits across all readable memory. +The `FIFA17.exe` image contains no FUT market identifier at all: every exe-only +`TRANSFER_*` string is Career Mode (`ScreenControllerTransferHistory::TransferList`, +`cm_ap_addtotransferlist`), and the one "currently unavailable" hit is the CRT +debug string next to `Callstack:`. The `CARDS_CB_ERR_*` lead is dead as well: all +forty-plus of those literals have exactly one xref each, all inside +`FUN_1800d7b70`, which is a single enum-to-string table. They are formatting +names, not raise sites. Note the brief's addresses for them were file offsets; +the VAs are `0x1802142d0` and `0x180214360`. + +What CardsDLL does is publish a small named context map to the UI script layer. +`FUN_18006cc60` is a straight-line publisher over a contiguous `.rdata` name run +at `0x1801fc118..0x1801fc228`, and the complete family is ten names: +`IS_TRADING_ENABLED`, `IS_STORE_ENABLED`, `IS_FRIENDLY_SEASON_ENABLED`, +`IS_TOURNAMENT_QUIT_ENABLED`, `IS_PROCESSING_STATE_ENABLED`, +`IS_RETURNING_USER_REWARDS_SCREEN_ENABLED`, `IS_STORY_MODE_REWARD_ENABLED`, +`IS_DRAFT_MODE_ENABLED`, `IS_USER_CHAT_RESTRICTED`, `IS_USER_UGM_RESTRICTED`. +`FUN_18000d550` publishes `TRADE_PILE_SIZE` and `FUN_1800377c0` publishes +`NUM_CURRENT_AUCTIONS`, `NUM_MAX_AUCTIONS` and `IS_MAX_AUCTIONS`. There is no +online or connected name in the published surface. + +The strongest evidence that the market screen reads `IS_TRADING_ENABLED` is a +control, not an assertion. In the client's interned-symbol heap the strings +`FUTGameHubDataHelper::_GotoGoldPlayerSearch()`, `IS_TRADING_ENABLED`, +`_startAuctionSearch` and `FUT_TRADING_DISABLED` are contiguous, and the same +neighbourhood carries the exact structural twin `IS_STORE_ENABLED` / +`FUT_STORE_DISABLED` around the `_GotoStore_*` handlers. `IS_STORE_ENABLED` +evaluates to 1 live and the store screen works this session (13 requests to +`/store` and 24 to `/store/purchasegroup/all`). `IS_TRADING_ENABLED` evaluates to +0 and the market refuses without asking. Same publisher, same script-symbol +pattern, one on and working, one off and refusing. + +One agent called that a constant pool; it is not. Each string is a separately +allocated interned symbol with a 0x20-byte header, so the adjacency is allocation +order while one script chunk loaded. That is weaker than a constant pool and +still meaningful, but it is not control flow. Nobody disassembled the script +bytecode. Grade the link **MEDIUM-HIGH**, and note the falsifier is now cheap: if +the byte reads 1 and the screen still refuses, the exe-side predicate has a term +we have not enumerated. + +### The condition, as a boolean expression over named inputs + +For the per-card menu entry, this is exact. `FUN_1801a7260` is 190 bytes, +disassembled in full from `.pdata`-derived bounds, and it returns 1 only if: + +``` +TO_TRADE_PILE = + model+0x1fd2e != 0 (IS_TRADING_ENABLED) + AND item+0x49 != 0 (tradeable; untradeable is stored INVERTED) + AND ( is_player (item+8 != 0 AND item+0x4c == 1) + ? item+0x145 == 0 AND FUN_1801a8900(item) == 0 (no injury; stats slots 4 and 5 both < 1) + : item+0x50 NOT IN {0xe7, 0xe8, 0xe9, 0xec} ) (subtype blacklist for non-players) +``` + +Polarity is proven by the consumer: `FUN_1800e2a40` stores the return at +`param_3[3]` and `FUN_18003e370` publishes that byte array to the UI in index +order as `(DISCARD, MODIFY, TO_ACTIVE_SQUAD, TO_TRADE_PILE, TO_STICKER_BOOK, +MAY_BE_REMOVED, QUICK_SEARCH, DREAM_REPLACE)`. Index 3 is `TO_TRADE_PILE` and 0 +means greyed. + +Two corrections to how this predicate has been described. The item is +`[rsi+0x18]`, not `rsi`, and `FUN_1801a8900` is called with `rsi` (the outer +wrapper), not the item pointer. Neither changes the logic. The brief's claim that +"both documented gates are satisfied so there must be a third condition" was +based on the gate byte being 1. It is 0. There is no third condition to hunt, and +`FUN_1801a7260` contains no pile term at all, so "send it to the club first" is +not the missing step either. + +For the market screen itself the expression is not known, only its input set: + +``` +market_screen_opens = f( IS_TRADING_ENABLED, [0 live] + IS_STORE_ENABLED, [1 live] + TRADE_PILE_SIZE, [0 live] + NUM_MAX_AUCTIONS, [-1 live] + NUM_CURRENT_AUCTIONS, [0 live] + IS_MAX_AUCTIONS, [0 live, computed] + ...exe-internal terms unknown... ) +``` + +Of the CardsDLL-supplied terms, exactly one is off and exactly one is zero: +trading and the pile size. + +### Every input, classified + +| Input | Live | Who owns it | Can we set it | +|---|---|---|---| +| `IS_TRADING_ENABLED` = `model+0x1fd2e` | 0 | settings struct `+0x28`, then zeroed by our `feature.trade` | **YES.** Remove `userInfo.feature`. Constructor default is already 1 | +| `TRADE_PILE_SIZE` = `model+0x1fd1c` | 0 | `userMassInfo.pileSizeClientData` entry `key: 2` | **YES.** One member we have never sent | +| watch-list size = `model+0x1fd20` | 0 | same member, `key: 4` | **YES** | +| `item+0x49` (tradeable) | 1 | our `untradeable: false`, stored inverted | **YES**, already correct with `FUT_TRADEABLE=1` | +| `item+0x4c`, `item+0x50`, `item+0x145`, stats slots 4/5 | not measured per-card | fields of the item record we serve | **YES**, but we do not know today whether any live card fails them | +| `NUM_MAX_AUCTIONS` = `model+0x54f8+0x30` | -1 | `GET /tradePile/counts`, `maxAuctionsAllowed` | **YES.** -1 is the unlimited sentinel, so it is not blocking | +| `IS_MAX_AUCTIONS` | 0 (not maxed) | computed as `!(max < 0 or cur < max)` | **YES** indirectly. Not currently a gate | +| `IS_STORE_ENABLED` | 1 | settings `+0x2c` / `+0x30` by region | **YES**, and it is already right. Do not disturb it | +| the exe-side script predicate | unknown | `FIFA17.exe` UI script | **NO** | +| POW / EASFC reachability | unreachable | `FUT_POW` unset in the responder | **YES**, but CardsDLL cannot read it, so it is not an input here | +| Blaze `IS_*` config rows | delivered | Blaze client-config store | **NO EFFECT.** Output names, never read | + +### The single fact that determines the ranking + +**The client fetches `/settings` and `/userMassInfo` exactly once each, at UT +entry, and never again.** Two agents disagreed about this, one citing repeated +polls at 12:56, 13:01, 13:03, 13:34 and 13:39. I checked the log myself and every +one of those carries `User-Agent: curl/8.21.0`; they are our own agents' probe +scripts. Filtered to the game's `ProtoHttp` user agent, pid 260692 issued ten +requests in its whole 79-minute lifetime, all between 12:40:05 and 12:40:16, in a +fixed bootstrap: + +``` +accountinfo -> POST /ut/auth -> GET /settings -> phishing x3 -> match/reset + -> GET /userMassInfo -> store/transaction/0 -> GET /hub +``` + +and nothing since. The same bootstrap appears at 10:41:34 and at 10:33 on earlier +pids, always within about 35 seconds of process start. So a utas-only restart +preserves the Blaze session but **buys no test**, because the body is never +re-read. The unit of experimental cost is a **relaunch**, and the advice from two +agents to "do the trade fix first, rank the capacity fix second" is wrong: two +sequenced fixes cost two relaunches for readouts that do not confound each other. + +Note also the ordering that falls out of the same log: `/settings` at 12:40:10 +precedes `/userMassInfo` at 12:40:16. Both apply the gate struct, so the last one +wins, and it is massinfo, which is the one carrying the kill switch. That is +consistent with the live 0 and it is why fixing only the standalone `/settings` +body would be overwritten six seconds later. + +### Ranked experiments + +**E0. Ask the user to back out to the main menu and re-enter Ultimate Team while +watching `/tmp/utas_server.log`.** Cost: zero, no restart of anything. Mechanism: +if UT re-entry re-runs the bootstrap, a `ProtoHttp`-UA `GET /user/accountinfo` +and `GET /userMassInfo` will appear. Falsifier: no new `ProtoHttp` line within a +minute means the bootstrap is process-scoped. This is ranked first because it is +free and because a positive result collapses the cost of every experiment below +from a relaunch to a menu round-trip, which would change the whole plan. Nobody +has ever tested it. + +**E1. The bundle. One utas edit, one relaunch, four independent readouts.** +Apply P1 through P5 from section 6 together: drop `userInfo.feature`, set +`FUT_SETTINGS=keep` with the corrected probe, serve `pileSizeClientData` keys 2 +and 4 with distinctive values, serve `GET /tradePile/counts`, and re-path the +market routes with correctly shaped empty bodies. Mechanism: the four changes +write four different destinations and are read out separately, so bundling costs +no diagnostic power. + +| Readout | Where | Expected if the mechanism holds | +|---|---|---| +| `model+0x1fd2e` via `vt+0x270` | `tools/gate_byte_probe.py` | 1 | +| `model+0x1fd28` via the applier's `+0x1c` source | live probe | 77, not 60. This is the control that proves the configs array was parsed at all | +| `model+0x1fd1c` via `vt+0xa58` | live probe | 77 | +| the hub header | on screen | `0/77` in white, not `0/0` in red | +| "Place on Transfer List" | per-card menu | no longer greyed | +| Transfers screen | on screen | opens, or refuses with a different story | + +Falsifiers, in order of what they would tell us. If `model+0x1fd28` stays 60, the +configs array never reached the applier and `response+0x1c` is the real blocker, +which is the one thing three agents could not rule out. If `+0x1fd28` becomes 77 +but `+0x1fd2e` stays 0, the `feature.trade` chain is wrong despite six confirmed +hops. If `+0x1fd2e` becomes 1 and the menu ungreys but the market screen still +refuses, the exe-side predicate has a term outside the published map, and section +4's model of how the UI is gated is wrong. If `+0x1fd1c` does not become 77, the +`key: 2` mapping is wrong. Use 77 and 33 rather than 100 and 50: 100 is the +stock-looking transfer-list size and reading `x/100` in game would prove nothing. + +**E2. Only if the market screen opens: exercise the real routes.** Cost: no +restart, the client drives it. Mechanism: the screen will issue +`GET ut/game/fifa17/transfermarket?type=..&start=..&num=..` and we log the real +`type` value, which is the one piece of the search spec that cannot be recovered +statically. Falsifier: if the client requests a path outside the twelve in +section 5, the route-table reading is wrong. The highest freeze risk in the whole +project is here, and it is the `marketdata/pricelimits` response, which must be a +**bare top-level JSON array**. + +**E3. Only if the menu is still greyed after `+0x1fd2e` reads 1: measure the item +terms.** Cost: a live probe, no relaunch. Mechanism: read `item+0x4c`, `+0x50`, +`+0x145` and stats slots 4 and 5 on a resident purchased-pile record and evaluate +the second and third terms of `FUN_1801a7260` by hand. Falsifier: if all four +terms pass and the entry is still greyed, the Flash layer has its own gate and +CardsDLL is no longer the right place to look. + +**E4. Seasons only, and it costs a Blaze restart: set `FUT_POW=1`.** Ranked last +because it drops the session and because section 4 argues it cannot affect the +market. Mechanism: `OSDK_POW` is an empty list unless `FUT_POW` is set, so +`FIFA_POW_URL` and `POW_IS_ON` have never been served and powdll is falling back +to unresolvable EA endpoints. Falsifier: a request line appearing in +`/tmp/pow_server.log`, and the `PRESS q TO RE-CONNECT` banner clearing. + +**Not ranked, and specifically do not run.** `FUT_PILESIZES=probe`: the key enum +is decoded, the probe would cost a relaunch to rediscover it, and its fallback +writes the club count into the transfer-list capacity. Serving +`maximumTradePileSize` as a capacity control: it lands in settings field `[0]`, +feeds a slot vector at `model+0x15f00`, and the pile applier runs afterwards on +the same vector, so it is inert twice over. Adding `TRADE_PILE_SIZE` or +`IS_TRADING_ENABLED` to `FUT_RS4_CONFIG`: both are output names, and it would +cost a Blaze restart for nothing. + +--- + +## 3. The gate-byte block + +Raw, read live at `model = 0xb80c84c0` while writing this, with all three slide +controls passing: + +``` ++0x1fd10 00000000 00000000 01000000 00000000 ++0x1fd20 00000000 00000000 3c000000 01010001 ++0x1fd30 01010101 01010101 01010101 01010101 ++0x1fd40 00000000 01010100 01960000 00000000 ++0x1fd50 00000000 e0010000 00000000 00000000 ++0x1fd60 c04e0000 00000000 +``` + +Every byte in `+0x1fd14..0x1fd48` and the dwords at `+0x1fd4c`, `+0x1fd54`, +`+0x1fd58`, `+0x1fd5c`, `+0x1fd60` are written by `FUN_18011dc50` and by nothing +else. That exclusivity is established form-independently: a scan of all 1,982,464 +bytes of `.text` for the raw four-byte little-endian displacement, which catches +`mov`, `movzx`, `cmp`, `lea` and `setcc` in every encoding, gives exactly two +sites per field, one accessor stub and one write. A second scan for any `imm32` in +`0x1fc80..0x1fe00` rules out a `lea`-a-block-base bulk writer, with `0x15f00` +found correctly as the same-form control. A third scan over all 451 MB of live +executable memory, including the 288 MB unpacked `FIFA17.exe` body that the +Denuvo-packed on-disk file cannot answer for, finds no writer outside CardsDLL. + +The applier is `byte = (struct_field == 1)` in every case. The struct is +initialised by `FUN_18014e320`, and with our empty configs array the live block +**is** the constructor default set, with exactly one exception. + +| Offset | Live | Settings atom | Struct off | Published as / gates | +|---|---|---|---|---| +| `+0x1fd14` | 0 | `clubCreateThreshold` 0x8c | `+0x04` | club-creation threshold | +| `+0x1fd18` | 1 | `tokenRedemptionEnabled` | `+0x08` | token redemption | +| `+0x1fd1c` | **0** | not settings. `pileSizeClientData` key 2 | `resp+0xd0` | **`TRADE_PILE_SIZE`, the red 0/0** | +| `+0x1fd20` | **0** | not settings. `pileSizeClientData` key 4 | `resp+0xd4` | watch-list size (by position) | +| `+0x1fd28` | 60 | `squadBuildingSetsGracePeriodMinutes` 0x2d0 | `+0x1c` | grace period. **Use as the parse control** | +| `+0x1fd2c` | 1 | `allowUntradeableForSquadBuildingSets` | `+0x18` | SBC untradeables | +| `+0x1fd2e` | **0** | **`tradingEnabled` 0x336** | `+0x28` | **`IS_TRADING_ENABLED`** | +| `+0x1fd2f` | 1 | `storeEnabled` 0x2f1 | `+0x2c` | `IS_STORE_ENABLED` (non-JP) | +| `+0x1fd30` | 1 | `storeEnabled_JP` 0x2f2 | `+0x30` | `IS_STORE_ENABLED` (region 4) | +| `+0x1fd31..39` | 1 | coin / cardpack / points store family | `+0x34..0x54` | store sub-features | +| `+0x1fd3a` | 1 | `friendlySeasonsEnabled` 0x133 | `+0x58` | `IS_FRIENDLY_SEASON_ENABLED` | +| `+0x1fd3b` | 1 | `tournamentQuitEnabled` 0x32d | `+0x80` | `IS_TOURNAMENT_QUIT_ENABLED` | +| `+0x1fd3c` | 1 | `processingStateEnabled` 0x257 | `+0x84` | `IS_PROCESSING_STATE_ENABLED` | +| `+0x1fd3d` | 1 | `enableDraftMode` 0xf9 | `+0x5c` | `IS_DRAFT_MODE_ENABLED` | +| `+0x1fd3e` | 1 | `enableSinglePlayerDraftMode` / `enableOfflineDraftMode` (shared arm) | `+0x60` | offline draft | +| `+0x1fd3f` | 1 | `storyModeRewardEnabled` 0x2f3 | `+0x7c` | `IS_STORY_MODE_REWARD_ENABLED` | +| `+0x1fd40` | **0** | `returningUserRewardsScreenEnabled` 0x28a | `+0x64` | `IS_RETURNING_USER_REWARDS_SCREEN_ENABLED` | +| `+0x1fd41` | **0** | unnamed arm | `+0x68` | unknown | +| `+0x1fd42` | **0** | `allowGracePeriodForSquadBuildingSets` 0x18 | `+0x6c` | SBC grace period | +| `+0x1fd43` | **0** | `enableLiveMessaging` 0xfb | `+0x88` | live messaging | +| `+0x1fd44` | 1 | `enableObjectives` (shared, CLEAR-only arm) | `+0x70` | objectives | +| `+0x1fd45` | 1 | `packOpeningAnimationEnabled` | `+0x74` | pack animation | +| `+0x1fd46` | 1 | `couchPlayEnabled` | `+0x78` | couch play | +| `+0x1fd47` | **0** | `enableFloatPointSquadRating` 0x30f | `+0x8c` | float squad rating | +| `+0x1fd48` | 1 | `enableLegacyYearInfoInItemResourceId` | `+0x90` | legacy resourceId years | +| `+0x1fd49` | 150 | **not applier-written** | n/a | owner unknown | +| `+0x1fd4c` | **0** | `itemDbVersion` 0x16c | `+0x0c` | item DB version | +| `+0x1fd54` | 480 | tunable | `+0x10` | ctor `0x1e0` | +| `+0x1fd58` | **0** | `numEndMatchRetriesAllowed` 0x1de | `+0x14` | end-match retries | +| `+0x1fd5c` | **0** | `constrainGracePeriod` 0xa3 | `+0x24` | ctor never writes `+0x24`; this 0 is allocation, not a default | +| `+0x1fd60` | 20160 | tunable | `+0x20` | ctor `0x4ec0` | + +### Which zeros are worth setting, and which are dangerous + +The brief asked which zero bytes are candidate blockers and stated we have a +proven way to set them via the Blaze store. That premise is false, as established +above, so the question becomes: which zeros are worth setting via the configs +array. + +**Worth setting: exactly one, and it is not even a config row.** `+0x1fd2e` is +the only zero in the block that is a known gate on a broken feature, and the way +to set it is to stop cancelling it, not to send a row. Its constructor default is +already 1. + +**Worth setting, but not through settings:** `+0x1fd1c` and `+0x1fd20`, via +`pileSizeClientData`. These are the red `0/0`. + +**Leave alone.** `+0x1fd40`, `+0x1fd41`, `+0x1fd42`, `+0x1fd43`, `+0x1fd47`, +`+0x1fd58`, `+0x1fd5c` are all zeros the client chose. None gates a feature we are +trying to open, and `returningUserRewardsScreenEnabled` in particular would put a +rewards screen in front of the user at boot. `+0x1fd14` and `+0x1fd4c` are +thresholds and versions, not gates. + +**Actively dangerous, and each for a reason established in code.** +`enableObjectives` and `enableObjectivesAsManagerTasks` share an arm that can only +*clear* the field (`if value == 0 then field = 0`), so 1 is a no-op and 0 turns +objectives off. `checkServerDbVersion` makes the applier go parse a string config +into `model+0x1fd50` and touches the DB-version path. `clientKeepAliveResetTimeoutSec` +and `getOperationTimeoutSec` do not set a field at all, they reprogram client +timers with `value * 1000`. `enableSquadBuildingSetsFeature` is a real atom with no +arm and does nothing. The existing comment block in `utas_server.py` gets all +five of these right and should be kept. + +### One safety question, settled + +`utas_server.py`'s own comment says that populating the configs array is "what +makes the applier run" and that omitting a flag "could turn the working store +off." Both halves are wrong, and the second one is the reason the flag has never +been flipped. The applier already runs on every session with `configs: []`. Every +flag we do not name keeps its constructor default, and the constructor sets +`storeEnabled` and `storeEnabled_JP` to 1 at `0x18014e368` and `0x18014e36f`. The +live block **is** the default set, so re-asserting the defaults changes nothing +and overriding one flag changes one byte. + +One verifier graded this a MEDIUM risk and recommended sending the full enable +set. It reached that grade by inferring "the applier never ran" from the live 1s, +which is refuted by `+0x1fd28 = 60`, `+0x1fd54 = 480` and `+0x1fd60 = 20160` +matching the constructor's `0x3c`, `0x1e0` and `0x4ec0` at three unrelated offsets +with sole writers. Its own gap list concedes it did not read the constructor. I +would bet on safe. That said, `_SETTINGS_KEEP` re-asserts the store flags at zero +cost and there is no reason to argue about it, so section 6 keeps them. + +--- + +## 4. Is there a shared root cause with the Seasons refusal? + +**No, and the evidence is a measurement rather than an argument.** Seasons and +the market both refuse with zero requests, which is a shared *shape*, and that +shape is what made a common cause attractive. But the gate bytes separate them +cleanly: `IS_FRIENDLY_SEASON_ENABLED` reads **1** live, in the same walk, off the +same object, with the same instruction form as the trading gate that reads 0. I +measured `model+0x1fd3a = 1` again while writing this. Seasons does not refuse +because its published gate is false. Its gate is true. The market's is false. +Whatever is stopping Seasons is not in the CardsDLL published context map, because +every name in that map either reads 1 or is not a Seasons term. + +This also corrects `docs/plan-2026-08-05-settings-gate.md`, which already carries +the correction but is worth restating here: that document's original claim that +`IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` "have never been set to +true by anything" was wrong, and wrong for the same reason the brief was wrong +about `/settings` being dead. The applier runs, the struct defaults those fields +to 1, and the bytes have been 1 all along. + +What the two refusals do share is a mechanism class, and if E1 works that class +becomes proven rather than hypothesised: a UI script in `FIFA17.exe` reads a named +boolean out of a context map that CardsDLL publishes, and refuses locally without +a network call when it is false. Confirming that model on the market is worth more +than the market itself, because it tells us exactly where to look for Seasons: +Seasons' term is **not** in CardsDLL's published surface, so it is either +exe-internal state or another DLL's. The complete CardsDLL surface is now +enumerated (ten booleans, `TRADE_PILE_SIZE`, and the auction triple), so this is +an exhaustion argument, not a guess. + +The best remaining Seasons candidate is POW/EASFC, and it is a genuinely different +subsystem rather than a shared root. `PRESS q TO RE-CONNECT` is produced by +`powdll_Win64_retail.dll`: `EASFCWidget::EnableButtonReconnect`, +`EASFCWidget::ReconnectToServers`, `POWService::PowReconnect`, +`TXT_EASFC_RECONNECT_PROMPT`, `POW_RECONNECT_TIMER_MS`. It is truthful rather than +cosmetic, because `blaze_responder_v3b.py` sets `OSDK_POW = []` unless `FUT_POW` +is set, `FUT_POW` is not in the running responder's environment, no +`FIFA_POW_URL` or `POW_IS_ON` was ever served, the client's live merged config +blob contains zero POW keys, and `/tmp/pow_server.log` has no request lines. So +powdll is falling back to `https://fifa17.service.easports.com/...`, which does +not resolve here. Seasons is an online mode and EASFC is the online service +layer, so a Seasons refusal that follows from POW being unreachable is coherent. + +The market cannot be affected by any of that. CardsDLL's import directory names +exactly four DLLs and powdll is not among them, so it cannot call into powdll +directly. Its only POW-shaped literals are three `pow/imgAssets/store_*.dds` +paths, two coin-boost reward names, and `%s/pow/mm/`, whose single reference sits +in the UTAS base-URL assembly cluster next to `FUT_RS4_APIURL_%s` and `game/%s`. +There is no EASFC status read anywhere in the FUT layer. + +Honest limits on this section. The Seasons caption was never located: "Ultimate +Team serv" has zero occurrences in the whole process, ASCII or UTF-16, so the +answer here is by elimination on the market side rather than by finding and +diffing the Seasons predicate. To do it properly the user has to reproduce the +Seasons refusal and leave it on screen, at which point the caption can be located +and its interned-symbol neighbours read exactly the way the market's were. That is +one relaunch and it is the cheapest path to the project's biggest open problem. + +--- + +## 5. The market wire protocol + +`{ns}` is `game/fifa17`. Everything below is from the on-disk PE. The route table +at `.rdata 0x18021df80` walks to exactly 45 `{char*, char*}` rows and stops +cleanly; the action table at `.data 0x1802caa20` walks to exactly 125 rows of +`0x30`, with the route index at `+0x08`. That index reading is controlled: +`ClubSearch`, `ClubStats`, `StaffStats` and `ConsumablesSearch` all carry 3 and +route 3 is `ut/%s/club`; `DreamSquadSearch` carries 4 and route 4 is `ut/%s/defid`. + +### The twelve operations + +| Action | Path | Request body | Response deser | Grade | +|---|---|---|---|---| +| `ISSEARCH` | `GET ut/{ns}/transfermarket?type=%s&start=%d&num=%d` + filters | none | `0x180163420` | CONFIRMED | +| `ISOFFERTRADE` | `POST ut/{ns}/trade/{tradeId}/offer` | `{"bid":N}` | `0x180165410` | CONFIRMED | +| `ISSTART` | `POST ut/{ns}/auctionhouse` | `{"itemData":{"id":N},"startingBid":N,"buyNowPrice":N,"duration":N}` | `0x180165df0` | CONFIRMED | +| `RELISTALL` | `PUT ut/{ns}/auctionhouse/relist` | none | `0x1801642c0` (no-op) | CONFIRMED | +| `ISREMOVEWATCH` | `DELETE ut/delete/{ns}/watchList?tradeId=a,b,c` or `.../watchList/expired` | none | `0x1801642c0` | CONFIRMED | +| `ISWATCHTRADE` | `PUT ut/{ns}/watchList?tradeId=N` | `{"auctionInfo":[{"id":N}]}` | `0x1801642c0` | CONFIRMED | +| `ISWATCHLIST` | `GET ut/{ns}/watchList?offset=%d&count=%d` | none | `0x180166240` | CONFIRMED | +| `ISVIEWTRADE` | `GET ut/{ns}/trade/status?tradeIds=a,b,c` | none | `0x1801644d0` | CONFIRMED | +| `GETTRADEPILE` | `GET ut/{ns}/tradePile` | none | `0x180170810` | **CONFIRMED ON THE WIRE** | +| `GETAUCTIONCOUNT` | `GET ut/{ns}/tradePile/counts` | none | `0x180163770` | CONFIRMED | +| `ISREMOVETRADE` | `DELETE ut/delete/{ns}/trade/{tradeId}` or `.../trade/sold` | none | `0x1801642c0` | CONFIRMED | +| `GETSUGGESTEDPRICING` | `GET ut/{ns}/marketdata/pricelimits?defId=a,b,c` | none | `0x180163ee0` | CONFIRMED | + +`GETTRADEPILE` is the only one the game has actually requested (10:34:24 and +10:42:35, `ProtoHttp` UA). Everything else rests on the two tables plus the +per-operation URL builders, located by rip-relative `lea` xrefs to the literal +sitting immediately after each `RS4:Fut...ServerResponse` name in `.rdata`. + +Two corrections to what has been written about these before. `/auctionhouse` **is** +a real client path, it is `ISStart`'s and `RelistAll`'s; what is wrong in +`utas_server.py` is using it for *search*. And several deserializer VAs in +`docs/ENDPOINT_MAP.md` point at constructors, factories or completion handlers +rather than deserializers; the corrected set is in the table above, resolved by +`RS4` name to installed vtable to slot `+0x08`, with `FutISSearch` and +`FutGetTradePile` as the control pair that had to come back `0x180163420` and +`0x180170810` and did. + +**HTTP verbs are not statically recoverable.** The action table has no verb field +and the handler table's first slot shows no verb pattern. The only structural +signal is that deletions are encoded as a `ut/delete/...` path prefix rather than +a DELETE method, and that the three ops with body serializers must be POST or PUT. +Make the emulator **method-agnostic and dispatch on path**. That removes the risk +entirely and costs nothing. + +### `ISSearch` filters + +Appended only when set, in emission order, all from `FUN_180162c90`: +`&maskedDefId=%d &definitionId=%d &micr=%d ¯=%d &minb=%d &maxb=%d &pos=%s +&zone=%s &pos=%s &form=%s &playStyle=%d &lev=%s &rare=SP &nat=%d &leag=%d +&team=%d &amount=%d &cardsubtype=%d &cat=%s`. Note the two different `pos` +enums. + +Vocabularies, all NULL-terminated `{const char*, int}` tables: +`type` (0x180229c30) `any=-1 player=1 staff=2 clubInfo=3 training=4 development=5 +stadium=6 ball=7`; `zone` (0x1802296e0) `goalKeeper=0 defense=1 midfield=2 +attacker=3`; `lev` (0x180229a60) `any=0 bronze=1 silver=2 gold=3`; `pos` +(0x1802295c0) `GK=0 RWB=2 RB=3 CB=5 LB=7 LWB=8 CDM=10 RM=12 CM=14 LM=16 CAM=18 +RF=20 CF=21 LF=22 RW=23 ST=25 LW=27`; the second `pos` (0x180229730) is a +20-entry dual-position table `LWB-LB=0 ... ST-CF=19`; `cat` (0x180229ab0) 23 +entries from `any=-1` through `ball=22`; `form` (0x180229880) 26 formation codes. + +Paging: `start` is request field `0x68` and `num` is field `0x6c` **plus one**. +This was graded MEDIUM by the agent that found it, on the grounds that the `+1` +rested on Ghidra's vararg slot ordering. It is upgraded to CONFIRMED by reading +the stores directly: `mov ebx,[rcx+0x28]; inc ebx` at `0x180162cc0`, then at the +`sprintf` the two stack varargs are `mov [rsp+0x20],eax` with `eax = [rsi+0x68]` +and `mov [rsp+0x28],ebx`. In the Win64 layout `[rsp+0x20]` is the first `%d` and +`[rsp+0x28]` is the second. The client asks for one more row than it will show, to +detect a next page. **Return at most `num` rows and let the client infer the end +from a short page; do not pad.** + +### The `auctionInfo` record + +Twelve fields, all optional, deserializer `0x18013e410`. + +| Atom | Key | Type | Note | +|---|---|---|---| +| 0x57 | `bidState` | string enum | see below | +| 0x65 | `buyNowPrice` | int32 | | +| 0xc1 | `currentBid` | int32 | | +| 0x116 | `expires` | int **64-bit** | SECONDS REMAINING, not epoch | +| 0x16b | `itemData` | **OBJECT** | the only freeze risk in the record | +| 0x2b6 | `sellerEstablished` | int32 | | +| 0x2b7 | `sellerName` | string | bounded copy, max 30 chars | +| 0x2e6 | `startingBid` | int32 | | +| 0x2f4 | `coinsProcessed` | int truncated to u8 | | +| 0x331 | `tradeId` | int **64-bit** | the client-side primary key | +| 0x335 | `tradeState` | string enum | see below | +| 0x380 | `watched` | bool | | + +Everything else falls through to value-SKIP `0x180135ff0`. After the field loop +the deserializer calls `model->vt+0xa00(ctx, tradeId)` to find an existing record +and re-parents the parsed `itemData` onto it, so **`tradeId` must be stable +across `/transfermarket`, `/tradePile`, `/watchList` and `/trade/status`** or the +client will hold duplicate ghost auctions. Allocate from a persisted monotonic +counter, never from an array index. + +A methodological note worth adding to the absence-trap list: this deserializer +dispatches through a **compare tree of mixed `<` and `==` tests**, not a ladder +and not a switch. A first mechanical extraction of it found only 10 of the 12 +atoms for exactly that reason. The enumerated dispatch forms are now: `== 0xNN`, +`!= 0xNN`, `switch { case 0xNN }`, sub/dec ladders, jump tables, and compare +trees. + +### The shared IS-list body + +Deserializer `0x18013e7f0`, used by `ISSearch`, `ISWatchList` and `GetTradePile`: +`auctionInfo` (0x35, **array**), `credits` (0xc0, int), `duplicateItemIdList` +(0xec, **array of objects**, `[]` is safe and recommended), `total` (0x325, int). + +### Per-response minimal bodies + +``` +ISSearch {"auctionInfo":[...], "credits":N, "total":N, "duplicateItemIdList":[]} +ISWatchList same shape +GetTradePile same shape +ISViewTrade {"auctionInfo":[...], "credits":N} +ISOfferTrade {"auctionInfo":[...], "credits":N, "errorState":"..."} (0x10d errorState) +ISStart {"id": } (0x15c only, 64-bit) +GetAuctionCount {"count":N,"maxAuctionsAllowed":N,"offered":N,"selling":N,"sold":N} +GetSuggestedPricing [ {"defId":N,"minPrice":N,"maxPrice":N}, ... ] BARE TOP-LEVEL ARRAY +RelistAll / ISWatchTrade / ISRemoveTrade / ISRemoveWatch {} parse nothing +``` + +`GetAuctionCount` stores `count`, `offered`, `selling` and `sold` as `u16` and +`maxAuctionsAllowed` as `i32` pre-set to -1. + +Two of these are type-fidelity hazards and both are new. **`GetSuggestedPricing` +is a bare top-level array**, which `ENDPOINT_MAP.md` currently specifies as an +object. `FUN_180163ee0`'s outer loop terminates on token `0xd` (END_ARRAY), and +the same-form control `FutISStart` at `0x180165df0` is structurally identical but +terminates on `10` (END_OBJECT), so the distinction is real and not an artefact of +reading the loop. Serving an object there is exactly the shape mismatch that hard +freezes the tokenizer at `0x1801c7f1a`. Conversely, the four ack responses install +vtable `0x18022cb58` whose deserializer is literally `return 1;`, so **any** body +is safe for them. + +### Lifecycle + +`tradeState` decodes through a table walk at `0x180229e40`: `active=1 inactive=2 +expired=3 closed=4`, anything else `-1`. `bidState` decodes through a **strcmp +ladder**, not a table: `none=0 outbid=1 highest=2 buyNow=3`, anything else +silently `0`. Those are the complete vocabularies; there is no `won`, `lost` or +`sold`. Reading each in its own dispatch form rather than grepping one pattern is +what made this reliable. + +The entire market UI presentation is a pure function of those two strings: +`flagA = bidState in {highest, buyNow}` and `flagB = (tradeState == closed) ? +bidState != none : bidState in {outbid, buyNow}`. + +``` +listed by user active / none currentBid 0, expires > 0 +outbid active / outbid +currently winning active / highest +bought now closed / buyNow +won at auction end closed / highest +lost at auction end closed / outbid +ran out unsold expired / none expires 0, relistable +sold, coins paid closed / highest coinsProcessed 1, clearable via DELETE .../trade/sold +``` + +Never invent a state string: an unrecognised `bidState` is swallowed as `none`, +which produces a plausible-looking but wrong UI. `inactive` decodes but no client +path treats it specially; do not emit it. + +### Errors + +`FUN_1801844c0` maps one transport status to an internal code: `200->0 204->99 +400->7 403->403 459->407 460->7 461->3 465->1 467->0x1f 470->5 475->4 476->8 +478->0x10 480->480 481->0x6b 483->0x6c 485->0x6d 489..492->identity 503->0x6f`, +the bucket `{401,402,404..417,462,463,466,468..474,477,479,500,501,502,504,505} +-> 998`, default `999`, and `-1 -> 0x68`. Note `500` lands in the generic bucket, +so a lazy 500 produces a generic failure message; pick a code from the table. + +`ISOfferTrade` overrides this at request vtable `0x180228ea0+0x10` +(`FUN_180165050`): when the code is exactly **461** it searches the response text +for five literals and sub-classifies. It is a **substring search** +(`FUN_180009ee0(begin, end, needle, "")`), not an equality test, so a JSON +envelope carrying the phrase works: + +``` +"You are not allowed to bid on this trade" -> 0x14 +"Auction state is invalid for bidding" -> 0x14 +"Incorrect parameters" -> 999 +"Permission Denied" -> 0x12 +"Watchlist is full" -> 0x21 +anything else -> 7 +``` + +The market error vocabulary present in `.rdata` is `NO_TRADE_EXISTS`, +`TRADE_CANCELED`, `TRADE_CLOSED`, `TRADE_YOUR_CARD`, `TRADE_INACTIVE`, +`TRADE_CARD_ALREADY_OFFERED`, `TRADE_MISMATCH`, `TRADE_LOCKED`, `WATCHLIST_FULL`, +`AUCTION_LIMIT_REACHED`. + +### Authority split + +**Server-authoritative, with no client fallback.** `credits`: every IS-list +response writes it straight into the global model via `vt+0x5b8`, so omitting it +leaves the balance stale and sending 0 visibly zeroes the player's coins. The +server owns the coin balance on **every** market response, not only on purchases. +`tradeId` allocation. `tradeState`, `bidState`, `currentBid`, `expires`, +`coinsProcessed`: the client derives its whole UI from these and never recomputes +them, so bid outcome, win/lose and expiry are entirely server decisions. The +auction cap: `maxAuctionsAllowed` and `selling` from `/tradePile/counts` are the +only inputs to `IS_MAX_AUCTIONS`, so the server decides whether the player may +list. Bid validation: no client-side minimum-increment check exists, rejection is +461 plus text. Price bands from `/marketdata/pricelimits`. + +**Client-side, do not implement.** The derived `flagA`/`flagB` presentation +booleans; the search-filter enum-to-string mapping; the `num = count + 1` paging +convention; the `duplicateItemIdList` id-rewrite merge. + +### What a single-player market must actually simulate + +Five stateful pieces and nothing else. A listing store keyed by a monotonic 64-bit +`tradeId` holding `{itemId, startingBid, buyNowPrice, duration, createdAt, state, +bidState, currentBid, sellerName}`. A clock: `expires = max(0, createdAt + +duration - now)`, flipping `active` to `expired` when unsold or `closed` plus +`coinsProcessed` when sold. That clock is the only simulation genuinely required, +because the client renders a live countdown and expects it to reach 0. A synthetic +seller pool for `/transfermarket`, generated from the card pool with a +rating-derived price and a `sellerName` of at most 30 characters: the search +endpoint *is* the other players. Coin arithmetic on buy and on sale settlement, +echoed as `credits` in every response. And `/tradePile/counts`. + +Stub outright: `/marketdata/pricelimits` (a static band per defId or a rating +formula), `/watchList` (an empty list is valid), the four ack bodies, and rival +bidder simulation. `bidState` going `none` to `buyNow` on purchase is a complete +and internally consistent story. + +--- + +## 6. Proposed patches + +None of these are applied. All are utas-only: **no Blaze restart, so the session +survives**. But per section 2 the client re-reads none of these bodies without a +UT bootstrap, so they land on the next relaunch (or on the next UT entry if E0 +comes back positive). + +### P1 -- `FUT_NO_TRADE_BAN`: stop switching trading off + +In `tools/utas_server.py`, near the other feature flags: + +```python +# FUT_NO_TRADE_BAN: omit userInfo.feature entirely. +# +# `"feature": {"trade": True}` is a TRADE BAN, not a trade grant. The massinfo +# userInfo deser (FUN_18013ec10) has one arm under feature(0x11c): atom 0x330 +# `trade`, read with the INT primitive 0x1801c79d0. Token 4 (JSON boolean) returns +# 0/1 from that getter (0x1801c7a38: xor eax,eax / cmp [rbx+0x108],al / setne al), +# so `true` arrives as 1 and passes `cmp eax,1` at 0x18013f032. It then does +# `mov [rdi+0xa4],al` -> response+0x17c. At the TOP-LEVEL END_OBJECT of the whole +# massinfo body, 0x180174f10 does `cmp byte [rsi+0x17c],0 / je / mov dword +# [rsi+0x50],0` -- forcing settings.tradingEnabled to 0. The applier FUN_18011dc50 +# then does `cmp [rbx+0x28],1 / sete al / mov [rdi+0x1fd2e],al`, and 0x1fd2e is +# IS_TRADING_ENABLED, the first term of the TO_TRADE_PILE predicate FUN_1801a7260. +# +# The settings-struct ctor FUN_18014e320 sets +0x28 to 1 at 0x18014e361, i.e. the +# client's own default for trading is ON. We are the only thing turning it off. +# +# The kill switch runs at END_OBJECT, AFTER every member including `settings`, so +# serving tradingEnabled:1 in configs CANNOT beat it. Removing the member is the +# only fix that works through /userMassInfo. +# +# Freeze risk: NONE. Removing a member cannot desync a reader; the 0x11c arm +# recognises only 0x330 and everything else in `feature` is already SKIP'd. The +# member has no other consumer: userInfo+0xa4 has exactly one writer in the whole +# 1945-byte body of FUN_18013ec10 (disp32 scan; 0xa4 has no disp8 encoding, so +# there is no alternative form to miss), and its initialiser FUN_18010ea80 sets +# the byte to 0. +_NO_TRADE_BAN = os.environ.get("FUT_NO_TRADE_BAN", "0") == "1" +``` + +and at line 255, inside the `_UI != "min"` block: + +```python + # "feature": {"trade": True}, <- REMOVED, see _NO_TRADE_BAN + **({} if _NO_TRADE_BAN else {"feature": {"trade": True}}), +``` + +Default off means today's behaviour, unchanged. + +### P2 -- correct the `/settings` probe control + +`_SETTINGS_PROBE` currently sends `maximumTradePileSize = 77` as a positive +control on the theory that transfer-list capacity is readable in game. It is not +that field. Atom `0x1c0` lands in settings field `[0]`, which the applier hands to +`FUN_18011f380(model+0x15f00, value)`. Worse, the pile applier `FUN_18011dbf0` +calls `FUN_18011f380` on the *same* sub-object afterwards (`vt+0x988` at +`0x180173f0b`, then `vt+0x998` at `0x18017405b`), and with `pileSizeClientData` +absent it passes 0, which takes the `requested <= current` path at `0x18011f48b` +and clears the container. So the control is inert twice over. + +```python +# The positive control. CORRECTED 2026-08-06: maximumTradePileSize was the WRONG +# control -- atom 0x1c0 lands in settings field [0], which the applier passes to +# FUN_18011f380(model+0x15f00), a slot vector, NOT model+0x1fd1c. And the pile +# applier resizes the same vector afterwards, so with pileSizeClientData absent it +# gets cleared to 0 regardless. The historical "we served 77 and nothing carried +# 77" measurement was aimed at a destination this field never reaches. +# +# squadBuildingSetsGracePeriodMinutes (atom 0x2d0) lands in settings field [7] +# (struct+0x1c), which the applier copies verbatim to model+0x1fd28. The ctor +# default is 0x3c (0x18014e353) and model+0x1fd28 reads exactly 60 live, so a 77 +# there is unambiguous and is readable by tools/gate_byte_probe.py without a UI. +# 77 on purpose: a number FUT would never pick by itself. +_SETTINGS_PROBE = (("squadBuildingSetsGracePeriodMinutes", 77),) +``` + +Also fix the block comment above `_SETTINGS_MODE`: "Populating the array is what +makes the applier run at all" is false. The applier runs on every response, +including with `configs: []`, which is why the live gate block matches the +constructor defaults exactly at 37 of 38 fields. Omitting a flag keeps its +constructor default, and `storeEnabled` and `storeEnabled_JP` default to 1 at +`0x18014e368` and `0x18014e36f`. `_SETTINGS_KEEP` is harmless belt-and-braces, not +a necessary guard. + +Freeze risk: none. `value` goes through `0x1801c79d0`, which coerces +int/float/bool/string to int64. Never put an object or array in `value`. + +### P3 -- `pileSizeClientData`: the real key enum + +```python +# pileSizeClientData -- parser FUN_18013adb0, called as FUN_18013adb0(reader, +# response+0xc8) from massinfo arm 0x227. +# +# CORRECTED 2026-08-06. The key enum IS recoverable and is now recovered. The +# parser has exactly TWO store arms and no default arm: +# 18013aeaf: cmp esi,0x2 / je -> mov [r14+0x8],eax -> response+0xd0 +# 18013aeb4: cmp esi,0x4 / jne -> mov [r14+0xc],eax -> response+0xd4 +# FUN_180173e00 copies response+0xc8..+0xd7 and calls model->vt+0x998 = +# FUN_18011dbf0, which does [model+0x1fd1c] = p+0x8 and [model+0x1fd20] = p+0xc. +# model+0x1fd1c is published by FUN_18000d550 as TRADE_PILE_SIZE -- the red +# "TRANSFER LIST 0/0". So key 2 is the TRADE PILE and key 4 is the WATCH LIST. +# +# There is NO club-pile key. Every key other than 2 and 4 is read and discarded, +# so FUT_PILE_KEY_CLUB does not exist and FUT_PILESIZES=probe must NOT be run -- +# it would cost a relaunch to rediscover a two-value enum and its 16-key fallback +# would write the club item count into the TRANSFER-LIST CAPACITY. +# +# Values must be POSITIVE: both key and value go through FUN_1800d7b30, which is +# `test rcx,rcx / jle -> return 0`, so -1 cannot mean unlimited here. +# +# Freeze risk: LOW. Documented member of the boot-critical massinfo parser, +# int-only, skip-safe on unrecognised fields. Instant fallback: FUT_MASSINFO=squad. +_PILESIZES = os.environ.get("FUT_PILESIZES", "") + +PILE_KEY_TRADEPILE = 2 +PILE_KEY_WATCHLIST = 4 + + +def pile_size_body(): + """massinfo.pileSizeClientData -- capacities, not counts.""" + if _PILESIZES == "probe": + # Distinctive values for the first run: 77 and 33 are numbers FUT would + # never pick. Read back at model+0x1fd1c (vt+0xa58) and +0x1fd20 (vt+0xa60). + return {"entries": [{"key": PILE_KEY_TRADEPILE, "value": 77}, + {"key": PILE_KEY_WATCHLIST, "value": 33}]} + return {"entries": [{"key": PILE_KEY_TRADEPILE, "value": 100}, + {"key": PILE_KEY_WATCHLIST, "value": 50}]} +``` + +The stock FIFA 17 values of 100 and 50 are convention, not derived from the +binary: nothing in CardsDLL carries a default and the response constructor zeroes +both at `0x180173a86`. + +### P4 -- `GET ut/{ns}/tradePile/counts` + +Never omit this route. If the object is never loaded, its loaded byte at `+0x28` +stays 0 and the completion handler `FUN_180163670` takes a different branch. + +```python +@route("GET", "/ut/game/fifa17/tradePile/counts") +def auction_counts(): + """GetAuctionCount, deser FUN_180163770 (vtable 0x180228638 slot +0x08). + Atoms: 0xbc count -> +0x34 (u16), 0x1bf maxAuctionsAllowed -> +0x30 (i32, + pre-set to -1), 0x1e5 offered -> +0x3a (u16), 0x2b8 selling -> +0x36 (u16), + 0x2c9 sold -> +0x38 (u16). + + These are the ONLY inputs to IS_MAX_AUCTIONS, published by FUN_1800377c0 as + !(max < 0 || cur < max) off the embedded sub-object at model+0x54f8 + (vt+0x130 = `lea rax,[rcx+0x54f8]; ret`). Live today max = -1 and selling = 0, + so IS_MAX_AUCTIONS is 0 and the auction cap is NOT currently blocking anything. + Keep maxAuctionsAllowed >= 1 and selling < it. + + Freeze risk: NONE. All five fields are scalars read with the int primitive.""" + return {"count": len(STORE.listings()), + "maxAuctionsAllowed": 100, + "offered": 0, + "selling": len(STORE.listings()), + "sold": 0} +``` + +### P5 -- `FUT_MARKET_ROUTES`: correctly shaped empty market responses + +Rationale: if E1 opens the Transfers screen, the client will immediately request +routes we do not serve. A 404 becomes transport status 404, which the mapper +buckets to 998 and produces a generic failure. That is survivable. A **wrongly +shaped** body is not: it desyncs the SAX reader and hard freezes at +`0x1801c7f1a`. Serving correct-shaped empty bodies is strictly safer than either +alternative, and it is what lets E2 observe the real `type` parameter. + +```python +# FUT_MARKET_ROUTES: serve the twelve real market paths with correctly shaped +# EMPTY bodies. Every existing market handler in this file is at a path the client +# never requests (route table .rdata 0x18021df80 x action table .data 0x1802caa20). +# Empty is deliberate: shape fidelity first, contents once the screen is proven to +# open. Note ut/delete/... is a PATH PREFIX in this client, not an HTTP method, and +# verbs are not statically recoverable -- dispatch on path only. +_MARKET_ROUTES = os.environ.get("FUT_MARKET_ROUTES", "0") == "1" + +_IS_LIST_EMPTY = {"auctionInfo": [], "credits": 0, + "total": 0, "duplicateItemIdList": []} +# ^ credits MUST be the real balance: atom 0xc0 in the shared IS-list body +# FUN_18013e7f0 writes it to the GLOBAL model via vt+0x5b8, so serving 0 here +# visibly zeroes the player's coins. Fill it from the profile before shipping. + +_MARKET_EMPTY = { + "/ut/game/fifa17/transfermarket": _IS_LIST_EMPTY, + "/ut/game/fifa17/tradePile": _IS_LIST_EMPTY, + "/ut/game/fifa17/watchList": _IS_LIST_EMPTY, + "/ut/game/fifa17/trade/status": {"auctionInfo": [], "credits": 0}, + "/ut/game/fifa17/auctionhouse/relist": {}, # deser is `return 1;` + "/ut/delete/game/fifa17/trade/sold": {}, # 0x1801642c0, parses nothing + "/ut/delete/game/fifa17/watchList/expired": {}, + # marketdata/pricelimits is a BARE TOP-LEVEL ARRAY, not an object. Its deser + # FUN_180163ee0 terminates its outer loop on token 0xd (END_ARRAY); the + # same-form control FutISStart 0x180165df0 terminates on 10 (END_OBJECT). + # Serving an object here is the exact shape mismatch that hard freezes at + # 0x1801c7f1a. One row per requested defId, parsed from the comma list. + "/ut/game/fifa17/marketdata/pricelimits": [], +} +``` + +`ISStart` and `ISOfferTrade` need real handlers rather than empty ones once +listing works, because `ISStart`'s response is `{"id": }` and the server +mints that id. + +### Not proposed, and why + +Removing `tradingEnabled` or `IS_TRADING_ENABLED` from `FUT_RS4_CONFIG` in +`blaze_responder_v3b.py`. They are inert for the gate bytes, removing them costs a +Blaze restart, and they may be doing something elsewhere that nobody has checked. +Leave them. + +--- + +## 7. Remaining unknowns + +### Needs decompiling only + +`FUN_18011f380` and the slot vector at `model+0x15f00`. Both the settings applier +and the pile applier write it, the pile applier runs second, and +`maximumTradePileSize` is its other input. Where that value surfaces in the UI is +unknown, and until it is known there is a small chance that `maximumTradePileSize` +matters for something after all. + +The owner of `model+0x1fd49` (live 150). It sits inside the gate block, has zero +disp32 sites in `.text`, and is not applier-written. The implication is worth +noting: if it is allocator residue then the model object is not zero-initialised, +which weakens any argument of the form "value X cannot be a zero-init". None of +the arguments in this document depend on that, because they rest on sole-writer +scans instead. + +The writer of `response+0x1c`, the field that makes both completion callbacks skip +the appliers. It is outside every deserialised region (settings starts at `0x28`, +pile sizes at `0xc8`, userInfo at `0xd8`), so the transport layer sets it, and the +failure branch hands it straight to the observers as an error code +(`mov r8d,[rbp+0x1c]` at `0x180174123`, message id `0x754d`). This is moot if E1's +control lands and becomes the top priority if it does not. + +The subsystem at `0x18018b000..0x180199fff`. `vt+0x270` (the trading gate) is read +from **34** call sites, not the two that have been discussed, and 21 of them +cluster in that contiguous range. A string-literal harvest over those function +bodies came back empty, so it is a lead and not an identification. If the market +screen is gated on trading, this is probably where the native half of the transfer +subsystem lives. + +The `FutGetSettings` standalone response's `+0xd0`, i.e. whether anything other +than massinfo can set the trade-pile capacity. The destination is proven; the +source is not found. + +### Needs a live probe (read-only, no relaunch) + +The item-side terms of `FUN_1801a7260` on a live purchased-pile card: `item+0x4c`, +`item+0x50`, `item+0x145` and stats slots 4 and 5. `item+0x49` was measured 1 on a +club record on an earlier pid, but the other four have never been read. This is +E3 and it only matters if the menu stays greyed after the gate flips. + +Whether `model+0x1fd1c` is the trade pile's current count or its capacity. It is +published as `TRADE_PILE_SIZE` and it also sizes the slot vector, and the hub +shows a count/capacity pair, so one of the two numbers comes from elsewhere. E1's +distinctive 77 answers this by showing which half of the header changes. + +The name of `model+0x1fd20`. "Watch list" is inference from its position next to +`TRADE_PILE_SIZE` and from `pileSizeClientData` key 4; its publisher at +`0x1800e06f5` clamps to 255 and stores a byte into a UI struct with no adjacent +literal. MEDIUM at best. + +### Needs a relaunch + +Everything in section 2's ranked list, unless E0 comes back positive. + +The Seasons caption and its interned-symbol neighbours. The user has to reproduce +the Seasons refusal and leave it on screen. This is the cheapest path to the +project's largest open problem and it is one relaunch. + +The FUT UI script bytecode. The `IS_TRADING_ENABLED` to `_startAuctionSearch` link +is interned-symbol adjacency plus a same-form control, not disassembled control +flow. Extracting the FUT chunk from `Data/Win32/ui.sb` would settle it without any +relaunch at all, but nobody has established whether the scripting layer is +Scaleform ActionScript or EAWebKit JS. `EAWebKit.dll` is present, so the +"Scaleform ActionScript" label used in one report is unsupported either way. + +--- + +## Coverage, honestly + +Six dimensions ran in parallel and three adversarial passes attacked them. The +attacks refuted six claims, four of them graded HIGH by the agent that made them: +that the response object defaults `tradingEnabled` to 0; that only `userMassInfo` +drives the applier and `/settings` does not; that the per-action byte at +`row+0x21` is a kill switch (all 125 read 0 live, and the agent's own falsifier +said that settles it); and that the brief's "zero market requests" premise had +failed (the cited requests were `Python-urllib`). Two more were corrected in +mechanism while their conclusions stood: the slot-vector "shrink branch" is +actually a `resize(0)` clear at `0x18011f48b`, and `maximumTradePileSize` and +`pileSizeClientData` share the `model+0x15f00` sink rather than being unrelated. + +The verifiers also disagreed with each other, and I settled the ones that mattered +myself rather than reporting both. On whether the client re-polls `userMassInfo` +during play, one verifier said yes with five timestamps and one said no; I grepped +the log and the five timestamps are `curl`, so the answer is no and the unit of +cost is a relaunch. On whether serving a partial configs array would turn the +store off, one verifier said MEDIUM danger and another said safe; I disassembled +`FUN_18014e320` and the constructor sets `storeEnabled` and `storeEnabled_JP` to 1 +seven and fourteen bytes after it sets `tradingEnabled` to 1, so it is safe, and +the verifier that warned had explicitly not read the constructor. On whether the +applier has ever run, one verifier argued it never had (because the live bytes are +1); that is refuted by `+0x1fd28 = 60`, `+0x1fd54 = 480` and `+0x1fd60 = 20160` +matching the constructor's `0x3c`, `0x1e0` and `0x4ec0` at three unrelated offsets +with sole writers, which I measured live and read out of the constructor. + +One disagreement is **not** settled and I am flagging it rather than picking. +Whether flipping `IS_TRADING_ENABLED` opens the Transfer Market *screen*, as +opposed to ungreying the per-card menu entry. The menu entry is certain: it reads +that byte as the first term of a 190-byte predicate I disassembled in full. The +screen is inference from the published input set plus the `IS_STORE_ENABLED` +control. I would bet on it, at roughly four to one, and the bet is cheap because +both surfaces are separately observable in E1: if the menu ungreys and the screen +still refuses, we learn something specific rather than nothing. + +What was verified independently while writing this document: the slide, with three +controls; `model+0x1fd2e = 0`, `+0x1fd1c = 0`, `+0x1fd2f = 1`, `+0x1fd28 = 60`, +`+0x1fd54 = 480`, `+0x1fd60 = 20160`; the whole `0x1fd10..0x1fd68` block; the +`model+0x15f00` slot vector empty; the auction object at `model+0x54f8+0x30` +reading `max = -1, selling = 0`; the settings-struct constructor head; the +massinfo kill switch; the `feature.trade` arm; the INT primitive's boolean token; +the applier's `tradingEnabled` write; the `/settings` callback's apply; the +`pileSizeClientData` key arms; the live wire bodies for `/settings` and +`/userMassInfo`; and the complete `ProtoHttp`-filtered request history of pid +260692. + +What was not verified: anything requiring a response to be served to the live +client. Every server-side claim in this document is falsifiable and untested. + +Safety: `/proc/260692/mem` was opened `O_RDONLY` and only `pread`. No `ptrace`, no +writes, no patches. No server was started, stopped, reconfigured or bound to a +port; the only traffic to 8099 was read-only GETs. `tools/fifa17_profile.json` was +never opened. No git operation. No server code was edited. All disassembly was of +the on-disk PE at `/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll` via `objdump`. +FIFA17.exe pid 260692 was confirmed alive at the end of the run. + +--- + +## The next action + +Before spending a relaunch, ask the user to back out of Ultimate Team to the main +menu and walk straight back in, while `tail -f /tmp/utas_server.log` runs. If a +`ProtoHttp`-UA `GET /user/accountinfo` appears, the UT bootstrap is not +process-scoped, the cost of every experiment in this document drops from a +relaunch to a menu round-trip, and the ranking in section 2 becomes far less +important than it currently is. That observation is free and has never been made. +Then, either way, apply P1 through P5 as one edit, restart utas only, and take the +relaunch once: read `model+0x1fd28` first, because if it is still 60 the configs +array never reached the applier and `response+0x1c` is the real story; if it is +77, read `model+0x1fd2e`, and if that is 1 the two-day-old greyed menu entry is +fixed and we finally learn whether the Transfer Market screen was ever about +trading at all. diff --git a/fifa17-recon/tools/blaze_responder_v3b.py b/fifa17-recon/tools/blaze_responder_v3b.py index 4d31fe8..c4b012a 100644 --- a/fifa17-recon/tools/blaze_responder_v3b.py +++ b/fifa17-recon/tools/blaze_responder_v3b.py @@ -713,8 +713,11 @@ FUT_RS4_CONFIG = ( # is zero. Which atom writes +0x1c is UNKNOWN and is the thing worth chasing. # # Default OFF and it should stay off. - + ([(k, "1") for k in ("tradingEnabled", "IS_TRADING_ENABLED")] - if os.environ.get("FUT_TRADING") else []) + # NOTE: FUT_TRADING no longer does anything here. These keys are inert (output + # names the DLL emits, never reads). The REAL trading fix is in utas_server.py: + # userInfo.feature was banning trade. Left disabled so the flag has one meaning. + + ([] if True else + [(k, "1") for k in ("tradingEnabled", "IS_TRADING_ENABLED")]) # NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any # response -- proven inert (wf_96b6c0c5): they are JSON field names that route # to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md. diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_mk_1.py b/fifa17-recon/tools/ghidra_queries/q_adv_mk_1.py new file mode 100644 index 0000000..868ad46 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_mk_1.py @@ -0,0 +1,90 @@ +"""ADVERSARIAL VERIFICATION BATCH 1 (dim4 + dim5). + +HYPOTHESES UNDER ATTACK + H1 (dim5 f5/f7): the publisher FUN_18006cc60 maps model vtable slots to IS_* names, + and IS_TRADING_ENABLED (0x1801fc118) has exactly ONE rip-relative reference in + .text (the lea), i.e. the name is output-only. + CONTROL: run the same rip-relative scanner against a literal that IS known to be + compared, e.g. one of the ISOfferTrade error strings 0x180228f20, which must show + up in a *different* instruction context, and against IS_STORE_ENABLED. + H2 (dim5 f5 positive control): IS_STORE_ENABLED's accessor (vt+0x280) - what does it + actually compute? If it is a live-evaluable expression we can compare STORE vs + TRADING under the same publish mechanism. + H3 (dim4 f2): FutGetSuggestedPricing deser 0x180163ee0 top-level token is + START_ARRAY (loop terminates on 0xd) - CONTROL FUN_180165df0 (ISStart) must + terminate on 10. + H4 (dim4 f4): 0x1801642c0 is `return 1;`. + H5 (dim4 f6): tradeState table 0x180229e40 / bidState ladder FUN_180166380. + H6 (dim4 f9): IS_MAX_AUCTIONS publisher FUN_1800377c0 + GetAuctionCount deser + 0x180163770. + H7 (dim4 f8): error mapper FUN_1801844c0. +Everything printed IN FULL with len(src). +""" +import traceback, struct + +def full(tag, va): + try: + s = dec(va) + print("\n----- %s %#x len=%d -----" % (tag, va, len(s))) + print(s) + except Exception: + traceback.print_exc() + +try: + print("### H1: publisher FUN_18006cc60") + full("publisher", 0x18006cc60) + + print("\n### model vtable slots") + VT = 0x18021c2a0 + for off in (0x270, 0x280, 0x2b0, 0x988, 0x998, 0xa58, 0xa60, 0x130, 0x5b8, 0xa00): + t = qword(VT + off) + print(" vt+%#05x -> %#x %s" % (off, t, fname(t) if 'fname' in dir() else '')) + full("vt+0x280 IS_STORE_ENABLED accessor", qword(VT + 0x280)) + full("vt+0x270 IS_TRADING_ENABLED accessor", qword(VT + 0x270)) + full("vt+0xa58 TRADE_PILE_SIZE accessor", qword(VT + 0xa58)) + + print("\n### H1 rip-relative reference scan, form independent") + # Scan .text for any 4-byte little-endian rel32 whose target == literal VA, + # for every instruction end position. This catches lea/mov/cmp/push equally. + tblk = None + for b in mem.getBlocks(): + if b.getName() == ".text": + tblk = b + TS = int(tblk.getStart().getOffset()); TE = int(tblk.getEnd().getOffset()) + text = read_bytes(TS, TE - TS + 1) + print(" .text %#x..%#x len=%d" % (TS, TE, len(text))) + + def ripscan(target, label): + hits = [] + for i in range(0, len(text) - 4): + rel = struct.unpack_from(' %s" % (nm[:40], [hex(x) for x in f])) + for nm, f in lits.items(): + for a in f: + ripscan(a, nm[:30].decode(errors='replace')) + + print("\n### H3 pricelimits vs ISStart control") + full("FutGetSuggestedPricing deser", 0x180163ee0) + full("FutISStart deser CONTROL", 0x180165df0) + + print("\n### H4 generic ack deser") + full("ack deser", 0x1801642c0) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_mk_2.py b/fifa17-recon/tools/ghidra_queries/q_adv_mk_2.py new file mode 100644 index 0000000..73e15d1 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_mk_2.py @@ -0,0 +1,84 @@ +"""ADVERSARIAL VERIFICATION BATCH 2. +Everything printed IN FULL with len(src). No truncation, no absence claimed from +a partial print. + H8 dim4 f5: auctionInfo record deser 0x18013e410 has exactly 12 atoms + tradeId + identity lookup via model vt+0xa00. + H9 dim4 f7: shared IS-list body 0x18013e7f0, credits -> model vt+0x5b8. + H10 dim4 f6: tradeState table walk FUN_180166bd0 (table 0x180229e40) and bidState + ladder FUN_180166380 -- two DIFFERENT dispatch forms, read separately. + H11 dim4 f8: FUN_1801844c0 status map, FUN_180165050 461 override. + H12 dim4 f9: FUN_1800377c0 IS_MAX_AUCTIONS + FUN_180163770 GetAuctionCount deser. + CONTROL for the publisher form: FUN_18000d550 TRADE_PILE_SIZE. + H13 dim4 f11: deser VAs for FutISWatchList / FutGetAuctionCount / FutISStart via + RS4 name -> abs64 ptr -> installed vtable -> slot +0x08, with FutISSearch and + FutGetTradePile as the CONTROL pair (must come back 0x180163420 / 0x180170810). +""" +import traceback, struct + +def full(tag, va): + try: + s = dec(va) + print("\n----- %s %#x len=%d -----" % (tag, va, len(s))) + print(s) + except Exception: + traceback.print_exc() + +try: + for tag, va in [("auctionInfo record deser", 0x18013e410), + ("shared IS-list body", 0x18013e7f0), + ("tradeState decoder", 0x180166bd0), + ("bidState decoder", 0x180166380), + ("status mapper", 0x1801844c0), + ("ISOfferTrade 461 override", 0x180165050), + ("IS_MAX_AUCTIONS publisher", 0x1800377c0), + ("TRADE_PILE_SIZE publisher CONTROL", 0x18000d550), + ("GetAuctionCount deser", 0x180163770), + ("ISWatchList deser", 0x180166240), + ("ISSearch deser CONTROL", 0x180163420), + ("GetTradePile deser CONTROL", 0x180170810)]: + full(tag, va) + + print("\n### tradeState table at 0x180229e40") + a = 0x180229e40 + for i in range(10): + p = qword(a + i * 16); v = dword(a + i * 16 + 8) + if p == 0: + print(" [%d] NULL terminator, value=%d" % (i, v)); break + print(" [%d] %#x %r = %d" % (i, p, rd_str(p), v if v < 0x80000000 else v - (1 << 32))) + + print("\n### H13 RS4 name -> installed vtable -> slot+0x08") + for nm, expect in [(b"RS4:FutISSearchServerResponse\x00", 0x180163420), + (b"RS4:FutGetTradePileServerResponse\x00", 0x180170810), + (b"RS4:FutISWatchListServerResponse\x00", None), + (b"RS4:FutGetAuctionCountServerResponse\x00", None), + (b"RS4:FutISStartServerResponse\x00", None), + (b"RS4:FutGetSuggestedPricingServerResponse\x00", None), + (b"RS4:FutRelistAllServerResponse\x00", None), + (b"RS4:FutISWatchTradeServerResponse\x00", None), + (b"RS4:FutISRemoveTradeServerResponse\x00", None), + (b"RS4:FutISRemoveWatchServerResponse\x00", None), + (b"RS4:FutISViewTradeServerResponse\x00", None), + (b"RS4:FutISOfferTradeServerResponse\x00", None)]: + locs = find_all(nm, blocks=(".rdata", ".data")) + print("\n %s -> %s" % (nm.decode().rstrip("\x00"), [hex(x) for x in locs])) + for L in locs: + xs = xrefs_to(L) + print(" xrefs: %s" % [(hex(a), t, f) for a, t, f, _ in xs]) + for a, t, f, ent in xs: + if ent: + s = dec(ent) + # find the vtable it installs: look for PTR_ / &DAT_ assignment + import re + m = re.findall(r"(?:PTR_[A-Za-z_0-9]*_|DAT_|&)([0-9a-fA-F]{9})", s) + print(" fn %s @%#x len=%d installs %s" % (f, ent, len(s), set(m))) + for cand in set(m): + try: + vt = int(cand, 16) + if 0x180200000 <= vt < 0x180290000: + slot = qword(vt + 8) + print(" vtable %#x slot+0x08 = %#x (expect %s)" + % (vt, slot, hex(expect) if expect else "?")) + except Exception: + pass +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_mk_3.py b/fifa17-recon/tools/ghidra_queries/q_adv_mk_3.py new file mode 100644 index 0000000..f2d7914 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_mk_3.py @@ -0,0 +1,26 @@ +"""ADVERSARIAL BATCH 3 -- the relaunch-critical path. + H14: does the settings deser FUN_18013c6d0 pre-initialise its struct fields + +0x28..+0x40 to 1 before parsing? If it zero-inits them, then the observed + live pattern (model+0x1fd2e=0 surrounded by 1s) cannot have come from the + applier, i.e. the applier NEVER RAN -- which decides "never set" vs + "set then cleared". + Also: which atom writes struct+0x1c (the field FUN_180173e00 gates on)? + H15: FUN_180173e00 in full -- the test rdx / cmp [rdx+0x1c],0 gate. + H16: dim5 f8 -- FUN_180180770 blaze client-config reader, full key list. +""" +import traceback + +def full(tag, va): + try: + s = dec(va) + print("\n===== %s %#x len=%d =====" % (tag, va, len(s))) + print(s) + except Exception: + traceback.print_exc() + +try: + full("settings deser FUN_18013c6d0", 0x18013c6d0) + full("settings completion FUN_180173e00", 0x180173e00) + full("blaze config reader FUN_180180770", 0x180180770) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_mk_4.py b/fifa17-recon/tools/ghidra_queries/q_adv_mk_4.py new file mode 100644 index 0000000..a6f940a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_mk_4.py @@ -0,0 +1,29 @@ +"""ADVERSARIAL BATCH 4 -- the settings RESPONSE object, not the model-side deser. +FUN_180173e00 reads its param_2 (the FutGetSettings response) at +0x1c (error gate), +copies +0x28..+0xc0 and hands & to the gate applier vt+0x988, and +copies +0xc8..+0xd4 and hands & to vt+0x998. +So model+0x1fd2e <- response+0x50, and model+0x1fd1c <- response+0xd0. +HYPOTHESIS: the FutGetSettings response deserializer writes response+0x50 and +0xd0 +from specific atoms. Find them. +CONTROL: the same RS4-name -> vtable -> slot+0x08 resolution that reproduced +FutISSearch 0x180163420 and FutGetTradePile 0x180170810 in batch 2. +""" +import traceback, re + +try: + for nm in (b"RS4:FutGetSettingsServerResponse\x00", b"RS4:FutSettingsServerResponse\x00", + b"RS4:FutISSearchServerResponse\x00"): + locs = find_all(nm, blocks=(".rdata", ".data")) + print("\n### %s -> %s" % (nm.decode().rstrip("\x00"), [hex(x) for x in locs])) + for L in locs: + for a, t, f, ent in xrefs_to(L): + if not ent: continue + s = dec(ent) + m = set(re.findall(r"(?:PTR_[A-Za-z_0-9]*_|DAT_|&)([0-9a-fA-F]{9})", s)) + print(" fn %s @%#x installs %s" % (f, ent, m)) + for c in m: + v = int(c, 16) + if 0x180200000 <= v < 0x180290000: + print(" vtable %#x slot+0x08 = %#x" % (v, qword(v + 8))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_mk_5.py b/fifa17-recon/tools/ghidra_queries/q_adv_mk_5.py new file mode 100644 index 0000000..7eb2b4d --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_mk_5.py @@ -0,0 +1,10 @@ +"""BATCH 5: which atom writes FutGetSettings response+0x50 (-> IS_TRADING_ENABLED) +and +0xd0 (-> TRADE_PILE_SIZE)? Two candidate desers resolved in batch 4.""" +import traceback, re +try: + for va in (0x18014e590, 0x180153060): + s = dec(va) + print("\n===== deser %#x len=%d =====" % (va, len(s))) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_feature_trade.py b/fifa17-recon/tools/ghidra_queries/q_feature_trade.py new file mode 100644 index 0000000..d2d3b19 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_feature_trade.py @@ -0,0 +1,71 @@ +"""Verify: does userInfo.feature={"trade":true} ZERO the trade gate byte? + +The claim (workflow wf_29791945): userInfo.feature (atom 0x11c) is a RESTRICTION map, +not a grant. Sending trade (atom 0x330) = true marks trade restricted, and at the +massinfo top-level END_OBJECT, 0x180174f19 does `mov dword [rsi+0x50],0`, which feeds +the applier 0x18011dc91 `mov [rdi+0x1fd2e],al`, forcing IS_TRADING_ENABLED = 0. It runs +LAST and unconditionally, so no configs/Blaze value can beat it. + +This has to be right before we change server code, because two prior trading root-causes +this session were wrong. Verify the actual instructions rather than trust the summary. + +CONTROL: storeEnabled path must NOT be zeroed the same way (the store works), so whatever +zeroes trade must be specific to the feature/trade branch, not applied to store. +""" +import re, traceback + +MASSINFO = 0x180174630 # massinfo deser root (calls settings deser + appliers) +ZERO_SITE = 0x180174f19 # claimed `mov dword [rsi+0x50],0` +APPLIER = 0x18011DC50 + +try: + src = dec(MASSINFO) + f = func(MASSINFO) + print("%#x massinfo root body %d / decompile %d chars" + % (MASSINFO, f.getBody().getNumAddresses() if f else -1, len(src))) + + # a) the instruction at the claimed zero site, read raw + print("\n=== instructions around %#x ===" % ZERO_SITE) + ins = listing.getInstructionAt(addr(ZERO_SITE)) + if ins is None: + # step back to find the containing instruction + ins = listing.getInstructionContaining(addr(ZERO_SITE)) + a = addr(ZERO_SITE - 0x18) + for _ in range(14): + i = listing.getInstructionAt(a) + if i is None: + a = a.add(1); continue + mark = " <== claimed zero site" if int(i.getAddress().getOffset()) == ZERO_SITE else "" + print(" %#x %s%s" % (int(i.getAddress().getOffset()), i, mark)) + a = i.getAddress().add(i.getLength()) + + # b) does the feature(0x11c)/trade(0x330) atom appear in the massinfo deser or a callee? + print("\n=== feature 0x11c / trade 0x330 dispatch, in massinfo + callees ===") + scan = [MASSINFO] + [a for a, _ in callees(MASSINFO)] + for ent in scan: + try: + d = dec(ent) + except Exception: + continue + hits = [] + for atom, name in ((0x11c, "feature"), (0x330, "trade")): + for m in re.finditer(r"(case |== |!= )0x%x\b" % atom, d): + hits.append(name) + if hits: + print(" %#x %-20s handles: %s" % (ent, fname(ent), sorted(set(hits)))) + + # c) confirm the applier writes 0x1fd2e from a field, and trace what feeds it + print("\n=== applier %#x: the 0x1fd2e write and its source ===" % APPLIER) + da = dec(APPLIER) + for ln in da.splitlines(): + if "0x1fd2e" in ln or "param_2[10]" in ln: + print(" " + ln.strip()) + + # d) CONTROL: is there a zero-write to the store field (0x1fd2f) anywhere near the + # trade zero site? there should NOT be, or the store would break too. + print("\n=== CONTROL: any 0x1fd2f (store) zeroing near the trade path? ===") + n = sum(1 for ln in src.splitlines() if "0x50] = 0" in ln.replace(" ", "") or "rsi+0x50" in ln) + print(" '[rsi+0x50]=0'-style writes in massinfo root: look above; store gate is a different offset") + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_listgate_1.py b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_1.py new file mode 100644 index 0000000..fc942f3 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_1.py @@ -0,0 +1,33 @@ +"""D3 Q1/Q4: full decompile of the TO_TRADE_PILE predicate FUN_1801a7260 and its +publisher FUN_18003e370 / filler FUN_1800e2a40. + +HYPOTHESIS: FUN_1801a7260 has MORE than the two documented terms (service gate, +item+0x49). Specifically it may consult the pile discriminator item+0x60 (live: +1 for /club, 6 for /purchased) or a pile/state field, which would make the +PURCHASED pile the reason the menu is greyed. + +CONTROL: FUN_18003e550 (the listing panel publisher, DURATION/START_PRICE/ +ASKING_PRICE) is decompiled in the same batch -- a function known to exist and to +be reachable, so a successful decompile there proves the decompiler is working +and a failure on the target is a real failure, not a harness problem. + +Every decompile prints len(src) and is printed IN FULL (absence trap rule). +""" +import traceback + +try: + TARGETS = [ + ("FUN_1801a7260 TO_TRADE_PILE predicate", 0x1801a7260), + ("FUN_18003e370 eight-flag publisher", 0x18003e370), + ("FUN_1800e2a40 flag filler", 0x1800e2a40), + ("FUN_18003e550 CONTROL listing panel publisher", 0x18003e550), + ] + for label, a in TARGETS: + src = dec(a) + print("=" * 78) + print("### %s @ %#x len(src)=%d" % (label, a, len(src))) + print("=" * 78) + print(src) + print() +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_listgate_10.py b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_10.py new file mode 100644 index 0000000..92712f1 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_10.py @@ -0,0 +1,42 @@ +"""D3: the FutGetUserMassInfoServerResponse constructor FUN_180173a50 -- what is +the DEFAULT of +0x17c, the byte that vetoes tradingEnabled? + +Also: is there a SEPARATE "/settings" request descriptor whose completion path +skips that veto? Slot +0x08 of descriptor vtable 0x18022d000 is +FUN_180173d60 -> route literal "/userMassInfo", so descriptors carry their route +as a plain string; find the one carrying "/settings" and walk its vtable the same +way. + +CONTROL: the "/userMassInfo" literal must resolve back to FUN_180173d60 through +the same machinery used to find "/settings". If it does not, an absence for +"/settings" proves nothing. +""" +import traceback + +try: + print("### FUN_180173a50 response ctor") + src = dec(0x180173A50) + print("len(src)=%d" % len(src)) + print(src) + + print() + print("#" * 78) + print("### route literals") + for lit in (b"/userMassInfo\x00", b"/settings\x00", b"/settings?", b"settings"): + hits = find_all(lit, blocks=(".rdata", ".data")) + print("--- %r %d hits: %s" % (lit, len(hits), [hex(h) for h in hits[:20]])) + for h in hits[:20]: + for frm, typ, fn, ent in xrefs_to(h): + print(" ref from %#x %s in %s (%#x)" % (frm, typ, fn, ent)) + + print() + print("#" * 78) + print("### every RS4: response class name mentioning Settings or MassInfo") + for h in find_all(b"RS4:Fut", blocks=(".rdata", ".data")): + s = rd_str(h, 80) + if "etting" in s or "assInfo" in s: + print(" %#x %s" % (h, s)) + for frm, typ, fn, ent in xrefs_to(h): + print(" ref from %#x %s in %s (%#x)" % (frm, typ, fn, ent)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_listgate_11.py b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_11.py new file mode 100644 index 0000000..66658be --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_11.py @@ -0,0 +1,54 @@ +"""D3: the SETTINGS SUB-STRUCT constructor and the standalone /settings path. + +The massinfo response ctor FUN_180173a50 calls FUN_18014e320(param_1 + 5), i.e. +on response+0x28 -- which is exactly the base the settings deserializer +FUN_18013c6d0 is handed and exactly the base the gate applier FUN_18011dc50 +reads. So FUN_18014e320 holds the DEFAULT of every gate the client will adopt +when `configs` is empty. + +WHY THIS MATTERS: with FUT_SETTINGS unset the server serves {"configs": []}, and +live the gate bytes are model+0x1fd28=60, +0x1fd2c=1, +0x1fd2e=0, +0x1fd2f=1, ++0x1fd3a..+0x1fd45=1. If those are FUN_18014e320's defaults, the applier ran and +simply copied defaults -- which both proves the apply path is live and means +sending extra config rows cannot regress a gate that is currently on. + +FALSIFIER: if FUN_18014e320 leaves index [7] (settings+0x1c) at something other +than 60, the "60 came from the default" story is wrong and model+0x1fd28 must +have another source. + +CONTROL, non-boolean and therefore not coincidence: index [7] -> model+0x1fd28 +must be 60 and index [4] -> model+0x1fd54 must be 480 in the constructor. + +Also: is the standalone /settings response applied at all? +FUN_18014e490 references RS4:FutGetSettingsServerResponse and 0x18014e473 +references the "/settings" route literal. Decompile that whole family. +""" +import traceback + +try: + for label, a in [ + ("FUN_18014e320 SETTINGS STRUCT CTOR (defaults)", 0x18014E320), + ("FUN_18014e490 /settings response factory", 0x18014E490), + ("FUN_180152350 other FutGetSettings ref", 0x180152350), + ]: + src = dec(a) + print("=" * 78) + print("### %s @ %#x len(src)=%d" % (label, a, len(src))) + print("=" * 78) + print(src) + print() + + f = fm.getFunctionContaining(addr(0x18014E473)) + print("### function containing the /settings literal ref 0x18014e473: %s" + % (f.getName() if f else "NONE")) + if f: + ent = int(f.getEntryPoint().getOffset()) + src = dec(ent) + print("### %#x len(src)=%d" % (ent, len(src))) + print(src) + else: + # not inside a recognised function; dump the surrounding vtable-ish data + print("raw around 0x18014e460..0x18014e4e0:") + print(read_bytes(0x18014E460, 0x80).hex()) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_listgate_12.py b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_12.py new file mode 100644 index 0000000..15f9a7e --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_12.py @@ -0,0 +1,53 @@ +"""D3 FINAL: pin down response+0x17c, the byte that vetoes tradingEnabled. + +The massinfo response ctor FUN_180173a50 constructs a sub-object at +0xd8 via +FUN_18010ea80. 0x17c - 0xd8 = 0xa4, so a deserializer handed base=+0xd8 would +reach it as [base+0xa4] (disp32 a4 00 00 00), which the earlier +0x17c scan could +not see. Scan for that displacement too, and decompile every sub-parser the +massinfo deserializer delegates to. + +CONTROL for the 0xa4 scan: the same scan is run for 0x28 (the settings sub-struct +base, whose ctor FUN_18014e320 is known to write +0x1c and +0x28) -- if the +harness cannot see a displacement it is known to contain, its absences are void. +Second control: FUN_18010ea80 must show writes consistent with a ~0xb0-byte +object, otherwise +0xd8 is not the parent of +0x17c and the arithmetic is wrong. +""" +import traceback + +try: + TEXT_LO, TEXT_HI = 0x180001000, 0x1801e5000 + text = read_bytes(TEXT_LO, TEXT_HI - TEXT_LO) + + def dispscan(disp): + pat = int(disp).to_bytes(4, "little") + out, i = [], text.find(pat) + while i != -1: + va = TEXT_LO + i + f = fm.getFunctionContaining(addr(va)) + out.append((va, f.getName() if f else "?", + int(f.getEntryPoint().getOffset()) if f else 0, + text[max(0, i - 8):i + 8].hex())) + i = text.find(pat, i + 1) + return out + + print("### disp32 +0xa4 sites in the 0x18010e000-0x180180000 band") + for va, nm, ent, ctx in dispscan(0xA4): + if 0x18010E000 <= va < 0x180180000: + print(" %#x %-24s (%#x) ctx=%s" % (va, nm, ent, ctx)) + + for label, a in [ + ("FUN_18010ea80 ctor of the +0xd8 sub-object", 0x18010EA80), + ("FUN_180142470 userData(0x36d) parser", 0x180142470), + ("FUN_18013adb0 pileSizeClientData(0x227) parser -> +0xc8", 0x18013ADB0), + ("FUN_180139610 arm 0x10c helper", 0x180139610), + ("FUN_180174160 arm 0x339 parser -> +0x200", 0x180174160), + ("FUN_18013a1c0 arm 0x19a", 0x18013A1C0), + ("FUN_18013bd40 arm 0x263", 0x18013BD40), + ]: + src = dec(a) + print("=" * 78) + print("### %s @ %#x len(src)=%d" % (label, a, len(src))) + print("=" * 78) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_listgate_13.py b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_13.py new file mode 100644 index 0000000..a14e187 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_13.py @@ -0,0 +1,37 @@ +"""D3 THE ANSWER: FUN_18013ec10 sets the veto byte. + +The +0xd8 sub-object's constructor FUN_18010ea80 defaults its byte +0xa4 to 0 +(`*(undefined1 *)(param_1 + 0x29) = 0`), and response+0xd8+0xa4 == response+0x17c +-- the byte that makes FUN_180174630 zero the tradingEnabled field. + +The disp32 +0xa4 scan found exactly one non-copy-constructor WRITE in a parser: +0x18013f039 in FUN_18013ec10, encoded `83 f8 01 / 75 06 / 88 87 a4 00 00 00` +i.e. "if (x == 1) byte[rdi+0xa4] = al". Decompile FUN_18013ec10 in full, find the +atom that feeds it, and find its callers to confirm the base is response+0xd8. + +CONTROL: FUN_1801129f0 is in the same scan and must turn out to be a +copy/assign of the same struct (it reads +0xa4 and writes +0xa4 from another +object), not a parser. If it is a parser the classification is wrong. +""" +import traceback + +try: + for label, a in [ + ("FUN_18013ec10 the parser that sets the veto byte", 0x18013EC10), + ("FUN_1801129f0 CONTROL, expected copy/assign", 0x1801129F0), + ]: + src = dec(a, 600) + print("=" * 78) + print("### %s @ %#x len(src)=%d" % (label, a, len(src))) + print("=" * 78) + print(src) + print() + + print("### callers of FUN_18013ec10") + for frm, typ, fn, ent in xrefs_to(0x18013EC10): + print(" from %#x %s in %s (%#x)" % (frm, typ, fn, ent)) + print("### absolute-pointer sites for FUN_18013ec10") + for h in find_all((0x18013EC10).to_bytes(8, "little"), blocks=(".rdata", ".data")): + print(" %#x" % h) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_listgate_2.py b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_2.py new file mode 100644 index 0000000..ffb3d95 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_2.py @@ -0,0 +1,66 @@ +"""D3 Q1 cont / Q3 / Q4: the sub-predicates of the eight action flags, and the +menu-side literal enumeration. + +HYPOTHESIS A: FUN_1801a8900 (the extra player-only term inside FUN_1801a7260) +and FUN_1801a8850 (the early-out inside FUN_1800e2a40, which if TRUE leaves all +eight flag bytes UNINITIALISED) are additional conditions nobody has enumerated. + +HYPOTHESIS B (Q2): there is a separate menu/action surface. Enumerate every +.rdata literal that looks like a per-card menu action so the enabled entries +("Send to Club", "Quick Sell", "Store all remaining") can be contrasted against +the disabled ones. + +CONTROL for the literal scan: "TO_TRADE_PILE" is a known-present literal +published by FUN_18003e370, so the scan MUST return it; if it does not, the scan +is broken and every absence in the same run is worthless. +""" +import traceback + +try: + for label, a in [ + ("FUN_1801a8900 extra player term in TO_TRADE_PILE", 0x1801a8900), + ("FUN_1801a8850 early-out guard in FUN_1800e2a40", 0x1801a8850), + ("FUN_1801a8110 family getter", 0x1801a8110), + ("FUN_1801a71c0 DISCARD predicate", 0x1801a71c0), + ("FUN_1801a7210 MODIFY predicate", 0x1801a7210), + ("FUN_1801a7250 TO_ACTIVE_SQUAD sub", 0x1801a7250), + ("FUN_1801a7180 TO_STICKER_BOOK", 0x1801a7180), + ("FUN_1801a7320 QUICK_SEARCH", 0x1801a7320), + ("FUN_1801a71e0 DREAM_REPLACE", 0x1801a71e0), + ]: + src = dec(a) + print("=" * 78) + print("### %s @ %#x len(src)=%d" % (label, a, len(src))) + print("=" * 78) + print(src) + print() + + print("#" * 78) + print("### LITERAL SCAN over .rdata for menu-action-shaped names") + print("#" * 78) + NEEDLES = [b"TO_TRADE_PILE", b"TRADE_PILE", b"TRANSFER", b"LIST_ITEM", + b"SEND_TO_CLUB", b"TO_CLUB", b"QUICK_SELL", b"QUICKSELL", + b"DISCARD", b"STORE_ALL", b"MOVE_TO", b"CONTEXT", b"MENU", + b"ACTION", b"AUCTION", b"WATCH", b"BID", b"BUY_NOW", + b"tradePile", b"watchList", b"transfermarket"] + for n in NEEDLES: + hits = find_all(n, blocks=(".rdata", ".data")) + print("--- %-16s %d hits" % (n.decode(), len(hits))) + seen = set() + for h in hits[:80]: + # walk back to the start of the C string + p = h + for _ in range(120): + try: + if mem.getByte(addr(p - 1)) & 0xFF == 0: + break + except Exception: + break + p -= 1 + s = rd_str(p, 160) + if s in seen: + continue + seen.add(s) + print(" %#x %r" % (p, s)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_listgate_3.py b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_3.py new file mode 100644 index 0000000..6c03db1 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_3.py @@ -0,0 +1,80 @@ +"""D3 Q2/Q3: the auction-count surface. + +HYPOTHESIS: the third condition is the AUCTION LIMIT. .rdata holds +NUM_CURRENT_AUCTIONS (0x1801f3658), NUM_MAX_AUCTIONS (0x1801f3670), +IS_MAX_AUCTIONS (0x1801f3688) and CARDS_CB_ERR_AUCTION_LIMIT_REACHED +(0x1802142f8). If NUM_MAX_AUCTIONS is fed from model+0x1fd1c (TRADE_PILE_SIZE, +measured 0) then IS_MAX_AUCTIONS is TRUE for every card and the transfer entries +are greyed by a full-trade-pile test, independently of TO_TRADE_PILE. + +Find the publishers and decompile them in full. + +CONTROL: TO_TRADE_PILE (0x1801f4d48) is resolved by the SAME reference machinery +in the same run; its only referencing function must come out as FUN_18003e370, +which is already established. If that control does not resolve, no absence in +this run means anything. + +Both Ghidra's reference manager AND a raw rip-relative displacement scan of +.text are used, because the two miss different things. +""" +import traceback + +try: + TEXT_LO, TEXT_HI = 0x180001000, 0x1801e5000 + text = read_bytes(TEXT_LO, TEXT_HI - TEXT_LO) + print("text bytes read: %d" % len(text)) + + def riprefs(target): + """Every 4-byte little-endian disp32 in .text whose rip-relative target is + `target`, for every instruction length 5..9 (covers lea/mov/cmp/push + encodings without assuming the opcode). Form-independent.""" + out = [] + for i in range(0, len(text) - 4): + d = int.from_bytes(text[i:i + 4], "little", signed=True) + va = TEXT_LO + i + for ilen in range(4, 10): + if va + ilen + d == target: + out.append((va - (ilen - 4), va, ilen, d)) + break + return out + + NAMES = [ + ("TO_TRADE_PILE CONTROL", 0x1801f4d48), + ("NUM_CURRENT_AUCTIONS", 0x1801f3658), + ("NUM_MAX_AUCTIONS", 0x1801f3670), + ("IS_MAX_AUCTIONS", 0x1801f3688), + ("CARDS_CB_ERR_AUCTION_LIMIT_REACHED", 0x1802142f8), + ("CARDS_CB_ERR_WATCHLIST_FULL", 0x1802140f8), + ] + funcs = {} + for label, a in NAMES: + print("=" * 78) + print("### %s @ %#x text=%r" % (label, a, rd_str(a, 60))) + print(" ghidra xrefs_to:") + for t in xrefs_to(a): + print(" from %#x %s in %s (%#x)" % t) + if t[3]: + funcs.setdefault(t[3], set()).add(label) + print(" raw rip-relative disp32 hits (form-independent):") + for start, dispva, ilen, d in riprefs(a): + f = fm.getFunctionContaining(addr(dispva)) + nm = f.getName() if f else "?" + ent = int(f.getEntryPoint().getOffset()) if f else 0 + print(" disp@%#x ilen=%d -> in %s (%#x) raw=%s" + % (dispva, ilen, nm, ent, read_bytes(dispva - 3, 10).hex())) + if ent: + funcs.setdefault(ent, set()).add(label) + + print() + print("#" * 78) + print("### FULL DECOMPILES of every function that touches those names") + print("#" * 78) + for ent in sorted(funcs): + src = dec(ent) + print("=" * 78) + print("### %#x touches %s len(src)=%d" % (ent, sorted(funcs[ent]), len(src))) + print("=" * 78) + print(src) + print() +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_listgate_4.py b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_4.py new file mode 100644 index 0000000..0beb831 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_4.py @@ -0,0 +1,44 @@ +"""D3 Q2 cont: resolve the auction-count struct. + +FUN_1800377c0 publishes NUM_CURRENT_AUCTIONS = u16 [S+0x36], NUM_MAX_AUCTIONS = +int [S+0x30], and IS_MAX_AUCTIONS = !(max<0 || currentvt+0x130(). + +HYPOTHESIS: S is a sub-object of the same model singleton (vtable 0x18021c2a0) +and S+0x30 is fed from the same settings applier family as model+0x1fd1c +(TRADE_PILE_SIZE). If S+0x30 is 0 then IS_MAX_AUCTIONS is permanently TRUE. + +CONTROL: slot +0xa58 of the same vtable must decompile to the known +"mov eax,[rcx+0x1fd1c]; ret" TRADE_PILE_SIZE stub. If it does not, the vtable +base or the slot arithmetic is wrong and nothing else in this run is trustworthy. + +Also decompiles FUN_1800d7b70 (the CARDS_CB_ERR_* name table) and locates +CARDS_CB_ERR_FEATURE_UNAVAILABLE's numeric id. +""" +import traceback + +try: + VT = 0x18021c2a0 + for slot in (0x130, 0xa58, 0xa60, 0x270): + t = qword(VT + slot) + f = fm.getFunctionAt(addr(t)) + print("=" * 78) + print("### vt+%#x -> %#x %s bytes=%s" + % (slot, t, f.getName() if f else "?", read_bytes(t, 16).hex())) + print(dec(t)) + print() + + print("#" * 78) + print("### FUN_1800d7b70 CARDS_CB_ERR_* table") + src = dec(0x1800d7b70) + print("len(src)=%d" % len(src)) + print(src) + + print("#" * 78) + print("### FUN_1800d7170 / FUN_180009c80 -- which service is being acquired") + for a in (0x1800d7170, 0x180009c80, 0x180018bd0, 0x180009b60): + s = dec(a) + print("--- %#x len=%d" % (a, len(s))) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_listgate_5.py b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_5.py new file mode 100644 index 0000000..4f13fca --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_5.py @@ -0,0 +1,113 @@ +"""D3: (a) constructor defaults for the gate block, (b) the Flash property +publisher registration table. + +HYPOTHESIS A: model+0x1fd2e's CONSTRUCTOR DEFAULT is 1. Live it is 0, and the +only disp32 writer is FUN_18011dc50 at 0x18011dc91. If the default is 1, then +that applier RAN and wrote 0, which means the /settings apply path executes and +merely computes the wrong value -- the opposite of "unreachable". +Method: locate the constructor by rip-relative reference to the vtable +0x18021c2a0, decompile it in full, and separately scan .text for the disp32 +0x0001fd2c/2d/2e/2f/0x1fd1c so every write form (mov imm, mov reg, movzx, cmp, +lea) is caught by the DISPLACEMENT rather than by the opcode. + +HYPOTHESIS B (Q2/Q3): FUN_18003e370 (per-card action flags) and FUN_1800377c0 +(auction counts) are entries in a property-provider table. Enumerating that +table gives every Flash property surface the transfer UI can read, which is how +to tell whether "Place on Transfer List" and "List on Transfer Market" share one +predicate. + +CONTROL for the disp32 scan: 0x1fd2e must return exactly the two already-known +sites (read 0x18011c670, write 0x18011dc91). If it returns something else the +scan is mis-tuned and its other answers are worthless. +""" +import traceback + +try: + TEXT_LO, TEXT_HI = 0x180001000, 0x1801e5000 + text = read_bytes(TEXT_LO, TEXT_HI - TEXT_LO) + print("text bytes: %d" % len(text)) + + def riprefs(target): + out = [] + for i in range(0, len(text) - 4): + d = int.from_bytes(text[i:i + 4], "little", signed=True) + va = TEXT_LO + i + for ilen in range(4, 12): + if va + ilen + d == target: + out.append((va, ilen)) + break + return out + + def dispscan(disp): + pat = int(disp).to_bytes(4, "little") + out = [] + i = text.find(pat) + while i != -1: + va = TEXT_LO + i + f = fm.getFunctionContaining(addr(va)) + out.append((va, f.getName() if f else "?", + int(f.getEntryPoint().getOffset()) if f else 0, + text[max(0, i - 6):i + 6].hex())) + i = text.find(pat, i + 1) + return out + + print("#" * 78) + print("### A1. disp32 scan (CONTROL first)") + for disp, nm in ((0x1FD2E, "IS_TRADING_ENABLED CONTROL"), + (0x1FD1C, "TRADE_PILE_SIZE"), + (0x1FD20, "watchlist size"), + (0x1FD28, "the 0x3c field"), + (0x1FD2C, "byte=1 live"), + (0x1FD2D, "byte=1 live"), + (0x1FD2F, "byte=1 live"), + (0x54F8, "auction sub-object base")): + hits = dispscan(disp) + print("--- +%#x %-28s %d sites" % (disp, nm, len(hits))) + for va, nm2, ent, ctx in hits: + print(" %#x %s (%#x) ctx=%s" % (va, nm2, ent, ctx)) + + print() + print("#" * 78) + print("### A2. references to the model vtable 0x18021c2a0 (constructor hunt)") + ctors = set() + for va, ilen in riprefs(0x18021C2A0): + f = fm.getFunctionContaining(addr(va)) + ent = int(f.getEntryPoint().getOffset()) if f else 0 + print(" disp@%#x ilen=%d in %s (%#x) raw=%s" + % (va, ilen, f.getName() if f else "?", ent, read_bytes(va - 3, 12).hex())) + if ent: + ctors.add(ent) + for a in find_all((0x18021C2A0).to_bytes(8, "little"), blocks=(".rdata", ".data")): + print(" absolute qword ptr at %#x" % a) + + print() + print("#" * 78) + print("### A3. constructor decompiles") + for ent in sorted(ctors): + src = dec(ent) + print("=" * 78) + print("### %#x len(src)=%d" % (ent, len(src))) + print("=" * 78) + print(src) + + print() + print("#" * 78) + print("### B. property-provider table around FUN_18003e370 / FUN_1800377c0") + for nm, fa in (("FUN_18003e370 action flags", 0x18003E370), + ("FUN_1800377c0 auction counts", 0x1800377C0), + ("FUN_18003e550 listing panel", 0x18003E550)): + hits = find_all(int(fa).to_bytes(8, "little"), blocks=(".rdata", ".data")) + print("--- %s: %d absolute-pointer sites: %s" + % (nm, len(hits), [hex(h) for h in hits])) + for h in hits: + print(" neighbourhood of %#x:" % h) + for k in range(-6, 7): + q = qword(h + k * 8) + f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None + s = "" + if 0x1801E5000 <= q < 0x18028A000: + s = repr(rd_str(q, 48)) + print(" %+4d %#018x %s %s" + % (k * 8, q, f.getName() if f else "", s)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_listgate_6.py b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_6.py new file mode 100644 index 0000000..af91200 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_6.py @@ -0,0 +1,41 @@ +"""D3: does the settings applier actually RUN, and where do the gate bytes' +values come from? + +LIVE FACTS this run (pid 260692, slide proven twice): + configs is served as [] (FUT_SETTINGS is unset -> "off") + model+0x1fd28 = 60, +0x1fd2c = 1, +0x1fd2d = 1, +0x1fd2e = 0, +0x1fd2f = 1 + model+0x1fd1c = 0, +0x1fd20 = 0, +0x1fd3a..+0x1fd45 = 1 +The disp32 scan says +0x1fd2c and +0x1fd2f have exactly ONE writer each and it is +FUN_18011dc50 (the settings applier), in `sete` form. + +HYPOTHESIS: the applier RUNS on every /settings response, even an empty one, and +copies the SETTINGS-RESPONSE STRUCT's own constructor defaults into the model. +The 1s are that struct's defaults; the 0 at +0x1fd2e is that struct's default for +the tradingEnabled field. That makes IS_TRADING_ENABLED=0 an explained, servable +condition rather than an unreachable one. + +FALSIFIER: if the settings struct's default for the field feeding +0x1fd2e is 1, +or if the model constructor writes these bytes after all, the hypothesis dies. + +CONTROL: model+0x1fd28 reads 60 live and is written by the same applier from +param+0x1c. If the settings struct's constructor default at +0x1c is 60, that is +an independent, non-boolean confirmation that the applier ran -- a boolean 1 +could be coincidence, 60 cannot. +""" +import traceback + +try: + for label, a in [ + ("FUN_18011dc50 gate applier (vt+0x988)", 0x18011DC50), + ("FUN_18011dbf0 size applier (vt+0x998)", 0x18011DBF0), + ("FUN_180173e00 settings completion callback", 0x180173E00), + ("FUN_180174580 descriptor builder", 0x180174580), + ]: + src = dec(a) + print("=" * 78) + print("### %s @ %#x len(src)=%d" % (label, a, len(src))) + print("=" * 78) + print(src) + print() +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_listgate_7.py b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_7.py new file mode 100644 index 0000000..29bd063 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_7.py @@ -0,0 +1,40 @@ +"""D3: the settings deserializer's atom -> field-index map, and the real +destination of maximumTradePileSize. + +ESTABLISHED SO FAR THIS RUN: + FUN_180173e00 copies response+0x28..+0xc0 to the stack and passes it to + model->vt+0x988 (FUN_18011dc50). So applier index [N] == response+0x28+4N. + applier[10] -> model+0x1fd2e IS_TRADING_ENABLED => response+0x50 + applier[7] -> model+0x1fd28 (=60 live) => response+0x44 + applier[0] -> FUN_18011f380(model+0x15f00, v) => response+0x28 + Separately vt+0x998 gets response+0xc8..0xd4, and reads +8 -> model+0x1fd1c + (TRADE_PILE_SIZE) => response+0xd0, and +0xc -> model+0x1fd20 => response+0xd4. + +HYPOTHESIS: the atom that writes response+0x50 is `tradingEnabled`, and +`maximumTradePileSize` writes response+0x28 -- which is NOT model+0x1fd1c. If so, +the historical probe "served maximumTradePileSize=77, no int gate field carries +77" looked in the wrong place: 77 goes into FUN_18011f380, not into any 0x1fd +field. + +CONTROL: the deserializer must also show an arm writing response+0xd0, and that +arm's atom is the true TRADE_PILE_SIZE lever. Finding response+0x50 but not +response+0xd0 would mean the offset arithmetic is off and neither answer counts. + +Prints FUN_18013c6d0 IN FULL with len(src), plus FUN_18011f380. +""" +import traceback + +try: + for label, a in [ + ("FUN_18013c6d0 settings deserializer", 0x18013C6D0), + ("FUN_18011f380 maximumTradePileSize consumer", 0x18011F380), + ("FUN_180174630 massinfo deserializer (caller)", 0x180174630), + ]: + src = dec(a, 600) + print("=" * 78) + print("### %s @ %#x len(src)=%d" % (label, a, len(src))) + print("=" * 78) + print(src) + print() +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_listgate_8.py b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_8.py new file mode 100644 index 0000000..5af5df9 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_8.py @@ -0,0 +1,72 @@ +"""D3 THE THIRD CONDITION: who writes massinfo-response+0x17c? + +FOUND: at the END of the userMassInfo deserializer FUN_180174630 (the token==10 +tail, i.e. after the whole body is parsed) there is + + if (*(char *)(param_1 + 0x17c) != '\\0') { *(undefined4 *)(param_1 + 0x50) = 0; } + +and param_1+0x50 is the settings sub-struct's field [10] -- the very field that +FUN_18011dc50 copies to model+0x1fd2e (IS_TRADING_ENABLED). Atom 0x336 +(tradingEnabled) writes that same field via FUN_18013c6d0(param_1+0x28). + +So a single byte at response+0x17c can veto tradingEnabled AFTER it is parsed. + +HYPOTHESIS: +0x17c is a userInfo-level boolean on the same response struct +(the massinfo response carries userInfo, squad and settings) whose atom we are +sending, or whose CONSTRUCTOR DEFAULT is non-zero, and it is the third condition. + +METHOD: 0x17c cannot be encoded as a disp8, so every access to [reg+0x17c] must +carry the literal disp32 bytes 7c 01 00 00. Scanning for that displacement is +form-independent -- it catches mov/movzx/cmp/setcc/lea in every encoding, which +is what the "== 0x" grep trap requires. + +CONTROL: the same scan must return the KNOWN read at 0x1801748xx inside +FUN_180174630. If the known read does not appear, the scan is broken and no +absence it reports means anything. + +Also decompiles FUN_180174580 and dumps descriptor vtable 0x18022d000 to find +the response-struct constructor, so the default value of +0x17c can be read. +""" +import traceback + +try: + TEXT_LO, TEXT_HI = 0x180001000, 0x1801e5000 + text = read_bytes(TEXT_LO, TEXT_HI - TEXT_LO) + print("text bytes: %d" % len(text)) + + def dispscan(disp): + pat = int(disp).to_bytes(4, "little") + out, i = [], text.find(pat) + while i != -1: + va = TEXT_LO + i + f = fm.getFunctionContaining(addr(va)) + out.append((va, f.getName() if f else "?", + int(f.getEntryPoint().getOffset()) if f else 0, + text[max(0, i - 8):i + 8].hex())) + i = text.find(pat, i + 1) + return out + + for disp in (0x17C, 0x50, 0x180): + hits = dispscan(disp) + print("=" * 78) + print("### disp32 +%#x : %d sites" % (disp, len(hits))) + if disp == 0x17C: + for va, nm, ent, ctx in hits: + print(" %#x %-24s (%#x) ctx=%s" % (va, nm, ent, ctx)) + else: + print(" (too many to be useful; count only)") + + print() + print("#" * 78) + print("### descriptor vtable 0x18022d000") + for slot, t, nm in vtable(0x18022D000, 24): + print(" +%#05x %#018x %s" % (slot, t, nm)) + + print() + print("#" * 78) + for label, a in [("FUN_180174580 descriptor builder", 0x180174580)]: + src = dec(a) + print("### %s len=%d" % (label, len(src))) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_listgate_9.py b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_9.py new file mode 100644 index 0000000..5d7653b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_listgate_9.py @@ -0,0 +1,46 @@ +"""D3: (a) the massinfo response-struct constructor, to read the DEFAULT of ++0x17c (the byte that vetoes tradingEnabled), and (b) Q3: every sibling property +publisher in the same table as FUN_18003e370, to see whether "List on Transfer +Market" has its own enable flag distinct from TO_TRADE_PILE. + +(a) HYPOTHESIS: +0x17c is not written by any deserializer arm -- the +form-independent disp32 scan for 7c 01 00 00 found exactly one site inside +FUN_180174630 and it is the READ at 0x180174f12. So its value is whatever the +response struct's constructor leaves. Candidates for the constructor are the +non-deserializer slots of descriptor vtable 0x18022d000. +FALSIFIER: a constructor that memsets the whole struct to 0 makes +0x17c always +0, the veto never fires, and this whole lead dies. + +(b) HYPOTHESIS: the publisher table at 0x1801f4c20 groups per-card Flash property +providers; one of the neighbours publishes the listing-panel enable flag. + +CONTROL: FUN_18003e370 is in the same batch and must still come out publishing +the eight known names; FUN_18003e550 must still publish DURATION/START_PRICE/ +ASKING_PRICE. Both are already established, so a deviation means the batch is +mis-addressed. +""" +import traceback + +try: + print("#" * 78) + print("### (a) descriptor vtable 0x18022d000 non-deserializer slots") + for slot in (0x00, 0x08, 0x10, 0x18, 0x48, 0x58, 0x68, 0x78, 0xa0, 0xa8, 0xb8): + t = qword(0x18022D000 + slot) + src = dec(t) + print("=" * 78) + print("### slot +%#05x -> %#x len(src)=%d" % (slot, t, len(src))) + print(src) + + print() + print("#" * 78) + print("### (b) sibling property publishers around 0x1801f4c20") + SIBS = [0x18003de00, 0x18003e930, 0x18003ea90, 0x18003ecc0, 0x18003e4b0, + 0x18003e500, 0x18003e550, 0x18003e1a0, 0x18003e370, 0x18003ded0, + 0x18003ebf0, 0x18003e5f0] + for a in SIBS: + src = dec(a) + print("=" * 78) + print("### %#x len(src)=%d" % (a, len(src))) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_online_1.py b/fifa17-recon/tools/ghidra_queries/q_mk_online_1.py new file mode 100644 index 0000000..1e5f11f --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_online_1.py @@ -0,0 +1,81 @@ +"""HYPOTHESIS: CardsDLL holds named-state literals for online/connection state, in the +same family as IS_TRADING_ENABLED (an OUTPUT name published by FUN_18006cc60). +If an ONLINE/CONNECT/SESSION named state exists, the UI's market refusal and the +Seasons refusal may both read it. + +CONTROL: IS_TRADING_ENABLED (0x1801fc118) MUST appear in the enumeration, with exactly +one rip-relative xref (the lea in the publisher). If the enumeration misses it, the +enumeration is broken. + +Enumerates .rdata ASCII literals matching online-ish tokens, and for each prints +xref count + containing functions. +""" +import re, traceback + +try: + blocks = {} + for b in mem.getBlocks(): + blocks[str(b.getName())] = (int(b.getStart().getOffset()), int(b.getEnd().getOffset())) + print("BLOCKS:", {k: ("%#x-%#x" % v) for k, v in blocks.items()}) + + def block_bytes(name): + s, e = blocks[name] + out = bytearray() + a = s + while a <= e: + n = min(1 << 20, e - a + 1) + out += read_bytes(a, n) + a += n + return s, bytes(out) + + rs, rdata = block_bytes(".rdata") + ds, data = block_bytes(".data") + ts, text = block_bytes(".text") + print("LEN .rdata=%d .data=%d .text=%d" % (len(rdata), len(data), len(text))) + + TOKENS = [b"ONLINE", b"OFFLINE", b"RECONNECT", b"RE-CONNECT", b"CONNECT", + b"DISCONNECT", b"SESSION", b"HEARTBEAT", b"PING", b"NUCLEUS", + b"PERSONA", b"SEASON", b"UNAVAILABLE", b"UNREACHABLE", + b"Online", b"Offline", b"Reconnect", b"reconnect", b"connected", + b"isOnline", b"online"] + + strre = re.compile(rb"[\x20-\x7e]{5,120}") + found = {} + for blkname, base, buf in ((".rdata", rs, rdata), (".data", ds, data)): + for m in strre.finditer(buf): + s = m.group() + if not any(t in s for t in TOKENS): + continue + # require NUL termination to be a real C string + end = m.end() + if end < len(buf) and buf[end] != 0: + continue + va = base + m.start() + found.setdefault(va, (blkname, s.decode("latin1"))) + + print("TOTAL candidate literals:", len(found)) + + # rip-relative xref counting over .text, form-independent: find any 4-byte + # displacement d such that (insn_end + d) == va. We approximate by scanning for + # the exact 4-byte LE of (va - (ts + i + 4)) at each i -- too slow. Instead use + # Ghidra's reference manager, and ALSO a raw disp scan for the control. + def xr(va): + try: + return xrefs_to(va) + except Exception: + return [] + + ctrl = 0x1801fc118 + print("\n=== CONTROL IS_TRADING_ENABLED %#x ===" % ctrl) + print(" str:", repr(rd_str(ctrl))) + print(" xrefs:", [(hex(a), t, n) for a, t, n, e in xr(ctrl)]) + print(" in enumeration:", ctrl in found) + + print("\n=== ENUMERATION (va | block | xrefcount | funcs | string) ===") + for va in sorted(found): + blk, s = found[va] + x = xr(va) + fns = sorted({n for a, t, n, e in x}) + print("%#x %s xr=%d %s | %s" % (va, blk, len(x), ",".join(fns[:5]), s)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_online_2.py b/fifa17-recon/tools/ghidra_queries/q_mk_online_2.py new file mode 100644 index 0000000..55355ca --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_online_2.py @@ -0,0 +1,33 @@ +"""HYPOTHESIS: IS_ONLINE (0x1802052b0) is a published named state like +IS_TRADING_ENABLED, and its producer reads a connectivity predicate. FUN_18011fc00 +converts an enum to "ONLINE"/"OFFLINE". FUN_180180770 owns FIFA_FUT_ALLOW_PING / +FUT_PING_TIMEOUT (heartbeat). FUN_1800d7b70 is the CARDS_CB_ERR_* name table. + +CONTROL: FUN_18006cc60 (the KNOWN publisher of IS_TRADING_ENABLED) is decompiled in +the same batch, so the "publisher shape" I claim for IS_ONLINE is compared against a +proven instance of that shape, not against my expectation of it. + +Full length printed for every function; never truncated. +""" +import traceback + +try: + TARGETS = [ + ("CONTROL publisher FUN_18006cc60", 0x18006cc60), + ("IS_ONLINE user A FUN_1800a3cb0", 0x1800a3cb0), + ("IS_ONLINE user B FUN_1800efe40", 0x1800efe40), + ("ONLINE/OFFLINE enum FUN_18011fc00", 0x18011fc00), + ("ping cfg FUN_180180770", 0x180180770), + ("err name table FUN_1800d7b70", 0x1800d7b70), + ] + for label, a in TARGETS: + src = dec(a) + f = func(a) + print("\n" + "=" * 78) + print("### %s entry=%#x name=%s len(src)=%d" % ( + label, int(f.getEntryPoint().getOffset()) if f else 0, + f.getName() if f else "?", len(src))) + print("=" * 78) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_online_3.py b/fifa17-recon/tools/ghidra_queries/q_mk_online_3.py new file mode 100644 index 0000000..a5fc5b0 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_online_3.py @@ -0,0 +1,34 @@ +"""HYPOTHESIS: FUN_1800f7c40 references BOTH FUT::SeasonsManagerOfflineHelper and +FUT::SeasonsManagerOnlineHelper (and the Competition pair), so it is the place that +CHOOSES online vs offline behaviour -- i.e. it contains the client's own notion of +"am I online for FUT". FUN_1800b2680 owns the GOTO_ONLINE_SEASON / GOTO_OFFLINE_SEASON +navigation names and should gate them on the same predicate. + +CONTROL: FUN_18006cc60 was already proven (query 2) to be a name-publisher whose values +come from model vtable slots. If FUN_1800b2680 turns out to publish names the same way, +the shape is the proven one, not an assumed one. I also print FUN_1800a6680 +(IS_MODE_ONLINE) whose online/offline split is known from its literals, as a second +same-shape reference point. + +Full source printed, length reported, never truncated. +""" +import traceback + +try: + TARGETS = [ + ("helper factory FUN_1800f7c40", 0x1800f7c40), + ("nav names FUN_1800b2680", 0x1800b2680), + ("draft hub FUN_1800a6680", 0x1800a6680), + ("seasons offline helper FUN_1801012c0", 0x1801012c0), + ("seasons online helper FUN_1801014c0", 0x1801014c0), + ] + for label, a in TARGETS: + src = dec(a) + f = func(a) + print("\n" + "=" * 78) + print("### %s entry=%#x len(src)=%d" % ( + label, int(f.getEntryPoint().getOffset()) if f else 0, len(src))) + print("=" * 78) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_online_4.py b/fifa17-recon/tools/ghidra_queries/q_mk_online_4.py new file mode 100644 index 0000000..25e243a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_online_4.py @@ -0,0 +1,68 @@ +"""HYPOTHESIS (Q3): the market refusal and the Seasons refusal are both taken BEFORE any +request is built, so the gate must sit in a caller of the request builder. Walking up +from the route literal "/transfermarket?..." and from the seasons route literals should +expose the predicate, and if the two chains share a callee that predicate is the shared +term. + +CONTROL: the SAME walk is run for routes we KNOW the client does issue this session +(/item, /squad/). If the walk produces a plausible-looking "gate" for a route that +demonstrably fires, the walk proves nothing and I say so. + +Form-independent: Ghidra refs AND a raw rip-relative disp32 scan over all of .text. +""" +import struct, traceback + +try: + ts = te = None + for b in mem.getBlocks(): + if str(b.getName()) == ".text": + ts, te = int(b.getStart().getOffset()), int(b.getEnd().getOffset()) + text = b"" + a = ts + while a <= te: + n = min(1 << 20, te - a + 1) + text += read_bytes(a, n) + a += n + print("len(.text)=%d base=%#x" % (len(text), ts)) + + def rip_refs(va): + out = [] + for i in range(0, len(text) - 4): + d = struct.unpack_from("= rs: + v = struct.unpack_from(" 40: + print(" ... (%d more)" % (ln - 40)) + + print("\n\n### decompile market builder FUN_180162c90") + s = dec(0x180162c90) + print("len=%d" % len(s)) + print(s) + print("\n\n### decompile seasons builder FUN_180175bd0") + s = dec(0x180175bd0) + print("len=%d" % len(s)) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_pile_1.py b/fifa17-recon/tools/ghidra_queries/q_mk_pile_1.py new file mode 100644 index 0000000..6e8d3b4 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_pile_1.py @@ -0,0 +1,99 @@ +"""Q1/Q3: trade-pile capacity literals -- who READS them? + +HYPOTHESIS: TRADE_PILE_SIZE is an OUTPUT name only (published by FUN_18000d550), +exactly like IS_TRADING_ENABLED, and is therefore NOT a Blaze client-config key +the DLL ever looks up. + +CONTROL (same form): IS_TRADING_ENABLED (0x1801fc118) is PROVEN output-only -- +exactly one rip-relative reference in .text, a `lea` inside the publisher. If my +scanner reproduces that exact result for IS_TRADING_ENABLED, the scanner is good. +SECOND CONTROL: a literal that IS read/compared somewhere, to prove the scanner +can see a consumer at all. I use the route string "/transfermarket?..." which must +be referenced by a request builder, and the atom-name strings. + +METHOD, form-independent: I do NOT grep for `== 0x` or trust Ghidra's xref db. +For every byte offset in .text I read the 4 bytes as a little-endian int32 and +test whether text_base+i+4+disp equals the target. That catches lea/mov/cmp/push +in EVERY rip-relative encoding. Separately I search .rdata/.data for the absolute +8-byte pointer, which catches vtable slots and pointer tables. +""" +import struct, traceback + +try: + def sect(name): + for b in mem.getBlocks(): + if b.getName() == name: + return int(b.getStart().getOffset()), int(b.getEnd().getOffset()) - int(b.getStart().getOffset()) + 1 + return None, None + + TB, TS = sect(".text") + print("text base %#x size %#x" % (TB, TS)) + TEXT = read_bytes(TB, TS) + print("read text len", len(TEXT)) + + RB, RS = sect(".rdata") + RDATA = read_bytes(RB, RS) + DB, DS = sect(".data") + DATA = read_bytes(DB, DS) + print("rdata %#x len %d ; data %#x len %d" % (RB, len(RDATA), DB, len(DATA))) + + def riprefs(target): + """all i such that some 4-byte window at TB+i is a rip-disp32 to target""" + out = [] + for i in range(0, len(TEXT) - 4): + d = struct.unpack_from(" the applier RUNS and the response carries defaults + ctor zeroes resp+0x80 => the applier does NOT run and 0x1fd3a=1 is a ctor default +Those are mutually exclusive and the live byte is already measured, so this is a +genuine prediction, not a post-hoc fit. +""" +import traceback +try: + for a in (0x180173da0,): + s = dec(a, 300) + print("########## %#x len=%d ##########" % (a, len(s))) + print(s) +except Exception: + traceback.print_exc() + +try: + for a, tag in [(0x180173cf0, "descriptor vt+0x18"), (0x180173d60, "descriptor vt+0x08"), + (0x180173cc0, "descriptor vt+0x58")]: + s = dec(a, 300) + print("\n\n########## %#x %s len=%d ##########" % (a, tag, len(s))) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_pile_11.py b/fifa17-recon/tools/ghidra_queries/q_mk_pile_11.py new file mode 100644 index 0000000..1a1784a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_pile_11.py @@ -0,0 +1,12 @@ +"""FUN_180173a50 = FutGetUserMassInfoServerResponse ctor (0x300 bytes). +Read the initialisers for resp+0x28..+0xd4. +CONTROL: resp+0x80 friendlySeasonsEnabled -- live model+0x1fd3a reads 1 while we +serve {"configs":[]}. ctor sets 1 => applier RUNS. ctor sets 0 => applier does NOT. +""" +import traceback +try: + s = dec(0x180173a50, 300) + print("########## 0x180173a50 ctor len=%d FULL ##########" % len(s)) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_pile_12.py b/fifa17-recon/tools/ghidra_queries/q_mk_pile_12.py new file mode 100644 index 0000000..7dbc30b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_pile_12.py @@ -0,0 +1,18 @@ +"""FUN_18014e320 initialises the SETTINGS sub-struct at resp+0x28 (ctor calls it as +FUN_18014e320(param_1 + 5), param_1 is undefined8* so +5 == +0x28). + +DECISIVE CONTROL: sub-offset 0x58 == resp+0x80 == friendlySeasonsEnabled +(atom 0x133 -> settings deser param_2[0x16] -> S2[0x16] -> model+0x1fd3a). +model+0x1fd3a reads 1 LIVE while we serve {"configs": []}. + init sets +0x58 to 1 => the callback FUN_180173e00 RUNS, and every unsent settings + field simply keeps the response default. + init sets +0x58 to 0 => the callback does NOT run and 0x1fd3a=1 is a model default. +Also read sub-offset 0x28 (== resp+0x50 == tradingEnabled -> model+0x1fd2e, live 0). +""" +import traceback +try: + s = dec(0x18014e320, 300) + print("########## 0x18014e320 settings-struct init len=%d FULL ##########" % len(s)) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_pile_13.py b/fifa17-recon/tools/ghidra_queries/q_mk_pile_13.py new file mode 100644 index 0000000..5d3a1f3 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_pile_13.py @@ -0,0 +1,24 @@ +"""The kill switch: resp+0x17c. userInfo deser base is resp+0xd8, so the byte is +userInfo_struct + 0xa4. Find (a) its default in the userInfo init FUN_18010ea80 and +(b) which atom writes it in the userInfo deser FUN_18013ec10. + +WHY IT MATTERS: the response ctor defaults tradingEnabled (resp+0x50) to 1, yet +model+0x1fd2e reads 0 live while we serve {"configs": []}. The settings deser only +touches resp+0x50 via atom 0x336, which we never send. The ONLY other writer is + if (*(char *)(resp + 0x17c) != 0) *(u32 *)(resp + 0x50) = 0; +at the tail of FUN_180174630. So that branch is taken. Something sets +0x17c. + +CONTROL for the offset arithmetic: resp+0x2f4 has ctor default 1 and is read by the +callback as *(u8*)(resp+0x2f4); the outer parser sets it to 0 in case 0x2cf. That is +an independently visible byte field in the same object, confirming that single-byte +fields in this struct are addressed exactly the way I am reading +0x17c. +""" +import traceback +for a, tag in [(0x18010ea80, "userInfo sub-struct init (resp+0xd8)"), + (0x18013ec10, "userInfo deserialiser")]: + try: + s = dec(a, 300) + print("\n\n########## %#x %s len=%d ##########" % (a, tag, len(s))) + print(s) + except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_pile_2.py b/fifa17-recon/tools/ghidra_queries/q_mk_pile_2.py new file mode 100644 index 0000000..dea4f5c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_pile_2.py @@ -0,0 +1,66 @@ +"""Q1/Q2/Q4: decompile every capacity consumer found in q_mk_pile_1. + +HYPOTHESIS: the "0/0" pair is model+0x1fd1c (TRADE_PILE_SIZE) and a second count, +and the TradePileFull / IS_MAX_AUCTIONS predicates compare a live count against +that capacity. FUN_1800377c0 reads [rsi+0x30] and [rsi+0x36] -- I need to know +whether rsi is the model (offsets would then be 0x30/0x36, NOT 0x1fd1c, so a +different object) or a view struct fed from the model. + +CONTROL: FUN_18006cc60 is the PROVEN publisher shape (lea rdx,name; call [r+0x38]). +If FUN_18000d550 decompiles to the same shape, TRADE_PILE_SIZE is output-only by +the same mechanism, which answers Q3 negatively with the template the brief asked +for. + +Also dumps the .data neighbourhood of the two absolute-pointer table entries +(0x1802d3560 maximumTradePileSize, 0x1802d3898 pileSizeClientData) to identify +what kind of table they live in (atom-name table vs clientdata field table). +""" +import struct, traceback + +try: + for a, tag in [ + (0x18000d550, "publisher of TRADE_PILE_SIZE (Q3 template test)"), + (0x1800377c0, "publisher of NUM_MAX_AUCTIONS / IS_MAX_AUCTIONS"), + (0x180038250, "TradePileFull raiser #1"), + (0x180038450, "TradePileFull raiser #2"), + (0x18011dbf0, "the TRADE_PILE_SIZE applier (writes +0x1fd1c)"), + ]: + src = dec(a) + print("\n\n########## %#x %s (len=%d) ##########" % (a, tag, len(src))) + print(src) + +except Exception: + traceback.print_exc() + +try: + print("\n\n########## .data table neighbourhoods ##########") + for t, nm in [(0x1802d3560, "maximumTradePileSize"), (0x1802d3898, "pileSizeClientData")]: + print("\n--- entry %#x (%s)" % (t, nm)) + for off in range(-0x60, 0x61, 8): + p = t + off + try: + v = qword(p) + except Exception: + continue + s = "" + if 0x1801e5000 <= v < 0x18028a000 or 0x18028a000 <= v < 0x1802f0000: + try: + txt = rd_str(v, 60) + if txt and all(32 <= ord(c) < 127 for c in txt): + s = " -> %r" % txt + except Exception: + pass + f = fm.getFunctionAt(addr(v)) if 0x180001000 <= v < 0x1801e5000 else None + if f: + s = " -> FUNC %s" % f.getName() + print(" %+#5x %#018x%s" % (off, v, s)) +except Exception: + traceback.print_exc() + +try: + print("\n\n########## GetMaxPileSize registration site 0x18003f5b7 ##########") + src = dec(0x18003f120) + print("len=%d" % len(src)) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_pile_3.py b/fifa17-recon/tools/ghidra_queries/q_mk_pile_3.py new file mode 100644 index 0000000..6478a99 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_pile_3.py @@ -0,0 +1,46 @@ +"""Q1 cont: the UI-facing readers. GetMaxPileSize -> FUN_18003ff60, +GetAuctionTunables -> FUN_18003fa40, and model vt+0x130 (the object whose +0x30 +holds NUM_MAX_AUCTIONS). + +HYPOTHESIS: GetMaxPileSize is what the transfer-list UI actually calls, and it +resolves to the same model+0x1fd1c that TRADE_PILE_SIZE publishes -- OR to the +separate (vt+0x130)+0x30 auction cap. These are two different numbers and the +brief conflates them. + +CONTROL: FUN_18000d550 (already decompiled) is the known-good reader of +model+0x1fd1c via vt+0xa58. If FUN_18003ff60 reaches vt+0xa58 too, they agree. + +Also: form-independent disp32 writer scan for the auction-cap field. I search +.text for the raw little-endian 4-byte displacement, which catches mov/movzx/cmp/ +lea in EVERY encoding -- the search form that caught the +0x1fd2e writer. For a +small offset like 0x30 a disp32 scan is useless (it would be disp8), so instead I +enumerate every writer of the object returned by vt+0x130 by decompiling its +allocator/deserialiser. +""" +import struct, traceback + +try: + for a, tag in [ + (0x18003ff60, "GetMaxPileSize script binding"), + (0x18003fa40, "GetAuctionTunables script binding"), + ]: + src = dec(a) + print("\n\n########## %#x %s (len=%d) ##########" % (a, tag, len(src))) + print(src) +except Exception: + traceback.print_exc() + +try: + VT = 0x18021c2a0 + print("\n\n########## model vtable slots of interest ##########") + for slot in (0x08, 0x130, 0x270, 0xa58, 0xa60, 0x988, 0x998, 0xb00): + t = qword(VT + slot) + f = fm.getFunctionAt(addr(t)) if 0x180001000 <= t < 0x1801e5000 else None + stub = read_bytes(t, 16) if f or (0x180001000 <= t < 0x1801e5000) else b"" + print(" vt+%#05x -> %#x %s stub=%s" % (slot, t, f.getName() if f else "?", stub.hex())) + if 0x180001000 <= t < 0x1801e5000: + s = dec(t) + print(" ---- decompile (len=%d) ----" % len(s)) + print(" " + s.replace("\n", "\n ")) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_pile_4.py b/fifa17-recon/tools/ghidra_queries/q_mk_pile_4.py new file mode 100644 index 0000000..81886dd --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_pile_4.py @@ -0,0 +1,84 @@ +"""Q1/Q2/Q4 decisive: which ATOM writes the struct field the capacity applier reads, +and is pileSizeClientData consumed anywhere. + +FUN_18011dbf0(model, S) does model+0x1fd1c = S[+0x08] and model+0x1fd20 = S[+0x0c]. +So I need the settings/response struct S and which atom arm writes S+0x08. + +HYPOTHESIS: atom 0x1c0 maximumTradePileSize writes S+0x08 and 0x1bf +maxAuctionsAllowed (or a watchlist atom) writes S+0x0c. + +ABSENCE-TRAP GUARD: a jump-table switch never contains the case value as an +immediate, so a literal scan for 0x1c0 would produce a FALSE ABSENCE. I therefore +do BOTH: (a) full decompile of the deserialiser and its callers, printed in FULL +with len(), and (b) a raw immediate scan in every common encoding. A disagreement +between the two is itself the finding. + +CONTROL: atom 0x361 (untradeable) and 0x336 are PROVEN to have arms in the settings +range switch. Whatever form I find them in is the form I must search for 0x1c0. +""" +import struct, traceback + +CONSTS = {0x1bf: "maxAuctionsAllowed", 0x1c0: "maximumTradePileSize", + 0x227: "pileSizeClientData", 0x333: "tradePile", 0x381: "watchlist", + 0x361: "CONTROL untradeable", 0x336: "CONTROL"} + +try: + for a, tag in [ + (0x180173e00, "settings completion callback -- calls BOTH appliers"), + (0x180174580, "builds the request descriptor"), + ]: + src = dec(a) + print("\n\n########## %#x %s (len=%d) FULL ##########" % (a, tag, len(src))) + print(src) +except Exception: + traceback.print_exc() + +try: + print("\n\n########## caller of vt+0x988 at 0x18011e21a ##########") + f = fm.getFunctionContaining(addr(0x18011e21a)) + print("containing:", f.getName() if f else "?", hex(int(f.getEntryPoint().getOffset())) if f else "") + if f: + s = dec(int(f.getEntryPoint().getOffset())) + print("len=%d" % len(s)); print(s) +except Exception: + traceback.print_exc() + +try: + print("\n\n########## raw immediate scan for the atom constants ##########") + def sect(name): + for b in mem.getBlocks(): + if b.getName() == name: + return int(b.getStart().getOffset()), int(b.getEnd().getOffset()) - int(b.getStart().getOffset()) + 1 + TB, TS = sect(".text") + TEXT = read_bytes(TB, TS) + print("text len", len(TEXT)) + for v, nm in sorted(CONSTS.items()): + pats = { + "cmp eax,imm32 (3d)": b"\x3d" + struct.pack(" model+0x1fd1c) from atom 0x1c0 maximumTradePileSize, +and response+0x1c (the callback's abort gate) from an error/code atom. + +CONTROL inside the same function: the arms that write +0x50 (which FUN_180173e00 +forwards as S2+0x28 -> model+0x1fd2e, the PROVEN tradingEnabled gate byte). If I can +see that arm and its atom id, the offset->atom mapping method is validated on a +field whose downstream effect is already established. +""" +import re, traceback + +try: + for a, tag in [ + (0x18013c6d0, "settings deserialiser"), + (0x180174630, "the 0x2cd..0x370 dispatch caller"), + ]: + src = dec(a, 300) + print("\n\n########## %#x %s len=%d FULL ##########" % (a, tag, len(src))) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_pile_6.py b/fifa17-recon/tools/ghidra_queries/q_mk_pile_6.py new file mode 100644 index 0000000..5a67dfa --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_pile_6.py @@ -0,0 +1,64 @@ +"""Q4 ANSWER CANDIDATE: FUN_18013adb0 is the pileSizeClientData deserialiser and it +writes response+0xc8..+0xd4, which FUN_180173e00 forwards to the capacity applier +vt+0x998 -> model+0x1fd1c (TRADE_PILE_SIZE) and model+0x1fd20. + +CHAIN UNDER TEST (each link already printed in full elsewhere): + atom 0x227 pileSizeClientData -> FUN_18013adb0(reader, resp+0xc8) + resp+0xc8..0xd4 -> local_108..uStack_fc -> vt+0x998 = FUN_18011dbf0 + FUN_18011dbf0: model+0x1fd1c = S[+0x08] (= resp+0xd0); model+0x1fd20 = S[+0x0c] + model+0x1fd1c -> vt+0xa58 -> published as "TRADE_PILE_SIZE" (measured 0 live) + +CONTROL, same method, already closed end to end: atom 0x336 tradingEnabled -> +settings deser param_2[0xa] = resp+0x50 -> S2[10] -> model+0x1fd2e (measured 0 live, +and 0x1fd2e is the PROVEN service gate at vt+0x270). The offset arithmetic is +therefore validated on a field whose whole chain is independently established. + +Remaining unknowns this query must settle: + * the ATOM NAMES of the four ints inside FUN_18013adb0 (which JSON key is capacity) + * who writes response+0x1c, the field that makes FUN_180173e00 skip BOTH appliers + * the response class name, to name the route in the write-up +""" +import struct, traceback + +try: + for a, tag in [ + (0x18013adb0, "pileSizeClientData deserialiser -> resp+0xc8"), + ]: + src = dec(a, 300) + print("\n\n########## %#x %s len=%d FULL ##########" % (a, tag, len(src))) + print(src) +except Exception: + traceback.print_exc() + +try: + print("\n\n########## head of FUN_180174630 (init of resp fields) ##########") + src = dec(0x180174630, 300) + print("\n".join(src.split("\n")[:110])) +except Exception: + traceback.print_exc() + +try: + print("\n\n########## descriptor vtable 0x18022d000 + class name hunt ##########") + for i in range(0, 12): + t = qword(0x18022d000 + i * 8) + f = fm.getFunctionAt(addr(t)) if 0x180001000 <= t < 0x1801e5000 else None + print(" +%#04x -> %#x %s" % (i * 8, t, f.getName() if f else "")) + # RS4: class names whose factory/vtable is near 0x18022d000 + hits = find_all(b"RS4:") + print(" total RS4: literals:", len(hits)) + for h in hits: + s = rd_str(h, 80) + if "MassInfo" in s or "Mass" in s or "UserMass" in s or "Pile" in s: + print(" %#x %r" % (h, s)) +except Exception: + traceback.print_exc() + +try: + print("\n\n########## who writes response+0x1c ##########") + # the response object is allocated/initialised by the descriptor; find the + # constructor by looking at what FUN_180174580 does, printed FULL + src = dec(0x180174580, 300) + print("len=%d" % len(src)) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_pile_7.py b/fifa17-recon/tools/ghidra_queries/q_mk_pile_7.py new file mode 100644 index 0000000..23c5017 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_pile_7.py @@ -0,0 +1,65 @@ +"""Two closers. + +(1) THE SCALAR-DECODE TRAP. FUN_18013adb0 compares the DECODED key + (iVar2 = FUN_1800d7b30(rawint)) against 2 and 4, and decodes the value with the + same FUN_1800d7b30. The settings deser uses a DIFFERENT decoder FUN_1800d7af0 + for most fields. If FUN_1800d7b30 is not identity, "key":2 on the wire is not + key 2 in the comparison and the whole recommendation is wrong. This is exactly + the failure the brief flags ("a scalar can be raw OR decoded, which broke a + control only yesterday"). Decompile both decoders and the int primitive. + +(2) THE massInfo TAIL KILL-SWITCH. FUN_180174630 ends with + if (*(char *)(param_1 + 0x17c) != 0) *(u32 *)(param_1 + 0x50) = 0; + and resp+0x50 is PROVEN to be tradingEnabled (atom 0x336 -> settings deser + param_2[0xa] -> S2[10] -> model+0x1fd2e). So a non-zero byte at resp+0x17c + ZEROES the trading gate no matter what /settings said. Find its writer. + +SEARCH FORM: 0x17c cannot be a disp8 (>0x7f), so every memory operand naming it +carries the literal 4 bytes 7c 01 00 00. A raw disp32 scan is therefore +form-independent here and catches mov/movzx/cmp/lea in all encodings -- the same +search that found the +0x1fd2e writer. CONTROL: run the identical scan for 0x50, +which IS a disp8 offset, and confirm it produces garbage -- that proves I know +which offsets this technique is valid for and am not over-claiming. +""" +import struct, traceback + +try: + for a, tag in [(0x1800d7b30, "decoder used by pileSizeClientData (key AND value)"), + (0x1800d7af0, "decoder used by the settings deser"), + (0x1801c79d0, "INT primitive getter")]: + s = dec(a) + print("\n\n########## %#x %s len=%d ##########" % (a, tag, len(s))) + print(s) +except Exception: + traceback.print_exc() + +try: + def sect(name): + for b in mem.getBlocks(): + if b.getName() == name: + return int(b.getStart().getOffset()), int(b.getEnd().getOffset()) - int(b.getStart().getOffset()) + 1 + TB, TS = sect(".text") + TEXT = read_bytes(TB, TS) + print("\n\n########## disp32 scan for +0x17c (form-independent) ##########") + for disp, note in [(0x17c, "the kill-switch condition byte"), + (0x50, "CONTROL: a disp8 offset, scan must be meaningless")]: + pat = struct.pack(" 60: + print(" TOO NOISY TO BE EVIDENCE -- not reporting individual sites") + continue + for k, v in sorted(agg.items()): + for h in v: + ctx = read_bytes(h - 6, 16) + print(" %-22s %#x bytes[-6..+10]=%s" % (k, h, ctx.hex())) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_pile_8.py b/fifa17-recon/tools/ghidra_queries/q_mk_pile_8.py new file mode 100644 index 0000000..b1c1ae5 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_pile_8.py @@ -0,0 +1,68 @@ +"""Final closer: is FUN_180173e00 (the massInfo callback) the ONLY route to the +capacity applier, and what is the constructor default of model+0x1fd1c? + +If vt+0x998 has exactly one call site, then pileSizeClientData inside userMassInfo is +the ONLY way to set TRANSFER LIST capacity, and no other endpoint can be blamed or +used. + +SEARCH FORM -- the trap that produced the "applier is unreachable" error before: +a virtual call is `ff /2` with a disp, and Ghidra does not resolve it, so a +direct-call/xref search finds NOTHING. I enumerate the ModRM byte myself for +disp32 form (ff 90..97 excluding 94 which needs SIB) over the whole .text. +CONTROL: the same scan for vt+0x988 (FUN_18011dc50) must reproduce the two known +sites 0x18011e21a and 0x180173f0b. If it does not, the scan is wrong and neither +result may be used. + +Also: constructor default. I find the writers of 0x1fd1c by raw disp32 (0x1fd1c is +far too large for disp8, so the 4 literal bytes appear in every encoding) and print +each with its containing function -- the same form-independent search that located +the +0x1fd2e writer. +""" +import struct, traceback + +try: + def sect(name): + for b in mem.getBlocks(): + if b.getName() == name: + return int(b.getStart().getOffset()), int(b.getEnd().getOffset()) - int(b.getStart().getOffset()) + 1 + TB, TS = sect(".text") + TEXT = read_bytes(TB, TS) + print("text len", len(TEXT)) + + def vcalls(slot): + """every `call [reg+slot]` in disp32 form: ff 90..97 (skip 94=SIB) + imm32""" + out = [] + d = struct.pack(" 0x18011f940 ##########") + print(dec(0x18011f940, 300)) +except Exception: + traceback.print_exc() + +try: + # the ctor should reference the RS4 name 0x18022d110 (-4 rule: the lea points at + # the "RS4:" header itself) + print("\n\n########## refs to RS4:FutGetUserMassInfoServerResponse (0x18022d110) ##########") + for frm, typ, fn, ent in xrefs_to(0x18022d110): + print(" %#x %s in %s (%#x)" % (frm, typ, fn, ent)) + def sect(name): + for b in mem.getBlocks(): + if b.getName() == name: + return int(b.getStart().getOffset()), int(b.getEnd().getOffset()) - int(b.getStart().getOffset()) + 1 + TB, TS = sect(".text") + TEXT = read_bytes(TB, TS) + for tgt in (0x18022d110, 0x18022d000): + out = [] + for i in range(0, len(TEXT) - 4): + d = struct.unpack_from(" FUN_180009c80(&obj, ...). So the true readers of the trading +gate are functions that call FUN_180009c80 AND contain call [reg+0x270] with no arg. +CONTROL: FUN_1801a7260 (TO_TRADE_PILE) and FUN_18006cc60 (the publisher) must both +survive the refinement; the squad functions FUN_180194b60 etc must all drop out. +Also: polarity of FUN_1801a7260 via its callers / the action-id table. +""" +import traceback, struct +try: + tstart = None + for b in mem.getBlocks(): + if b.getName() == ".text": + tstart = int(b.getStart().getOffset()) + tb = read_bytes(tstart, int(b.getEnd().getOffset()) - tstart + 1) + + getters = {} + for g, gname in [(0x180009c80, "FUN_180009c80 mgr-getter"), + (0x1800d7170, "FUN_1800d7170")]: + s = set() + for frm, typ, fn, ent in xrefs_to(g): + if ent: s.add(ent) + getters[gname] = s + print("%s: %d callers" % (gname, len(s))) + + mgr = getters["FUN_180009c80 mgr-getter"] + print("\n=== vt+0x270 call sites whose function ALSO calls the mgr getter ===") + d = struct.pack(" %#x %s bytes=%s" % (slot, t, f.getName() if f else "", + read_bytes(t, 12).hex(" "))) + print("\n=== IS_STORE_ENABLED accessor (vt+0x280) full ===") + print(dec(qword(0x18021c2a0 + 0x280))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_refuse_8.py b/fifa17-recon/tools/ghidra_queries/q_mk_refuse_8.py new file mode 100644 index 0000000..f64ec60 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_refuse_8.py @@ -0,0 +1,46 @@ +"""(a) maximumTradePileSize -> FUN_18011f380(model+0x15f00, N): is that the trade-list +CAPACITY (the second 0 in the red "TRANSFER LIST 0/0")? +(b) FUN_18000d550's decompile hit a jumptable; dump the raw listing so no published +name is missed. +(c) enumerate the whole published-name family in .rdata around IS_TRADING_ENABLED +(0x1801fc118) so every UI-visible term is on the table, including any online/connected +one. CONTROL: IS_STORE_ENABLED and IS_DRAFT_MODE_ENABLED must appear in the dump. +""" +import traceback +try: + print("=== FUN_18011f380 (maximumTradePileSize consumer) ===") + s = dec(0x18011f380); print("len=%d" % len(s)); print(s) + + print("\n=== raw listing 0x18000d550..0x18000d640 ===") + ci = listing.getCodeUnits(addr(0x18000d550), True) + n = 0 + while ci.hasNext() and n < 70: + cu = ci.next() + if int(cu.getAddress().getOffset()) > 0x18000d640: break + print(" %#x %s" % (int(cu.getAddress().getOffset()), cu)) + n += 1 + + print("\n=== .rdata string family around 0x1801fc118 ===") + b = read_bytes(0x1801fbe00, 0x900) + cur = b""; start = 0 + for i, ch in enumerate(b): + if 32 <= ch < 127: + if not cur: start = i + cur += bytes([ch]) + else: + if len(cur) >= 6: + print(" %#x %s" % (0x1801fbe00 + start, cur.decode())) + cur = b"" + + print("\n=== every UI context name published anywhere: strings starting IS_ ===") + seen = set() + for h in find_all(b"IS_", blocks=(".rdata",)): + s2 = rd_str(h, 60) + if s2.isupper() or "_" in s2: + if len(s2) > 5 and s2 not in seen: + seen.add(s2) + xr = xrefs_to(h) + if xr: + print(" %#x %-46s %s" % (h, s2, [(hex(x[0]), x[2]) for x in xr][:3])) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_refuse_9.py b/fifa17-recon/tools/ghidra_queries/q_mk_refuse_9.py new file mode 100644 index 0000000..a5599ac --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_refuse_9.py @@ -0,0 +1,27 @@ +"""FALSIFIER for "tradingEnabled defaults to 0 while storeEnabled defaults to 1": +find the massinfo/settings response object's constructor and read the immediates it +writes at +0x50 (tradingEnabled, applier p[10]) and +0x54/+0x58 (storeEnabled / +storeEnabled_JP, applier p[0xb]/p[0xc]). +CONTROL: live model+0x1fd2f and +0x1fd30 read 1 and +0x1fd2e reads 0 with configs:[] +on the wire, and the applier is the sole writer of all three -- so the ctor MUST show +1,1 at +0x54/+0x58 and 0 (or absent) at +0x50 or my chain is wrong. +Also: every caller of FUN_18011f380 (the trade-pile slot-vector resize). +""" +import traceback +try: + print("=== callers of FUN_18011f380 (trade-pile vector resize) ===") + for frm, typ, fn, ent in xrefs_to(0x18011f380): + print(" %#x %-22s %s ent=%#x" % (frm, typ, fn, ent)) + + print("\n=== descriptor vtable 0x18022d000 ===") + for off, t, n in vtable(0x18022d000, 12): + print(" +%#05x -> %#x %s" % (off, t, n)) + + print("\n=== xrefs to descriptor vtable 0x18022d000 ===") + for frm, typ, fn, ent in xrefs_to(0x18022d000): + print(" %#x %-14s %s ent=%#x" % (frm, typ, fn, ent)) + + print("\n=== find the ctor: functions writing an immediate to [reg+0x50] near [reg+0x54] ===") + print(dec(0x180174580)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_wire_1.py b/fifa17-recon/tools/ghidra_queries/q_mk_wire_1.py new file mode 100644 index 0000000..91668d6 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_wire_1.py @@ -0,0 +1,38 @@ +"""DIM4 Q1/Q3. Hypothesis: each row of the client action table 0x1802caa20 holds a +FACTORY fn at +0x28 that constructs the request object; from it we can read the +request class vtable (serializer) and the response class name. Also decode the two +enum converters 0x180166380 (str->bidState) and 0x180166bd0 (str->tradeState). + +CONTROL: SaveSquad factory 0x1801245f0 -- a KNOWN-GOOD op whose request body +(PUT /squad/ with a squad object) is already proven live. If the method that +works for SaveSquad also works for the IS ops, the form matches. +""" +import traceback +try: + FACT = [ + ("ISSearch", 0x180124110), + ("ISOfferTrade", 0x1801240e0), + ("ISStart", 0x180124120), + ("RelistAll", 0x1801245b0), + ("ISRemoveWatch", 0x180124100), + ("ISWatchTrade", 0x180124150), + ("ISWatchList", 0x180124140), + ("ISViewTrade", 0x180124130), + ("GetTradePile", 0x180124060), + ("GetAuctionCount", 0x180123d60), + ("ISRemoveTrade", 0x1801240f0), + ("GetSuggestedPricing", 0x180124050), + ("SaveSquad(CONTROL)", 0x1801245f0), + ] + for nm, a in FACT: + print("\n########## FACTORY %s @ %#x ##########" % (nm, a)) + s = dec(a) + print("len(src)=%d" % len(s)) + print(s) + for nm, a in [("str->bidState", 0x180166380), ("str->tradeState", 0x180166bd0)]: + print("\n########## ENUM %s @ %#x ##########" % (nm, a)) + s = dec(a) + print("len(src)=%d" % len(s)) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_wire_2.py b/fifa17-recon/tools/ghidra_queries/q_mk_wire_2.py new file mode 100644 index 0000000..e14de39 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_wire_2.py @@ -0,0 +1,48 @@ +"""DIM4 Q1/Q2. Find the REQUEST BUILDER for each market op by xref'ing the per-op +path-suffix format literal found in .rdata next to each RS4 response-class name, +then decompile the containing function (that is where the query string is built and, +for POST/PUT ops, where the request body is serialized). + +Also re-read the auctionInfo record deser 0x18013e410 and the shared IS-list body +0x18013e7f0 IN FULL (len printed) so the atom set can be confirmed, not inherited. + +CONTROL for the xref method: '/relist' (0x1802289a8) must land in a function that +also references route index 0 / 'ut/%s/auctionhouse'-derived state; and the record +deser must show the 12 atoms already documented at HIGH confidence in ENDPOINT_MAP. +A control that reproduces the known 12 validates the read for the unknown ones. +""" +import traceback +try: + LITS = [ + ("/transfermarket?type=%s&start=%d&num=%d", 0x180228490), + ("&definitionId=%d", 0x1802284c8), + ("/counts", 0x180228718), + ("/pricelimits", 0x1802288a8), + ("/relist", 0x1802289a8), + ("/status?tradeIds=%lld", 0x180228ae0), + ("/sold", 0x180228bec), + ("?tradeId=%lld", 0x180228d18), + ("/%lld/offer", 0x180228fc0), + ("/expired", 0x1802290c8), + ("?tradeId=", 0x1802290d8), + ("?offset=%d&count=%d", 0x180229390), + ("Auction state is invalid for bidding", 0x180228f80), + ] + seen = {} + for nm, a in LITS: + print("\n===== XREFS to %r %#x =====" % (nm, a)) + xs = xrefs_to(a) + if not xs: + print(" (none direct) trying a-4 (RS4 rule n/a here, but try anyway)") + xs = xrefs_to(a - 4) + for frm, typ, fn, ent in xs: + print(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + if ent: + seen.setdefault(ent, set()).add(nm) + for ent, tags in sorted(seen.items()): + print("\n########## BUILDER %#x (lits: %s) ##########" % (ent, sorted(tags))) + s = dec(ent) + print("len(src)=%d" % len(s)) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_wire_3.py b/fifa17-recon/tools/ghidra_queries/q_mk_wire_3.py new file mode 100644 index 0000000..5ae337c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_wire_3.py @@ -0,0 +1,49 @@ +"""DIM4 Q1/Q2/Q3. The per-op handler table at 0x180214db8.. has 3-qword rows +[?, url_builder, body_serializer]. Decompile the body serializers for the three ops +that POST/PUT a body (ISStart 0x180165a90, ISOfferTrade 0x180164e50, +ISWatchTrade 0x180164970), the two URL builders Ghidra left undefined +(GetAuctionCount 0x180163580 '/counts', RelistAll 0x1801641c0 '/relist'), the +no-body stub 0x18011f940, and the search-filter enum stringifiers. + +CONTROL 1: 0x18011f940 must decompile to a trivial/no-op emitter -- if the "slot2 is +the body serializer" reading is right, the GET-only ops all share it and it must +write nothing. If it writes a body, the slot reading is wrong. +CONTROL 2: the auctionInfo record deser 0x18013e410 must reproduce the 12 atoms +already documented HIGH in ENDPOINT_MAP; that validates the same reading method for +the response structs whose atom sets are still MED. +""" +import traceback +try: + FN = [ + ("GetAuctionCount_url /counts", 0x180163580), + ("RelistAll_url /relist", 0x1801641c0), + ("ISStart_BODY", 0x180165a90), + ("ISOfferTrade_BODY", 0x180164e50), + ("ISWatchTrade_BODY", 0x180164970), + ("ISStart_url(generic)", 0x180122420), + ("nobody_stub(CONTROL1)", 0x18011f940), + ("row A slot2 0x180162530", 0x180162530), + ("searchtype->str", 0x180166340), + ("pos->str", 0x1801668a0), + ("zone->str", 0x180166550), + ("pos2->str", 0x180166c60), + ("form->str", 0x180166620), + ("lev->str", 0x1801667d0), + ("cat->str", 0x180166300), + ] + for nm, a in FN: + print("\n########## %s @ %#x ##########" % (nm, a)) + f = func(a) + print("containing func = %s @ %#x" % (f.getName() if f else "NONE", + int(f.getEntryPoint().getOffset()) if f else 0)) + s = dec(a) + print("len(src)=%d" % len(s)) + print(s) + print("\n\n########## CONTROL2 auctionInfo record deser 0x18013e410 ##########") + s = dec(0x18013e410) + print("len(src)=%d" % len(s)); print(s) + print("\n########## shared IS-list body 0x18013e7f0 ##########") + s = dec(0x18013e7f0) + print("len(src)=%d" % len(s)); print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_wire_4.py b/fifa17-recon/tools/ghidra_queries/q_mk_wire_4.py new file mode 100644 index 0000000..44271a1 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_wire_4.py @@ -0,0 +1,43 @@ +"""DIM4 Q1/Q2/Q3/Q4. Decompile the 12 market RESPONSE deserializers to confirm/extend +their atom sets, plus the error-envelope mapper FUN_1801844c0 (reached when the +ISOfferTrade code is NOT 0x1cd) and the two URL builders Ghidra left undefined. + +CONTROL: FutISSearch 0x180163420 and FutGetTradePile 0x180170810 are documented HIGH +in ENDPOINT_MAP as tail-delegating to the shared IS-list body 0x18013e7f0. If the +decompile of those two shows the call to 0x18013e7f0, the deser VAs in ENDPOINT_MAP +are right and the same list can be trusted for the MED ones (RelistAll, ISWatchTrade, +ISRemoveTrade, ISRemoveWatch) whose bodies were only partially read. +""" +import traceback +try: + DESER = [ + ("FutISSearch(CONTROL)", 0x180163420), + ("FutGetTradePile(CONTROL)", 0x180170810), + ("FutISStart", 0x180165d70), + ("FutISViewTrade", 0x1801644d0), + ("FutISWatchList", 0x180166130), + ("FutISWatchTrade", 0x180164cd0), + ("FutISOfferTrade", 0x180165410), + ("FutISRemoveTrade", 0x1801648d0), + ("FutISRemoveWatch", 0x1801659f0), + ("FutRelistAll", 0x180164210), + ("FutGetAuctionCount", 0x180163670), + ("FutGetSuggestedPricing", 0x180163bb0), + ("errenvelope_generic", 0x1801844c0), + ("dupItemIdList elem", 0x180138e10), + ] + for nm, a in DESER: + print("\n########## %s @ %#x ##########" % (nm, a)) + f = func(a) + print("fn=%s @ %#x" % (f.getName() if f else "NONE", + int(f.getEntryPoint().getOffset()) if f else 0)) + s = dec(a) + print("len(src)=%d" % len(s)); print(s) + # who READS the per-action enable byte at actiontable row+0x21? + print("\n########## xrefs to action table 0x1802caa20 and row0 flag 0x1802caa41 ##########") + for a in (0x1802caa20, 0x1802caa40, 0x1802caa41): + print(" -- %#x" % a) + for frm, typ, fn, ent in xrefs_to(a): + print(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_wire_5.py b/fifa17-recon/tools/ghidra_queries/q_mk_wire_5.py new file mode 100644 index 0000000..d8afe4a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_wire_5.py @@ -0,0 +1,68 @@ +"""DIM4. Several VAs listed as "deserializer" in ENDPOINT_MAP are in fact the +response object's CONSTRUCTOR or FACTORY (they end in `*obj = &PTR_FUN_`). +Resolve the REAL deserializer properly: RS4 name -> factory (xref) -> the .rdata +vtable the factory installs -> slot +0x08. + +CONTROL: FutISSearchServerResponse must resolve to 0x180163420 and +FutGetTradePileServerResponse to 0x180170810 -- the two VAs just PROVEN correct in +q_mk_wire_4 (both visibly tail-call the shared IS-list body 0x18013e7f0). If the +method reproduces those two it can be trusted for the four that were only MED. +""" +import traceback +try: + NAMES = ["FutISSearchServerResponse", "FutGetTradePileServerResponse", + "FutISStartServerResponse", "FutISViewTradeServerResponse", + "FutISWatchListServerResponse", "FutISWatchTradeServerResponse", + "FutISOfferTradeServerResponse", "FutISRemoveTradeServerResponse", + "FutISRemoveWatchServerResponse", "FutRelistAllServerResponse", + "FutGetAuctionCountServerResponse", "FutGetSuggestedPricingServerResponse"] + + def vtables_in(ent): + out = set() + f = func(ent) + if f is None: + return out + it = refs.getReferencesFrom(f.getEntryPoint()) + body = f.getBody() + ai = listing.getInstructions(body, True) + while ai.hasNext(): + ins = ai.next() + for r in ins.getReferencesFrom(): + t = int(r.getToAddress().getOffset()) + if 0x1801e5000 <= t < 0x18028a000: + try: + s0 = qword(t); s1 = qword(t + 8) + except Exception: + continue + if 0x180001000 <= s0 < 0x1801e5000 and 0x180001000 <= s1 < 0x1801e5000: + out.add(t) + return out + + resolved = {} + for cls in NAMES: + print("\n===== %s =====" % cls) + hits = find_all(b"RS4:" + cls.encode() + b"\x00") + print(" RS4 literal at %s" % [hex(h) for h in hits]) + for h in hits: + for frm, typ, fn, ent in xrefs_to(h): + print(" factory ref from %#x in %s @ %#x" % (frm, fn, ent)) + for vt in sorted(vtables_in(ent)): + d8 = qword(vt + 8) + f8 = fm.getFunctionAt(addr(d8)) + print(" vtable %#x slot+0x00=%#x slot+0x08=%#x %s" + % (vt, qword(vt), d8, f8.getName() if f8 else "(undef)")) + resolved.setdefault(cls, set()).add(d8) + print("\n\n==================== REAL DESERIALIZERS ====================") + for cls in NAMES: + print(" %-40s %s" % (cls, [hex(x) for x in sorted(resolved.get(cls, []))])) + done = set() + for cls in NAMES: + for a in sorted(resolved.get(cls, [])): + if a in done: + continue + done.add(a) + print("\n########## DESER for %s @ %#x ##########" % (cls, a)) + s = dec(a) + print("len(src)=%d" % len(s)); print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_wire_6.py b/fifa17-recon/tools/ghidra_queries/q_mk_wire_6.py new file mode 100644 index 0000000..edc11a7 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_wire_6.py @@ -0,0 +1,39 @@ +"""DIM4 final. (a) FutGetSuggestedPricing deser 0x180163ee0 top-level shape -- the +loop terminates on 0xd (END_ARRAY) at the outer level, so it may be a BARE top-level +ARRAY rather than a keyed object; that is a freeze-risk decision and must be read, +not guessed. (b) The auction-limit publisher literals NUM_MAX_AUCTIONS / +NUM_CURRENT_AUCTIONS / IS_MAX_AUCTIONS / TRADE_DATA_AVAILABLE -- which model fields +back the "can I list another card" gate. (c) where the numeric error code fed to the +error mappers comes from. + +CONTROL for (b): TRADE_PILE_SIZE 0x1801eafe8 is ALREADY PROVEN (previous run) to be +published by FUN_18000d550 reading model vt+0xa58 -> model+0x1fd1c. If the same +lea-then-call-slot shape shows up for the AUCTION literals, the reading is the same +form that was already validated; if it does not, I report nothing for (b). +""" +import traceback +try: + print("########## SuggestedPricing deser 0x180163ee0 (FULL) ##########") + s = dec(0x180163ee0) + print("len(src)=%d" % len(s)); print(s) + print("\n########## auction-limit publisher literals ##########") + for nm, a in [("NUM_CURRENT_AUCTIONS", 0x1801f3658), ("NUM_MAX_AUCTIONS", 0x1801f3670), + ("IS_MAX_AUCTIONS", 0x1801f3688), ("TRADE_DATA_AVAILABLE", 0x180239190), + ("TRADE_PILE_SIZE(CONTROL)", 0x1801eafe8), ("FUT_TOTAL_AUCTIONS", 0x18020a080)]: + print("\n -- %s @ %#x" % (nm, a)) + xs = xrefs_to(a) + for frm, typ, fn, ent in xs: + print(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + if not xs: + print(" (no xrefs)") + print("\n########## publisher FUN_180163600 / count-object accessors ##########") + for a in (0x180163600, 0x1801635a0): + print("\n---- %#x ----" % a) + s = dec(a); print("len(src)=%d" % len(s)); print(s) + print("\n########## callers of the error mappers ##########") + for a in (0x180165050, 0x1801844c0): + print("\n -- callers of %#x" % a) + for c in callers(a): + print(" %s" % (c,)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_mk_wire_7.py b/fifa17-recon/tools/ghidra_queries/q_mk_wire_7.py new file mode 100644 index 0000000..344c63b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_mk_wire_7.py @@ -0,0 +1,19 @@ +"""DIM4 close-out. FUN_1800377c0 publishes NUM_CURRENT_AUCTIONS / NUM_MAX_AUCTIONS / +IS_MAX_AUCTIONS -- read which accessors back them, i.e. whether the "auction limit" +UI gate is fed by the GetAuctionCount response (ut/%s/tradePile/counts) or by a +constructor default like the trading gate was. +CONTROL: the same function shape (lea ; call [reg+slot]) was already proven +for TRADE_PILE_SIZE in FUN_18000d550; that is the validated form. +Also FUN_18016c060: the generic response completion path that feeds the numeric error +code into the mapper -- establishes whether the code is the HTTP status or a body field. +""" +import traceback +try: + for nm, a in [("auction-limit publisher FUN_1800377c0", 0x1800377c0), + ("TRADE_PILE_SIZE publisher (CONTROL)", 0x18000d550), + ("generic completion / error source FUN_18016c060", 0x18016c060)]: + print("\n########## %s @ %#x ##########" % (nm, a)) + s = dec(a) + print("len(src)=%d" % len(s)); print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pilesize_keys.py b/fifa17-recon/tools/ghidra_queries/q_pilesize_keys.py new file mode 100644 index 0000000..28a4147 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pilesize_keys.py @@ -0,0 +1,55 @@ +"""Verify the pileSizeClientData key enum before changing boot-critical massinfo. + +Contradiction to resolve: + - utas_server.py comment: pileSizeClientData is "the MY CLUB counter", enum + "not recoverable", so it sprays the club count across keys 0..15. + - workflow wf_29791945: parser FUN_18013adb0 has EXACTLY two arms, key 2 -> trade + pile (response+0xd0 -> model+0x1fd1c = TRADE_PILE_SIZE, the red 0/0) and key 4 -> + watch list (+0xd4 -> +0x1fd20). No club key. Spraying the club count (246) onto + key 2 would set the TRANSFER-LIST CAPACITY to 246, which is wrong. + +Only one can be right. Read the parser. If it has cmp esi,2 / cmp esi,4 and no +default store, the workflow is right and the current code is a latent bug. + +CONTROL: the parser must be reached from the massinfo root via arm 0x227 +(pileSizeClientData atom). Confirm the atom and the call. +""" +import re, traceback + +PARSER = 0x18013ADB0 + +try: + src = dec(PARSER) + f = func(PARSER) + print("%#x pileSizeClientData parser body %d / decompile %d chars (IN FULL)" + % (PARSER, f.getBody().getNumAddresses() if f else -1, len(src))) + print("=" * 78) + print(src) + + print("\n=== raw instructions: every cmp against a small immediate + the stores ===") + a = f.getBody().getMinAddress() + end = f.getBody().getMaxAddress() + while a is not None and a.compareTo(end) <= 0: + i = listing.getInstructionAt(a) + if i is None: + a = a.add(1); continue + m = i.getMnemonicString().lower() + t = str(i) + if (m == "cmp" and re.search(r",0x[0-9a-f]$|,0x[0-9a-f]\b", t)) or \ + (m == "mov" and "0xd0" in t) or (m == "mov" and "0xd4" in t) or \ + (m == "mov" and "+ 0x8]" in t) or (m == "mov" and "+ 0xc]" in t): + print(" %#x %s" % (int(i.getAddress().getOffset()), t)) + a = i.getAddress().add(i.getLength()) + + print("\n=== who calls the parser, and the pileSizeClientData atom (0x227) ===") + for adr, n in callers(PARSER): + print(" caller %#x %s" % (adr, n)) + aid = None + for line in open("/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"): + p = line.rstrip("\n").split("\t") + if len(p) >= 3 and p[2] == "pileSizeClientData": + aid = p[1] + print(" pileSizeClientData atom id (from tsv):", aid) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_pricelimits.py b/fifa17-recon/tools/ghidra_queries/q_pricelimits.py new file mode 100644 index 0000000..35f41b6 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_pricelimits.py @@ -0,0 +1,49 @@ +"""Confirm the marketdata/pricelimits response shape before serving it (it just froze). + +We returned {"minPrice":150,"maxPrice":15000} (an OBJECT) and the client froze at the +price screen -> classic container-type desync (0x1801c7f1a). The doc says the response +is a BARE TOP-LEVEL ARRAY of {defId,minPrice,maxPrice}, deser 0x180163ee0 +(GETSUGGESTEDPRICING). Verify the element fields and types so the fix does not re-freeze. + +CONTROL: minPrice=0x1ca, maxPrice=0x1c2 must appear; find the defId/id atom the element +keys identity on, and confirm each is read with the INT primitive 0x1801c79d0 (scalar), +so our int values are type-correct. +""" +import re, traceback + +DESER = 0x180163EE0 + +try: + src = dec(DESER) + f = func(DESER) + print("%#x GetSuggestedPricing deser body %d / decompile %d chars (IN FULL)" + % (DESER, f.getBody().getNumAddresses() if f else -1, len(src))) + print("=" * 78) + print(src) + + print("\n=== atoms this deser (and its element callee) dispatch on ===") + atoms = {} + for line in open("/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"): + p = line.rstrip("\n").split("\t") + if len(p) >= 3: + try: atoms[int(p[1], 16)] = p[2] + except ValueError: pass + scan = [DESER] + [a for a, _ in callees(DESER)] + for ent in scan: + try: d = dec(ent) + except Exception: continue + found = set() + for m in re.finditer(r"(case |== |!= )(0x[0-9a-f]+)\b", d): + found.add(int(m.group(2), 16)) + rel = [(a, atoms.get(a, "?")) for a in sorted(found) + if a in (0x1ca, 0x1c2, 0x2e6, 0x65, 0x24d) or (atoms.get(a, "").lower() in + ("defid", "id", "minprice", "maxprice", "startingbid", "buynowprice"))] + if rel: + print(" %#x %-20s -> %s" % (ent, fname(ent), + ", ".join("%s(%#x)" % (n, a) for a, n in rel))) + # is it an array reader? look for the 0xd (END_ARRAY) sentinel loop + if "!= 0xd" in d or "== 0xd" in d: + print(" (has an END_ARRAY 0xd loop -> reads an ARRAY, consistent with bare-array root)") + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 2434fa4..4e03c28 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -147,6 +147,28 @@ def squad_list_body(squad=None): # below) and it is still NOT enough to make this bool safe -- see FUT_CLUB_RENAME. _UI = os.environ.get("FUT_USERINFO", "roster") +# FUT_TRADING: stop banning our own trading. +# +# userInfo.feature (atom 0x11c) is a RESTRICTION map, not a grant. Sending +# feature={"trade": true} marks TRADE RESTRICTED. Verified at the instruction level +# 2026-08-06 (q_feature_trade.py): FUN_18013ec10 parses feature/trade into +# userInfo+0x17c, and at the massinfo top-level END_OBJECT the client runs +# 0x180174f10 cmp byte [rsi+0x17c], 0 +# 0x180174f17 jz 0x180174f20 ; not restricted -> skip +# 0x180174f19 mov dword [rsi+0x50], 0 ; restricted -> zero the trade field +# which feeds applier 0x18011dc91 -> IS_TRADING_ENABLED (model+0x1fd2e) = 0. It runs +# LAST and unconditionally, which is why the gate byte read 0 all day no matter what +# /settings or the Blaze client-config store sent. We were disabling trading ourselves. +# +# With the flag on we send feature={} (no trade key -> +0x17c stays 0 -> the jz skips +# the zeroing -> the gate keeps its constructor default of 1). Empty object is +# type-safe: feature is an OBJECT and {} parses with no members. +# +# Default OFF for one relaunch only: this is on the critical path into FUT and has +# never been in front of the game. Verify by reading model+0x1fd2e (should become 1) +# and by checking the per-card "Place on Transfer List" entry is no longer greyed. +TRADING = os.environ.get("FUT_TRADING", "0") == "1" + # ---- FUT_CLUB_RENAME: the in-game rename experiment (DEFAULT OFF) ----------- # clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. # @@ -252,7 +274,9 @@ def user_info(): "clubNameChangeAllowed": _CLUB_RENAME, "divisionOffline": 10, "divisionOnline": 10, "purchased": False, # 0x262 -> bool at +0x68 - "feature": {"trade": True}, + # {"trade": true} = trade RESTRICTED (see FUT_TRADING note up top). {} lifts + # the restriction. Default keeps the historical value until one live test. + "feature": ({} if TRADING else {"trade": True}), "reliability": {"reliability": 100, "matchUnfinishedTime": 0}, "bidTokens": {"count": 0, "updateTime": 0}, "trophies": 0, "sessionCoinsBankBalance": 0, @@ -605,43 +629,62 @@ SETTINGS = _settings_body() _MI = os.environ.get("FUT_MASSINFO", "full") -# ---- pileSizeClientData: the MY CLUB counter -------------------------------- -# LIVE EVIDENCE (2026-08-04): the user opened MY CLUB, the client fetched GET /club -# and DISPLAYED all 99 players -- and the MY CLUB counter still read 0. So that -# counter is NOT derived from the item list; it is a PILE SIZE, delivered -# separately. massinfo's pileSizeClientData(0x227) is that member and we have never -# sent it. Parser 0x18013adb0: {"entries":[{"key":,"value":}]} -- key and -# value BOTH read with the int getter 0x1801c79d0, and the parser IS skip-safe. +# ---- pileSizeClientData: the TRANSFER LIST + WATCH LIST CAPACITIES ----------- +# CORRECTED 2026-08-06 (q_pilesize_keys.py). The old "MY CLUB counter" theory here +# was WRONG. Parser FUN_18013adb0 has EXACTLY two storing arms and no default: +# key(0x177)==2 -> value -> param_2+0x8 -> model+0x1fd1c = TRADE_PILE_SIZE +# key(0x177)==4 -> value -> param_2+0xc -> model+0x1fd20 = watch-list size +# every other key hits the SKIP handler. So this member is the transfer-list and +# watch-list CAPACITIES, not counts and not the club. The real MY CLUB counter is +# the /hub clubPlayers field (model+0x1fd70+0x3c), which we already serve. # -# The pile-id enum is not recoverable from the strings (the "club"/"tradepile" -# literals are just atom names in the alphabetical key table). So rather than guess: +# This is THE fix for the red "TRANSFER LIST 0/0" and the "TRANSFER LIST FULL" +# refusal on Place on Transfer List: with this member absent, model+0x1fd1c stays at +# its constructor default of 0, so the list has zero capacity and nothing can be +# listed even though trading is now enabled. Confirmed live: byte read 0, client +# said FULL. # -# FUT_PILESIZES=probe -> emit one entry per candidate key 0..15 with a UNIQUE -# recognisable value (100+key). Whatever number MY CLUB then displays names the -# club pile's key: 103 means key 3. One launch identifies the enum. -# FUT_PILESIZES=1 -> emit the REAL counts once PILE_KEY_CLUB below is known. +# key and value both pass through FUN_1800d7b30 (test rcx,rcx / jle -> 0), so values +# must be POSITIVE; -1 does not mean unlimited. 100/50 are the stock FIFA 17 +# convention (nothing in the binary carries a default; the ctor zeroes both). # -# Default OFF: this adds a member to boot-critical massinfo. It is a documented -# member of that parser and carries only ints, so the risk is low -- but "low" is -# what I said about displayGroup before it froze the store, so it ships behind a flag. -_PILESIZES = os.environ.get("FUT_PILESIZES", "") -PILE_KEY_CLUB = int(os.environ.get("FUT_PILE_KEY_CLUB", "-1")) # set once probed +# Freeze risk: LOW. Documented int-only member of the boot-critical massinfo parser, +# skip-safe on unrecognised fields. Instant fallback: FUT_MASSINFO=squad. +# Default OFF for one live test; this adds a member to boot-critical massinfo. +_PILESIZES = os.environ.get("FUT_PILESIZES", "0") == "1" +PILE_KEY_TRADEPILE = 2 +PILE_KEY_WATCHLIST = 4 + + +def marketdata_route(h): + """GET marketdata/pricelimits?defId=a,b,c -- FutGetSuggestedPricing (deser + 0x180163ee0). The response is a BARE TOP-LEVEL ARRAY, one element per requested + defId, each {defId, minPrice, maxPrice}, all scalar ints. + + FROZE THE CLIENT 2026-08-06: we returned an OBJECT {"minPrice","maxPrice"} where + the deser's root loop reads an ARRAY (while tok != 0xd). Object-where-array is the + type-desync busy loop at 0x1801c7f1a. Confirmed live: listing a card at the price + screen pinned a core. Element atoms verified: defId 0xcf, maxPrice 0x1c2, minPrice + 0x1ca, all read via the INT getter 0x1801c79d0, so int values are type-correct. + The container was the whole bug. + + defId can be a comma-separated list. Echo each so the client can match the band to + the item it asked about. Bands are a placeholder (150..15000); real per-item + pricing is a later refinement, not a freeze concern. + """ + from urllib.parse import urlparse, parse_qs + q = parse_qs(urlparse(h.path).query) + raw = q.get("defId", [""])[0] + ids = [int(x) for x in raw.split(",") if x.strip().isdigit()] + return 200, [{"defId": d, "minPrice": 150, "maxPrice": 15000} for d in ids] def pile_size_body(): - """massinfo.pileSizeClientData -- see the note above.""" - if _PILESIZES == "probe": - return {"entries": [{"key": k, "value": 100 + k} for k in range(16)]} - counts = { - "club": len(STORE.items()), - "purchased": len(STORE.purchased()), - "tradepile": len(STORE.listings()), - } - if PILE_KEY_CLUB >= 0: - return {"entries": [{"key": PILE_KEY_CLUB, "value": counts["club"]}]} - # No verified key yet -> announce the club count on every candidate key. Crude, - # but every value is truthful, so no pile can be told a wrong number. - return {"entries": [{"key": k, "value": counts["club"]} for k in range(16)]} + """massinfo.pileSizeClientData -- transfer-list and watch-list CAPACITIES.""" + return {"entries": [ + {"key": PILE_KEY_TRADEPILE, "value": 100}, + {"key": PILE_KEY_WATCHLIST, "value": 50}, + ]} def massinfo(): @@ -1111,7 +1154,7 @@ ROUTES = [ # LIVE GROUND TRUTH: FIFA's market SEARCH hits /transfermarket (one word), not # /auctionhouse (was UNMAPPED -> {} => empty market). Serve the same listings. (re.compile(G + r"/transfermarket"), lambda m, h: auctionhouse_route(h)), - (re.compile(G + r"/marketdata"), lambda m, h: (200, {"minPrice": 150, "maxPrice": 15000})), + (re.compile(G + r"/marketdata"), lambda m, h: marketdata_route(h)), # QUICK SELL. Live-observed 2026-08-04: the reveal screen's "Quick Sell All" # sends POST ut/delete/%s/item -- it was UNMAPPED (catch-all {}), which the # client ACCEPTS (no error, session survives) but which paid 0 coins: the user