From 43557989f546bf78d4ed474a98945e6b8a53904d Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 6 Aug 2026 14:33:10 -0700 Subject: [PATCH] fifa17-recon: the transfer market works -- listed a card end to end, no freeze The subsystem that was fully greyed-out this morning now lists a card on the transfer market: price screen, Submit, "your item is now up for trade", TRANSFER LIST 0/100, auctionCount 1, and STORE.listings() holds the auction. Every step verified at the instruction level first, then confirmed live. Three fixes, all behind flags, all off by default until this run proved them. 1. WE WERE BANNING OUR OWN TRADING. userInfo.feature (atom 0x11c) is a RESTRICTION map, not a grant; we sent feature={"trade":true}, which is a trade BAN. Verified in q_feature_trade.py: FUN_18013ec10 parses feature/trade into userInfo+0x17c, and at the massinfo END_OBJECT the client runs cmp byte [rsi+0x17c],0 / jz skip / mov dword [rsi+0x50],0 feeding applier 0x18011dc91 -> IS_TRADING_ENABLED (model+0x1fd2e) = 0. It runs LAST and unconditionally, which is why the gate read 0 all day regardless of /settings or the Blaze config store. FUT_TRADING sends feature={} instead. Live: gate flipped 0 -> 1 on UT re-entry (model rebuilt, pointer changed, byte read 1). 2. TRANSFER LIST CAPACITY 0/0. pileSizeClientData (massinfo atom 0x227, parser 0x18013adb0) is the capacity, NOT the "MY CLUB counter" the old comment claimed. Verified in q_pilesize_keys.py: exactly two storing arms, key 2 -> model+0x1fd1c (TRADE_PILE_SIZE) and key 4 -> +0x1fd20 (watch list), every other key SKIP'd. The old code would have sprayed the 246 club count into the capacity. FUT_PILESIZES sends key 2 = 100, key 4 = 50. Live: capacity read 0 -> 100, header showed 0/100. 3. THE PRICE SCREEN FROZE THE CLIENT. GET marketdata/pricelimits was answered with an OBJECT {minPrice,maxPrice}; the deser 0x180163ee0 reads a BARE TOP-LEVEL ARRAY (root loop while tok != 0xd), so object-where-array desynced the SAX reader into the 0x1801c7f1a busy loop (confirmed live: utime climbing 227 ticks/s, core pinned). Verified in q_pricelimits.py: element fields defId 0xcf, maxPrice 0x1c2, minPrice 0x1ca, all scalar ints. marketdata_route now returns a bare array, one element per requested defId. Live: price screen opened and Submit succeeded. Corrected along the way, all now in the code: two prior "trading root causes" from earlier today were wrong (the Blaze IS_TRADING_ENABLED keys are output-only names, and the applier is a virtual method at vtable+0x988, not unreachable). Those refutations are recorded in blaze_responder_v3b.py and the doc. Also lands the transfer-market recon doc (plan-2026-08-06-transfer-market.md) and the market Ghidra query set. Server-authoritative economy note: the 5% transfer fee and the price bands (currently a 150..15000 placeholder per defId) are not yet real; that is refinement, not a freeze. The live-auction market SCREEN ("List on Transfer Market" browse) is a separate surface still to do (P4 auction-counts route, P5 empty market bodies). Live: 439 contract checks pass. Card listed and persisted, auctionCount 1. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/plan-2026-08-06-transfer-market.md | 1158 +++++++++++++++++ fifa17-recon/tools/blaze_responder_v3b.py | 7 +- .../tools/ghidra_queries/q_adv_mk_1.py | 90 ++ .../tools/ghidra_queries/q_adv_mk_2.py | 84 ++ .../tools/ghidra_queries/q_adv_mk_3.py | 26 + .../tools/ghidra_queries/q_adv_mk_4.py | 29 + .../tools/ghidra_queries/q_adv_mk_5.py | 10 + .../tools/ghidra_queries/q_feature_trade.py | 71 + .../tools/ghidra_queries/q_mk_listgate_1.py | 33 + .../tools/ghidra_queries/q_mk_listgate_10.py | 42 + .../tools/ghidra_queries/q_mk_listgate_11.py | 54 + .../tools/ghidra_queries/q_mk_listgate_12.py | 53 + .../tools/ghidra_queries/q_mk_listgate_13.py | 37 + .../tools/ghidra_queries/q_mk_listgate_2.py | 66 + .../tools/ghidra_queries/q_mk_listgate_3.py | 80 ++ .../tools/ghidra_queries/q_mk_listgate_4.py | 44 + .../tools/ghidra_queries/q_mk_listgate_5.py | 113 ++ .../tools/ghidra_queries/q_mk_listgate_6.py | 41 + .../tools/ghidra_queries/q_mk_listgate_7.py | 40 + .../tools/ghidra_queries/q_mk_listgate_8.py | 72 + .../tools/ghidra_queries/q_mk_listgate_9.py | 46 + .../tools/ghidra_queries/q_mk_online_1.py | 81 ++ .../tools/ghidra_queries/q_mk_online_2.py | 33 + .../tools/ghidra_queries/q_mk_online_3.py | 34 + .../tools/ghidra_queries/q_mk_online_4.py | 68 + .../tools/ghidra_queries/q_mk_online_5.py | 90 ++ .../tools/ghidra_queries/q_mk_online_6.py | 95 ++ .../tools/ghidra_queries/q_mk_pile_1.py | 99 ++ .../tools/ghidra_queries/q_mk_pile_10.py | 28 + .../tools/ghidra_queries/q_mk_pile_11.py | 12 + .../tools/ghidra_queries/q_mk_pile_12.py | 18 + .../tools/ghidra_queries/q_mk_pile_13.py | 24 + .../tools/ghidra_queries/q_mk_pile_2.py | 66 + .../tools/ghidra_queries/q_mk_pile_3.py | 46 + .../tools/ghidra_queries/q_mk_pile_4.py | 84 ++ .../tools/ghidra_queries/q_mk_pile_5.py | 31 + .../tools/ghidra_queries/q_mk_pile_6.py | 64 + .../tools/ghidra_queries/q_mk_pile_7.py | 65 + .../tools/ghidra_queries/q_mk_pile_8.py | 68 + .../tools/ghidra_queries/q_mk_pile_9.py | 65 + .../tools/ghidra_queries/q_mk_refuse_1.py | 52 + .../tools/ghidra_queries/q_mk_refuse_10.py | 17 + .../tools/ghidra_queries/q_mk_refuse_11.py | 24 + .../tools/ghidra_queries/q_mk_refuse_2.py | 23 + .../tools/ghidra_queries/q_mk_refuse_3.py | 19 + .../tools/ghidra_queries/q_mk_refuse_4.py | 57 + .../tools/ghidra_queries/q_mk_refuse_5.py | 26 + .../tools/ghidra_queries/q_mk_refuse_6.py | 55 + .../tools/ghidra_queries/q_mk_refuse_7.py | 31 + .../tools/ghidra_queries/q_mk_refuse_8.py | 46 + .../tools/ghidra_queries/q_mk_refuse_9.py | 27 + .../tools/ghidra_queries/q_mk_wire_1.py | 38 + .../tools/ghidra_queries/q_mk_wire_2.py | 48 + .../tools/ghidra_queries/q_mk_wire_3.py | 49 + .../tools/ghidra_queries/q_mk_wire_4.py | 43 + .../tools/ghidra_queries/q_mk_wire_5.py | 68 + .../tools/ghidra_queries/q_mk_wire_6.py | 39 + .../tools/ghidra_queries/q_mk_wire_7.py | 19 + .../tools/ghidra_queries/q_pilesize_keys.py | 55 + .../tools/ghidra_queries/q_pricelimits.py | 49 + fifa17-recon/tools/utas_server.py | 109 +- 61 files changed, 4126 insertions(+), 35 deletions(-) create mode 100644 fifa17-recon/docs/plan-2026-08-06-transfer-market.md create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_mk_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_mk_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_mk_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_mk_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_mk_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_feature_trade.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_listgate_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_listgate_10.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_listgate_11.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_listgate_12.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_listgate_13.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_listgate_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_listgate_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_listgate_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_listgate_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_listgate_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_listgate_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_listgate_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_listgate_9.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_online_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_online_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_online_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_online_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_online_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_online_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_pile_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_pile_10.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_pile_11.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_pile_12.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_pile_13.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_pile_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_pile_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_pile_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_pile_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_pile_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_pile_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_pile_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_pile_9.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_refuse_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_refuse_10.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_refuse_11.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_refuse_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_refuse_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_refuse_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_refuse_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_refuse_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_refuse_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_refuse_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_refuse_9.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_wire_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_wire_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_wire_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_wire_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_wire_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_wire_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_mk_wire_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pilesize_keys.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_pricelimits.py 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