From 21a81ad63ca0de277139af273e31499ab25a9885 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 6 Aug 2026 07:43:51 -0700 Subject: [PATCH] fifa17-recon: the real quick-sell table, and the grouping bug is not in our layer Multi-agent pass over the store subsystem, 11 agents, findings run through three adversarial verifiers. Full writeup in docs/plan-2026-08-05-store-subsystem.md. THE REAL DISCARD TABLE IS RECOVERED. quick_sell() paid an invented rating tier (600/300/150/50) that was wrong for every single card. The real table is fcc_discardcoins in the client's own game DB, 141 rows keyed (cardtype, level, rare), read out of the running client and verified 22/22 against live items: value = round_half_up(rating * price / 100) level = 3 if rating >= 75, 2 if 65..74, else 1 (0x180141e8a..0x180141ea3, derived from rating, NOT a wire field) cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and checked across every subtype 0..599 with zero disagreements A 94-rated gold rare is 752, not 600. A 76 rare is 608, not 150. A 55 bronze is 17, not 50. This also closes a disagreement nobody had noticed: the CLIENT already computes and displays the correct value locally whenever our discardValue (atom 0xd7) is 0 or absent. FUN_18013fe00 stores our value at item +0x38 and the guard at 0x180141025 skips the local computation when it is non-zero. So the screen has been showing the real number while the server paid a made-up one, on every quick sell ever made. Verified beyond what the report claimed, because a missing table row pays ZERO and that would be a regression the old flat tier could not produce: across all 236 items in the live profile, 230 map to cardtype 1 and 6 to cardtype 6, and NOT ONE would pay 0 coins. Table reproduces at 141 rows and the worked example lands exactly. ZERO WIRE CHANGE, FUT_DISCARD_TABLE default off. Nothing new is sent; only the coin figure the server credits moves. This is the patch worth defaulting on after one in-game check, which is simply quick-selling a card and seeing the coins paid match the value the card was already displaying. THE GROUPING BUG IS NOT IN CARDSDLL, and the fix ranked first would have wasted a launch. Live in the running client all three display groups own exactly the right pack, there is exactly one copy of each pack record in 4 GiB, and nothing we send is mis-parsed. The parsed model is correct and the Scaleform layer picks the wrong pack when turning a tile click into a category id. displayGroupAssetId is served as 1/5/6 while the screen's category field reads 3, and group tiles carry a hardcoded CATEGORY_ID of 0. Confirmed by direct read: ordinal 3, assetId 6, i.e. Premium, while the last click was Gold. The heap map that made this possible, all scoped to one pid: display-group vector control block, 3 elements of 0x108; group record fields at +0x00 sortPriority, +0x04 displayGroupAssetId, +0x40 a one-element pack vector; inner pack record 0x1a8 with packType at +0x38, ids at +0x70/+0xac, price at +0xa0, quantities at +0xc0..+0xd0. extPrice SHOULD BE DELETED, not corrected. Both sub-parsers read only externalPriceId; amount and currency are discarded. Sending the key at all creates an "mtx" currency row that switches on a real-money price line the client can never fill offline, which is the literal "or %1s" on every tile. A WORRY NOBODY HAD RAISED, and I confirmed it from our own logs: the client has sent packId 6 on every purchase it has ever made, four for four tonight and six for six across history. We have never observed a successful buy of anything but Premium Gold. Also settled: FUT_STORE_DISPLAYGROUP=0 is the right resting state, argued from mechanism rather than from history; FUT_USERINFO=packs stays off because the unopened-pack counter is client-mutable and the flag ladder silently drops squadList; POST /user is a latent hard freeze that has never fired because the client never issues that POST. Honest coverage: the ActionScript layer is unread by everyone and every remaining store mystery lives there. Live: 439 contract checks pass, market suite passes, both flags off. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/plan-2026-08-05-store-subsystem.md | 1171 +++++++++++++++++ fifa17-recon/tools/fut_store.py | 95 ++ fifa17-recon/tools/ghidra_queries/q_adv_1.py | 84 ++ fifa17-recon/tools/ghidra_queries/q_adv_2.py | 97 ++ fifa17-recon/tools/ghidra_queries/q_adv_3.py | 72 + fifa17-recon/tools/ghidra_queries/q_adv_4.py | 29 + fifa17-recon/tools/ghidra_queries/q_adv_5.py | 30 + .../tools/ghidra_queries/q_group_1.py | 100 ++ .../tools/ghidra_queries/q_st_dup_1.py | 100 ++ .../tools/ghidra_queries/q_st_dup_10.py | 75 ++ .../tools/ghidra_queries/q_st_dup_11.py | 60 + .../tools/ghidra_queries/q_st_dup_12.py | 94 ++ .../tools/ghidra_queries/q_st_dup_13.py | 55 + .../tools/ghidra_queries/q_st_dup_14.py | 28 + .../tools/ghidra_queries/q_st_dup_15.py | 42 + .../tools/ghidra_queries/q_st_dup_16.py | 15 + .../tools/ghidra_queries/q_st_dup_17.py | 48 + .../tools/ghidra_queries/q_st_dup_18.py | 29 + .../tools/ghidra_queries/q_st_dup_2.py | 62 + .../tools/ghidra_queries/q_st_dup_3.py | 110 ++ .../tools/ghidra_queries/q_st_dup_4.py | 80 ++ .../tools/ghidra_queries/q_st_dup_5.py | 89 ++ .../tools/ghidra_queries/q_st_dup_6.py | 102 ++ .../tools/ghidra_queries/q_st_dup_7.py | 65 + .../tools/ghidra_queries/q_st_dup_8.py | 66 + .../tools/ghidra_queries/q_st_dup_9.py | 71 + .../tools/ghidra_queries/q_st_group_1.py | 85 ++ .../tools/ghidra_queries/q_st_group_2.py | 59 + .../tools/ghidra_queries/q_st_group_3.py | 51 + .../tools/ghidra_queries/q_st_group_4.py | 56 + .../tools/ghidra_queries/q_st_group_5.py | 50 + .../tools/ghidra_queries/q_st_group_6.py | 49 + .../tools/ghidra_queries/q_st_group_7.py | 57 + .../tools/ghidra_queries/q_st_group_8.py | 50 + .../tools/ghidra_queries/q_st_price_1.py | 149 +++ .../tools/ghidra_queries/q_st_price_2.py | 104 ++ .../tools/ghidra_queries/q_st_price_3.py | 91 ++ .../tools/ghidra_queries/q_st_price_4.py | 43 + .../tools/ghidra_queries/q_st_qs_1.py | 162 +++ .../tools/ghidra_queries/q_st_qs_2.py | 164 +++ .../tools/ghidra_queries/q_st_qs_3.py | 121 ++ .../tools/ghidra_queries/q_st_qs_4.py | 94 ++ .../tools/ghidra_queries/q_st_qs_5.py | 90 ++ .../tools/ghidra_queries/q_st_qs_6.py | 73 + .../tools/ghidra_queries/q_st_qs_7.py | 101 ++ .../tools/ghidra_queries/q_st_qs_8.py | 66 + .../tools/ghidra_queries/q_st_qs_9.py | 89 ++ .../tools/ghidra_queries/q_st_unop_1.py | 59 + .../tools/ghidra_queries/q_st_unop_2.py | 72 + .../tools/ghidra_queries/q_st_unop_3.py | 45 + .../tools/ghidra_queries/q_st_unop_4.py | 59 + .../tools/ghidra_queries/q_st_unop_5.py | 55 + .../tools/ghidra_queries/q_st_unop_6.py | 31 + .../tools/ghidra_queries/q_st_unop_7.py | 25 + .../tools/ghidra_queries/q_st_unop_8.py | 33 + fifa17-recon/tools/ghidra_queries/q_zver_3.py | 91 ++ fifa17-recon/tools/ghidra_queries/q_zver_4.py | 50 + fifa17-recon/tools/ghidra_queries/q_zver_5.py | 44 + fifa17-recon/tools/utas_server.py | 30 + 59 files changed, 5267 insertions(+) create mode 100644 fifa17-recon/docs/plan-2026-08-05-store-subsystem.md create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_group_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_10.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_11.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_12.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_13.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_14.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_15.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_16.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_17.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_18.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_dup_9.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_group_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_group_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_group_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_group_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_group_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_group_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_group_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_group_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_price_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_price_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_price_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_price_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_qs_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_qs_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_qs_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_qs_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_qs_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_qs_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_qs_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_qs_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_qs_9.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_unop_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_unop_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_unop_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_unop_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_unop_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_unop_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_unop_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_st_unop_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_zver_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_zver_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_zver_5.py diff --git a/fifa17-recon/docs/plan-2026-08-05-store-subsystem.md b/fifa17-recon/docs/plan-2026-08-05-store-subsystem.md new file mode 100644 index 0000000..af75499 --- /dev/null +++ b/fifa17-recon/docs/plan-2026-08-05-store-subsystem.md @@ -0,0 +1,1171 @@ +# The store subsystem: grouping, prices, quick sell + +Written 2026-08-05, later the same evening as `plan-2026-08-05-pack-opening.md`. +Five parallel reversing passes over the store catalogue, the display-group +resolver, the FIFA Points price line, the duplicate list and the unopened-pack +counter, plus three adversarial verification rounds that refuted four claims and +corrected eleven more. FIFA 17 was running throughout as pid 134663, in the store +screen, and was read strictly read-only. No server was restarted and no server +code was changed. + +Slide used for every live read in this document, re-derived from +`/proc/134663/maps` and the on-disk PE by three separate agents rather than +asserted: `live = static - 0x180000000 + 0x6ffffc140000`. Controls were the FNV +hasher prologue at `0x180180d00` (`.text`) and the single-occurrence literal +`RS4:FutSquadSaveServerResponse` at `0x18022c618` (`.rdata`, located by searching +the file rather than by trusting an address). One verifier added a third control: +the instruction bytes `41 81 fe 1a 01 00 00` at `0x180139245`, which also proves +the running code is unpatched. All three matched byte for byte, every time. The +live addresses die with the process; the static ones do not. + +--- + +## 1. What we now know that we did not know this morning + +**The store grouping bug is not in CardsDLL.** Every display group in live memory +owns exactly the right pack. The group captioned "Gold Pack", ordinal 2, contains +one pack record and that record is the 5000-coin, 7-item, `id` 5, `packType` +`GOLD` one. Bronze owns bronze, Premium owns premium. An exhaustive pointer sweep +over all 4186 MiB of readable memory finds exactly one referrer per node and no +second copy of any pack record anywhere in the address space, and a separate +verbatim-byte-window search (76 bytes spanning id, price and all five quantities, +matched at any alignment) also returns exactly one hit each. There is no stale +copy, no aliased group, no mis-parse. Nothing we send is being misread. + +That is a negative result and it leads this document because it deletes an entire +class of work. Every fix we were about to spend a launch on was a change to what +the server sends, aimed at a parser that is already producing the correct answer. +The fault is downstream of the C++ model, in the Scaleform layer that turns a +click on a tile back into a category id, and that layer is in Denuvo-packed +`FIFA17.exe` and in `.apt` movie bytecode nobody has read. + +**The fix that was ranked first is refuted, and it would have cost a launch.** +One pass recommended setting `displayGroupAssetId` equal to the group's ordinal so +that the tile's `ASSET_ID` and `CHILD_CATEGORY` Flash fields would carry the same +number and the drill-down would be right whichever one the movie echoes back. The +report's own live datum kills it. `displayGroupAssetId` is currently served as +1, 5, 6, and the store screen's selected-category field reads **3**. Three is not +in that set, so `ASSET_ID` is not what comes back. Worse, `FUN_180014610`, the +group-tile builder, writes the tile's own `CATEGORY_ID` field (`model+0x94`) +as literal **0** and puts the ordinal in `CHILD_CATEGORY` (`model+0x9c`), so a +group tile does not even carry a category id for the movie to echo. The +mechanism the recommendation rested on does not exist. + +**The real FIFA 17 quick-sell value table is recovered, complete, and verified.** +The client does not need the server to tell it what a card is worth and it does +not invent a number: it runs a SQL query against a table in its own loaded game +database, `fcc_discardcoins`, 141 rows keyed `(cardtype, level, rare) -> price`, +and scales by rating. The full table, the formula, the `cardsubtypeid -> cardtype` +map and the rating-to-level rule are all in section 3.6 of this document, checked +against 22 live club-item structs with 22 of 22 exact. Our `quick_sell()` pays an +invented 600/300/150/50 tier that is wrong for every card: a 94-rated gold rare is +worth 752, not 600, and a 50-rated bronze common is worth 15, not 50. This is a +pure server-side arithmetic fix with zero wire risk, and it is the largest piece +of finished, shippable work this run produced. + +**There is no correct `extPrice` JSON. The correct `extPrice` is no `extPrice`.** +Both inner deserializers read exactly one key, `externalPriceId` (0x11a, INT); +`amount` and `currency` are discarded by the value-SKIP. But before parsing +anything, both of them look up, and if missing **create**, an entry named `mtx` +in the pack's currency vector, and the tile adapter turns the real-money price +line on purely because that row exists. The number that should fill it is fetched +from the Origin/Dime commerce catalogue by `externalPriceId`, a catalogue that no +longer exists offline, so the formatter early-returns and every string stays at +its constructor default. That is where the `or %1s` on the tile comes from: a +Scaleform format string whose only possible argument is one of four +catalogue-sourced strings that were never written. Sending `extPrice` at all is +what switches the broken line on. Deleting the key strictly reduces executed code, +including an unguarded call into the packed exe's commerce vtable that currently +runs on every store load. + +**The 0x108 versus 0x158 versus 0x1a8 confusion is resolved, and it matters +because three previous documents compute offsets from the wrong one.** There are +three structures, all real, none of them a contradiction: + +| size | what it is | built by | +|---|---|---| +| `0x158` | the deserialized **wire pack record**, one per element of `purchase[]` | `FUN_18013af30`, copy-ctor `FUN_1801340e0`, grow `FUN_180132180` | +| `0x108` | the **display group** | `FUN_180012950`, pushed by `FUN_1800150d0` | +| `0x1a8` | the **store-tile view model**, used for both pack rows and group tiles | `FUN_18002c3c0` from the wire record, `FUN_180014610` for group tiles | + +The ground-truth pass declared the 0x158 record non-existent because a +stride-0x158 memory search found nothing. It found nothing because the wire +records are transient: `FUN_1800150d0` converts them and the response object is +freed. Its secondary evidence, that `FUN_1801340e0` "carries 0x108 three times and +0x158 never", was an artefact of counting `imm32`s past the end of the function: +those three 0x108s are the field addresses `param_1 + 0x108` and `param_2 + 0x108` +of a `FUT String` member inside the 0x158 record, and two of the cited addresses +are inside the *next* function, the record constructor at `0x1801342d0`. Two +verifiers decompiled `FUN_1801340e0` in full and it is a memberwise copy with no +striding of any kind, last member at `+0x154`. **The task premise that was +declared "contradicted by live memory" was in fact exactly right**: +`displayGroupAssetId` really is at wire record `+0x30`, `displayGroup.priority` at +`+0x34`, `displayGroupUseDefaultImage` at `+0x38`. Restore those. + +Three smaller things worth carrying forward: + +**`CREATEPACK` in every parsed pack record is a constructor default, not a client +decision.** `FUN_1801342d0` initialises the record's string at `+0xd8` from the +literal at `0x180223110`, which is the only occurrence of `CREATEPACK` in the file +and has exactly one code reference, that one. It reaches the tile at `+0x08` and +is pushed to Flash as `POST_PURCHASE_ACTION`. It is not a member of the +`TRANSACTIONCANCEL` state vocabulary and it tells us nothing about the +CreatePack-versus-PurchaseItems fork. The same literal also exists twice in the +Scaleform string pool, so it is a UI constant as well. + +**`unopenedPacks` is not a display counter, and `FUT_USERINFO=packs` should stay +off.** One pass reported the counter at `model+0x20950` as read by three consumers +with no HTTP path. A verifier found nine getter call sites in six functions and +six setter sites in six functions, three of which read-modify-write it. One of the +missed ones, `FUN_180019780`, reads the counter, increments it by the number of +set booleans in a response, writes it back, and then dispatches a request whose +vtable `0x1801ed690` has `FUN_180124ee0` at slot `+0x20`, the `FutGetPurchasedItems` +deserializer. So the counter is client-mutable and is wired to a fetch. At count 0 +the field buys nothing; above 0 it enters a subsystem with client-side arithmetic +that nobody has audited. Separately the ladder is non-monotonic: `packs` does not +include `squadList`, so switching from the current `roster` default would remove +the MY SQUADS roster that is known working. + +**`POST /ut/game/fifa17/user` is a latent hard freeze and has never been +exercised.** The body `user_post()` builds sends `login` as a bool, `bonusPacks` +as an array and `starterPack` as an object. The real shapes are the opposite in +every case: `login` is the `userInfo` record object, `bonusPacks` is a bool, +`starterPack` is an array of shared ITEM objects. A bool fed to the `login` arm +makes the `userInfo` deserializer swallow the rest of the body through its +container-aware SKIP, after which the tokenizer returns 8 or 1 at EOF, never 10, +and the CreateUser loop re-dispatches the stale atom forever inside +`FUN_1801c7f10`. The project's known freeze PC `0x1801c7f1a` is ten bytes into +that function. One verifier upgraded this from MEDIUM to HIGH by reading the EOF +path in raw assembly rather than from a decompile. It has never fired because the +client's own `ProtoHttp` user agent has never issued that POST: all 48 `GET /user` +hits in the log are the Python contract suite, and `POST /user` appears zero times +in any log. + +--- + +## 2. The grouping bug + +### 2.1 Current best explanation + +The chain, all of it decompiled and most of it live-confirmed: + +`FUN_1800150d0` walks the `purchase[]` array in **array order**. For each pack it +takes `displayGroup.value` (wire record `+0x00`) and calls `FUN_180014380`, a +plain string compare against `group+0x70` across the group vector at stride +0x108. On a miss it calls `FUN_180012950` to build a new group whose `+0x00` is a +**1-based ordinal equal to the number of groups so far plus one**, `+0x04` is +`displayGroupAssetId`, `+0x100` is `displayGroup.priority` and `+0x104` is +`value == "mypacks"`. Three copies of the caption go to `+0x70`, `+0xa0` and +`+0xd0`, and they are not redundant: `+0x70` is the lookup key, `+0xa0` becomes +the tile NAME and `+0xd0` becomes its DESCRIPTION and CONTENT. The pack is +converted by `FUN_18002c3c0` into a 0x1a8 tile model and pushed into `group+0x40`. + +Rendering is `FUN_18007dab0` calling `FUN_1800147f0(model, screen+0x290, +dataProvider, 0, 0)`. `screen+0x290 == 0` means "list the group tiles" +(`FUN_180014610`); any other value goes to `FUN_180014420`, which exact-matches +`group+0x00` and returns NULL on a miss, after which `FUN_1800147f0` dereferences +`[RAX+0x40]` with no guard. The absence of that guard was checked in raw +disassembly against a same-form control (the compiler does emit `CMP`/`JZ` twenty +instructions later where the source has one), so it is a real absence. **The only +legal values of `screen+0x290` are 0 and the ordinals 1..N.** + +`screen+0x290` is written in exactly two places in CardsDLL, and this was checked +by enumerating not just displacement-0x290 writes but qword writes at 0x288 and +0x28c that would cover the byte, plus XMM stores over the range: the screen +constructor `FUN_18007d1a0` writes 0, and `FUN_18007e7f0` case `0x7551` copies the +Flash message field `CATEGORY_ID` verbatim and posts `0x753f`. Thirteen other +functions write that displacement and none of them appears in the store screen's +vtable `0x1801ff690`. So the group ordinal makes a round trip through the movie +and comes back as `CATEGORY_ID`. + +Live right now: the store screen object is at `0x41934180`, `+0x290` is 3 and +`+0x294` (`SERVER_ID`) is 6. Sampled three times over fifteen minutes, stable. +That state is fully self-consistent with **correct** behaviour: category 3 is the +Premium group and server id 6 is the Premium pack. It is not by itself evidence +of the bug. What it does establish is that the number the movie sends lives in +ordinal space, which eliminates `ASSET_ID` (1, 5, 6) as the carrier. + +That leaves two candidates for what the movie echoes as `CATEGORY_ID`. The tile's +own `CHILD_CATEGORY` field, which `FUN_180014610` sets to the group ordinal, or +`GetActiveTabId()`, which `StoreFront::populateCategory` uses when it sends +`ACTION_GET_PACKLIST`. **I would bet on `CHILD_CATEGORY`**, because 3 is a valid +ordinal and an unbound tab id is -1, and because six-tab lookups are currently +all missing (below), so a tab-derived id would be wrong for Bronze too and Bronze +resolves correctly. If `CHILD_CATEGORY` is the carrier then the fault is not in +which number a tile holds but in which tile the movie thinks was clicked, and the +suspects are the highlight events `ACTION_STORE_ITEM_GROUP_HIGHLIGHTED` and +`StoreFront::cbfnOnRowUpdate`. That is a movie-side selection desync, and no +field we send can fix it directly. + +There is a second, structural problem sitting underneath, and it is the one we +can actually act on. The FIFA 17 store UI is a fixed six-tab category bar bound by +hardcoded lowercase strings. `FUN_180014580` is a switch 0..5 selecting the +literals `mypacks`, `points`, `bronze`, `silver`, `gold`, `special`, each passed +to the same `FUN_180014380` caption compare; `FUN_180014df0` is the same table as +an existence test; `FUN_18007e5e0` gives each of the six UI panels `PANEL_ID` = the +matching group's ordinal or **hides the panel**; `FUN_18007df60` publishes +`MYPACK_/BRONZE_/SILVER_/GOLD_/SPECIAL_/POINTS_CATEGORY_ID` to the movie. Our +captions are "Bronze Pack", "Gold Pack" and "Premium Gold", so all six lookups +miss, all six ids are -1 and all six panels are hidden. The store is running with +no bound tabs at all. Whatever the movie does with `GetActiveTabId()` in that +state, it is doing it in a configuration EA never shipped. + +One live datum is consistent with the movie being in a strange place: the store +screen is in a visible re-entry loop. Reconstructed telemetry shows the cycle +`StoreFront -> livemessaging -> gameHub -> FUT_STORE -> FUT_WAIT_FOR_STORE -> +StoreFront` twice, matching seven `RS4::StorePackTypes sz:1908` entries in the +client's own trace ring buffer for one session. Not diagnostic on its own, but +worth watching after any change. + +And one datum that should worry us more than it has: **the client has sent +`packId` 6 on every purchase it has ever made.** Six `POST /purchased/items` in +the logs, six occurrences of `"packId":6`, zero of 1 or 5, corroborated by the +client's own telemetry (`"store_id":"6"`). So we have never observed a successful +purchase of anything other than the Premium pack, in any configuration, grouped +or ungrouped. The purchase id is statically resolved: `SERVER_ID` is tile `+0x70`, +which `FUN_18002c3c0` fills from wire `+0x70` truncated to 16 bits, i.e. the `id` +field, not `assetId` and not `displayGroupAssetId`. So the question "does anything +but pack 6 buy" is genuinely open and is not the same question as "does the Gold +tile open the Gold group". + +### 2.2 Should we sit at `FUT_STORE_DISPLAYGROUP=0`? + +**Yes. Sit at 0 between experiments and switch it on only for the launch being +tested.** But the argument has to be made from mechanism, not from history, +because the history is uncited. + +The mechanism is sound. With no `displayGroup` key, wire `+0x00` keeps its +constructor default on every pack, `FUN_180014380` matches the first group every +time, and all three packs land in one group with ordinal 1. There is then exactly +one legal value of `screen+0x290` besides 0, the six-tab bar is irrelevant, and +there is no group-to-pack round trip through the movie left to get wrong. The +packs are listed as rows in that one group and each row carries its own +`SERVER_ID`, which is the value the buy uses. Fewer moving parts, and the ones +that remain are the ones we have direct evidence work. + +The uncited part is the claim that this configuration had "3 of 3 packs buyable" +against the current "2 of 3". No log line, no capture and no memory read supports +it, and the purchase log actively fails to support it: every purchase ever +recorded is `packId` 6. Do not let a decision rest on the 3-of-3 number. Rest it +on the mechanism, and treat "can the ungrouped layout buy pack 1" as an open +question that experiment 1 below answers for free. + +Ugly and working still beats pretty and unbuyable. But we should stop saying +"working" until a `packId` other than 6 appears in the log. + +### 2.3 Ranked experiments + +One launch each unless marked otherwise. Ranking is the deliverable. + +**#0. Free, no launch, do it before anything else while pid 134663 is alive.** +Ask the user to click the Gold Pack tile, then read `0x41934180 + 0x290` +(read-only, this pid only; re-resolve the pid by `comm` and re-check the two +controls first, and if the client has been relaunched this experiment needs a +fresh object address, found by scanning writable memory for the slid ctor vtable +`0x1801ff690 -> 0x6ffffc33f690` with `+0x138` and `+0x2d0` matching). Cost: zero. +Mechanism: `screen+0x290` is the category the resolver will use, written straight +from the movie's `CATEGORY_ID`. Outcomes: **2** means the movie sent the right +ordinal, the drill-down is correct and the reported symptom is something else +entirely, probably the tile captions or the detail panel, and the whole ranking +below is aimed at the wrong thing. **3** means the Gold tile really does produce +the Premium ordinal, and since `ASSET_ID` is eliminated, the fault is in the +movie's tile-to-click association or in the unbound tab path. Falsifier for the +whole exercise: any value outside {0, 1, 2, 3} would mean something writes that +field from outside CardsDLL and the ordinal model is incomplete. + +**#1. Sit at `FUT_STORE_DISPLAYGROUP=0` and buy two different packs.** Cost: the +launch you were going to do anyway to get back to a usable store. Mechanism: as +in 2.2, one group, one legal category, no round trip. What it tests that has never +been tested: whether a `packId` other than 6 ever reaches the wire. Watch +`/tmp/utas_server.log` for `POST /purchased/items`. Falsifier: if buying the +Bronze row still sends `"packId":6`, the bug is not grouping at all, it is row +selection, and every experiment below is moot. This is ranked above the candidate +fixes precisely because it can invalidate them. + +**#2. Serve `displayGroup.value` as three distinct canonical lowercase tokens: +`bronze`, `gold`, `special`.** Mechanism: those are three of the six literals +`FUN_180014580` hardcodes. Binding them gives three of the six panels a real +`PANEL_ID` instead of -1 and makes `GOLD_CATEGORY_ID` and friends carry the +group's ordinal, which means the category id can come from CardsDLL instead of +surviving a round trip through an unbound tab bar. It keeps one pack per group, so +it does not touch the never-exercised multi-pack-per-group path. Cost: the Premium +pack appears under the Specials tab, and tile captions become the client's own +localised tab labels rather than our strings, so the "unknown" cosmetics problem +comes back in a different form. Send them in exact lowercase; whether the compare +thunk `FUN_1800081c0` is case sensitive was never established. Falsifier: if the +tab bar still does not appear, or a category comes back empty +(`FUT_ZERO_PACKS_AVAILABLE`, `StoreFront::_OnEmptyCategory`), the tab theory is +dead and we are back to the movie-side selection hypothesis. + +**#3. Only if #2 binds the tabs but you want Premium under Gold: merge to +`bronze`, `gold`, `gold`.** Mechanism: identical, one fewer group. This is how the +real FUT 17 catalogue was shaped. Cost and risk: it is the first time any group +has ever held two packs. That activates `FUN_1800159b0`'s `BEST_COINS_PRICE` and +`BEST_POINTS_PRICE` loops and the `sortPriority` merge sort in `FUN_180010890`, +neither of which has ever run with more than one element, and the second of those +price loops decompiles oddly enough that one agent flagged it. Falsifier: the gold +tab shows one pack instead of two, or a price of 0. + +**#4. Do not spend a launch on these.** `displayGroupAssetId = ordinal` is +refuted (section 1). `displayGroupUseDefaultImage` only chooses whether the tile's +`OVERRIDE_ASSET_ID` is the assetId or -1, and note it is **not** the field that +gates that: the adapter's gate is `useDefaultImage` (atom 0x36a) at wire `+0xcc`, +a different atom, while `displayGroupUseDefaultImage` (0xdb) at wire `+0x38` only +picks the group's `packs_backgrounds_%d.dds` texture. `state`, `saleType`, +`quantity`, prices and contents are already correct in the live model, measured +field by field. + +**A note on `sortPriority` and `displayGroup.priority`, which one pass called +inert dead ends.** That was a false absence, caught by a verifier searching the +whole `.text` listing rather than reading eight functions. `FUN_180016ef0` walks +the group vector and calls `FUN_180011480` on the groups, whose comparator reads +`group+0x100`, which is `displayGroup.priority`, as a median-of-3 introsort key; +then for each group it calls into `FUN_180010890`, whose merge key is +`model+0x1a0`, which is `sortPriority`. Both have readers. Neither explains the +current symptom, because an introsort permuting the group vector permutes each +group's caption and ordinal together, so the pairing a tile presents cannot break +that way, and the live vector is in creation order anyway. But an introsort on +all-equal keys is not order preserving, and we send neither field, so this is a +landmine: a future response could reorder the tiles without changing any ordinal. +If you ever want deterministic tile order, send distinct `displayGroup.priority` +values, and expect the order to change. + +**A second landmine, found unprompted.** A group is only rendered as a tile if it +contains at least one pack whose tile `+0x00` visible byte is non-zero +(`FUN_180014610` gated on `FUN_18002c8b0`, and the per-row loop applies the same +test at `0x1800148d9`). That byte comes from wire `+0x138`, written only by atom +`0x37d` `visible`, a key we do not send, and it is currently 1 on all three packs, +so nothing is hidden today. If a future response ever makes it false, a whole tile +vanishes and every later tile shifts up while its `CHILD_CATEGORY` does not. + +--- + +## 3. The store and pack wire model as now understood + +Confidence is marked per element. CONFIRMED means an address plus either a live +read or two independent decompiles that agree. INFERRED means the code says so and +nothing has contradicted it. UNKNOWN means exactly that. + +### 3.1 `GET ut/%s/store/purchasegroup/...` root + +CONFIRMED. Deser `0x1801234e0`. Two keys: `purchase` (0x260) is an ARRAY of pack +objects each dispatched to `FUN_18013af30`; `timestamp` (0x31b) is an INT to +`[rdi+0x5c]`. The body we serve is 1908 bytes and the client's own trace ring +buffer logs it as `RS4::StorePackTypes sz:1908`. + +### 3.2 The 0x158 wire pack record, deser `FUN_18013af30` + +Dispatch in this function is a **jump table for atoms below 0x119 plus running-sum +`sub`/`cmp` ladders above it**. An immediate grep for an atom value returns zero +hits even for atoms that provably have arms. Do not make absence claims about this +parser without enumerating the table at `0x18013b001`/`0x18013b7ba` and decoding +the ladders. + +CONFIRMED offsets, from the frame arithmetic of the stack struct plus the field +types recovered from the copy constructor `FUN_1801340e0`: + +| key | atom | type | wire offset | notes | +|---|---|---|---|---| +| `displayGroup` | 0xd9 | flat OBJECT | | `value` (0x377, STR) to `+0x00`, `priority` (0x250, INT) to `+0x34` | +| `displayGroupAssetId` | 0xda | INT | `+0x30` | premise restored, see section 1 | +| `displayGroupUseDefaultImage` | 0xdb | BOOL | `+0x38` | group texture only | +| `currencies` | 0xc5 | ARRAY | `+0x40..+0x48` | 0x30-byte records via `FUN_180138bd0` | +| `extPrice` | 0x119 | OBJECT | | see 3.4 | +| (id) | see below | INT | `+0x70` | truncated to u16 into tile `+0x70` = `SERVER_ID` = purchase `packId` | +| `assetId` | 0x23 | INT | `+0x74` | tile `+0xac` = `ASSET_ID` | +| `firstPartyStoreId` | 0x127 | STR then `atoi()` | `+0x78` | an int here is the documented freeze | +| `sortPriority` | 0x2cb | INT | `+0x7c` | tile `+0x1a0`, merge-sort key | +| `description` | 0xd1 | STR | `+0x80` | tile caption | +| `state` | 0x2eb | STR vs `"active"` | `+0xb0` | ctor default 0, not active | +| `start` | 0x2e3 | INT, clamped | `+0xb4` | **top level, not inside packContentInfo** | +| `end` | 0x102 | INT | `+0xb8` | | +| `quantity` | 0x26b | INT | `+0xbc` | a sent 0 becomes -1 | +| `purchaseLimit` | 0x265 | INT | `+0xc0` | | +| `purchaseCount` | 0x261 | INT | `+0xc4` | copied to tile `+0x78` | +| `limitType` | | STR to enum | `+0xc8` | tile `+0x90`, Flash `SALE_TYPE`; `NONE` 0, `QUANTITY` 1, `TIME` 2, `TIME_QUANTITY` 5 | +| `saleType` | 0x298 | STR | | `promo` lands at tile `+0xbb` `IS_PROMO` | +| `dealType` | 0xcc | STR, lowercased | `+0xcf/+0xd0` | | +| `useDefaultImage` | 0x36a | BOOL, stored inverted | `+0xcc` | gates tile `+0xb0` `OVERRIDE_ASSET_ID` | +| `unopened` | 0x35d | BOOL | `+0xcd` | **top level**; tile `+0x69`; live 0 on all three | +| `isPremium` | 0x176 | BOOL | `+0xce` | tile `+0xb8` | +| `actionType` | 0x08 | STR | `+0xd8` | ctor default `"CREATEPACK"`; tile `+0x08` `POST_PURCHASE_ACTION` | +| `packType` | 0x20f | STR | `+0x108` | ctor default `"INVALID"`; tile `+0x38`; **no Flash consumer** | +| `visible` | 0x37d | presence only | `+0x138` | value never read; gates whether the tile renders | +| `points` | 0x240 | INT | `+0x140` | | +| `packContentInfo` | 0x20c | flat OBJECT | `+0x144..+0x154` | exactly five children | + +`packContentInfo` has exactly five children and nothing else: `itemQuantity` +0x170 to `+0x144`, `goldQuantity` 0x149 to `+0x148`, `silverQuantity` 0x2c6 to +`+0x14c`, `bronzeQuantity` 0x63 to `+0x150`, `rareQuantity` 0x273 to `+0x154`. +The inner loop's default reaches the safe SKIP and jumps back into the **inner** +loop; the `unopened`/`start` ladder's default jumps back into the **outer** loop. +Two different back edges, which is how the nesting was proven. Our `_pack_body()` +sends `"unopened": false` inside `packContentInfo`, which is therefore inert, and +`packContentInfo` does reach the tile: the live tiles read 5/7/11 items. + +UNKNOWN: which atom writes wire `+0x70`. It is almost certainly `id` (0x15c) and +the verifier calls it that, but we serve `id` and `assetId` identically so no live +read can separate them, and the arm was not located in the jump table. This +matters more than it looks: wire `+0x70` is `SERVER_ID`, and `SERVER_ID` is the +`packId` the purchase sends. `ENDPOINT_MAP.md` currently claims `id` is skipped +entirely, which cannot be right if `+0x70` is fed from anywhere. + +`packType` deserves a note because two agents disagreed. One argued it is the +"strongest candidate lookup key" for the mis-resolution, on the grounds that it is +the only string field where Gold and Premium collide while Bronze differs. It is +not a key for anything: it lands at tile `+0x38`, and a full dump of +`FUN_180015d80`'s Flash push list (32 fields) does not contain `+0x38`. The +{Bronze} versus {Gold, Premium} partition is a coincidence of two packs sharing a +tier. The six store tabs key on `displayGroup.value`, not on `packType`. + +### 3.3 Currencies, `FUN_180138bd0`, 0x30 stride + +CONFIRMED, and this is the canonical example of the sub-ladder dispatch form: +`sub edx,0x124` then `sub edx,0x10` then `cmp edx,0x9c`, so no single immediate +equals any atom. `name` 0x1d0 STR to `+0x00`, `finalFunds` 0x124 INT to `+0x24`, +`funds` 0x134 INT to `+0x20`, both through the non-negative clamp +`FUN_1800d7b30` so a negative price silently becomes 0. + +The asymmetry here is real and was verified twice. The **store tile** takes its +coin price from `finalFunds` (`elem+0x24`, adapter at `0x18002c75b`), which +matches tonight's live probe where `funds=15000, finalFunds=4321` rendered 4,321. +The **HUD balance** parser `FUN_180122c50` takes `funds` (`elem+0x20`) for all +three recognised names, `coins`, `points` and `DRAFT_TOKEN`. Same array, two +fields, two consumers. + +The only server-side lever for a FIFA Points number on a tile is a second +currency entry named lowercase `points`, which the adapter maps to tile `+0xa4` +and `+0xa8` behind flag `+0xb7`, gated by a call into the commerce manager whose +return value is unknowable statically. INFERRED, medium. Note that the `mtx` arm +and the `points` arm **both write tile `+0xa4`**, so with both rows present the +later vector element silently wins. Never test a `points` row with `extPrice` +still in place. + +### 3.4 `extPrice`, and why it should go + +CONFIRMED, by two agents with different toolchains, one of them printing both +functions end to end from `.pdata`-derived bounds. `FUN_180139070` (finalPrice, +0x227 bytes) and `FUN_18013aae0` (originalPrice, 0x220 bytes) each contain +exactly one atom comparison, `cmp r,0x11a`, for `externalPriceId`, an INT. +Neither reads `amount` (0x1b) or `currency` (0xc4); both go to the value-SKIP. +Neither function contains an indirect jump, a sub ladder or a switch, and the atom +register is seeded to `0x38c`, a value with no atom, so nothing can fall through. +`finalPrice` writes its value to both `[rdi+0x2c]` and the pack's +`firstPartyStoreId` slot at `+0x78`, so `extPrice.finalPrice` and top-level +`firstPartyStoreId` are the same slot reached two ways. + +The side effect is the whole story. Before parsing, both scan the pack's currency +vector for an entry named `mtx` and create one if absent, memmoving 3 bytes from +the literal at `0x1801efea0` (`"mtx\0coins\0"`). The adapter then sets tile +`+0xb5 = 1` at `0x18002c729`, the only write to that byte outside the bulk +zeroing, purely because the row exists. `FUN_18002cc90` would fill the price text +from the commerce catalogue, but its first statement is `if (tile+0x6c == -1) +return`, and `+0x6c` is pre-seeded to -1 by the adapter at `0x18002c4a2` and +copied from wire `+0x78` at `0x18002c685`. We send no `externalPriceId`, so it +stays -1, the formatter returns, and all four catalogue strings keep their +constructor defaults. Live on all three tiles: `+0x6c` = -1, `+0xb5` = 1, +`+0xb6` = 1, `+0xb7` = 0, `+0xa4` = 0, `+0x168` = `"0"`, `+0xd8`/`+0x108`/`+0x138` += `" "`. + +`or %1s` occurs exactly once in the entire 4.09 GiB process, at `0xb8697c20`, and +it is a Scaleform string-pool record: the eight preceding bytes are +`01 00 06 00 07 00 00 00`, where 6 is the length and 7 the length plus one, the +same header shape as the neighbouring `highbid`. There is no `%1s` anywhere in the +3,179,952-byte PE. Its argument can only be one of the four strings the formatter +never wrote. + +A third safe shape exists that nobody had noticed: `"extPrice": {}` opens the +nested loop at `0x18013b59d`, immediately reads END_OBJECT and exits, so it +creates no `mtx` row. Useful if schema fidelity is wanted for some reason, but +deletion is simpler and strictly better. + +### 3.5 Purchase + +CONFIRMED from the wire. `POST /ut/game/fifa17/purchased/items`, body +`{"packId":6,"useCredits":1,"usePreOrder":0,"currency":"COINS"}`, six times, always +6. Serializer `FUN_180162530`, four keys from one mode integer: `packId` (0x20b), +`useCredits` (0x369) = mode 0, `usePreOrder` (0x36b) = mode 4, `currency` (0xc4) = +`MTX`/`POINTS`/`COINS` and omitted entirely when mode is 4. The request-side +currency vocabulary is UPPERCASE; the response-side currency names are lowercase. +Do not mix them. + +The separate first-party path `PUT /ut/v2/game/fifa17/store/transaction/0` with +`{"state":"TRANSACTIONCANCEL"}` fires on every boot and is live code. + +### 3.6 Quick sell, and the discard economy + +This is the most complete part of the run. + +**The wire.** `DELETE /ut/game//item/`, no body, confirmed live tonight +and now served. The URL suffix builder emits `/%llu`. The response is +balance-bearing: answering `{}` put 1,133,686,384 coins on screen. The real +response class is `FutDiscardCardServerResponse` (`RS4:` literal `0x180220540`, +vtable `0x180220488`, deser `FUN_180127300` at slot `+0x08`), and its shape is +`{"items":[{"id":N}], "totalCredits":N}`. `items` (0x171) is an array of OBJECTS +with an inner `id` (0x15c); there is no top-level `id`. `totalCredits` (0x326) is +a plain `MOV` into a freshly allocated 0x38-byte object at `+0x28`; an exhaustive +whole-DLL scan in cmp, sub-ladder, dec-ladder and switch-range forms finds exactly +two functions carrying that atom, both discard responses, and neither accumulates. +Whether the wallet assigns or adds is still unproven because the consumer is a +delegate registered outside CardsDLL. Keep sending the absolute balance; the field +is named `totalCredits` and `GET /user/credits` resyncs within seconds anyway. + +**Bulk quick sell exists and our parser expects a shape that does not.** +`FUN_180126f40`, printed in full at 0x21a bytes, emits `{"itemId":[, ...]}` +using atom 0x16d over a vector of 8-byte elements. `quick_sell_route()` looks for +`itemData` and `itemIds`, which appear nowhere in that serializer. The URL and +method for the bulk call were not established; the decisive test is a live capture +of "Quick Sell All". + +**The value the client displays is computed locally.** `FUN_18013fe00`, the shared +item deserializer, stores our `discardValue` (atom 0xd7) at item `+0x38`. Then, at +`0x180141025`, `cmp dword [rbp+0x198],0` followed by `ja` skips the entire local +computation if that value is non-zero. When it is zero, the client runs + +```sql +SELECT "price" FROM "fcc_discardcoins" WHERE "cardtype"==? AND "level"==? AND "rare"==? +``` + +(literals at `0x1802231e4`, `0x1802231f0`, `0x180223208`, `0x180207848`, +`0x18022315c`) and then, at `0x180141119..0x180141140`: + +``` +value = (rating * price) / 100, rounded half up +``` + +stored at item `+0x3c`. If the query returns no row the price register stays 0 and +the value is 0. + +`cardtype` comes from `cardsubtypeid` through `FUN_1800d8330`, decoded from its +raw two-level jump table and checked against every subtype 0..599 with zero +disagreements: + +``` +0..3 -> 1 (players) +4 -> 2 +5 -> 3 +6 -> 10 +7 -> 5 +8 -> 4 +9..11 -> 7 +30,31,145..150,231,232,233,236 -> 9 +51..136, 201..220, 250..273, 300..341 -> 6 +anything else -> 0 (no table row, value 0) +``` + +`level` is derived purely from rating at the tail of `FUN_180141660` +(`0x180141e8a..0x180141ea3`): 3 if rating >= 75, 2 if 65..74, else 1. It is not a +wire field; the slot at item `+0x54` is never written through the deserializer's +frame, which was confirmed by an operand census showing `rbp+0x1b4` appearing +exactly once in all 1801 instructions of `FUN_18013fe00`, as a read. + +The table itself, 141 rows, decoded from the client's loaded DB at `0x4278e1a8` +with bit layout `level[0:7] id[7:17] cardtype[17:24] price[24:44] rare[44:51]`. +That layout is not merely self-consistent: an exhaustive search over all field +offsets and widths produced exactly one split satisfying sequential ids 1..141, +cardtype in 1..10, price under 200000 and unique keys. Prices are given per level +1 / 2 / 3: + +``` +cardtype 1 (players) + rare 0 30 / 150 / 400 + rare 1 75 / 350 / 800 + rare 7 1500 / 5000 / 9000 + rare 2,3,10,13,17..31 2000 / 7000 / 12200 + rare 4,8,9 6000 / 10000 / 18000 + rare 11 10000 / 15000 / 24000 + rare 5,6 20000 / 40000 / 80000 + rare 12 120000 /120000 /120000 +cardtype 2 rare 0 20 / 70 / 110 rare 1 25 / 120 / 320 +cardtype 3 rare 0 10 / 55 / 110 rare 1 50 / 100 / 300 +cardtype 4 rare 0 10 / 55 / 110 rare 1 50 / 100 / 300 +cardtype 5 rare 0 10 / 55 / 110 rare 1 50 / 100 / 300 +cardtype 6 rare 0 5 / 20 / 40 rare 1 20 / 50 / 70 +cardtype 7 rare 0 5 / 20 / 40 rare 1 20 / 50 / 70 +cardtype 8 rare 0 5 / 20 / 40 rare 1 20 / 50 / 70 +cardtype 9 rare 0 5 / 20 / 40 rare 1 20 / 50 / 70 +cardtype 10 rare 0 10 / 55 / 110 rare 1 50 / 100 / 300 +``` + +Rare values 14, 15 and 16 are absent for cardtype 1, and cardtypes 2..10 carry +only rare 0 and 1. A key we do not have pays 0, so use a `.get(key, 0)`, not a +subscript. + +Verified against 22 live club items, 22 of 22 exact. A gold rare player is +`8 * rating`: 75 gives 600, 94 gives 752. A gold common is `4 * rating`. A +50-rated bronze common is 15. Our placeholder underpays a 94 gold rare by 152 and +overpays a bronze common by 35, and is blind to both `rareflag` and card type. + +One scope correction worth having, because it inverts the practical advice one +pass gave. For **cardtypes 2, 3, 4, 5 and 10** the client overwrites the rating +and rare flag we send with values from its own card database before computing, +querying `managercards`, `headcoachcards`, `fitnesscoachcards`, `physiocards` and +`gkcoachcards` by `carddbid`. For **cardtypes 6, 7, 8 and 9** the jump table at +rva `0x141eb4` goes straight to the default arm with no DB query and no overwrite. +Every consumable in our profile (`cardsubtypeid` 52, 54, 92, 98, 100) maps to +cardtype 6, live-confirmed on the two resident consumables, so for exactly the +items the warning was aimed at, the server's rating and rare flag are +authoritative. + +### 3.7 `duplicateItemIdList` + +CONFIRMED shape, INFERRED effect, never observed. Element deser `FUN_180138e10`, +0x20-byte records: `itemId` (0x16d, int64) at `+0x00`, `itemLoans` (0x16f, int32 +narrowed) at `+0x08`, `duplicateItemId` (0xeb, int64) at `+0x10`, +`duplicateItemLoans` (0xed, int32 narrowed) at `+0x18`. It is an array of objects, +not an int list, in both places the old docs describe it. + +Four deserializers accept the key (`FUN_180162880` CreatePack, `FUN_18013bd40` +shared purchased/massinfo, `FUN_1801293d0` FutViewCards, `FUN_18013e7f0` shared +IS-list, which reaches the item through the auction record at `+0xb0`). All four +run the identical fixup: for each record, walk the item list parsed from the same +response and where `item+0x08` equals the record's `itemId`, set `item+0x10` to +`duplicateItemId`. Only record indices 0 and 2 are ever touched; both loans fields +are parsed and discarded. An entry naming an item not in the same response has no +effect at all. + +The only visible consequence is a boolean. Six sites publish the Scaleform key +`HAS_DUPLICATE` as `item+0x10 != 0` (the literal at `0x1801f6510` has exactly one +occurrence in the file and, per a live scan, one in the whole process; the six +functions are `FUN_180043880`, `FUN_180084720`, `FUN_180094220`, `FUN_180094ae0`, +`FUN_180096490`, `FUN_180096670`). One C++ flow branches on it, `FUN_18009bc40`, +the completion of the sign-loan-player chain: not duplicate means the client +auto-issues `PUT ut/%s/item` with `{"itemData":[{"id":N,"pile":"club","swap":0, +"tradeId":0}]}`, duplicate means it suppresses that call and navigates to +`GotoNewItems`. So the flag gates an endpoint we already serve rather than +unlocking a new one, and no request anywhere serialises any of the four atoms: a +census of all 257 mentions of the atom-name helper `FUN_180180cd0` (216 calls plus +41 tail calls) resolves 168 distinct atoms and none of them is 0xeb, 0xec, 0xed or +0x16f, and each of the four wire-name literals has exactly one reference and it is +the atom-name table. + +Net server work: optional. Continuing to send `[]` is coherent. Implementing it +needs no new endpoint. + +### 3.8 `FutCreateUserServerResponse`, deser `FUN_18014cc60` + +CONFIRMED, 3662 characters decompiled in full by two agents independently. +`login` (0x1a5) is the `userInfo` record object into `response+0x28`; +`userData` (0x36d) is an object with exactly four keys parsed by `FUN_180142470` +(`onlineELORating` 0x1f2 INT, `onlineRatedUser` 0x1f3 BOOL, `accountResetCount` +0x1f4 INT, `winForm` 0x383 INT **narrowed to a single byte**); `squad` (0x2cd) is +an object to `FUN_18013d1f0`; `starterPack` (0x2e5) is an ARRAY of shared ITEM +objects into a 0x18-stride vector at `response+0x158`; `bonusPacks` (0x5d) is a +BOOL byte at `response+0x150`. `ENDPOINT_MAP.md` has `login` and `userData` +swapped and types `bonusPacks` as an array. + +The `userInfo` deserializer's top-level arm set, recovered by decoding its 225-entry +byte table at `0x18013f2c8` and 11-entry dword table at `0x18013f29c` plus its +ladders, is `{0x6, 0xb, 0x59, 0x8d, 0x8e, 0x8f, 0xc5, 0xdd, 0xde, 0xe6, 0x110, +0x11c, 0x121, 0x122, 0x1a6, 0x1be, 0x21b, 0x262, 0x27e, 0x2bb, 0x2d4, 0x330, 0x340, +0x35e, 0x387}`. `0xbc` and `0x363` are children of `bidTokens` (0x59), not +top-level. + +`unopenedPacks` (0x35e) reads only `preOrderPacks` (0x24b) and `recoveredPacks` +(0x27b); `count` (0xbc) has no arm and is skipped. The sum goes through model +vtable slot `+0x4e0` (`FUN_18011e120`) to `model+0x20950` and broadcasts UI event +`0x273d`. The same pair also arrives on the credits response (`FUN_180122c50`, +`resp+0x34`/`+0x38`) with two consumers, `FUN_180017390` and `FUN_1800ad390`, and +the client requests `/user/credits` fourteen times a session against five +`userMassInfo` calls, so that is the more-travelled carrier. + +The claimable-items container is `model+0x5a38`, reached through vtable slot +`+0x160` (`FUN_18011b780`). One pass said nothing fills it but `starterPack`. It +is filled by the CreatePack deserializer `FUN_180162880`, pushing 0x18-stride +items directly, and `FUN_18009bc40` searches that same container, so the two +dimensions contradicted each other and the CreatePack reading is the right one. +Live it is empty with capacity 0x180 already allocated, i.e. it has been populated +and drained this session. + +--- + +## 4. Server-authoritative versus client-side + +Strict test: server-authoritative only if the server's response actually +determines the value. This is the table that decides what work exists. + +| thing | authority | why | +|---|---|---| +| Which packs exist, their captions, prices, quantities, limits, dates | SERVER | every field lands in the wire record and reaches the tile; live-verified field by field | +| The purchase `packId` | SERVER | it is wire `+0x70` echoed back as `SERVER_ID`; we choose the number | +| Which display groups exist and what is in each | SERVER | membership keys only on `displayGroup.value`; verified live | +| The group **ordinal** | CLIENT | 1-based creation order assigned by `FUN_180012950`; the server can only influence it through array order | +| The six store tabs and their labels | CLIENT | six hardcoded lowercase literals in `FUN_180014580`; the server can only match them | +| Tile background art | CLIENT | `packs_backgrounds_%d.dds` chosen by `displayGroupAssetId`; the file must exist | +| Which tile the movie thinks you clicked | CLIENT | Scaleform, unread | +| The FIFA Points price line | CLIENT | Origin/Dime catalogue by `externalPriceId`; the catalogue is gone, so this is unfixable server-side | +| `or %1s` | CLIENT | AS string-pool constant; suppressed only by not creating the `mtx` currency row | +| Quick sell **displayed** value | CLIENT when `discardValue` is 0 or absent | `fcc_discardcoins` in the client's own game DB, scaled by rating | +| Quick sell displayed value | SERVER when `discardValue` is non-zero | but sending it **suppresses** the client's own correct number, and which field the UI renders is unproven | +| Quick sell **paid** value | SERVER | it is our arithmetic; today it disagrees with the display | +| `level` (bronze/silver/gold tier for pricing) | CLIENT | derived from rating alone | +| `cardtype` | SERVER | derived from the `cardsubtypeid` we send | +| `rating` and `rareflag` for cardtypes 6..9 (consumables) | SERVER | no DB override on that arm | +| `rating` and `rareflag` for cardtypes 2,3,4,5,10 (staff) | CLIENT | overwritten from local card DB before pricing | +| `totalCredits` after a discard | SERVER | assigned into the response object; wallet consumer unproven | +| Which items a pack contains | SERVER | `GET /purchased` fills the item manager | +| Declared pack contents (`packContentInfo`) versus delivered items | SERVER, unchecked | nothing compares them; cosmetic | +| Duplicate flagging | SERVER | `item+0x10` is written only by the four response deserializers; the client never derives it | +| CreatePack versus PurchaseItems | CLIENT | two separate ServerCall classes chosen client-side; no response selects between them | +| Unopened pack count | SERVER for the initial value, CLIENT thereafter | five CardsDLL functions read-modify-write `model+0x20950` | +| `packOpeningAnimationEnabled` and friends | CLIENT | settings struct defaults are already 1 | + +--- + +## 5. Remaining unknowns and the cheapest experiment for each + +### Needs decompiling (free, no launch, no live process) + +1. **Which atom writes wire `+0x70`.** It feeds `SERVER_ID` and therefore the + purchase `packId`. Decode `FUN_18013af30`'s jump table at `0x18013b001` and find + the arm that stores to frame slot `record-0xb0+0x70`. High value for the price. +2. **`FUN_18013fe00`'s jump table.** Nobody has enumerated it, which is why the + `loans` (0x19b) to `item+0x90` attribution is only corroborated and why the atom + that writes `item+0x58` (rare flag) is unidentified. Same byte/dword table shape + as the `userInfo` one at `0x18013f2c8`/`0x18013f29c`. +3. **Is `FUN_180016ef0` reached on the store path?** Its only caller is + `FUN_180017420`. This decides whether the introsort landmine in 2.3 is live. +4. **The remaining `vt+0x4d8`/`vt+0x4e0` callers.** `FUN_1800706e0` does + `set(get() + delta)` and nobody knows where the delta comes from, nor which + response class feeds `FUN_180019780`'s five booleans. Do this before anyone ever + serves a non-zero unopened count. +5. **The bulk-discard action row.** Which action index owns `FUN_180126f40`, and + the URL and method. The pointer block at `0x1801f3110` mixes it with the + single-id URL builder and the three action factories at `0x180123cc0/cd0/ce0` + are not disassembled as functions in the current project. +6. **Re-run dimension 4's `item+0x10` write census with a raw-instruction matcher.** + The original was decompiler-based and the verifier declined to reproduce it, so + it currently carries only one agent's confidence. Low urgency, listed for + honesty. + +### Needs a live probe (read-only, no launch, needs a running client) + +1. **Which field the UI renders for quick sell, `item+0x38` or `item+0x3c`.** The + cheap version needs a throwaway server on a spare port serving one item with + `discardValue` 999. Until this is settled, do not start emitting `discardValue`. +2. **What `commerce_mgr->vt[0x140]()` returns.** It gates the `points` currency + arm. Its code is in Denuvo-decrypted rwx pages of `FIFA17.exe`, so it needs a + live read at the right moment, not a decompile. +3. **Does the movie hold pack captions at all?** "Gold Pack" has zero ActionScript + string nodes in the whole process while "Bronze Pack" has three; the only + rendered UTF-16 store caption anywhere is a doubled "Premium Gold". Re-run the + census after scrolling the store to the Gold tile to find out whether that + absence is a fact about the pool or just about what is on screen. +4. **The silver band.** No resident item is rated 65..74, though the profile + contains eight. Load one and confirm `item+0x54` reads 2 and the value matches + the table. + +### Needs a launch the user must drive (the scarce resource, ranked) + +1. **Click the Gold tile and read `screen+0x290`.** Strictly this needs no launch + at all if the current session is still alive, which is why it is experiment #0 + in section 2.3 and the closing recommendation of this document. +2. **`FUT_STORE_DISPLAYGROUP=0` plus buy two different packs.** Answers the + never-asked question of whether any `packId` but 6 has ever gone out. +3. **Canonical lowercase category tokens** (`bronze`, `gold`, `special`). +4. **Drop `extPrice`** and confirm the `or %1s` line disappears and, critically, + that all tiles remain buyable. Can be combined with 2 or 3, at the cost of + ambiguity if something breaks. + +--- + +## 6. Proposed patches + +Not applied. Every new flag defaults off. Each carries its freeze risk and its +type-fidelity argument, because the last two rounds were broken by +technically-correct fields. + +### P1. Real quick-sell values in `fut_store.py` + +Zero wire change. This is the one patch I would argue for defaulting **on** after +a single verification, but it ships off. + +```python +# tools/fut_store.py, near the top + +# FUT_DISCARD_TABLE: pay the real FIFA 17 discard value instead of an invented +# tier. The table is fcc_discardcoins, read out of the running client's own +# loaded game DB (141 rows, pid 134663, 2026-08-05) and verified against 22 live +# club items, 22/22 exact. See docs/plan-2026-08-05-store-subsystem.md 3.6. +DISCARD_TABLE = os.environ.get("FUT_DISCARD_TABLE", "0") == "1" + +# (cardtype, level, rare) -> price. Compressed: prices are level 1/2/3. +_DP = {} +def _dp(ct, rares, p1, p2, p3): + for r in rares: + _DP[(ct, 1, r)] = p1; _DP[(ct, 2, r)] = p2; _DP[(ct, 3, r)] = p3 +_dp(1, [0], 30, 150, 400) +_dp(1, [1], 75, 350, 800) +_dp(1, [7], 1500, 5000, 9000) +_dp(1, [2, 3, 10, 13] + list(range(17, 32)), 2000, 7000, 12200) +_dp(1, [4, 8, 9], 6000, 10000, 18000) +_dp(1, [11], 10000, 15000, 24000) +_dp(1, [5, 6], 20000, 40000, 80000) +_dp(1, [12], 120000, 120000, 120000) +_dp(2, [0], 20, 70, 110); _dp(2, [1], 25, 120, 320) +for _ct in (3, 4, 5, 10): + _dp(_ct, [0], 10, 55, 110); _dp(_ct, [1], 50, 100, 300) +for _ct in (6, 7, 8, 9): + _dp(_ct, [0], 5, 20, 40); _dp(_ct, [1], 20, 50, 70) + +def _cardtype(sub): + """FUN_1800d8330, exact. 0 means no table row, i.e. a value of 0.""" + if sub is None: return 0 + if 0 <= sub <= 3: return 1 + if sub == 4: return 2 + if sub == 5: return 3 + if sub == 6: return 10 + if sub == 7: return 5 + if sub == 8: return 4 + if 9 <= sub <= 11: return 7 + if sub in (30, 31, 231, 232, 233, 236) or 145 <= sub <= 150: + return 9 + if (51 <= sub <= 136) or (201 <= sub <= 220) or (250 <= sub <= 273) \ + or (300 <= sub <= 341): return 6 + return 0 + +def discard_value(item): + """round_half_up(rating * price / 100), price from fcc_discardcoins.""" + ct = _cardtype(item.get("cardsubtypeid")) + r = int(item.get("rating") or 0) + lvl = 3 if r >= 75 else 2 if r >= 65 else 1 + price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0) + n = r * price + return n // 100 + (1 if n % 100 >= 50 else 0) +``` + +and inside `quick_sell()`: + +```python + def value(it): + dv = it.get("discardValue") or 0 + if dv: + return int(dv) + if DISCARD_TABLE: + return discard_value(it) + r = it.get("rating") or 0 + return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50 +``` + +**Freeze risk: none.** Nothing on the wire changes; `discardValue` is still not +emitted. **Type fidelity: not applicable** for the same reason. The only failure +mode is arithmetic, and it is bounded: a missing key pays 0, which is exactly what +the client does. Verify with one gold rare 85, which must credit 680, not 600. + +### P2. Stop sending `extPrice` + +Invert the current unconditional emission into a flag that defaults to not +sending it. + +```python +# tools/utas_server.py, with the other store flags near line 800 + +# FUT_STORE_EXTPRICE: send the extPrice object. DEFAULT OFF as of 2026-08-05. +# extPrice's two sub-parsers (0x180139070, 0x18013aae0) read exactly ONE key, +# externalPriceId (0x11a); amount and currency are SKIPped. Before parsing, +# both CREATE an "mtx" row in the pack's currency vector, and the tile adapter +# FUN_18002c3c0 sets tile+0xb5 = 1 purely because that row exists, which is what +# turns on the real-money price line. The number that should fill it comes from +# the Origin/Dime catalogue by externalPriceId, which does not exist offline, so +# FUN_18002cc90 early-returns on tile+0x6c == -1 and every string stays at its +# ctor default. That is the "or %1s" on the tile. There is no correct extPrice. +STORE_EXTPRICE = os.environ.get("FUT_STORE_EXTPRICE", "0") == "1" +``` + +and in `_pack_body()`, replace the unconditional `"extPrice": {...}` member with + +```python + if STORE_EXTPRICE: + body["extPrice"] = {"finalPrice": {"amount": mtx, "currency": "mtx"}, + "originalPrice": {"amount": mtx, "currency": "mtx"}} +``` + +**Freeze risk: strictly negative.** Omitting the key means atom 0x119 never +matches at `0x18013afc9`, the nested loop at `0x18013b59d` never opens and neither +sub-parser runs. No reader-state change of any kind. It also removes an unguarded +`call FUN_1801a0040` followed by `mov rdx,[rax]` at `0x18013ba98`, with no null +test, that currently executes on every store load because an `mtx` row exists. +**Type fidelity:** the genuine hazard in this area is sending `finalPrice` or +`originalPrice` as a scalar, since `FUN_180139070` runs its own loop until +END_OBJECT unconditionally; omitting the key removes even that. + +**The untested part, stated plainly:** the step "tile `+0xb5` drives the observed +`or %1s` line" is inference, not proof. And the displayGroup precedent is exactly +this shape: a plausible field change made two packs unbuyable. Check buyability +after this change, not just the price line. + +### P3. Canonical lowercase category tokens + +```python +# tools/utas_server.py + +# FUT_STORE_CATTOKENS: send displayGroup.value as the client's own hardcoded +# lowercase category tokens instead of our pack names. FUN_180014580 switches +# 0..5 over exactly mypacks/points/bronze/silver/gold/special and looks each up +# by the same caption strcmp (FUN_180014380) that builds groups. Today all six +# miss, FUN_18007e5e0 hides all six panels and the store runs with NO bound tabs, +# which is the degenerate state the drill-down bug lives in. Tile captions become +# the client's localised tab labels. DEFAULT OFF. +STORE_CATTOKENS = os.environ.get("FUT_STORE_CATTOKENS", "0") == "1" +_CAT_TOKEN = {1: "bronze", 5: "gold", 6: "special"} # keyed by pack id +``` + +in `_pack_body()`, inside the existing `if STORE_DISPLAYGROUP:` block: + +```python + if STORE_CATTOKENS: + body["displayGroup"] = {"value": _CAT_TOKEN.get(p["id"], "special")} + else: + body["displayGroup"] = {"value": p["name"]} +``` + +**Freeze risk: none beyond what `displayGroup` already carries.** It is the same +key, the same flat object, the same single STR member into the same STR getter. +Only the string content changes. **Type fidelity: unchanged.** The risk is +behavioural, not structural: this is a field that selects a render path, and the +last time we changed one of those two packs became unbuyable. Send exact +lowercase; the case sensitivity of the compare thunk `FUN_1800081c0` was never +established. + +### P4. Accept the real bulk-discard body + +Additive. Do not remove the working single-item `DELETE` path. + +```python +# in quick_sell_route(), before the existing itemData/itemIds handling + + # The client's bulk discard serializer FUN_180126f40 emits exactly + # {"itemId": [, ...]} + # using atom 0x16d. It emits no itemData and no itemIds; those two names + # appear nowhere in that function. Kept additive because the URL and method + # for the bulk call are still unknown and the single-item DELETE works. + if isinstance(body, dict) and isinstance(body.get("itemId"), list): + ids = [int(x) for x in body["itemId"] if isinstance(x, (int, str))] +``` + +**Freeze risk: none.** Request parsing only; nothing changes on the response side. +**Type fidelity: not applicable.** + +### P5. Reshape the CreateUser body + +Behind a flag because the route has never been exercised and there is no captured +EA response to validate the reshape against. + +```python +# FUT_CREATEUSER_FIX: POST /user currently sends login as a BOOL, bonusPacks as +# an ARRAY and starterPack as an OBJECT. FutCreateUserServerResponse's deser +# 0x18014cc60 wants the opposite in every case: login is the userInfo OBJECT, +# bonusPacks is a BOOL, starterPack is an ARRAY of shared ITEM objects, userData +# is a 4-key OBJECT. Each of the three mismatches is independently sufficient to +# desync the SAX reader into the freeze at 0x1801c7f1a. The client has never +# issued this POST (zero occurrences in any log; all GET /user hits are the +# Python suite), so this is latent, not live. DEFAULT OFF: shape derived from +# the deserializer alone, never validated against a real response. +CREATEUSER_FIX = os.environ.get("FUT_CREATEUSER_FIX", "0") == "1" +``` + +```python + if CREATEUSER_FIX: + body = {"login": _user_info(), # OBJECT, deser 0x18013ec10 + "squad": _active_squad(), # OBJECT, deser 0x18013d1f0 + "starterPack": [], # ARRAY of ITEM objects + "bonusPacks": False, # BOOL + "userData": {"onlineELORating": 0, "onlineRatedUser": False, + "accountResetCount": 0, "winForm": 0}} +``` + +**Freeze risk: the patch removes three freezes and adds none**, provided +`_user_info()` returns an object and `starterPack` stays an array. **Type +fidelity:** `login`, `squad` and `userData` reach object parsers; `starterPack` +reaches an array loop that calls the shared item element deser `0x18013fe00`, so +its elements follow the ordinary `itemData` element schema verbatim; `bonusPacks` +reaches the BOOL getter `0x1801c7620`. Note `winForm` is narrowed to a single byte +by `FUN_1800d7ab0`, so anything above 255 truncates silently. + +--- + +## 7. Proposed `ENDPOINT_MAP.md` corrections + +Paste-ready, in the existing entry format. + +**Section 1, `FutStoreGetPackTypesServerResponse`, pack object field table.** +Replace the `displayGroup`, `extPrice` and `packContentInfo` rows and add the +missing offsets: + +``` + | `displayGroup` | 0xd9 | **flat OBJECT** | NOT an array. Exactly two members: `value` (0x377, STR) → record `+0x00`, `priority` (0x250, INT) → record `+0x34`. The `+0x00` slot's ctor default is the literal `"unknown"`, which is why untouched tiles read "unknown". `value` is the ONLY key group membership is decided by (`FUN_180014380`, exact strcmp against `group+0x70`). | + | `displayGroupAssetId` | 0xda | INT | record `+0x30` → `group+0x04` → tile `ASSET_ID` and the `packs_backgrounds_%d.dds` texture name. NOT a lookup key anywhere in CardsDLL. | + | `displayGroupUseDefaultImage` | 0xdb | BOOL | record `+0x38`. Chooses the GROUP tile texture only. It is NOT the gate on `OVERRIDE_ASSET_ID`; that is `useDefaultImage` (0x36a) at record `+0xcc`, a different atom. | + | `extPrice` | 0x119 | **OBJECT** | → `finalPrice` (0x125, `0x180139070`) + `originalPrice` (0x205, `0x18013aae0`). **Each reads exactly ONE inner key, `externalPriceId` (0x11a, INT). `amount` (0x1b) and `currency` (0xc4) do NOT exist in either parser and are SKIPped.** `finalPrice` also writes the pack's `firstPartyStoreId` slot at `+0x78`. SIDE EFFECT, and this is the reason not to send the key at all: both parsers CREATE an `"mtx"` entry in the pack's currency vector if one is absent, and the tile adapter sets tile `+0xb5 = 1` purely because that row exists, enabling a real-money price line the client can never fill offline. **Recommended: omit `extPrice` entirely.** | + | `packContentInfo` | 0x20c | **flat OBJECT** | record `+0x144..+0x154`. EXACTLY five children: `itemQuantity` (0x170), `goldQuantity` (0x149), `silverQuantity` (0x2c6), `bronzeQuantity` (0x63), `rareQuantity` (0x273). **`unopened` (0x35d) and `start` (0x2e3) are TOP-LEVEL pack keys, NOT children of this object.** Sending them nested is inert, not harmful. | + | `unopened` | 0x35d | BOOL | **top level.** Record `+0xcd` → tile `+0x69`. Consumer is in packed FIFA17.exe; meaning unestablished. | + | `start` | 0x2e3 | INT | **top level.** Record `+0xb4`. | + | `packType` | 0x20f | STR | Record `+0x108` → tile `+0x38`. Ctor default `"INVALID"`. Pushed to NO Flash field; it is not a tab key and not a lookup key. | + | `actionType` | 0x08 | **STR, not INT** | Record `+0xd8` → tile `+0x08`, Flash `POST_PURCHASE_ACTION`. Ctor default is the literal `"CREATEPACK"` (`0x180223110`, written by `FUN_1801342d0`). | + | `sortPriority` | 0x2cb | INT | Record `+0x7c` → tile `+0x1a0`. Pushed to no Flash field, but it IS the merge key of `FUN_180010890`, which sorts each group's pack list. Not inert. | +``` + +**Section 1, delete the "none of these atoms exist in the pack deser" bullet.** +It is wrong for at least `quantity` (0x26b → `+0xbc`), `saleType` (0x298), +`packType` (0x20f → `+0x108`) and `isPremium` (0x176 → `+0xce`), all of which have +arms. `id` (0x15c) is unresolved, but record `+0x70` exists, feeds tile `+0x70` as +`SERVER_ID` and is the number the purchase sends as `packId`, so something writes +it. Replace with: + +``` + - **Dispatch in `0x18013af30` is a jump table (bound `cmp eax,0xfa`) for atoms below 0x119 plus running-sum `sub`/`cmp` ladders above it.** An immediate grep for an atom value returns ZERO hits even for atoms that provably have arms. No absence claim about this parser is valid without decoding the tables at `0x18013b001` / `0x18013b7ba` and the ladders. + - **The purchase identity is record `+0x70`**, copied to tile `+0x70` truncated to u16, published to Flash as `SERVER_ID` and sent back as `packId`. It is NOT `assetId` (record `+0x74` → tile `+0xac` → `ASSET_ID`) and NOT `displayGroupAssetId`. Which atom writes `+0x70` is an open question; `id` (0x15c) is the obvious candidate and we currently serve `id` == `assetId`, which is why no live read can separate them. +``` + +**Section 1, corrected minimal known-good:** + +```json +{"purchase":[{"id":1,"assetId":1,"description":"Bronze Pack","sortPriority":1, + "packType":"BRONZE","state":"active", + "displayGroup":{"value":"bronze"}, + "currencies":[{"name":"coins","funds":400,"finalFunds":400}], + "packContentInfo":{"bronzeQuantity":5,"silverQuantity":0,"goldQuantity":0, + "rareQuantity":0,"itemQuantity":5}}], + "timestamp":1596326400} +``` + +Note the deliberate absence of `extPrice`. `finalFunds` is the number the tile +renders; `funds` is not displayed on the tile but IS what the HUD balance reads +from the `/credits` currencies array. Same field name, two consumers, two +meanings. + +**New subsection, display groups.** There is no entry for this today. + +``` +### 1a. Display groups (client-side, built from the store response) + +Builder `FUN_1800150d0`. Walks `purchase[]` in ARRAY ORDER. For each pack it +finds-or-creates a group by exact strcmp of `displayGroup.value` against +`group+0x70` (`FUN_180014380`). A new group (`FUN_180012950`, 0x108 bytes) gets: +`+0x00` = a 1-BASED ORDINAL equal to (groups so far)+1, `+0x04` = +`displayGroupAssetId`, `+0x70`/`+0xa0`/`+0xd0` = three copies of the caption +(`+0x70` is the lookup key, `+0xa0` the tile NAME, `+0xd0` the DESCRIPTION and +CONTENT), `+0x100` = `displayGroup.priority`, `+0x104` = (value == "mypacks"), +`+0x40` = the group's vector of 0x1a8 tile models. + +Resolution: `FUN_1800147f0(model, N, ...)` treats N == 0 as "list the group +tiles" and otherwise calls `FUN_180014420`, which EXACT-matches `group+0x00` and +returns NULL on a miss, after which the caller dereferences `[RAX+0x40]` with no +guard. **The only legal category values are 0 and the ordinals 1..N.** N arrives +from the Flash movie: `FUN_18007e7f0` case 0x7551 copies the message field +`CATEGORY_ID` verbatim into `screen+0x290`. + +The store's tab bar is SIX hardcoded lowercase tokens, `mypacks`, `points`, +`bronze`, `silver`, `gold`, `special` (`FUN_180014580`, `FUN_180014df0`), each +looked up by the same caption strcmp. `FUN_18007e5e0` gives panel 0..5 a +`PANEL_ID` of the matching group's ordinal or HIDES the panel; `FUN_18007df60` +publishes `MYPACK_/BRONZE_/SILVER_/GOLD_/SPECIAL_/POINTS_CATEGORY_ID`. Serving +captions outside that vocabulary leaves all six at -1 and all six panels hidden. + +A group renders only if it holds at least one pack whose tile `+0x00` visible byte +is non-zero (`FUN_18002c8b0`), which comes from `visible` (0x37d) at record +`+0x138`, presence-only. +``` + +**Section on discard, replace the `FutDiscardCardServerResponse` body.** + +``` +- **name VA** `0x180220540` · **vtable** `0x180220488` · **deserializer** `0x180127300` (slot +0x08) +- Wire: `DELETE ut/%s/item/` (URL suffix builder emits `/%llu`), NO body. CONFIRMED live 2026-08-05. +- Response: `{"items":[{"id":123456789}], "totalCredits":15000}` + - `items` (0x171) is an **ARRAY OF OBJECTS**; the inner key is `id` (0x15c), int64. There is NO top-level `id`. Each parsed id calls the item manager's `vt[0xa30]`, which removes the card from the club. + - `totalCredits` (0x326) is an INT, plain assign into a freshly allocated 0x38-byte response object at `+0x28`. No accumulation anywhere in CardsDLL. Send the ABSOLUTE new balance. +- Bulk discard: the client's body serializer `FUN_180126f40` emits `{"itemId":[, ...]}` (atom 0x16d). It emits NO `itemData` and NO `itemIds`. URL and method not yet established. +- **The client computes the displayed value itself** from its own game DB table `fcc_discardcoins` whenever the server's `discardValue` (0xd7) is 0 or absent: `value = round_half_up(rating * price / 100)` with `price` keyed on `(cardtype, level, rare)`, `cardtype = FUN_1800d8330(cardsubtypeid)` and `level` = 3 if rating>=75, 2 if 65..74, else 1. Sending a non-zero `discardValue` SUPPRESSES that computation (guard `ja` at `0x180141025`). Table and formula in docs/plan-2026-08-05-store-subsystem.md 3.6. +``` + +**`duplicateItemIdList`, both places it appears (`:1095` and `:218`).** + +``` + | `duplicateItemIdList` | 0xec | **ARRAY OF OBJECTS**, not an int list | element deser `0x180138e10`, 0x20-byte records: `itemId` (0x16d, int64), `itemLoans` (0x16f, int32), `duplicateItemId` (0xeb, int64), `duplicateItemLoans` (0xed, int32). Only `itemId` and `duplicateItemId` are ever read; both loans fields are parsed and discarded. Effect: for each record, where an item in the SAME response has `id == itemId`, set that item's `+0x10` to `duplicateItemId`. Entries naming an item not in the same body do nothing. Accepted by `0x180162880` (CreatePack), `0x18013bd40` (purchased/massinfo), `0x1801293d0` (ViewCards) and `0x18013e7f0` (IS-list, item reached via auction `+0xb0`). The only C++ consequence is `HAS_DUPLICATE` (a `!= 0` test) plus one branch in the sign-loan-player completion that SUPPRESSES an automatic `PUT ut/%s/item`. No request ever serialises any of these four atoms. Serving `[]` is coherent. | +``` + +**`FutCreateUserServerResponse`.** + +``` + | `login` | 0x1a5 | **OBJECT** (the userInfo record, deser `0x18013ec10`) → `response+0x28` | was documented as a bool | + | `userData` | 0x36d | **OBJECT**, deser `0x180142470`, exactly four keys: `onlineELORating` (0x1f2, INT), `onlineRatedUser` (0x1f3, BOOL), `accountResetCount` (0x1f4, INT), `winForm` (0x383, INT **narrowed to one byte**) | was swapped with `login` | + | `squad` | 0x2cd | **OBJECT**, LoadActiveSquad `0x18013d1f0` | | + | `starterPack` | 0x2e5 | **ARRAY of shared ITEM objects** (`0x18013fe00`) → 0x18-stride vector at `response+0x158` | was documented as an object | + | `bonusPacks` | 0x5d | **BOOL** → `response+0x150` | was documented as an array | +``` + +**`userInfo.unopenedPacks`.** + +``` + | `unopenedPacks` | 0x35e | OBJECT | exactly two children, `preOrderPacks` (0x24b) and `recoveredPacks` (0x27b), both INT. **`count` (0xbc) has no arm here and is SKIPped.** Their SUM goes to `model+0x20950` via model vtable slot `+0x4e0` and broadcasts UI event 0x273d. The SAME pair also arrives on `FutUpdateCreditsServerResponse` (`FUN_180122c50`, `resp+0x34`/`+0x38`), which the client requests ~14x per session against ~5 userMassInfo calls. The counter is CLIENT-MUTABLE: at least five CardsDLL functions read-modify-write it, and `FUN_180019780` increments it and then issues `GET ut/%s/purchased`. Do not serve a non-zero value until that subsystem is audited. | +``` + +--- + +## 8. Coverage, honestly + +Two dimensions came back thick and hold up: the quick-sell economy and the price +model. Both were reproduced end to end by a verifier using a different toolchain +(`.pdata`-bounded objdump instead of Ghidra, brute-force table relocation instead +of the original pointer chain) and 20 of 23 claims survived with no refutations. +The discard table's bit layout was upgraded from self-consistent to provably +unique. + +The grouping dimension came back confident and was the one that took damage. Its +top-ranked recommendation is refuted, its "sortPriority and displayGroup.priority +are inert" claim was a false absence found by searching the whole `.text` listing +rather than eight functions, and its ActionScript evidence is constant-pool string +order, which is suggestive and not proof. What survives is the mechanism, which is +solid and well controlled: the group builder, the ordinal, the exact-match +resolver, the missing null guard and the six-tab vocabulary were all read in raw +disassembly with same-form controls. + +Dimension 6 (live state) contradicted dimension 1 twice and lost both times: it +claimed `packType` was the likely lookup key, and that the store tabs key on +`packType` tier. Both are wrong; the tabs key on `displayGroup.value` and +`packType` has no Flash consumer at all. Its other work, the process-wide sweeps +and the telemetry reconstruction, was not re-verified but is presence-only +evidence from direct reads, which is the lowest-risk category. Its author also +caught and logged one of its own filter bugs mid-run, which is worth more than the +findings it corrected. + +The duplicates dimension is thorough and entirely static. It predicts a flow (loan +signing) that has never been exercised, and its one exhaustive-census claim was +explicitly not reproduced by the verifier, so treat that claim as carrying one +agent's confidence rather than two. + +The unopened-packs dimension had the single largest error of the run, calling a +counter display-only when six functions write it and one of them fires an HTTP +request. That was caught only because a verifier grepped for vtable-slot call +sites rather than for the raw field displacement. The lesson generalises: a field +reached exclusively through a vtable slot is invisible to a displacement search. + +**The unresolved contradiction I would flag hardest** is which Flash field becomes +`CATEGORY_ID`. `ASSET_ID` is eliminated by live memory. Between `CHILD_CATEGORY` +and `GetActiveTabId()`, nobody read the AS2 bytecode and nobody should pretend +otherwise. I would bet on `CHILD_CATEGORY`, because 3 is a valid ordinal and an +unbound tab id is -1, and because a tab-derived id would break Bronze too and +Bronze works. If that bet is right, no field we send fixes the bug directly and +the value of binding the tabs is that it removes a degenerate subsystem, not that +it addresses the carrier. I would not spend more than one launch on it before +someone unpacks the FUT SWF. + +The whole ActionScript layer is unread. That is the thinnest and most consequential +coverage gap in this document, and every remaining store mystery lives there. + +--- + +## Next action + +Before anything else, and before the process dies: while pid 134663 is still in +the store, have the user click the **Gold Pack** tile, then read +`0x41934180 + 0x290` out of `/proc/134663/mem` read-only, having first +re-resolved the pid by `comm` and re-checked both slide controls. That single +number decides which half of this document matters. If it reads 2, the drill-down +is correct, the bug is not group resolution at all, and the entire ranked +experiment list in section 2.3 is aimed at the wrong layer. If it reads 3, the +movie is echoing the wrong ordinal and the tab-binding experiment becomes the +first launch worth spending. It costs nothing, it needs no restart, it touches no +server, and it is the only observation available right now that can invalidate a +whole branch of planned work. diff --git a/fifa17-recon/tools/fut_store.py b/fifa17-recon/tools/fut_store.py index 0e2ae90..cf43ded 100644 --- a/fifa17-recon/tools/fut_store.py +++ b/fifa17-recon/tools/fut_store.py @@ -19,6 +19,95 @@ from fut_account import ACCOUNT # single source of truth for identity/c PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json")) +# ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------ +# +# quick_sell() used to pay an invented rating tier (600/300/150/50). That number +# was wrong for every card. The real table is `fcc_discardcoins` in the client's +# own game DB, 141 rows keyed (cardtype, level, rare) -> price, recovered from the +# running client 2026-08-05 and verified against 22 live club items, 22/22 exact. +# +# The client computes the DISPLAYED value itself with the same table whenever our +# `discardValue` (atom 0xd7) is 0 or absent: FUN_18013fe00 stores our value at item +# +0x38, and the guard at 0x180141025 (`cmp dword [rbp+0x198],0` / `ja`) skips the +# local computation when it is non-zero. So today the client shows the real value +# while the server pays a made-up one, and the two disagree on every card. This +# makes the paid value agree with the shown value. +# +# value = round_half_up(rating * price / 100) +# level = 3 if rating >= 75, 2 if 65..74, else 1 (0x180141e8a..0x180141ea3; +# derived from rating, NOT a wire field) +# cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and +# checked across every subtype 0..599 with zero disagreements +# +# ZERO WIRE CHANGE. Nothing new is sent; only the coin figure the server credits +# changes. Default off per the house rule, but this is the one patch worth +# defaulting on after a single verification. +# See docs/plan-2026-08-05-store-subsystem.md section 3.6. +DISCARD_TABLE = os.environ.get("FUT_DISCARD_TABLE", "0") == "1" + +_DP = {} + + +def _dp(ct, rares, p1, p2, p3): + for r in rares: + _DP[(ct, 1, r)] = p1 + _DP[(ct, 2, r)] = p2 + _DP[(ct, 3, r)] = p3 + + +_dp(1, [0], 30, 150, 400) +_dp(1, [1], 75, 350, 800) +_dp(1, [7], 1500, 5000, 9000) +_dp(1, [2, 3, 10, 13] + list(range(17, 32)), 2000, 7000, 12200) +_dp(1, [4, 8, 9], 6000, 10000, 18000) +_dp(1, [11], 10000, 15000, 24000) +_dp(1, [5, 6], 20000, 40000, 80000) +_dp(1, [12], 120000, 120000, 120000) +_dp(2, [0], 20, 70, 110) +_dp(2, [1], 25, 120, 320) +for _ct in (3, 4, 5, 10): + _dp(_ct, [0], 10, 55, 110) + _dp(_ct, [1], 50, 100, 300) +for _ct in (6, 7, 8, 9): + _dp(_ct, [0], 5, 20, 40) + _dp(_ct, [1], 20, 50, 70) + + +def _cardtype(sub): + """FUN_1800d8330. 0 means no table row, which the client renders as value 0.""" + if sub is None: + return 0 + if 0 <= sub <= 3: + return 1 + if sub == 4: + return 2 + if sub == 5: + return 3 + if sub == 6: + return 10 + if sub == 7: + return 5 + if sub == 8: + return 4 + if 9 <= sub <= 11: + return 7 + if sub in (30, 31, 231, 232, 233, 236) or 145 <= sub <= 150: + return 9 + if (51 <= sub <= 136) or (201 <= sub <= 220) or (250 <= sub <= 273) \ + or (300 <= sub <= 341): + return 6 + return 0 + + +def discard_value(item): + """round_half_up(rating * price / 100), price from fcc_discardcoins.""" + ct = _cardtype(item.get("cardsubtypeid")) + r = int(item.get("rating") or 0) + lvl = 3 if r >= 75 else 2 if r >= 65 else 1 + price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0) + n = r * price + return n // 100 + (1 if n % 100 >= 50 else 0) + # Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX # and UTAS cannot drift apart; prefer ACCOUNT. in new code. These are # import-time snapshots and will NOT reflect a later adopt_from_auth(). @@ -215,6 +304,12 @@ class Store: dv = it.get("discardValue") or 0 if dv: return int(dv) + if DISCARD_TABLE: + # The real table. Matches what the client already displays, so the + # coins paid and the coins shown finally agree. + return discard_value(it) + # The invented tier. Wrong for every card, kept only as the live-proven + # default until FUT_DISCARD_TABLE has been in front of the game once. r = it.get("rating") or 0 return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50 with _LOCK: diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_1.py b/fifa17-recon/tools/ghidra_queries/q_adv_1.py new file mode 100644 index 0000000..c1e0ae8 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_1.py @@ -0,0 +1,84 @@ +"""ADVERSARIAL Q1. + +HYPOTHESIS UNDER ATTACK (dim1 claim 3): "FUN_1800150d0 ... finds-or-creates a group by +an exact string compare on displayGroup.value", i.e. wire-record +0x00 holds +displayGroup.value. + +WHY IT IS NOT PROVEN: live we serve description == displayGroup.value == the SAME +STRING for all three packs ("Bronze Pack"/"Gold Pack"/"Premium Gold"), so the live +group caption cannot distinguish displayGroup.value (atom 0xd9->0x377) from +description (atom 0xd1). If the key is actually `description`, recommendation #2 +(serve displayGroup.value="gold") silently does nothing. + +METHOD: decompile the 0x158 wire-record element deserializer 0x18013af30 IN FULL, +print len(src), and enumerate the atom dispatch. Explicitly search the raw +disassembly of the function for EVERY syntactic dispatch form the brief warns about: + == imm, != imm, switch case labels (jump table), and sub/dec ladders. +CONTROL: atom 0x20f (packType) is known-present (live pack model +0x38 = "BRONZE"), +so whatever form finds packType must also be applied to 0xd1/0xd9/0xda/0x2cb. +The control uses the SAME method (raw immediate scan over the same instruction +range), not a different one. +""" +import sys, traceback, re +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q1_out.txt" +try: + fh = open(OUT, "w") + def P(*a): + s = " ".join(str(x) for x in a) + print(s); fh.write(s + "\n") + + ATOMS = {0x23:"assetId",0xd1:"description",0xd9:"displayGroup",0xda:"displayGroupAssetId", + 0xdb:"displayGroupUseDefaultImage",0x15c:"id",0x20f:"packType",0x250:"priority", + 0x2cb:"sortPriority",0x377:"value",0x36a:"useDefaultImage",0x260:"purchase"} + + for target in (0x18013af30,): + f = func(target) + P("=== FUNCTION %s @ %#x body=%s ===" % (f.getName(), int(f.getEntryPoint().getOffset()), f.getBody())) + src = dec(target, 300) + P("len(src) =", len(src)) + P("---- FULL DECOMPILE BEGIN ----") + P(src) + P("---- FULL DECOMPILE END ----") + + # raw instruction scan of the whole function body for every atom immediate + P() + P("=== RAW INSTRUCTION SCAN over FUN_18013af30 body: all forms ===") + f = func(0x18013af30) + body = f.getBody() + it = listing.getInstructions(body, True) + ins = [] + while it.hasNext(): + i = it.next() + ins.append((int(i.getAddress().getOffset()), str(i.getMnemonicString()), str(i))) + P("instruction count:", len(ins)) + # collect all immediates appearing anywhere in the text form + found = {} + for a, mn, txt in ins: + for m in re.finditer(r'0x([0-9a-fA-F]+)', txt): + v = int(m.group(1), 16) + if v in ATOMS: + found.setdefault(v, []).append((a, mn, txt)) + for v in sorted(ATOMS): + lst = found.get(v, []) + P("atom %#05x %-28s hits=%d" % (v, ATOMS[v], len(lst))) + for a, mn, txt in lst: + P(" %#x %s" % (a, txt)) + # dispatch-form census: CMP/SUB/DEC ladders on the atom register + P() + P("=== dispatch-form census (CMP/SUB/DEC/SWITCH inside the function) ===") + forms = {"CMP":0,"SUB":0,"DEC":0,"JMP":0,"SWITCH":0} + for a, mn, txt in ins: + if mn in forms: forms[mn]+=1 + if mn == "JMP" and "[" in txt: forms["SWITCH"]+=1 + P(forms) + P("all CMP with a small immediate (candidate atom compares):") + for a, mn, txt in ins: + if mn in ("CMP","SUB","DEC","ADD") : + m = re.search(r'0x([0-9a-fA-F]{1,4})\s*$', txt) + if m: + v=int(m.group(1),16) + if 0x10 <= v <= 0x400: + P(" %#x %-8s %s -> imm %#x %s" % (a, mn, txt, v, ATOMS.get(v,""))) + fh.close() +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_2.py b/fifa17-recon/tools/ghidra_queries/q_adv_2.py new file mode 100644 index 0000000..e3c071a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_2.py @@ -0,0 +1,97 @@ +"""ADVERSARIAL Q2. Batch. + +Targets under attack: + (a) dim1 claim 4: "FUN_1800147f0 ... a miss returns NULL and the caller then + dereferences address 0x40, i.e. it would crash" -- ABSENCE OF A NULL CHECK. + Method: print the RAW DISASSEMBLY of FUN_1800147f0 from the CALL to + FUN_180014420 to the next 40 instructions, so a TEST/JZ is visible if present. + Control: the same raw-listing method applied to FUN_180014380's call sites, + where the decompiler DOES show a null test, must show TEST/JZ. Same form. + (b) dim1 claim 5: "+0x290 is written in exactly TWO places in all of CardsDLL". + objdump found 12 dword/qword writes at +0x290 plus one QWORD write at +0x28c + that covers it. Resolve the containing function of every one and decide. + (c) dim1 claim 9/10: model+0x94 = group ordinal, model+0x1a0 = sortPriority; + +0x1a0 pushed to no Flash field. Print FUN_18002c3c0 and FUN_180015d80 in full + and print their exact address ranges so the claim can be re-checked in objdump. + (d) dim1 claim 3: FUN_1800150d0 / FUN_180012950 / FUN_180014380 full. + (e) dim1 claim 7: FUN_180014580 / FUN_180014df0 six literals; enumerate. + (f) FUN_180014610 group-tile builder: does tile+0x9c really get the ordinal + (CHILD_CATEGORY) and tile+0xac the displayGroupAssetId? Recommendation #1 + depends entirely on this. +""" +import sys, traceback, re +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q2_out.txt" +try: + fh = open(OUT, "w") + def P(*a): + s = " ".join(str(x) for x in a) + print(s); fh.write(s + "\n") + + TARGETS = [0x1800150d0, 0x180012950, 0x180014380, 0x180014420, 0x1800147f0, + 0x180014610, 0x18002c3c0, 0x180015d80, 0x180014580, 0x180014df0, + 0x18007e7f0, 0x18007d1a0, 0x18007dab0] + P("=== FUNCTION BOUNDS ===") + for t in TARGETS: + f = func(t) + if f is None: + P("%#x -> NO FUNCTION" % t); continue + P("%#x %-22s min=%#x max=%#x size=%#x" % (t, f.getName(), + int(f.getBody().getMinAddress().getOffset()), + int(f.getBody().getMaxAddress().getOffset()), + int(f.getBody().getNumAddresses()))) + + # (b) resolve containing functions of every +0x290 write objdump found + P() + P("=== (b) containing functions of every raw +0x290 / +0x28c write ===") + W = [0x180051da3,0x18007d3ba,0x18007f0c0,0x18008c777,0x18008fd45,0x1800d3564, + 0x1800d43fc,0x18013454a,0x180189d84,0x18018caa3,0x18018e1ff,0x180191f77, + 0x18015b885,0x180067eb0,0x180067ebf] + for w in W: + f = func(w) + P(" %#x -> %s @ %#x" % (w, f.getName() if f else "NONE", + int(f.getEntryPoint().getOffset()) if f else 0)) + # is any of those functions in the store-screen vtable? + P() + P("=== store screen vtable 0x1801ff690 (first 48 slots) ===") + ents = set() + for off, tgt, nm in vtable(0x1801ff690, 48): + P(" +%#04x %#x %s" % (off, tgt, nm)) + ents.add(tgt) + P("vtable also at 0x1801ff6f8 / 0x1801ff610 per the claim; dumping 0x1801ff610:") + for off, tgt, nm in vtable(0x1801ff610, 24): + P(" +%#04x %#x %s" % (off, tgt, nm)) + + # (a) raw disassembly around the FUN_180014420 call inside FUN_1800147f0 + P() + P("=== (a) RAW LISTING of FUN_1800147f0 (whole function) ===") + f = func(0x1800147f0) + it = listing.getInstructions(f.getBody(), True) + n = 0 + while it.hasNext(): + i = it.next(); n += 1 + P(" %#x %s" % (int(i.getAddress().getOffset()), str(i))) + P("instruction count:", n) + + P() + P("=== (a-control) RAW LISTING of FUN_180014610 (whole function) ===") + f = func(0x180014610) + it = listing.getInstructions(f.getBody(), True) + n = 0 + while it.hasNext(): + i = it.next(); n += 1 + P(" %#x %s" % (int(i.getAddress().getOffset()), str(i))) + P("instruction count:", n) + + for t in TARGETS: + P() + f = func(t) + P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t)) + src = dec(t, 300) + P("len(src) =", len(src)) + P(src) + P("======== END %#x ========" % t) + fh.close() +except Exception: + traceback.print_exc() + try: fh.close() + except Exception: pass diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_3.py b/fifa17-recon/tools/ghidra_queries/q_adv_3.py new file mode 100644 index 0000000..bff2153 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_3.py @@ -0,0 +1,72 @@ +"""ADVERSARIAL Q3. + +Attacking dim1 claim 10: "sortPriority is inert at the UI. It reaches pack+0x1a0 and is +pushed to no Flash field ... Both are dead ends for this bug." +An objdump scan of the store cluster found 0x1800108cd/0x1800108d3 + mov eax,[rsi+0x1a0] ; cmp [rbx+0x1a0],eax +which is the shape of a SORT COMPARATOR on two 0x1a8 models, and 0x18002cc62 + mov [rbx+0x1a0],esi +inside FUN_18002cc90, which FUN_18002c3c0 tail-calls AFTER setting +0x1a0 = sortPriority. +Both were missed by "grep the push list". + +Also decompile: + FUN_18002c8b0 -- the per-group filter in FUN_180014610; if it can HIDE a group the + tile ordinals the user sees stop matching the group ordinals. + FUN_18007e5e0 / FUN_18007df60 -- the six-panel binding (dim1 claim 7). + FUN_18007e7f0 cases 0x7551 / 0x753f -- the CATEGORY_ID round trip. + callers of FUN_1800147f0. +""" +import sys, traceback +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q3_out.txt" +try: + fh = open(OUT, "w") + def P(*a): + s = " ".join(str(x) for x in a) + print(s); fh.write(s + "\n") + + for a in (0x1800108cd, 0x18002cc62, 0x180010b5c, 0x180011c2c): + f = func(a) + P("%#x -> %s @ %#x size=%#x" % (a, f.getName() if f else "NONE", + int(f.getEntryPoint().getOffset()) if f else 0, + int(f.getBody().getNumAddresses()) if f else 0)) + + P() + P("=== callers of FUN_1800147f0 ===") + for frm, typ, fn, ent in xrefs_to(0x1800147f0): + P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + P("=== callers of FUN_180014610 ===") + for frm, typ, fn, ent in xrefs_to(0x180014610): + P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + P("=== callers of FUN_18002c8b0 ===") + for frm, typ, fn, ent in xrefs_to(0x18002c8b0): + P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + P("=== callers of the comparator's containing function ===") + cf = func(0x1800108cd) + if cf: + for frm, typ, fn, ent in xrefs_to(int(cf.getEntryPoint().getOffset())): + P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + + tg = [] + if cf: tg.append(int(cf.getEntryPoint().getOffset())) + tg += [0x18002cc90, 0x18002c8b0, 0x18007e5e0, 0x18007df60, 0x180014b60] + for t in tg: + f = func(t) + P() + P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t)) + src = dec(t, 300) + P("len(src) =", len(src)) + P(src) + P("======== END %#x ========" % t) + + # full FUN_18007e7f0 (big) -- print only, it is the CATEGORY_ID round trip + P() + P("======== DECOMPILE FUN_18007e7f0 (full) ========") + src = dec(0x18007e7f0, 600) + P("len(src) =", len(src)) + P(src) + P("======== END ========") + fh.close() +except Exception: + traceback.print_exc() + try: fh.close() + except Exception: pass diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_4.py b/fifa17-recon/tools/ghidra_queries/q_adv_4.py new file mode 100644 index 0000000..6ab372f --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_4.py @@ -0,0 +1,29 @@ +"""ADVERSARIAL Q4. Where is the +0x1a0 (sortPriority) merge sort actually used, and +what does the 0x1a8 ctor leave in +0x1a0 / +0x94 for GROUP TILES (FUN_180014610 sets +neither)? Also FUN_180012950 and FUN_180014380 in full for the group-key claim.""" +import sys, traceback +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q4_out.txt" +try: + fh = open(OUT, "w") + def P(*a): + s = " ".join(str(x) for x in a) + print(s); fh.write(s + "\n") + P("=== callers of FUN_180010cd0 (the merge-sort driver over +0x1a0) ===") + for frm, typ, fn, ent in xrefs_to(0x180010cd0): + P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + P("=== callers of FUN_180010890 ===") + for frm, typ, fn, ent in xrefs_to(0x180010890): + P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + for t in (0x1800130c0, 0x180012950, 0x180014380, 0x180010cd0): + f = func(t) + P() + P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t)) + src = dec(t, 300) + P("len(src) =", len(src)) + P(src) + P("======== END %#x ========" % t) + fh.close() +except Exception: + traceback.print_exc() + try: fh.close() + except Exception: pass diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_5.py b/fifa17-recon/tools/ghidra_queries/q_adv_5.py new file mode 100644 index 0000000..dfa19be --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_5.py @@ -0,0 +1,30 @@ +"""ADVERSARIAL Q5. The sortPriority merge sort has exactly one entry point +(0x180016f81 -> FUN_180010bc0). Identify its containing function, what list it sorts, +and who calls it. Also print FUN_1800130c0 in full to see whether +0x1a0 / +0x94 are +initialised at all for group tiles (FUN_180014610 sets neither).""" +import sys, traceback +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q5_out.txt" +try: + fh = open(OUT, "w") + def P(*a): + s = " ".join(str(x) for x in a) + print(s); fh.write(s + "\n") + f = func(0x180016f81) + P("0x180016f81 is inside %s @ %#x size=%#x" % (f.getName(), int(f.getEntryPoint().getOffset()), + int(f.getBody().getNumAddresses()))) + ent = int(f.getEntryPoint().getOffset()) + P("=== callers of %s ===" % f.getName()) + for frm, typ, fn, e in xrefs_to(ent): + P(" from %#x %s in %s @ %#x" % (frm, typ, fn, e)) + for t in (ent, 0x1800130c0): + g = func(t) + P() + P("======== DECOMPILE %s @ %#x ========" % (g.getName(), t)) + src = dec(t, 300) + P("len(src) =", len(src)); P(src) + P("======== END %#x ========" % t) + fh.close() +except Exception: + traceback.print_exc() + try: fh.close() + except Exception: pass diff --git a/fifa17-recon/tools/ghidra_queries/q_group_1.py b/fifa17-recon/tools/ghidra_queries/q_group_1.py new file mode 100644 index 0000000..ac12310 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_group_1.py @@ -0,0 +1,100 @@ +"""Why do all three packs collapse into one store tile, and does displayGroupAssetId fix it? + +OBSERVED LIVE 2026-08-05. With FUT_STORE_DISPLAYGROUP=1 the store shows three group +tiles named Bronze Pack / Gold Pack / Premium Gold, but drilling into ANY of them +renders the same single Premium Gold pack. Two of three packs are unbuyable. We send +displayGroup {"value": name} per pack and never displayGroupAssetId (0xda). + +The risk was predicted in utas_server.py before it happened: sending displayGroup may +select a grouped RENDER PATH rather than merely filling a caption, and if so the packs +need something to group BY. The obvious candidate is displayGroupAssetId, which we omit, +so every pack presumably shares a default of 0 and lands in one group. + +That is a hypothesis. Do not ship a fix on it. Establish: + + Q1 where does displayGroupAssetId (0xda) store in the pack element deser 0x18013af30, + and what is its constructor default? If the default is not a constant, the + "everything shares group 0" story is wrong. + Q2 who READS that offset. The reader is the grouping code, and whether it lives in + CardsDLL or in the packed FIFA17.exe decides whether this is answerable statically + at all. + Q3 what does displayGroup (0xd9) store, and is there a second slot (the group's own + identity) distinct from the +0x00 caption slot that `value` writes? + Q4 does anything build a LIST of packs per group, e.g. a loop comparing one pack's + group id against another's? That is the function that decides tile membership. + +CONTROLS + * `assetId` 0x23 is a known INT field of the same deser storing to [rbp-0x3c]. It must + resolve the same way, or the offset extraction is unreliable. + * the "unknown" literal at 0x180223108 is documented as the constructor default of the + caption slot written by FUN_180133f60. Reproducing that anchors Q3. + +COVERAGE RULE: print every decompile in full with its length. No absence claim may be +made from a truncated print, and no claim of "X is the only reader" without showing the +xref list it came from. +""" +import traceback + +PACK_DESER = 0x18013AF30 +CTOR = 0x180133F60 +UNKNOWN_LIT = 0x180223108 +A = {"displayGroup": 0xD9, "displayGroupAssetId": 0xDA, + "displayGroupUseDefaultImage": 0xDB, "assetId": 0x23, "value": 0x377, + "priority": 0x250} + + +def dump(va, title): + try: + f = func(va) + src = dec(va) + print("\n" + "=" * 78) + print("%#x %s body %d bytes / decompile %d chars (IN FULL)" + % (va, title, f.getBody().getNumAddresses() if f else -1, len(src))) + print("=" * 78) + print(src) + return src + except Exception: + print("!! failed %#x" % va) + traceback.print_exc() + return "" + + +try: + src = dump(PACK_DESER, "pack element deserializer") + print("\n--- atom comparisons present, BOTH == and != forms ---") + import re as _re + for name, a in sorted(A.items(), key=lambda kv: kv[1]): + hits = _re.findall(r"[!=]= 0x%x\b" % a, src) + print(" %-30s %#-6x %s" % (name, a, hits or "ABSENT")) + print(" (the != form matters: q_hub_1 missed clubPlayers by grepping only for ==)") + + dump(CTOR, "constructor that writes the caption default") + + print("\n" + "=" * 78) + print("WHO REFERENCES THE 'unknown' LITERAL %#x" % UNKNOWN_LIT) + print("=" * 78) + for frm, typ, fn, ent in xrefs_to(UNKNOWN_LIT): + print(" %#x %s (entry %#x)" % (frm, fn, ent)) + + print("\n" + "=" * 78) + print("CALLERS OF THE PACK DESER (the store root and anything else)") + print("=" * 78) + for a, n in callers(PACK_DESER): + print(" %#x %s" % (a, n)) + + print("\n" + "=" * 78) + print("CALLEES OF THE PACK DESER (sub-object parsers, incl. the displayGroup body)") + print("=" * 78) + for a, n in callees(PACK_DESER): + print(" %#x %s" % (a, n)) + + # The store root: whatever assembles the tile list must walk the parsed vector. + print("\n" + "=" * 78) + print("STORE ROOT 0x1801234e0 IN FULL, and its callers") + print("=" * 78) + dump(0x1801234E0, "FutStoreGetPackTypes root") + for a, n in callers(0x1801234E0): + print(" caller %#x %s" % (a, n)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_1.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_1.py new file mode 100644 index 0000000..e305ed5 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_1.py @@ -0,0 +1,100 @@ +"""DIMENSION 4 (duplicates), pass 1. + +HYPOTHESIS: duplicateItemIdList (atom 0xec, element deser 0x180138e10, 0x20-byte +records) is consumed by (a) the CreatePack deser 0x180162880 and (b) the shared +IS-list body 0x18013e7f0. In CreatePack the only fields read from each record are ++0x00 (itemId) and +0x10 (duplicateItemId); the loans fields look dead there. +We want every caller and the exact per-caller consumption. + +CONTROL: FUN_180138e10 must appear as a called-function of every caller we claim, +and we also enumerate xrefs by the reference manager (not by grepping text), so +"absent" verdicts are not derived from a text search. Additionally we print +len(src) for every decompile and state FULL/TRUNCATED. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup1_out.txt" + +def w(fh, s=""): + fh.write(str(s) + "\n") + +try: + fh = open(OUT, "w") + + w(fh, "=" * 70) + w(fh, "A. xrefs_to(0x180138e10) -- the duplicateItemIdList element deser") + w(fh, "=" * 70) + for frm, typ, fn, ent in xrefs_to(0x180138e10): + w(fh, " from %#x %-14s in %s @ %#x" % (frm, typ, fn, ent)) + + w(fh, "") + w(fh, "=" * 70) + w(fh, "B. xrefs to the ATOM constant 0xec is meaningless (too common);") + w(fh, " instead: callers of each caller, to place the consumers.") + w(fh, "=" * 70) + callers_of_deser = sorted(set(e for _, _, _, e in xrefs_to(0x180138e10) if e)) + for c in callers_of_deser: + w(fh, "-- callers of %#x (%s):" % (c, fname(c) if callable(globals().get("fname")) else "?")) + for frm, typ, fn, ent in xrefs_to(c): + w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + + w(fh, "") + w(fh, "=" * 70) + w(fh, "C. FULL decompile: IS-list shared body 0x18013e7f0") + w(fh, "=" * 70) + s = dec(0x18013E7F0) + w(fh, "// len(src)=%d FULL (printed in its entirety, no truncation)" % len(s)) + w(fh, s) + + w(fh, "") + w(fh, "=" * 70) + w(fh, "D. FULL decompile of every OTHER caller found in A") + w(fh, "=" * 70) + for c in callers_of_deser: + if c in (0x18013E7F0, 0x180162880): + w(fh, "// %#x printed elsewhere / already known" % c) + continue + s = dec(c) + w(fh, "---- %#x len(src)=%d FULL ----" % (c, len(s))) + w(fh, s) + + w(fh, "") + w(fh, "=" * 70) + w(fh, "E. the item-model singleton FUN_18011a830 and its vtable slot 0xa08") + w(fh, " (CreatePack reaches the item via node+0x10 which this slot sets)") + w(fh, "=" * 70) + s = dec(0x18011A830) + w(fh, "// FUN_18011a830 len(src)=%d FULL" % len(s)) + w(fh, s) + + w(fh, "") + w(fh, "=" * 70) + w(fh, "F. strings containing dup/Dup/DUP/loan/Loan/LOAN/swap/Swap/SWAP in rdata") + w(fh, "=" * 70) + for pat in (b"uplicate", b"UPLICATE", b"oanItem", b"LOAN", b"Loan", b"SWAP", b"Swap"): + hits = find_all(pat, blocks=(".rdata", ".data", ".text")) + w(fh, "-- pattern %r : %d hits" % (pat, len(hits))) + seen = set() + for h in hits[:400]: + # walk back to string start + st = h + for _ in range(120): + try: + b = mem.getByte(addr(st - 1)) & 0xFF + except Exception: + break + if b < 0x20 or b > 0x7E: + break + st -= 1 + if st in seen: + continue + seen.add(st) + txt = rd_str(st, 160) + xr = xrefs_to(st) + w(fh, " %#x %-60r xrefs=%d %s" % (st, txt, len(xr), + ",".join("%#x/%s" % (e, fn) for _, _, fn, e in xr[:6]))) + + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_10.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_10.py new file mode 100644 index 0000000..112e178 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_10.py @@ -0,0 +1,75 @@ +"""DIMENSION 4, pass 10: NAME the endpoint on the not-duplicate branch. + +Chain established so far: + FUN_18009bc40 (branches on item->duplicateItemId != 0) + not-duplicate -> builds a server-call object whose vtable is 0x1801f3158, + pushes one 0x20-byte record {itemId:int64, 7:int32, 0, 0} into its + "FUT Vector", and calls manager vt+0xc0 (FUN_1801180a0) + FUN_1801180a0 -> stores the delegate and calls FUN_18016c730 + FUN_18016c730 -> the generic dispatcher: vt+0x38 writes the URL into a 0x200 + buffer, then the body into a 0xaf0 buffer, then hands both to the HTTP + layer (FUN_180122550 vt+0x28). + +So vtable 0x1801f3158 IS the request class. Dump it, decompile its URL builder +(slot +0x38) and its body serializer, and find its RS4: class name. + +CONTROL: a known-good request class is dumped alongside -- the CreatePack +request, whose serializer is 0x180162530 and whose class name literal +"RS4:FutCreatePackServerResponse" is already established -- so the slot layout +interpretation is checked against something with a known answer. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup10_out.txt" + +def w(fh, s=""): + fh.write(str(s) + "\n") + +def dumpvt(fh, a, label, n=32): + w(fh, "") + w(fh, "=== vtable %s @ %#x ===" % (label, a)) + for off, tgt, nm in vtable(a, n): + w(fh, " +%#05x -> %#x %s" % (off, tgt, nm)) + +def dump(fh, a, label): + s = dec(a) + w(fh, "") + w(fh, "#" * 70) + w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s))) + w(fh, "#" * 70) + w(fh, s) + +try: + fh = open(OUT, "w") + dumpvt(fh, 0x1801F3158, "the not-duplicate request class", 40) + + w(fh, "") + w(fh, "=== xrefs to the vtable 0x1801f3158 (ctor sites) ===") + for frm, typ, fn, ent in xrefs_to(0x1801F3158): + w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + + # decompile the interesting slots + for off in (0x08, 0x10, 0x30, 0x38, 0x40, 0x48, 0x50, 0x58, 0x60): + try: + t = qword(0x1801F3158 + off) + except Exception: + continue + if fm.getFunctionAt(addr(t)) is not None: + dump(fh, t, "vt+%#04x of the request class" % off) + else: + w(fh, "// vt+%#04x -> %#x (no function)" % (off, t)) + + w(fh, "") + w(fh, "=== every RS4: literal within +-0x400 of nothing; instead: all RS4: names ===") + hits = find_all(b"RS4:") + w(fh, "RS4: literals: %d" % len(hits)) + for h in hits: + nm = rd_str(h, 80) + xr = xrefs_to(h) + if xr: + w(fh, " %#x %-52s %s" % (h, nm, ",".join("%#x" % e for _, _, _, e in xr[:4]))) + + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_11.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_11.py new file mode 100644 index 0000000..48f1fb9 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_11.py @@ -0,0 +1,60 @@ +"""DIMENSION 4, pass 11: decode the request on the not-duplicate branch. + +From FUN_18016c730 (the generic dispatcher) the payload object's vtable slots +are: +0x08 = header/url contributor, +0x10 = body serializer. For the object +built in FUN_18009bc40 the vtable is 0x1801f3158, so + body serializer = 0x180127cc0 + header/url = 0x180122420 +Decompile both. The body serializer's FUN_180180cd0() calls name the +request's keys, which is exactly what dimension 4 needs. + +Also decompile the five OTHER builders that instantiate the same vtable +(FUN_1800db920, FUN_1800dbb90, FUN_1800dcad0, FUN_1800363e0, FUN_1800366f0): +comparing the int they store at record+0x08 (our site stores 7) decodes that +enum. + +CONTROL: FUN_180126440 is the already-established PurchaseItems request +serializer; decompile it too so the "what a request serializer looks like" +reading is anchored on a known case. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup11_out.txt" + +def w(fh, s=""): + fh.write(str(s) + "\n") + +def dump(fh, a, label): + s = dec(a) + w(fh, "") + w(fh, "#" * 70) + w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s))) + w(fh, "#" * 70) + w(fh, s) + +try: + fh = open(OUT, "w") + dump(fh, 0x180127CC0, "BODY SERIALIZER vt+0x10 of the not-duplicate request") + dump(fh, 0x180122420, "header/url contributor vt+0x08") + dump(fh, 0x180129200, "vt+0x38 of the same vtable") + for a in (0x1800DB920, 0x1800DBB90, 0x1800DCAD0, 0x1800363E0, 0x1800366F0): + dump(fh, a, "sibling builder using vtable 0x1801f3158") + dump(fh, 0x180126440, "CONTROL: PurchaseItems request serializer") + + w(fh, "") + w(fh, "=== raw disassembly of FUN_1801180a0 (manager vt+0xc0) ===") + try: + flat.disassemble(addr(0x1801180A0)) + flat.createFunction(addr(0x1801180A0), None) + except Exception as e: + w(fh, "createFunction: %s" % e) + f = func(0x1801180A0) + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + i = it.next() + w(fh, " %s %s" % (i.getAddress(), i)) + + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_12.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_12.py new file mode 100644 index 0000000..bb91aba --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_12.py @@ -0,0 +1,94 @@ +"""DIMENSION 4, pass 12: name the two endpoints in the loan-signing chain, and +name the response class whose deserializer is 0x1801293d0 (one of the four +duplicateItemIdList consumers). + +Chain: -> FUN_18009c360 (HTTP 403 -> "GotoAlreadySignedPopup") + -> manager vt+0x158 request, vtable 0x1801ed690, continuation FUN_18009bc40 + -> FUN_18009bc40 branches on item->duplicateItemId + not dup -> vtable 0x1801f3158 request = PUT item {"itemData":[{"id":.., + "pile":"club","swap":0,"tradeId":0}]} (atoms 0x16b/0x15c/ + 0x226/0x87/0x2fe/0x331 -- decoded from FUN_180127cc0) + dup -> UI command "GotoNewItems", no request at all + +So: dump vtable 0x1801ed690 (its +0x38 URL builder and +0x10 body serializer), +and resolve the class name of the response whose deser is 0x1801293d0 by taking +the vtable that holds it at slot +0x08 (data ref 0x180220ba8 => vtable base +0x180220ba0) and looking for the RS4: literal referenced by its factory. + +CONTROL: for the RS4 resolution, also run the same procedure on the known +CreatePack deser 0x180162880 (whose class RS4:FutCreatePackServerResponse is +already established) and check it comes out right. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup12_out.txt" + +def w(fh, s=""): + fh.write(str(s) + "\n") + +def dump(fh, a, label): + s = dec(a) + w(fh, "") + w(fh, "#" * 70) + w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s))) + w(fh, "#" * 70) + w(fh, s) + +def rs4_for_deser(fh, deser, label): + """find vtables holding `deser` at slot +8, then the RS4: name near a factory""" + w(fh, "") + w(fh, "=== RS4 resolution for deser %#x (%s) ===" % (deser, label)) + for frm, typ, fn, ent in xrefs_to(deser): + if typ != "DATA": + continue + vt_base = frm - 8 + w(fh, " data ref at %#x -> candidate vtable %#x" % (frm, vt_base)) + try: + q0 = qword(vt_base) + except Exception: + continue + f0 = fm.getFunctionAt(addr(q0)) if 0x180000000 <= q0 < 0x181000000 else None + w(fh, " slot0 = %#x %s" % (q0, f0.getName() if f0 else "(not a function)")) + for frm2, typ2, fn2, ent2 in xrefs_to(vt_base): + w(fh, " vtable referenced from %#x in %s @ %#x" % (frm2, fn2, ent2)) + if not ent2: + continue + f = func(ent2) + if f is None: + continue + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + ins = it.next() + for r in ins.getReferencesFrom(): + t = int(r.getToAddress().getOffset()) + try: + s = rd_str(t, 70) + except Exception: + continue + if s.startswith("RS4:"): + w(fh, " -> %s (at %#x)" % (s, t)) + +try: + fh = open(OUT, "w") + + w(fh, "=== vtable 0x1801ed690 (the request made after signing) ===") + for off, tgt, nm in vtable(0x1801ED690, 12): + w(fh, " +%#05x -> %#x %s" % (off, tgt, nm)) + for off in (0x10, 0x38): + t = qword(0x1801ED690 + off) + if fm.getFunctionAt(addr(t)) is not None: + dump(fh, t, "vtable 0x1801ed690 slot +%#04x" % off) + + w(fh, "") + w(fh, "=== builders that use vtable 0x1801ed690 ===") + for frm, typ, fn, ent in xrefs_to(0x1801ED690): + w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + + rs4_for_deser(fh, 0x1801293D0, "one of the four dup consumers") + rs4_for_deser(fh, 0x18013BD40, "another dup consumer") + rs4_for_deser(fh, 0x180162880, "CONTROL: CreatePack deser") + + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_13.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_13.py new file mode 100644 index 0000000..8ed398c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_13.py @@ -0,0 +1,55 @@ +"""DIMENSION 4, pass 13: the authority question. Enumerate EVERY qword write to +an offset +0x10 anywhere in CardsDLL, so the claim "the duplicate field is only +ever filled from the wire" is not an absence claim from a narrow search. + +Matcher: any line assigning through a longlong/undefined8 pointer at +0x10, in +any of the syntactic forms the decompiler emits: + *(longlong *)(X + 0x10) = ... + *(undefined8 *)(X + 0x10) = ... + *(ulonglong *)(X + 0x10) = ... +and the indexed forms (X + 0x10 + i*0x18). + +CONTROL: the four known writers MUST appear: 0x180162880, 0x18013bd40, +0x1801293d0, 0x18013e7f0. +""" +import re, traceback, time + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup13_out.txt" +W = re.compile(r"\*\((?:longlong|undefined8|ulonglong|code \*)\s*\*\)\([^;\n]{0,90}\+ 0x10\)\s*=") +CONTROLS = [0x180162880, 0x18013BD40, 0x1801293D0, 0x18013E7F0] + +try: + fh = open(OUT, "w") + t0 = time.time() + funcs = [] + it = fm.getFunctions(True) + while it.hasNext(): + funcs.append(it.next()) + fh.write("functions: %d\n" % len(funcs)) + hits = {} + for f in funcs: + ent = int(f.getEntryPoint().getOffset()) + try: + s = dec(ent, timeout=25) + except Exception: + continue + if s.startswith("// decompile"): + continue + lines = [l.strip() for l in s.split("\n") if W.search(l)] + if lines: + hits[ent] = (f.getName(), lines) + fh.write("swept in %.0fs, %d functions contain a qword write at +0x10\n" % + (time.time() - t0, len(hits))) + fh.write("\n=== CONTROL ===\n") + for c in CONTROLS: + fh.write(" %#x present=%s\n" % (c, c in hits)) + fh.write("\n=== all writers ===\n") + for ent in sorted(hits): + nm, lines = hits[ent] + fh.write(" %#x %s\n" % (ent, nm)) + for l in lines[:6]: + fh.write(" %s\n" % l[:150]) + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_14.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_14.py new file mode 100644 index 0000000..5900d2b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_14.py @@ -0,0 +1,28 @@ +"""DIMENSION 4, pass 14: rule out the four remaining candidate writers of a +qword at +0x10 that are also "item-shaped" (pass 13 x pass 5 cross-filter): +0x18009dc80, 0x1801aae50, 0x180156ac0, 0x180157050. +If none of them is writing an ITEM's +0x10, then the duplicate field is written +only by the four response deserializers and copied by the item assignment +operator FUN_1800515e0. +CONTROL: FUN_1800515e0 is printed too; it must show the +0x10 copy alongside the +other item members (+0x18 resourceId, +0x50, +0x5c, +0x8c, +0x90), which is what +makes it the item assignment operator rather than a coincidence. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup14_out.txt" + +try: + fh = open(OUT, "w") + for a in (0x18009DC80, 0x1801AAE50, 0x180156AC0, 0x180157050): + s = dec(a) + fh.write("\n" + "#" * 70 + "\n# %#x len(src)=%d FULL\n" % (a, len(s)) + "#" * 70 + "\n") + fh.write(s + "\n") + s = dec(0x1800515E0) + fh.write("\n" + "#" * 70 + "\n# CONTROL item assign 0x1800515e0 len(src)=%d FULL\n" % len(s) + + "#" * 70 + "\n") + fh.write(s + "\n") + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_15.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_15.py new file mode 100644 index 0000000..44bf72c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_15.py @@ -0,0 +1,42 @@ +"""DIMENSION 4, pass 15 (last): what does the script command GetCardDuplicate +actually return -- the duplicate ITEM ID, or just a flag? + +The handler FUN_180039fb0 forwards to DAT_1802def18 vtable slot +0x18. +DAT_1802def18 is installed by FUN_180039b40 / FUN_180039ba0. Decompile those and +whatever they install, so the slot can be resolved. + +This matters for the server: if nothing ever reads the VALUE of +duplicateItemId, then only zero vs non-zero is observable, and the server may +put any non-zero id there. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup15_out.txt" + +def w(fh, s=""): + fh.write(str(s) + "\n") + +def dump(fh, a, label): + s = dec(a) + w(fh, "") + w(fh, "#" * 70) + w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s))) + w(fh, "#" * 70) + w(fh, s) + +try: + fh = open(OUT, "w") + dump(fh, 0x180039B40, "installer A") + dump(fh, 0x180039BA0, "installer B") + dump(fh, 0x180094AE0, "single-card DP builder 1") + dump(fh, 0x180096490, "single-card DP builder 2") + w(fh, "") + w(fh, "=== callers of the two single-card DP builders ===") + for a in (0x180094AE0, 0x180096490): + w(fh, "-- %#x" % a) + for frm, typ, fn, ent in xrefs_to(a): + w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_16.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_16.py new file mode 100644 index 0000000..968a3f7 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_16.py @@ -0,0 +1,15 @@ +"""DIMENSION 4, pass 16: GetCardDuplicate's real implementation, resolved from +the live provider object (DAT_1802def18 -> vtable static 0x1801f44c0, slot +0x18 +-> 0x18003b790; control literal RS4:FutSquadSaveServerResponse re-checked before +the read). Control in this pass: slot +0x10 (GetCardCategory) is dumped too.""" +import traceback +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup16_out.txt" +try: + fh = open(OUT, "w") + for a, l in ((0x18003B790, "GetCardDuplicate impl (vt+0x18)"), + (0x18003B6B0, "CONTROL GetCardCategory impl (vt+0x10)")): + s = dec(a) + fh.write("\n%s\n# %s %#x len(src)=%d FULL\n%s\n%s\n" % ("#"*70, l, a, len(s), "#"*70, s)) + fh.close(); print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_17.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_17.py new file mode 100644 index 0000000..c31ceb5 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_17.py @@ -0,0 +1,48 @@ +"""DIMENSION 4, pass 17: close the last gap in the reader census. + +Passes 5/6 required the item pointer to be loaded from memory (node+0x10) before +the +0x10 access. A plain getter `return *(longlong *)(param_1 + 0x10);` would +be MISSED by that matcher, so an absence claim is not yet safe. This pass finds +every qword read at +0x10 off a FUNCTION PARAMETER, in every function, and lists +the small ones (getters). + +CONTROL: the matcher is verified by requiring it to find 0x18011cca0 (the item +registration, which reads *(param_3 + 8)) -- no; that is +8. Instead the control +is FUN_1801a7180, found in pass 6, whose body is +`return *(longlong *)(lVar1 + 0x10) == 0;` -- a parameter-derived +0x10 read. +It is listed below so the reader can see the matcher firing on a known case. +""" +import re, traceback, time +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup17_out.txt" +P = re.compile(r"\*\((?:longlong|undefined8|ulonglong|int|uint) \*\)\((?:param_\d+|this) \+ 0x10\)") +try: + fh = open(OUT, "w"); t0 = time.time() + fns = [] + it = fm.getFunctions(True) + while it.hasNext(): + fns.append(it.next()) + hits = [] + for f in fns: + ent = int(f.getEntryPoint().getOffset()) + try: + s = dec(ent, timeout=25) + except Exception: + continue + if s.startswith("// decompile"): + continue + ls = [l.strip() for l in s.split("\n") if P.search(l)] + if ls: + hits.append((ent, f.getName(), len(s), ls)) + fh.write("swept %d functions in %.0fs; %d contain a +0x10 read off a parameter\n" + % (len(fns), time.time() - t0, len(hits))) + fh.write("\n--- SMALL functions (len(src) < 1500), i.e. plausible getters ---\n") + for ent, nm, n, ls in hits: + if n < 1500: + fh.write(" %#x %s len=%d\n" % (ent, nm, n)) + for l in ls[:4]: + fh.write(" %s\n" % l[:140]) + fh.write("\n--- all %d, addresses only ---\n" % len(hits)) + fh.write(" ".join("%#x" % e for e, _, _, _ in hits) + "\n") + fh.close(); print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_18.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_18.py new file mode 100644 index 0000000..4b07a84 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_18.py @@ -0,0 +1,29 @@ +"""DIMENSION 4, pass 18: name the flow that FUN_18009bc40 belongs to. +FUN_18009c1f0 installs FUN_18009c360 as a completion delegate; decompile it and +its caller chain, and print any string literals, to confirm (or refute) the +"sign loan player" attribution inferred from adjacency to FUN_18009b480 +(LOAN_SIGNED / FUT_LoanPlayerSigned) and from the HTTP-403 -> GotoAlreadySignedPopup +branch.""" +import traceback +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup18_out.txt" +try: + fh = open(OUT, "w") + todo = [0x18009C1F0] + seen = set() + for _ in range(3): + nxt = [] + for a in todo: + if a in seen: + continue + seen.add(a) + s = dec(a) + fh.write("\n%s\n# %#x len(src)=%d FULL\n%s\n%s\n" % ("#"*70, a, len(s), "#"*70, s)) + fh.write("-- callers:\n") + for frm, typ, fn, ent in xrefs_to(a): + fh.write(" %#x %s in %s @ %#x\n" % (frm, typ, fn, ent)) + if ent: + nxt.append(ent) + todo = nxt + fh.close(); print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_2.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_2.py new file mode 100644 index 0000000..5211431 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_2.py @@ -0,0 +1,62 @@ +"""DIMENSION 4 (duplicates), pass 2: who READS the duplicate field. + +ESTABLISHED IN PASS 1: all four consumers of the 0x20-byte duplicate record +(0x180162880 CreatePack, 0x18013e7f0 IS-list, 0x18013bd40, 0x1801293d0) do the +identical fixup: for each record, scan the just-parsed item list, and where +item->+0x08 == record->+0x00 (itemId), set item->+0x10 = record->+0x10 +(duplicateItemId). The record's +0x08 (itemLoans) and +0x18 (duplicateItemLoans) +are never read by any of the four, and the record vector is a stack local freed at +the end of each, so no other code can see it. + +HYPOTHESIS FOR THIS PASS: the UI reads item->+0x10 and surfaces it as the +Scaleform key "HAS_DUPLICATE" (0x1801f6510) and/or the script command +"GetCardDuplicate" (0x1801f3ad8). + +CONTROL: for each candidate reader we print the FULL decompile with len(src) and +say FULL, and we look for BOTH `+ 0x10` load forms and the `== 0` / `!= 0` test +forms. We also decompile a control function that references "IS_LOAN_PLAYER" but +not HAS_DUPLICATE, to check that our reading of the data-provider idiom is right. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup2_out.txt" + +def w(fh, s=""): + fh.write(str(s) + "\n") + +def dump(fh, a, label): + s = dec(a) + w(fh, "") + w(fh, "#" * 70) + w(fh, "# %s %#x len(src)=%d FULL (no truncation)" % (label, a, len(s))) + w(fh, "#" * 70) + w(fh, s) + +try: + fh = open(OUT, "w") + + w(fh, "A. xrefs to string literals of interest") + for name, a in (("GetCardDuplicate", 0x1801F3AD8), + ("HAS_DUPLICATE", 0x1801F6510), + ("SwapCard", 0x18021EF58), + ("SWAPCARD", 0x18021EF68), + ("IS_LOAN_PLAYER", 0x1801F6520), + ("FUT_LOAN_MATCHES", 0x1802044F8)): + w(fh, "-- %s @ %#x" % (name, a)) + for frm, typ, fn, ent in xrefs_to(a): + w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + + for a, lbl in ((0x1800394C0, "GetCardDuplicate registrar/handler"), + (0x180043880, "HAS_DUPLICATE user 1"), + (0x180094220, "HAS_DUPLICATE user 2"), + (0x180084720, "HAS_DUPLICATE user 3")): + dump(fh, a, lbl) + + w(fh, "") + w(fh, "B. CONTROL: 0x18015fa80 references IS_LOAN_PLAYER but not HAS_DUPLICATE") + dump(fh, 0x18015FA80, "CONTROL loan-only user") + + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_3.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_3.py new file mode 100644 index 0000000..0aafcae --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_3.py @@ -0,0 +1,110 @@ +"""DIMENSION 4 (duplicates), pass 3. + +Q2 is "what REQUEST does the duplicate flow emit". Request serializers in this +client do NOT use string literals for keys: they call FUN_180180cd0() to +turn an atom id into its wire name (proved by the CreatePack request serializer +0x180162530, which serialises atoms 0x20b, 0x369, 0x36b, 0xc4 that way). + +HYPOTHESIS: no serializer ever emits duplicateItemId (0xeb), duplicateItemIdList +(0xec), duplicateItemLoans (0xed) or itemLoans (0x16f); they are response-only. + +METHOD / CONTROL: enumerate EVERY call site of FUN_180180cd0 from the reference +manager, disassemble backwards up to 12 instructions in the same function, and +record every immediate moved into ECX/RCX. Then assert the four control atoms +0x20b/0x369/0x36b/0xc4 ARE found (if the method cannot see known-present atoms it +cannot be trusted to prove absence). Immediate forms handled: MOV ECX,imm and +XOR ECX,ECX (zero) and LEA ECX,[imm]; anything unresolved is reported as UNKNOWN +so absence is never inferred from a silent miss. + +Also: GetCardDuplicate script handler, and the owner of the item container. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup3_out.txt" + +def w(fh, s=""): + fh.write(str(s) + "\n") + +def dump(fh, a, label): + s = dec(a) + w(fh, "") + w(fh, "#" * 70) + w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s))) + w(fh, "#" * 70) + w(fh, s) + +try: + fh = open(OUT, "w") + + # ---------------- A: atom serialisation census ---------------- + w(fh, "=" * 70) + w(fh, "A. every call site of the atom->wire-name helper FUN_180180cd0") + w(fh, "=" * 70) + sites = [(frm, fn, ent) for frm, typ, fn, ent in xrefs_to(0x180180CD0) + if "CALL" in typ] + w(fh, "call sites: %d" % len(sites)) + found = {} + unknown = [] + for frm, fn, ent in sites: + ins = listing.getInstructionAt(addr(frm)) + val = None + cur = ins + for _ in range(12): + if cur is None: + break + cur = cur.getPrevious() + if cur is None: + break + m = str(cur.getMnemonicString()).upper() + ops = str(cur) + if m == "MOV" and ops.upper().startswith("MOV ECX,"): + t = ops.split(",")[1].strip() + try: + val = int(t, 16) if t.startswith("0x") else int(t) + except ValueError: + val = ("RAW", t) + break + if m == "XOR" and "ECX,ECX" in ops.upper().replace(" ", ""): + val = 0 + break + if m == "CALL": + break + if isinstance(val, int): + found.setdefault(val, []).append((frm, fn, ent)) + else: + unknown.append((frm, fn, ent, val)) + w(fh, "resolved distinct atoms: %d ; unresolved sites: %d" % (len(found), len(unknown))) + w(fh, "") + w(fh, "CONTROLS (must be present): 0x20b=%s 0x369=%s 0x36b=%s 0xc4=%s" % ( + 0x20B in found, 0x369 in found, 0x36B in found, 0xC4 in found)) + w(fh, "TARGETS: 0xeb(duplicateItemId)=%s 0xec(duplicateItemIdList)=%s " + "0xed(duplicateItemLoans)=%s 0x16f(itemLoans)=%s 0x16d(itemId)=%s" % ( + 0xEB in found, 0xEC in found, 0xED in found, 0x16F in found, 0x16D in found)) + for t in (0xEB, 0xEC, 0xED, 0x16F, 0x16D): + if t in found: + for frm, fn, ent in found[t]: + w(fh, " atom %#x serialised at %#x in %s @ %#x" % (t, frm, fn, ent)) + w(fh, "") + w(fh, "-- all resolved atoms, sorted:") + w(fh, " ".join("%#x" % k for k in sorted(found))) + w(fh, "") + w(fh, "-- UNRESOLVED call sites (absence claims must exclude these):") + for frm, fn, ent, val in unknown: + w(fh, " %#x in %s @ %#x last=%r" % (frm, fn, ent, val)) + + # ---------------- B: script handler ---------------- + dump(fh, 0x180039FB0, "GetCardDuplicate script handler") + dump(fh, 0x180039E10, "CONTROL: GetCardCategory script handler") + + # ---------------- C: who owns the item container ---------------- + w(fh, "") + w(fh, "=" * 70) + w(fh, "C. writers/readers of the singleton pointer DAT_1802e6398") + w(fh, "=" * 70) + for frm, typ, fn, ent in xrefs_to(0x1802E6398): + w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_4.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_4.py new file mode 100644 index 0000000..7fafab7 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_4.py @@ -0,0 +1,80 @@ +"""DIMENSION 4 (duplicates), pass 4: the item class, and the one unresolved +atom-serialisation site. + +Open items from pass 3: + (a) the single UNRESOLVED FUN_180180cd0 call site, 0x1801438e2 in FUN_180143760, + takes its atom from [RBP+0x60]. Until it is characterised, "no request ever + serialises 0xeb/0xec/0xed/0x16f" is not airtight. + (b) which class implements GetCardDuplicate (DAT_1802def18 vtable slot 0x18). + (c) the item-model manager: DAT_1802e6398 is written by FUN_18011d780. Get the + concrete vtable so slots 0x160 / 0x7d8 / 0xa08 can be named, and so the item + constructor (which must zero item+0x10) can be found. + +CONTROL for the vtable walk: slot 0x08 of any of these vtables must decode to a +real function in .text, and we print the raw qwords so a bogus vtable is visible. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup4_out.txt" + +def w(fh, s=""): + fh.write(str(s) + "\n") + +def dump(fh, a, label): + s = dec(a) + w(fh, "") + w(fh, "#" * 70) + w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s))) + w(fh, "#" * 70) + w(fh, s) + +try: + fh = open(OUT, "w") + + dump(fh, 0x180143760, "(a) dynamic-atom serialiser") + + w(fh, "") + w(fh, "=" * 70) + w(fh, "(b) xrefs to DAT_1802def18 (the script card-info provider pointer)") + w(fh, "=" * 70) + for frm, typ, fn, ent in xrefs_to(0x1802DEF18): + w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + + dump(fh, 0x18011D780, "(c) manager singleton installer FUN_18011d780") + + w(fh, "") + w(fh, "=" * 70) + w(fh, "(c2) candidate manager vtables referenced from FUN_18011d780") + w(fh, "=" * 70) + f = func(0x18011D780) + seen = set() + if f is not None: + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + ins = it.next() + for r in ins.getReferencesFrom(): + t = int(r.getToAddress().getOffset()) + if 0x180000000 <= t < 0x181000000 and t not in seen: + seen.add(t) + try: + q0 = qword(t) + q1 = qword(t + 8) + except Exception: + continue + f0 = fm.getFunctionAt(addr(q0)) if 0x180000000 <= q0 < 0x181000000 else None + f1 = fm.getFunctionAt(addr(q1)) if 0x180000000 <= q1 < 0x181000000 else None + if f0 and f1: + w(fh, " possible vtable %#x : [0]=%#x %s [8]=%#x %s" % + (t, q0, f0.getName(), q1, f1.getName())) + for off in (0x160, 0x7D8, 0xA08, 0xA40, 0x5B8): + try: + q = qword(t + off) + except Exception: + continue + ff = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None + w(fh, " +%#05x -> %#x %s" % (off, q, ff.getName() if ff else "(not a function)")) + + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_5.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_5.py new file mode 100644 index 0000000..e0713c2 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_5.py @@ -0,0 +1,89 @@ +"""DIMENSION 4 (duplicates), pass 5: EXHAUSTIVE census of readers of the item +model's duplicate field (item+0x10). + +WHY EXHAUSTIVE. Pass 2 found the field surfaced as the Scaleform bool +HAS_DUPLICATE at two sites. A bounded search cannot prove that is the only +consumer, and this project has been bitten four times by absence claims from +narrow searches. So: decompile EVERY function in the binary and match text. + +MATCHERS (deliberately two independent ones, plus a control): + M1 "nested +0x10" regex: the idiom every known site uses, an item pointer + loaded out of a 0x18-byte list node at node+0x10, then dereferenced at + +0x10. e.g. *(longlong *)(*(longlong *)(lVar10 + 0x10) + 0x10) + M2 "item-shaped struct" heuristic: a function that mentions at least THREE of + the known item offsets (+0x18 resourceId, +0x50 cardsubtype, +0x5c state, + +0x8c timesWon, +0x90 loans) AND also mentions "+ 0x10". + CONTROL: the three sites already known by hand MUST appear -- + 0x180162880 / 0x18013bd40 / 0x1801293d0 / 0x18013e7f0 (writers) and + 0x180043880 / 0x180094220 (readers). If any is missed, the matcher is + broken and no absence conclusion may be drawn from this pass. + +Output is written incrementally so a timeout still leaves usable results. +""" +import re, traceback, time + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup5_out.txt" + +M1 = re.compile(r"\*\(longlong \*\)\(\*\(longlong \*\)\([^;\n]{0,80}?\) \+ 0x10\)") +ITEMOFF = ("+ 0x18)", "+ 0x50)", "+ 0x5c)", "+ 0x8c)", "+ 0x90)") +CONTROLS = [0x180162880, 0x18013BD40, 0x1801293D0, 0x18013E7F0, 0x180043880, 0x180094220] + +try: + fh = open(OUT, "w") + t0 = time.time() + funcs = [] + it = fm.getFunctions(True) + while it.hasNext(): + f = it.next() + funcs.append(f) + fh.write("total functions: %d\n" % len(funcs)) + fh.flush() + + m1_hits = [] + m2_hits = [] + n = 0 + for f in funcs: + n += 1 + ent = int(f.getEntryPoint().getOffset()) + try: + s = dec(ent, timeout=25) + except Exception: + fh.write("DECOMPILE-ERROR %#x\n" % ent) + continue + if s.startswith("// decompile failed") or s.startswith("// no function"): + fh.write("DECOMPILE-FAILED %#x\n" % ent) + continue + ms = M1.findall(s) + if ms: + m1_hits.append((ent, f.getName(), ms)) + k = sum(1 for o in ITEMOFF if o in s) + if k >= 3 and "+ 0x10" in s: + m2_hits.append((ent, f.getName(), k)) + if n % 500 == 0: + fh.write("... %d/%d %.0fs m1=%d m2=%d\n" % (n, len(funcs), time.time() - t0, + len(m1_hits), len(m2_hits))) + fh.flush() + + fh.write("\nSWEPT %d functions in %.0fs\n" % (n, time.time() - t0)) + + fh.write("\n=== CONTROL CHECK ===\n") + m1set = set(a for a, _, _ in m1_hits) + m2set = set(a for a, _, _ in m2_hits) + for c in CONTROLS: + fh.write(" %#x M1=%s M2=%s\n" % (c, c in m1set, c in m2set)) + + fh.write("\n=== M1 hits (nested +0x10 idiom): %d functions ===\n" % len(m1_hits)) + for ent, nm, ms in m1_hits: + fh.write(" %#x %s : %d match(es)\n" % (ent, nm, len(ms))) + for m in ms[:12]: + fh.write(" %s\n" % m) + + fh.write("\n=== M2 hits (item-shaped struct, >=3 known item offsets + 0x10): %d ===\n" + % len(m2_hits)) + for ent, nm, k in m2_hits: + fh.write(" %#x %s (%d/5 offsets)\n" % (ent, nm, k)) + + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_6.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_6.py new file mode 100644 index 0000000..6df7eae --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_6.py @@ -0,0 +1,102 @@ +"""DIMENSION 4 (duplicates), pass 6: exhaustive census, matcher generation 2. + +Pass 5's matcher M1 (single-expression nested +0x10) MISSED the IS-list consumer +0x18013e7f0, which splits the access across two statements: + lVar6 = *(longlong *)(*(longlong *)(lVar14 + 0x10) + 0xb0); + ... *(longlong *)(lVar6 + 0x10) = plVar13[2]; +So M1 alone cannot support an absence claim. M3 below does a textual +def-use: any local assigned from a qword load, then used as base of a +0x10 +qword access. + +M3 = for every assignment ` = *(longlong *)();` remember ; + then flag the function if ` + 0x10)` appears anywhere. + PLUS the M1 nested form. This is deliberately over-broad; the output is + reviewed by hand. + +CONTROL: all six hand-known sites must be flagged: + writers 0x180162880, 0x18013bd40, 0x1801293d0, 0x18013e7f0 + readers 0x180043880, 0x180094220 +If any is missed the pass is void for absence purposes. + +Also dumps the three functions M1 newly found (0x180094ae0, 0x180096490, +0x18009bc40) and their callers, to name the UI screens involved. +""" +import re, traceback, time + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup6_out.txt" + +ASSIGN = re.compile(r"(\w+) = \*\(longlong \*\)\([^;\n]{0,120}\);") +M1 = re.compile(r"\*\(longlong \*\)\(\*\(longlong \*\)\([^;\n]{0,80}?\) \+ 0x10\)") +CONTROLS = [0x180162880, 0x18013BD40, 0x1801293D0, 0x18013E7F0, 0x180043880, 0x180094220] + +def hitlines(s, var): + out = [] + pat = re.compile(r"\b%s \+ 0x10\b" % re.escape(var)) + for ln in s.split("\n"): + if pat.search(ln): + out.append(ln.strip()) + return out + +try: + fh = open(OUT, "w") + t0 = time.time() + funcs = [] + it = fm.getFunctions(True) + while it.hasNext(): + funcs.append(it.next()) + fh.write("total functions: %d\n" % len(funcs)) + + flagged = {} + n = 0 + for f in funcs: + n += 1 + ent = int(f.getEntryPoint().getOffset()) + try: + s = dec(ent, timeout=25) + except Exception: + continue + if s.startswith("// decompile"): + continue + why = [] + if M1.search(s): + why.append("M1:" + M1.search(s).group(0)[:70]) + for m in ASSIGN.finditer(s): + v = m.group(1) + hl = hitlines(s, v) + if hl: + why.append("M3[%s]: %s" % (v, hl[0][:110])) + break + if why: + flagged[ent] = (f.getName(), why) + if n % 2000 == 0: + fh.write("... %d/%d %.0fs flagged=%d\n" % (n, len(funcs), time.time() - t0, len(flagged))) + fh.flush() + + fh.write("\nSWEPT %d in %.0fs, flagged %d\n" % (n, time.time() - t0, len(flagged))) + fh.write("\n=== CONTROL CHECK ===\n") + ok = True + for c in CONTROLS: + fh.write(" %#x flagged=%s\n" % (c, c in flagged)) + ok = ok and (c in flagged) + fh.write("ALL CONTROLS FLAGGED: %s\n" % ok) + + fh.write("\n=== flagged functions (%d) ===\n" % len(flagged)) + for ent in sorted(flagged): + nm, why = flagged[ent] + fh.write(" %#x %s\n" % (ent, nm)) + for x in why: + fh.write(" %s\n" % x) + + fh.write("\n\n=== decompiles of the three NEW M1 functions ===\n") + for a in (0x180094AE0, 0x180096490, 0x18009BC40): + s = dec(a) + fh.write("\n" + "#" * 68 + "\n# %#x len(src)=%d FULL\n" % (a, len(s)) + "#" * 68 + "\n") + fh.write(s + "\n") + fh.write("-- callers:\n") + for frm, typ, fn, e in xrefs_to(a): + fh.write(" %#x %s in %s @ %#x\n" % (frm, typ, fn, e)) + + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_7.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_7.py new file mode 100644 index 0000000..342db67 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_7.py @@ -0,0 +1,65 @@ +"""DIMENSION 4 (duplicates), pass 7: the ONE flow that branches on the duplicate +flag, and the request it does or does not emit. + +FOUND IN PASS 6: FUN_18009bc40 looks up an item by resourceId in the manager's +vt[0x160] container, reads item+0x10 (duplicateItemId) into a bool, and then: + duplicate -> UI command "GotoNewItems", NO server call + not duplicate -> builds a 1-element FUT Vector of 0x20-byte records + {itemId, 7, 0, 0} and calls manager vt[0xc0] with the + callback FUN_18009bec0 +This is the only branch on the flag in the whole binary (pass 6 was exhaustive +over 13308 functions with controls passing). + +THIS PASS: name the actors. + 1. the concrete manager class: who calls FUN_18011d780 (the setter for the + singleton pointer DAT_1802e6398) and with what object -> its vtable -> + slots 0xc0, 0x160, 0x7d8, 0xa08. + 2. FUN_18009bec0 (the completion callback), FUN_18009c360 (the owner of the + function pointer), FUN_18009b480 (the loan-player message builder that + sits next to it in .text). + 3. what 7 means in the 0x20-byte record. + +CONTROL for the vtable: print raw qwords and require slot 0 and 8 to resolve to +real functions before believing any slot; also print slot 0x160 and check it +looks like a small getter (the CreatePack deser calls it and then uses the +result+0x30 as a vector). +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup7_out.txt" + +def w(fh, s=""): + fh.write(str(s) + "\n") + +def dump(fh, a, label): + s = dec(a) + w(fh, "") + w(fh, "#" * 70) + w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s))) + w(fh, "#" * 70) + w(fh, s) + +try: + fh = open(OUT, "w") + + w(fh, "=== callers of FUN_18011d780 (singleton setter) ===") + for frm, typ, fn, ent in xrefs_to(0x18011D780): + w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + + for frm, typ, fn, ent in xrefs_to(0x18011D780): + if ent and "CALL" in typ: + dump(fh, ent, "caller of singleton setter") + + dump(fh, 0x18009BEC0, "completion callback FUN_18009bec0") + dump(fh, 0x18009C360, "owner of the fn-ptr FUN_18009c360") + dump(fh, 0x18009B480, "loan-player message builder FUN_18009b480") + + w(fh, "") + w(fh, "=== xrefs to FUN_18009bc40 users' neighbours: callers of FUN_18009c360 ===") + for frm, typ, fn, ent in xrefs_to(0x18009C360): + w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_8.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_8.py new file mode 100644 index 0000000..34cb7aa --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_8.py @@ -0,0 +1,66 @@ +"""DIMENSION 4 (duplicates), pass 8: name the request emitted on the +NOT-duplicate branch. + +The FUT item-manager singleton's vtable was resolved from the LIVE process +(read-only): DAT_1802e6398 -> object 0xb81b0940 -> vtable live 0x6ffffc35c2a0 = +static 0x18021c2a0, control-checked against the RS4:FutSquadSaveServerResponse +literal. Slots: + +0x0c0 -> 0x1801180a0 <- called on the NOT-duplicate branch of FUN_18009bc40 + +0x160 -> 0x18011b780 <- container the CreatePack fixup writes into + +0x7d8 -> 0x18011b500 <- container the HAS_DUPLICATE providers read + +0xa08 -> 0x18011cca0 <- item registration (called by the item deser) + +0xa40 -> 0x18011bf40 + +HYPOTHESIS: 0x1801180a0 issues a server call, and the 0x20-byte record +{itemId:int64, 7:int32, 0, 0} it is handed is an item-action list entry (7 being +an action/pile enum). If so the duplicate flag GATES an existing endpoint rather +than unlocking a new one. + +CONTROL: 0x18011b780 must be a small getter returning an object whose +0x30 is a +vector (that is how FUN_180162880 uses it), and 0x18011b500 likewise; if those +two do not look like getters, the live vtable read is wrong and nothing here +stands. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup8_out.txt" + +def w(fh, s=""): + fh.write(str(s) + "\n") + +def dump(fh, a, label, depth=0): + s = dec(a) + w(fh, "") + w(fh, "#" * 70) + w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s))) + w(fh, "#" * 70) + w(fh, s) + +try: + fh = open(OUT, "w") + dump(fh, 0x18011B780, "CONTROL vt+0x160 container getter") + dump(fh, 0x18011B500, "CONTROL vt+0x7d8 container getter") + dump(fh, 0x1801180A0, "vt+0x0c0 the call made when NOT a duplicate") + dump(fh, 0x18011CCA0, "vt+0xa08 item registration") + + w(fh, "") + w(fh, "=== callees of 0x1801180a0 ===") + f = func(0x1801180A0) + if f is not None: + seen = set() + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + ins = it.next() + for r in ins.getReferencesFrom(): + t = int(r.getToAddress().getOffset()) + g = fm.getFunctionAt(addr(t)) + if g is not None and t not in seen: + seen.add(t) + w(fh, " %#x %s" % (t, g.getName())) + for t in sorted(seen): + dump(fh, t, "callee of vt+0xc0") + + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_dup_9.py b/fifa17-recon/tools/ghidra_queries/q_st_dup_9.py new file mode 100644 index 0000000..b97aeca --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_dup_9.py @@ -0,0 +1,71 @@ +"""DIMENSION 4, pass 9: three of the manager vtable slots resolved from live +memory land on addresses Ghidra never turned into functions +(0x1801180a0 vt+0xc0, 0x18011b500 vt+0x7d8, 0x18011b780 vt+0x160). Disassemble +and create them, then decompile. + +CONTROL: 0x18011b780 and 0x18011b500 must come out as tiny getters (the callers +treat the result as an object whose +0x30 is a vector). If instead they decode +as garbage, the live vtable read (or these addresses) is wrong. + +Also: FUN_1800515e0 is the item copy/assign called by the item registration +(vt+0xa08 = FUN_18011cca0) -- it should copy the duplicate field at +0x10, which +independently confirms +0x10 is a member of the item record rather than of a +list node. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup9_out.txt" + +def w(fh, s=""): + fh.write(str(s) + "\n") + +try: + fh = open(OUT, "w") + for a in (0x1801180A0, 0x18011B500, 0x18011B780): + try: + flat.disassemble(addr(a)) + except Exception as e: + w(fh, "disassemble(%#x) -> %s" % (a, e)) + try: + f = flat.createFunction(addr(a), None) + w(fh, "createFunction(%#x) -> %s" % (a, f)) + except Exception as e: + w(fh, "createFunction(%#x) -> %s" % (a, e)) + + for a, lbl in ((0x18011B780, "vt+0x160"), (0x18011B500, "vt+0x7d8"), + (0x1801180A0, "vt+0x0c0 THE CALL ON THE NOT-DUPLICATE BRANCH"), + (0x1800515E0, "item copy/assign FUN_1800515e0")): + s = dec(a) + w(fh, "") + w(fh, "#" * 70) + w(fh, "# %s %#x len(src)=%d FULL" % (lbl, a, len(s))) + w(fh, "#" * 70) + w(fh, s) + + # callees of the vt+0xc0 target, once it is a function + w(fh, "") + w(fh, "=== callees of 0x1801180a0 ===") + f = func(0x1801180A0) + seen = [] + if f is not None: + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + ins = it.next() + for r in ins.getReferencesFrom(): + t = int(r.getToAddress().getOffset()) + g = fm.getFunctionAt(addr(t)) + if g is not None and t not in seen: + seen.append(t) + w(fh, " %#x %s" % (t, g.getName())) + for t in seen: + s = dec(t) + w(fh, "") + w(fh, "-" * 70) + w(fh, "-- callee %#x len(src)=%d FULL" % (t, len(s))) + w(fh, "-" * 70) + w(fh, s) + + fh.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_group_1.py b/fifa17-recon/tools/ghidra_queries/q_st_group_1.py new file mode 100644 index 0000000..a2445e3 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_group_1.py @@ -0,0 +1,85 @@ +"""DIMENSION 1 / query 1. + +HYPOTHESIS: a CardsDLL function converts the 0x158 pack deser records into +0x1a8 pack model records grouped into 0x108 display-group records, and a +second function resolves a display group back to a pack at render time. If +the resolver keys on a field that collides between our two GOLD packs +(packType), that explains Gold-group -> Premium-Gold. + +CONTROL: FUN_1800150d0 is documented as walking the pack array and comparing +displayGroup.value against the literal "mypacks" at 0x1801ec008. Decompiling it +with the same helper proves the helper works and shows the house style of a +group filter. Every "not present" claim below prints len(src) first. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q1_out.txt" +buf = [] +def P(*a): + s = " ".join(str(x) for x in a) + buf.append(s) + print(s) + +try: + P("=" * 78) + P("A. CONTROL: FUN_1800150d0, the documented mypacks filter") + P("=" * 78) + s = dec(0x1800150d0) + P("len(src) =", len(s)) + P(s) + + P("=" * 78) + P("B. FUN_1801340e0 (0x108 vector grow) - full decompile + callers") + P("=" * 78) + s = dec(0x1801340e0) + P("len(src) =", len(s)) + P(s) + P("--- callers of 0x1801340e0 ---") + for f in callers(0x1801340e0): + P(" ", f.getName(), hex(int(f.getEntryPoint().getOffset()))) + + P("=" * 78) + P("C. FUN_180132180 (0x158) - callers only") + P("=" * 78) + for f in callers(0x180132180): + P(" ", f.getName(), hex(int(f.getEntryPoint().getOffset()))) + + P("=" * 78) + P("D. callers of the pack element deser 0x18013af30") + P("=" * 78) + for f in callers(0x18013af30): + P(" ", f.getName(), hex(int(f.getEntryPoint().getOffset()))) + + P("=" * 78) + P("E. where does imm 0x1a8 appear? (the live pack model record size)") + P("=" * 78) + # scan .text for the 32-bit immediate 0xa8 0x01 0x00 0x00 in instructions + import java.lang as _jl # noqa + from ghidra.program.model.address import AddressSet # noqa + cnt = 0 + it = listing.getInstructions(True) + hits = [] + while it.hasNext(): + ins = it.next() + for i in range(ins.getNumOperands()): + for o in ins.getOpObjects(i): + try: + v = int(o.getValue()) + except Exception: + continue + if v == 0x1a8: + hits.append((int(ins.getAddress().getOffset()), str(ins))) + P("0x1a8 immediate occurrences:", len(hits)) + seen = {} + for a, t in hits: + f = fm.getFunctionContaining(addr(a)) + n = f.getName() if f else "?" + seen.setdefault(n, []).append((hex(a), t)) + for n in sorted(seen, key=lambda k: -len(seen[k])): + P(" %-28s x%d %s" % (n, len(seen[n]), seen[n][:4])) + +except Exception: + P(traceback.format_exc()) + +open(OUT, "w").write("\n".join(buf)) +print("WROTE", OUT) diff --git a/fifa17-recon/tools/ghidra_queries/q_st_group_2.py b/fifa17-recon/tools/ghidra_queries/q_st_group_2.py new file mode 100644 index 0000000..16d201e --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_group_2.py @@ -0,0 +1,59 @@ +"""DIMENSION 1 / query 2. + +FUN_1800150d0 is now proven to be the display-group builder: it walks the +0x158 pack records at stride 0x158, calls FUN_180014380(model, rec+0x00) to +find-or-create a group, FUN_180012950(tmp, ordinal, rec+0x30) to construct a +0x108 group, and FUN_18002c3c0(tmp2, rec, group+0x00, -1) to build the 0x1a8 +pack model that is pushed into group+0x40. + +HYPOTHESIS: the group *lookup* FUN_180014380 keys on something that collides +between our two GOLD packs, or a second consumer resolves group -> pack by a +colliding key. Live memory already proves the built vector is CORRECT, so the +fault is in a consumer. + +CONTROL: FUN_18002c3c0 is decompiled in full and its field writes compared to +the live 0x1a8 record measured this run (inner+0x0a0 = 400/5000/15000, ++0x0c0 = 5/7/11). If the decompile's offsets do not reproduce those, the +decompile is being misread and nothing else here can be trusted. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q2_out.txt" +buf = [] +def P(*a): + s = " ".join(str(x) for x in a) + buf.append(s) + print(s) + +def C(a, label): + P("=" * 78) + P(label, hex(a)) + P("=" * 78) + s = dec(a) + P("len(src) =", len(s)) + P(s) + P("--- callers ---") + for ent, nm in callers(a): + P(" %-30s %s" % (nm, hex(ent))) + +try: + C(0x180014380, "A. group LOOKUP FUN_180014380") + C(0x180012950, "B. group CTOR FUN_180012950 (0x108)") + C(0x18002c3c0, "C. pack model builder FUN_18002c3c0 (0x158 -> 0x1a8) [CONTROL]") + P("=" * 78) + P("D. callers of the group builder FUN_1800150d0") + P("=" * 78) + for ent, nm in callers(0x1800150d0): + P(" %-30s %s" % (nm, hex(ent))) + P("=" * 78) + P("E. callers of 0x1801340e0 and 0x180132180 (vector grows)") + P("=" * 78) + for a in (0x1801340e0, 0x180132180, 0x180010160, 0x1800102d0): + P(" -- %s" % hex(a)) + for ent, nm in callers(a): + P(" %-30s %s" % (nm, hex(ent))) +except Exception: + P(traceback.format_exc()) + +open(OUT, "w").write("\n".join(buf)) +print("WROTE", OUT) diff --git a/fifa17-recon/tools/ghidra_queries/q_st_group_3.py b/fifa17-recon/tools/ghidra_queries/q_st_group_3.py new file mode 100644 index 0000000..dcbe2fb --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_group_3.py @@ -0,0 +1,51 @@ +"""DIMENSION 1 / query 3: the CONSUMERS. + +ESTABLISHED so far: FUN_1800150d0 builds the display-group vector; the group +key is the displayGroup.value STRING compared at group+0x70 by FUN_180014380; +the 0x1a8 pack model carries id at +0x70, assetId at +0xac, group ordinal at ++0x94, sortPriority at +0x1a0, coins price at +0xa0. + +HYPOTHESIS: one of the other FUN_180014380 callers (FUN_180014580, +FUN_180014b60, FUN_180014df0) or the other 0x1a8 push_back callers +(FUN_180014610, FUN_1800147f0) is the "which pack does this group tile open" +resolver, and it keys on a field that collides between our two GOLD packs. + +CONTROL: FUN_1800080c0 must turn out to be a string compare (it is used as the +group-name equality test in FUN_180014380 whose result we have already +confirmed live: three distinct names produced three distinct groups). If it +decompiles as something other than a compare, the whole reading is wrong. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q3_out.txt" +buf = [] +def P(*a): + s = " ".join(str(x) for x in a) + buf.append(s) + print(s) + +def C(a, label, show_callers=True): + P("=" * 78) + P(label, hex(a)) + P("=" * 78) + s = dec(a) + P("len(src) =", len(s)) + P(s) + if show_callers: + P("--- callers ---") + for ent, nm in callers(a): + P(" %-30s %s" % (nm, hex(ent))) + +try: + C(0x1800080c0, "CONTROL: FUN_1800080c0 (expected string compare)", False) + for a, lbl in ((0x180014580, "FUN_180014580"), + (0x180014b60, "FUN_180014b60"), + (0x180014df0, "FUN_180014df0"), + (0x180014610, "FUN_180014610"), + (0x1800147f0, "FUN_1800147f0")): + C(a, lbl) +except Exception: + P(traceback.format_exc()) + +open(OUT, "w").write("\n".join(buf)) +print("WROTE", OUT) diff --git a/fifa17-recon/tools/ghidra_queries/q_st_group_4.py b/fifa17-recon/tools/ghidra_queries/q_st_group_4.py new file mode 100644 index 0000000..60b790a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_group_4.py @@ -0,0 +1,56 @@ +"""DIMENSION 1 / query 4: the resolver and the store screen. + +ESTABLISHED: FUN_1800147f0(model, groupOrdinal, out, incInvisible, checkPlat) +is "give me the contents of group N". groupOrdinal 0 means "give me the list of +group tiles" (FUN_180014610). Otherwise it calls FUN_180014420(model, +groupOrdinal) and copies that group's pack vector at +0x40. + +FUN_180014580 / FUN_180014df0 map a UI category enum to the HARDCODED lowercase +group-name strings mypacks / points / bronze / silver / gold / special, look the +group up by name, and return group+0x00 (the ordinal). + +HYPOTHESIS H-CLAMP: FUN_180014420 does not handle "ordinal not found" by +failing; it falls through to the last group. With displayGroupAssetId 1/5/6 and +only 3 groups, 5 and 6 both resolve to the last group (Premium), which is +exactly the observed Bronze-ok / Gold-wrong / Premium-ok pattern. + +CONTROL: FUN_18002c8b0 is decompiled in the same batch as a same-shape +predicate; and every "field X is not read" claim is backed by printing the whole +function and its length, never by grep. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q4_out.txt" +buf = [] +def P(*a): + s = " ".join(str(x) for x in a) + buf.append(s) + print(s) + +def C(a, label, show_callers=True): + P("=" * 78) + P(label, hex(a)) + P("=" * 78) + s = dec(a) + P("len(src) =", len(s)) + P(s) + if show_callers: + P("--- callers ---") + for ent, nm in callers(a): + P(" %-30s %s" % (nm, hex(ent))) + +try: + C(0x180014420, "*** FUN_180014420 group lookup by ordinal ***") + C(0x18002c8b0, "CONTROL predicate FUN_18002c8b0") + P("=" * 78) + P("callers of the resolver FUN_1800147f0") + P("=" * 78) + for ent, nm in callers(0x1800147f0): + P(" %-30s %s" % (nm, hex(ent))) + for a in (0x18007dab0, 0x18007df60, 0x18007e430, 0x18007e5e0): + C(a, "store screen FUN_%x" % a) +except Exception: + P(traceback.format_exc()) + +open(OUT, "w").write("\n".join(buf)) +print("WROTE", OUT) diff --git a/fifa17-recon/tools/ghidra_queries/q_st_group_5.py b/fifa17-recon/tools/ghidra_queries/q_st_group_5.py new file mode 100644 index 0000000..4fac013 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_group_5.py @@ -0,0 +1,50 @@ +"""DIMENSION 1 / query 5: what the client actually PUSHES to the Flash UI. + +ESTABLISHED: FUN_1800147f0 builds the visible item list then, per item i, calls +FUN_180015d80(model, dataProvider, i, item) and conditionally +FUN_1800159b0(model, dataProvider, i, item). FUN_1800159b0 is the second caller +of the by-ordinal group lookup FUN_180014420. + +LIVE FACT this must explain: exactly ONE 0x1a8 copy of each pack exists in the +whole 4 GiB address space and each sits in the correct group, yet the Gold group +tile renders the Premium numbers. So the wrong value is produced at push time, +not stored. + +HYPOTHESIS: FUN_1800159b0 resolves the group for an item and pushes group-level +fields (price, contents) using a key that collides. + +CONTROL: FUN_180015d80 is dumped in the same batch. It is the unconditional +push, so any field seen only in FUN_1800159b0 is conditional on the mypacks +test, and any field in both is not. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q5_out.txt" +buf = [] +def P(*a): + s = " ".join(str(x) for x in a) + buf.append(s) + print(s) + +def C(a, label, show_callers=True): + P("=" * 78) + P(label, hex(a)) + P("=" * 78) + s = dec(a) + P("len(src) =", len(s)) + P(s) + if show_callers: + P("--- callers ---") + for ent, nm in callers(a): + P(" %-30s %s" % (nm, hex(ent))) + +try: + C(0x180015d80, "*** FUN_180015d80 per-item push (CONTROL) ***") + C(0x1800159b0, "*** FUN_1800159b0 per-item group push ***") + C(0x18007d880, "store screen dispatcher FUN_18007d880") + C(0x180014de0, "FUN_180014de0") +except Exception: + P(traceback.format_exc()) + +open(OUT, "w").write("\n".join(buf)) +print("WROTE", OUT) diff --git a/fifa17-recon/tools/ghidra_queries/q_st_group_6.py b/fifa17-recon/tools/ghidra_queries/q_st_group_6.py new file mode 100644 index 0000000..2b8316f --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_group_6.py @@ -0,0 +1,49 @@ +"""DIMENSION 1 / query 6: who sets the selected category (+0x290) and where do +a pack's NAME / DESCRIPTION / CONTENT come from. + +ESTABLISHED: FUN_18007dab0 renders the store list from +FUN_1800147f0(model, *(int*)(screen+0x290), dataProvider, 0, 0). Ordinal 0 means +"list the group tiles". So screen+0x290 IS the drill-down selector and the whole +bug reduces to what value the UI puts there. + +Live: pack records have EMPTY strings at +0xd8/+0x108/+0x138, which are exactly +the slots FUN_180015d80 pushes as NAME / DESCRIPTION / CONTENT, so a pack's +caption must be produced elsewhere -> FUN_18002cc90, the tail of the pack model +builder, and FUN_180016a80/bf0/840 for the group tiles. + +CONTROL: FUN_180016a80/FUN_180016bf0/FUN_180016840 are three same-shape setters +called on the SAME group strings; if they do not land on three different offsets +among +0xd8/+0x108/+0x138 the reading of FUN_180014610 is wrong. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q6_out.txt" +buf = [] +def P(*a): + s = " ".join(str(x) for x in a) + buf.append(s) + print(s) + +def C(a, label, show_callers=True): + P("=" * 78) + P(label, hex(a)) + P("=" * 78) + s = dec(a) + P("len(src) =", len(s)) + P(s) + if show_callers: + P("--- callers ---") + for ent, nm in callers(a): + P(" %-30s %s" % (nm, hex(ent))) + +try: + for a in (0x18007dbd0, 0x18007ddd0, 0x18007da30, 0x18007e230, 0x18007d930): + C(a, "store msg handler FUN_%x" % a) + C(0x18002cc90, "pack model tail FUN_18002cc90") + for a in (0x180016a80, 0x180016bf0, 0x180016840): + C(a, "CONTROL string setter FUN_%x" % a, False) +except Exception: + P(traceback.format_exc()) + +open(OUT, "w").write("\n".join(buf)) +print("WROTE", OUT) diff --git a/fifa17-recon/tools/ghidra_queries/q_st_group_7.py b/fifa17-recon/tools/ghidra_queries/q_st_group_7.py new file mode 100644 index 0000000..a2470b1 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_group_7.py @@ -0,0 +1,57 @@ +"""DIMENSION 1 / query 7: who WRITES the store screen's selected-category field. + +FUN_18007dab0 reads *(int*)(screen+0x290) and hands it to FUN_1800147f0 as the +group ordinal. FUN_18007dbd0 reads screen+0x294 as SERVER_ID and screen+0x298 as +a callback string. Those three are set by whatever handles the incoming UI +message. + +HYPOTHESIS: the writer copies a value straight out of the Flash message. If it +copies the tile's ASSET_ID (displayGroupAssetId) rather than its CHILD_CATEGORY +(the group ordinal), then non-contiguous displayGroupAssetId values break the +drill-down, which is the reported bug. + +CONTROL: 0x294 and 0x298 are enumerated by the same scan. They are known to be +read by FUN_18007dbd0, so if the scan cannot find their writers either, the scan +is at fault rather than the code, and no absence claim is made. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q7_out.txt" +buf = [] +def P(*a): + s = " ".join(str(x) for x in a) + buf.append(s) + print(s) + +try: + targets = (0x290, 0x294, 0x298, 0x2c8, 0x2cc) + hits = {t: [] for t in targets} + it = listing.getInstructions(True) + n = 0 + while it.hasNext(): + ins = it.next() + n += 1 + txt = str(ins) + for i in range(ins.getNumOperands()): + for o in ins.getOpObjects(i): + try: + v = int(o.getValue()) + except Exception: + continue + if v in targets: + f = fm.getFunctionContaining(ins.getAddress()) + hits[v].append((int(ins.getAddress().getOffset()), + f.getName() if f else "?", txt)) + P("instructions scanned:", n) + for t in targets: + P("") + P("=" * 70) + P("displacement/immediate %#x : %d instruction(s)" % (t, len(hits[t]))) + P("=" * 70) + for a, fn, txt in hits[t]: + P(" %-12s %-28s %s" % (hex(a), fn, txt)) +except Exception: + P(traceback.format_exc()) + +open(OUT, "w").write("\n".join(buf)) +print("WROTE", OUT) diff --git a/fifa17-recon/tools/ghidra_queries/q_st_group_8.py b/fifa17-recon/tools/ghidra_queries/q_st_group_8.py new file mode 100644 index 0000000..bb39ca0 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_group_8.py @@ -0,0 +1,50 @@ +"""DIMENSION 1 / query 8: THE WRITERS of screen+0x290. + +Instruction scan found exactly two writes to +0x290 inside the store-screen +cluster: + 0x18007d3ba FUN_18007d1a0 MOV dword ptr [R14 + 0x290],EBP + 0x18007f0c0 FUN_18007e7f0 MOV dword ptr [R15 + 0x290],EAX +Everything else at that displacement in CardsDLL is a vtable CALL/JMP or an +unrelated object. + +QUESTION: what value do those two store? If it is a number that came out of the +Flash message (the tile's ASSET_ID) rather than the group ordinal, then +displayGroupAssetId is being used as a group ordinal and non-contiguous ids +break the drill-down. + +CONTROL: FUN_18007dab0 (the reader, already decompiled) is in the same cluster +and uses the same object; the two writers must be seen to operate on an object +that also touches +0x294 / +0x2c8, otherwise they are a different class that +merely shares the offset. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q8_out.txt" +buf = [] +def P(*a): + s = " ".join(str(x) for x in a) + buf.append(s) + print(s) + +def C(a, label, show_callers=True): + P("=" * 78) + P(label, hex(a)) + P("=" * 78) + s = dec(a) + P("len(src) =", len(s)) + P(s) + if show_callers: + P("--- callers ---") + for ent, nm in callers(a): + P(" %-30s %s" % (nm, hex(ent))) + +try: + C(0x18007d1a0, "*** WRITER 1 FUN_18007d1a0 ***") + C(0x18007e7f0, "*** WRITER 2 FUN_18007e7f0 ***") + C(0x1800144a0, "FUN_1800144a0 CATEGORY_LOCACTION by server id") + C(0x180014ee0, "FUN_180014ee0 IS_AVAILABLE by server id") +except Exception: + P(traceback.format_exc()) + +open(OUT, "w").write("\n".join(buf)) +print("WROTE", OUT) diff --git a/fifa17-recon/tools/ghidra_queries/q_st_price_1.py b/fifa17-recon/tools/ghidra_queries/q_st_price_1.py new file mode 100644 index 0000000..9464baf --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_price_1.py @@ -0,0 +1,149 @@ +"""D3 store-price q1. + +HYPOTHESES + H1 extPrice.finalPrice (0x180139070) / originalPrice (0x18013aae0) read ONLY atom + 0x11a (externalPriceId). Atoms 0x1b (amount) and 0xc4 (currency) are SKIPped. + H2 The real-money price line is rendered from the Origin/Dime commerce catalog + singleton FUN_1801a0040 -> DAT_1802ef5a0, looked up by externalPriceId, inside + FUN_18002cc90, which early-returns when vm+0x6c == -1. + H3 The pack->viewmodel adapter FUN_18002c3c0 recognises exactly three currency + name literals: "mtx", "coins", "points". + H4 pack record +0x78 (externalPriceId sink) is constructed to -1. + +CONTROL for the absence check (H1): the SAME syntactic form. I enumerate EVERY +scalar operand of EVERY instruction in each function, so ==, !=, switch tables and +sub/dec ladders are all covered by construction. The positive control is that the +scan MUST find 0x11a in both functions and MUST find 0x124/0x134/0x1d0 in the +sibling currency-element parser FUN_180138bd0, which is known to read them. +""" +import traceback, sys + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store" + +try: + w = open(OUT + "/q1_raw.txt", "w") + + def p(*a): + s = " ".join(str(x) for x in a) + print(s) + w.write(s + "\n") + + # ---------- 1. instruction-level scalar census (absence check) ---------- + from ghidra.program.model.lang import OperandType + + def scalars(entry): + f = func(entry) + body = f.getBody() + out = {} + it = listing.getInstructions(body, True) + n = 0 + while it.hasNext(): + ins = it.next() + n += 1 + for i in range(ins.getNumOperands()): + for o in ins.getOpObjects(i): + try: + v = int(o.getValue()) + except Exception: + continue + out.setdefault(v & 0xFFFFFFFFFFFFFFFF, []).append( + (int(ins.getAddress().getOffset()), str(ins.getMnemonicString()))) + return n, out + + p("=" * 70) + p("H1 SCALAR CENSUS -- every immediate/scalar operand in the function body") + for name, ent in [("finalPrice FUN_180139070", 0x180139070), + ("originalPrice FUN_18013aae0", 0x18013aae0), + ("CONTROL currencyElem FUN_180138bd0", 0x180138bd0)]: + n, sc = scalars(ent) + p("") + p("--- %s : %d instructions, %d distinct scalars" % (name, n, len(sc))) + for atom, label in [(0x11a, "externalPriceId"), (0x1b, "amount"), + (0xc4, "currency"), (0x124, "finalFunds"), + (0x134, "funds"), (0x1d0, "name"), (0xa, "active")]: + hits = sc.get(atom, []) + p(" atom %-6s %-16s : %s" % (hex(atom), label, + ("ABSENT" if not hits else ", ".join("%x %s" % h for h in hits)))) + # any indirect jump (jump table) would break the census -> report + f = func(ent) + it = listing.getInstructions(f.getBody(), True) + ind = [] + while it.hasNext(): + ins = it.next() + if ins.getFlowType().isJump() and ins.getFlowType().isComputed(): + ind.append(hex(int(ins.getAddress().getOffset()))) + p(" computed/indirect jumps in body: %s" % (ind or "NONE")) + # full sorted scalar list, so nothing is hidden + p(" all scalars: %s" % sorted(hex(k) for k in sc)) + + # ---------- 2. who consumes the viewmodel ---------- + p("") + p("=" * 70) + p("H3 callers of the pack->viewmodel adapter FUN_18002c3c0") + for frm, typ, fn, ent in xrefs_to(0x18002c3c0): + p(" %-12x %-10s %s @ %x" % (frm, typ, fn, ent)) + + p("") + p("H2 callers of the price formatter FUN_18002cc90") + for frm, typ, fn, ent in xrefs_to(0x18002cc90): + p(" %-12x %-10s %s @ %x" % (frm, typ, fn, ent)) + + # ---------- 3. the mtx sibling FUN_18002e680 ---------- + p("") + p("=" * 70) + p("other 'mtx' consumer FUN_18002e680") + src = dec(0x18002e680) + p("len(src) = %d (printed IN FULL below)" % len(src)) + p(src) + + # ---------- 4. commerce singleton ---------- + p("") + p("=" * 70) + p("H2 DAT_1802ef5a0 (returned by FUN_1801a0040) xrefs") + for frm, typ, fn, ent in xrefs_to(0x1802ef5a0): + p(" %-12x %-10s %s @ %x" % (frm, typ, fn, ent)) + p(" live qword value in the STATIC image: %#x" % qword(0x1802ef5a0)) + p("") + p("callers of FUN_1801a0040 (commerce getter)") + cs = xrefs_to(0x1801a0040) + p(" count=%d" % len(cs)) + for frm, typ, fn, ent in cs[:60]: + p(" %-12x %-10s %s @ %x" % (frm, typ, fn, ent)) + + # ---------- 5. the points gate ---------- + p("") + p("=" * 70) + p("H3 points gate DAT_1802de0d0 xrefs") + for frm, typ, fn, ent in xrefs_to(0x1802de0d0): + p(" %-12x %-10s %s @ %x" % (frm, typ, fn, ent)) + + # ---------- 6. pack record ctor ---------- + p("") + p("=" * 70) + p("H4 pack record ctor FUN_1801342d0") + src = dec(0x1801342d0) + p("len(src) = %d (FULL)" % len(src)) + p(src) + + # ---------- 7. currency-name literals census ---------- + p("") + p("=" * 70) + p("every xref to the 'mtx' / 'coins' / 'points' / 'DRAFT_TOKEN' literals") + for lit in [b"mtx\x00", b"coins\x00", b"points\x00", b"DRAFT_TOKEN\x00", + b"POINTS\x00", b"FIFA_POINTS\x00", b"MTX\x00"]: + hits = find_all(lit) + p("") + p(" literal %-14s occurrences=%d %s" % (lit, len(hits), [hex(h) for h in hits[:8]])) + for h in hits[:8]: + xs = xrefs_to(h) + for frm, typ, fn, ent in xs[:20]: + p(" %#x <- %-12x %-8s %s @ %x" % (h, frm, typ, fn, ent)) + + w.close() +except Exception: + traceback.print_exc() + try: + w.write(traceback.format_exc()) + w.close() + except Exception: + pass diff --git a/fifa17-recon/tools/ghidra_queries/q_st_price_2.py b/fifa17-recon/tools/ghidra_queries/q_st_price_2.py new file mode 100644 index 0000000..f1dd716 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_price_2.py @@ -0,0 +1,104 @@ +"""D3 store-price q2. + +HYPOTHESES + H5 Some function reads the 0x1a8 store-tile viewmodel's currency flags + vm+0xb5 (has mtx) / vm+0xb6 (has coins) / vm+0xb7 (has points) and the + formatted price string vm+0x168, and that function is the Scaleform push + that supplies the "or %1s" argument. + H6 DAT_1802de0d0 vtable slot +0x30 is the gate that disables the "points" + currency branch in FUN_18002c3c0. + +METHOD / CONTROL. For H5 I scan EVERY function in .text and collect the set of +scalar operands, then report functions whose scalar set contains the distinctive +triple {0xb5,0xb6,0xb7}. The positive control is that FUN_18002c3c0 (known to +write all three) MUST appear in the result; if it does not, the scan is broken and +every negative is worthless. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store" + +try: + w = open(OUT + "/q2_raw.txt", "w") + + def p(*a): + s = " ".join(str(x) for x in a) + print(s) + w.write(s + "\n") + + # ---------- H5 whole-.text scalar-set scan ---------- + want = {0xb5, 0xb6, 0xb7} + hits = [] + nfun = 0 + it = fm.getFunctions(True) + while it.hasNext(): + f = it.next() + nfun += 1 + sc = set() + ii = listing.getInstructions(f.getBody(), True) + while ii.hasNext(): + ins = ii.next() + for i in range(ins.getNumOperands()): + for o in ins.getOpObjects(i): + try: + sc.add(int(o.getValue()) & 0xFFFFFFFFFFFFFFFF) + except Exception: + pass + if want <= sc: + hits.append((int(f.getEntryPoint().getOffset()), f.getName(), + 0x168 in sc, 0xa4 in sc, 0xa8 in sc, 0xa0 in sc, 0x6c in sc)) + p("=" * 70) + p("H5 scanned %d functions; %d contain the {0xb5,0xb6,0xb7} triple" % (nfun, len(hits))) + p("CONTROL: FUN_18002c3c0 present? %s" % + any(h[0] == 0x18002c3c0 for h in hits)) + p("%-12s %-24s %-7s %-6s %-6s %-6s %-5s" % + ("entry", "name", "has168", "hasa4", "hasa8", "hasa0", "has6c")) + for h in hits: + p("%-12x %-24s %-7s %-6s %-6s %-6s %-5s" % h) + + # ---------- H6 the points gate ---------- + p("") + p("=" * 70) + p("H6 writers of DAT_1802de0d0") + for a in (0x180013ed0, 0x180013f20): + src = dec(a) + p("") + p("--- FUN_%x len=%d FULL" % (a, len(src))) + p(src) + + p("") + p("H6 the gate call site, FUN_18002c3c0 around 0x18002c77f") + ii = listing.getInstructions(addr(0x18002c750), True) + n = 0 + while ii.hasNext() and n < 40: + ins = ii.next() + p(" %x %s" % (int(ins.getAddress().getOffset()), ins)) + n += 1 + + # try to resolve the vtable of the gate object: find its ctor via the writer + p("") + p("H6 vtable candidates: qword at DAT_1802de0d0 in the static image = %#x" % + qword(0x1802de0d0)) + + # ---------- extra: literal 0x1801f04f0 / 0x1801f04f8 used by FUN_18002e680 ---------- + p("") + p("=" * 70) + p("entitlement-category literals used by FUN_18002e680") + for a in (0x1801f04f0, 0x1801f04f8, 0x180228338, 0x1801eab80, 0x1802055ea): + p(" %#x = %r" % (a, rd_str(a, 40))) + + # ---------- extra: CreatePack request serializer currency vocabulary ---------- + p("") + p("=" * 70) + p("CreatePack request serializer FUN_180162530 (writes MTX / POINTS literals)") + src = dec(0x180162530) + p("len=%d FULL" % len(src)) + p(src) + + w.close() +except Exception: + traceback.print_exc() + try: + w.write(traceback.format_exc()); w.close() + except Exception: + pass diff --git a/fifa17-recon/tools/ghidra_queries/q_st_price_3.py b/fifa17-recon/tools/ghidra_queries/q_st_price_3.py new file mode 100644 index 0000000..8cd8d8d --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_price_3.py @@ -0,0 +1,91 @@ +"""D3 store-price q3. + +HYPOTHESES + H7 Omitting extPrice from the store pack JSON leaves the pack record's currency + vector with no "mtx" entry, so FUN_18002c3c0 never sets vm+0xb5 and the + real-money price line is suppressed. + THREAT TO H7: the pack-element deserializer FUN_18013af30 ALSO references the + "mtx" literal (0x18013ba76) and ALSO calls the commerce singleton FUN_1801a0040 + (0x18013ba98, 0x18013bab7). If it creates the "mtx" row unconditionally, H7 is + false. Decompile it IN FULL and read that block. + H8 DAT_1802de0d0 is a 0x2c0-byte singleton built by FUN_180012a50; its vtable + slot +0x30 is the boolean that disables the "points" currency branch. + H9 Enumerate every atom FUN_18013af30 dispatches on, by instruction-level scalar + census, so the "which keys does the pack record accept" question is answered + without the absence trap. CONTROL: the census must find the atoms we already + know it reads (0xd9 displayGroup, 0xc5 currencies, and the extPrice atoms). +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store" +ATOMS = {} +for line in open("/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"): + parts = line.rstrip("\n").split("\t") + if len(parts) >= 3: + try: + ATOMS[int(parts[0])] = parts[2] + except ValueError: + pass + +try: + w = open(OUT + "/q3_raw.txt", "w") + + def p(*a): + s = " ".join(str(x) for x in a) + print(s) + w.write(s + "\n") + + # ---- H7 / H9 : the pack element deserializer ---- + src = dec(0x18013af30, 300) + p("=" * 70) + p("H7 pack element deser FUN_18013af30 len(src)=%d PRINTED IN FULL" % len(src)) + p(src) + + p("") + p("=" * 70) + p("H9 scalar census of FUN_18013af30 -- every scalar operand, atom-annotated") + f = func(0x18013af30) + sc = {} + n = 0 + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + ins = it.next() + n += 1 + for i in range(ins.getNumOperands()): + for o in ins.getOpObjects(i): + try: + v = int(o.getValue()) & 0xFFFFFFFFFFFFFFFF + except Exception: + continue + sc.setdefault(v, []).append((int(ins.getAddress().getOffset()), + str(ins.getMnemonicString()))) + p("instructions=%d distinct scalars=%d" % (n, len(sc))) + ind = [] + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + ins = it.next() + if ins.getFlowType().isJump() and ins.getFlowType().isComputed(): + ind.append(hex(int(ins.getAddress().getOffset()))) + p("computed/indirect jumps: %s" % (ind or "NONE")) + p("") + p("scalars in the plausible atom range 1..0x400 (CMP/SUB/DEC sites shown):") + for v in sorted(k for k in sc if 0 < k <= 0x400): + sites = [h for h in sc[v] if h[1] in ("CMP", "SUB", "DEC", "ADD", "MOV", "LEA")] + p(" %-6s %-28s %s" % (hex(v), ATOMS.get(v, ""), + ", ".join("%x/%s" % s for s in sc[v][:6]))) + + # ---- H8 the points gate object ---- + p("") + p("=" * 70) + p("H8 FUN_180012a50 (ctor of the DAT_1802de0d0 singleton)") + s2 = dec(0x180012a50) + p("len=%d FULL" % len(s2)) + p(s2) + + w.close() +except Exception: + traceback.print_exc() + try: + w.write(traceback.format_exc()); w.close() + except Exception: + pass diff --git a/fifa17-recon/tools/ghidra_queries/q_st_price_4.py b/fifa17-recon/tools/ghidra_queries/q_st_price_4.py new file mode 100644 index 0000000..23c93d6 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_price_4.py @@ -0,0 +1,43 @@ +"""D3 store-price q4: name the points gate. + +H10 The object at DAT_1802de0d0 has vtable PTR_FUN_1801ebaf0; slot +0x30 is the + predicate that suppresses the "points" currency branch in FUN_18002c3c0. +CONTROL: slot +0x00 and +0x08 must be functions (a real vtable), and the ctor +FUN_180012a50 must be the only writer of that vtable pointer. +""" +import traceback +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store" +try: + w = open(OUT + "/q4_raw.txt", "w") + + def p(*a): + s = " ".join(str(x) for x in a) + print(s); w.write(s + "\n") + + p("vtable at 0x1801ebaf0 (first 24 slots)") + for off, tgt, nm in vtable(0x1801ebaf0, 24): + p(" +%#04x -> %#x %s" % (off, tgt, nm)) + + t = qword(0x1801ebaf0 + 0x30) + p("") + p("SLOT +0x30 target = %#x" % t) + s = dec(t) + p("len=%d FULL" % len(s)); p(s) + + # what else calls it / who else reads the gate the same way + p("") + p("=" * 60) + p("other call sites of vtable+0x30 on this singleton -- decompile two readers") + for a in (0x18007e7f0, 0x1800a5650): + s = dec(a) + p("") + p("--- FUN_%x len=%d (first 4000 chars; FULL length stated)" % (a, len(s))) + p(s[:4000]) + + w.close() +except Exception: + traceback.print_exc() + try: + w.write(traceback.format_exc()); w.close() + except Exception: + pass diff --git a/fifa17-recon/tools/ghidra_queries/q_st_qs_1.py b/fifa17-recon/tools/ghidra_queries/q_st_qs_1.py new file mode 100644 index 0000000..4ce30ff --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_qs_1.py @@ -0,0 +1,162 @@ +"""D2 QUICK SELL ECONOMY, batch 1. + +HYPOTHESIS (from an earlier agent's decompile of the shared ITEM element deser +FUN_18013fe00, scratchpad/packres/d4_item_deser.txt lines 779-801): the client +computes a card's quick-sell value LOCALLY, from a game-database table called +"fcc_discardcoins", ONLY when the server-sent discardValue (atom 0xd7) is zero: + + if ((int)local_150 == 0) { // discardValue not sent / 0 + q = select "price" from "fcc_discardcoins" + where cardtype = local_13c // = f(cardsubtypeid) + and level = local_138._4_4_ + and = uStack_130 & 0xffffffff // = rareflag + p = q.row0["price"] + v = (rating * p) / 100, round half up at remainder > 0x31 + local_150.hi = v + } + +QUESTIONS THIS BATCH ANSWERS + A. what is the string at DAT_18022315c (the third query key)? + B. where does "level" (local_138._4_4_) come from? no JSON atom in the switch + writes it, so read the RAW INSTRUCTIONS, not the decompiler's locals. + C. what is FUN_1800d8330 (cardsubtypeid -> cardtype)? + D. what are the db-query wrapper functions 0x1801a0020 / 0x18019fd10 / + 0x18019fd40 / 0x1801a0280 / 0x1801a0000 / 0x18019ff00 / 0x18019ffc0 / + 0x18019fea0 / 0x1801a00a0 / 0x1801a0080 / 0x18019fe40 / 0x18019fe10, + i.e. confirm this really is a SELECT col FROM table WHERE k=v chain, and + find the underlying dbdata entry point so the table can be located live. + E. Q2: FutDiscardCardServerResponse deser 0x180127300 stores totalCredits at + resp+0x28. WHO reads resp+0x28, and does it ASSIGN or ACCUMULATE? + F. Q3: bulk discard. FUN_180126f40 builds a body {"itemId":[...]} (atom 0x16d). + which action/route uses it? print its callers and their callers. + +CONTROLS + * class_deser is known-broken in rebuilt projects, so classes are resolved with + find_all(b"RS4:" + name). CONTROL: RS4:FutSquadSaveServerResponse must be + found exactly once at 0x18022c618 (measured on disk this session). If that + fails the whole batch is suspect. + * CONTROL for the atom-arm search: the ITEM deser must contain an arm for atom + 0x274 (rating) AND one for 0xd7 (discardValue); both are switch-case labels, + the same syntactic form as anything else searched for here. + +Everything is printed in full with len() stated. Nothing is truncated. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/qs/" +os.makedirs(OUT, exist_ok=True) + + +def dump(tag, va, path=None): + try: + src = dec(va) + except Exception as e: + src = "// decompile threw %r" % (e,) + print("=" * 78) + print("%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" + % (tag, va, fname(va), len(src))) + print("=" * 78) + print(src) + if path: + open(OUT + path, "w").write("// %s %#x len=%d\n%s" % (tag, va, len(src), src)) + return src + + +def disasm(lo, hi, tag): + print("-" * 78) + print("ASM %s %#x..%#x" % (tag, lo, hi)) + print("-" * 78) + a = addr(lo) + while int(a.getOffset()) < hi: + ins = listing.getInstructionAt(a) + if ins is None: + a = a.add(1) + continue + print("%#x %s" % (int(a.getOffset()), str(ins))) + a = ins.getAddress().add(ins.getLength()) + + +try: + print("##### CONTROL 0: RS4 literal lookup #####") + for nm in ("FutSquadSaveServerResponse", "FutDiscardCardServerResponse", + "FutDiscardCardByResServerResponse", "FutDiscardACardServerResponse"): + h = find_all(b"RS4:" + nm.encode() + b"\x00") + print(" RS4:%-40s hits=%s" % (nm, [hex(x) for x in h])) + + print("\n##### A: strings used by the discardcoins query #####") + for nm, a in (("DAT_18022315c", 0x18022315c), ("fcc_discardcoins_lit", 0x1802231f0), + ("DAT_1801eeeb0", 0x1801eeeb0), ("DAT_1802ef590", 0x1802ef590)): + try: + print(" %-22s %#x -> %r bytes=%s" + % (nm, a, rd_str(a, 64), read_bytes(a, 24).hex())) + except Exception as e: + print(" %-22s %#x -> ERR %r" % (nm, a, e)) + # neighbourhood of the literal pool so column names are visible + try: + blob = read_bytes(0x180223100, 0x200) + print(" literal pool 0x180223100..0x180223300:") + for piece in blob.split(b"\x00"): + if len(piece) >= 3: + print(" %r" % piece) + except Exception as e: + print(" pool ERR %r" % e) + try: + print(" _DAT_1801f66a0 16 bytes = %s" % read_bytes(0x1801f66a0, 16).hex()) + except Exception as e: + print(" _DAT_1801f66a0 ERR %r" % e) + + print("\n##### B: raw instructions of the discardcoins block in the ITEM deser #####") + # the query build sits after atom dispatch; xref to the literal pins it + for frm, typ, fn, ent in xrefs_to(0x1802231f0): + print(" xref to fcc_discardcoins literal: from %#x %s in %s (%#x)" + % (frm, typ, fn, ent)) + disasm(0x180140f80, 0x1801411e0, "item deser: discardcoins query build") + + print("\n##### B2: every write to the two stack slots feeding cardtype/level #####") + print(" (searching the whole ITEM deser for MOV [RBP+..] style stores is noisy;") + print(" instead: full decompile is dumped to disk, and the asm above is authoritative)") + dump("ITEM element deser FUN_18013fe00", 0x18013fe00, "qs_item_deser.txt") + + print("\n##### C: cardsubtypeid -> cardtype #####") + dump("FUN_1800d8330 cardsubtype->cardtype", 0x1800d8330, "qs_d8330.txt") + dump("FUN_1800d84e0", 0x1800d84e0, "qs_d84e0.txt") + + print("\n##### D: the db query wrapper chain #####") + for a in (0x1801a0020, 0x18019fd10, 0x18019fd40, 0x1801a0280, 0x1801a0000, + 0x18019ff00, 0x18019ffc0, 0x18019fea0, 0x1801a00a0, 0x1801a0080, + 0x18019fe40, 0x18019fe10): + dump("dbquery %#x" % a, a, "qs_db_%x.txt" % a) + + print("\n##### E: discard response consumers #####") + dump("FutDiscardCard deser FUN_180127300", 0x180127300, "qs_discard_deser.txt") + dump("FUN_1800d7af0 (int conv used on totalCredits)", 0x1800d7af0, "qs_d7af0.txt") + print(" --- callers of the discard deser / its owning class ---") + for frm, typ, fn, ent in xrefs_to(0x180127300): + print(" xref %#x %s %s %#x" % (frm, typ, fn, ent)) + # the singleton FUN_18011a830 vtable: slot 0xa30 removes an item; find the + # credits setter near it + dump("singleton getter FUN_18011a830", 0x18011a830, "qs_singleton.txt") + + print("\n##### F: bulk discard #####") + dump("bulk discard body builder FUN_180126f40", 0x180126f40, "qs_bulkbody.txt") + dump("single discard url builder FUN_180127570", 0x180127570, "qs_urlbuild.txt") + for a in (0x180126f40, 0x180127570): + print(" --- xrefs to %#x ---" % a) + for frm, typ, fn, ent in xrefs_to(a): + print(" %#x %s %s %#x" % (frm, typ, fn, ent)) + if ent: + for f2, t2, n2, e2 in xrefs_to(ent): + print(" ^ %#x %s %s %#x" % (f2, t2, n2, e2)) + + print("\n##### F2: action table rows for the three discard actions #####") + for nm, rowa in (("DiscardCard", 0x1802cb230), ("DiscardCardByRes", 0x1802cb260), + ("DiscardACard", 0x1802cb290)): + try: + print(" %s row %#x bytes=%s" % (nm, rowa, read_bytes(rowa, 0x30).hex())) + except Exception as e: + print(" %s ERR %r" % (nm, e)) + for frm, typ, fn, ent in xrefs_to(rowa): + print(" xref %#x %s %s %#x" % (frm, typ, fn, ent)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_qs_2.py b/fifa17-recon/tools/ghidra_queries/q_st_qs_2.py new file mode 100644 index 0000000..1b6ff07 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_qs_2.py @@ -0,0 +1,164 @@ +"""D2 QUICK SELL, batch 2. Nail down the remaining unknowns from batch 1. + +BATCH 1 ESTABLISHED (raw asm, 0x180141025..0x180141140 inside FUN_18013fe00): + if ([RBP+0x198] == 0) // server discardValue, JA = unsigned + price = SELECT [0x1802231e4] FROM "fcc_discardcoins" + WHERE "cardtype" == [RBP+0x1ac] + AND [0x180207848] == [RBP+0x1b4] + AND "rare" == [RBP+0x1b8] + v = (byte[RBP+0x214] * price) / 100, +1 if remainder >= 0x32 + [RBP+0x19c] = v + +REMAINING QUESTIONS + 1. what are the strings at 0x1802231e4 and 0x180207848? + 2. EVERY instruction in FUN_18013fe00 that WRITES [RBP+0x1b4] (the second key, + the decompiler shows no atom arm writing it, which would mean it is always + the initialiser value; that must be checked on instructions, not on the + decompiler's merged locals). Same for [RBP+0x198], [RBP+0x19c], [RBP+0x1ac], + [RBP+0x214], [RBP+0x1b8]. CONTROL: [RBP+0x214] must show exactly one write + from the atom-0x274 (rating) arm, which we already know exists. + 3. where do [RBP+0x198] and [RBP+0x19c] end up in the heap item object? print + the tail copy-construct. + 4. Q2 ASSIGN-vs-ADD. resolve the three vtables that hold the discard triple + (0x1802fb340.., 0x180270580.., 0x180220470..) and 0x1801f3100.., print every + slot, then decompile the response-apply methods so the consumer of resp+0x28 + is read directly. + 5. the FUT manager singleton DAT_1802e6398: print vtable slots 0x9c0..0xa80 so + the credit setter next to the item-remove slot 0xa30 / id-touch slot 0xa48 is + visible, and decompile 0xa30 and any credit-looking neighbour. + 6. every function in the DLL that contains the immediate 0x326 (totalCredits) + in any of the four dispatch forms, so all credit consumers are enumerated. + CONTROL: the list must contain FUN_180127300 and FUN_1801279c0, both of which + are already known to carry a `== 0x326` arm. + +Nothing is truncated; len() printed for every decompile. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/qs/" +os.makedirs(OUT, exist_ok=True) + + +def dump(tag, va, path=None): + try: + src = dec(va) + except Exception as e: + src = "// decompile threw %r" % (e,) + print("=" * 78) + print("%s %#x fname=%s len(src)=%d (FULL)" % (tag, va, fname(va), len(src))) + print("=" * 78) + print(src) + if path: + open(OUT + path, "w").write(src) + return src + + +def insns(va): + f = func(va) + out = [] + if f is None: + return out + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + i = it.next() + out.append((int(i.getAddress().getOffset()), str(i))) + return out + + +try: + print("##### 1: the two unknown literals #####") + for a in (0x1802231e4, 0x180207848, 0x180223208, 0x18022315c): + print(" %#x -> %r" % (a, rd_str(a, 64))) + + print("\n##### 2: every reference to the six stack slots in the ITEM deser #####") + ins = insns(0x18013fe00) + print(" instruction count in FUN_18013fe00 = %d" % len(ins)) + slots = ("0x198", "0x19c", "0x1ac", "0x1b0", "0x1b4", "0x1b8", "0x214", "0x1a0", + "0x1a4", "0x1a8") + for s in slots: + pat = "RBP + " + s + "]" + hits = [(a, t) for a, t in ins if pat in t] + print(" --- [RBP + %s] : %d refs ---" % (s, len(hits))) + for a, t in hits: + print(" %#x %s" % (a, t)) + + print("\n##### 3: the tail of the ITEM deser (copy-construct into the heap item) #####") + f = func(0x18013fe00) + lo = int(f.getEntryPoint().getOffset()) + hi = int(f.getBody().getMaxAddress().getOffset()) + print(" function body %#x..%#x" % (lo, hi)) + for a, t in ins: + if a >= 0x180141160: + print(" %#x %s" % (a, t)) + + print("\n##### 4: the discard action vtables #####") + for base in (0x1802fb300, 0x180270560, 0x180220450, 0x1801f3100): + print(" --- vtable-ish dump at %#x ---" % base) + for off, tgt, nm in vtable(base, 48): + extra = "" + if 0x180000000 <= tgt < 0x181000000: + try: + extra = repr(rd_str(tgt, 40)) + except Exception: + extra = "" + print(" +%#05x %#018x %-20s %s" % (off, tgt, nm, extra if not nm else "")) + + print("\n##### 4b: response-apply candidates #####") + # everything referenced next to the deser in those tables + seen = set() + for base in (0x1802fb300, 0x180270560, 0x180220450, 0x1801f3100): + for off, tgt, nm in vtable(base, 48): + if nm and tgt not in seen and fm.getFunctionAt(addr(tgt)): + seen.add(tgt) + print(" %d distinct functions in those tables" % len(seen)) + for t in sorted(seen): + try: + src = dec(t) + except Exception as e: + src = "// threw %r" % e + if "0x28" in src or "0x326" in src or "credit" in src.lower(): + print("=" * 78) + print("CANDIDATE %#x %s len=%d" % (t, fname(t), len(src))) + print("=" * 78) + print(src) + + print("\n##### 5: FUT manager singleton vtable #####") + try: + mgr_vt_holder = 0x1802e6398 + print(" DAT_1802e6398 is a runtime pointer; using static xrefs instead") + except Exception: + pass + # find the vtable by looking at who writes DAT_1802e6398 + for frm, typ, fn, ent in xrefs_to(0x1802e6398): + print(" xref to DAT_1802e6398: %#x %s %s %#x" % (frm, typ, fn, ent)) + + print("\n##### 6: every function containing the 0x326 immediate #####") + ATOM = 0x326 + found = {} + it = fm.getFunctions(True) + n = 0 + while it.hasNext(): + fn = it.next() + n += 1 + try: + body = listing.getInstructions(fn.getBody(), True) + except Exception: + continue + hit = [] + prev = None + while body.hasNext(): + i = body.next() + t = str(i) + if "0x326" in t: + hit.append((int(i.getAddress().getOffset()), t)) + if hit: + found[int(fn.getEntryPoint().getOffset())] = (fn.getName(), hit) + print(" scanned %d functions; %d contain 0x326" % (n, len(found))) + for e in sorted(found): + nm, hit = found[e] + print(" %#x %s" % (e, nm)) + for a, t in hit: + print(" %#x %s" % (a, t)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_qs_3.py b/fifa17-recon/tools/ghidra_queries/q_st_qs_3.py new file mode 100644 index 0000000..97ce73d --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_qs_3.py @@ -0,0 +1,121 @@ +"""D2 QUICK SELL, batch 3. + +ESTABLISHED SO FAR + * item struct base in FUN_18013fe00 is RBP+0x160 (local_188). Therefore + item+0x38 = discardValue as SENT BY THE SERVER (atom 0xd7) + item+0x3c = discardValue COMPUTED LOCALLY, only when item+0x38 == 0 + item+0x4c = cardtype (= FUN_1800d8330(cardsubtypeid)) + item+0x50 = cardsubtypeid, item+0x54 = "level" query key + item+0x58 = rareflag, item+0xb4 = rating + * the local formula is round_half_up(rating * price / 100) where + price = SELECT price FROM fcc_discardcoins + WHERE cardtype==item+0x4c AND level==item+0x54 AND rare==item+0x58 + * item+0x54 ("level") has EXACTLY ONE reference in the whole function and it is + a READ; the only write is the 16-byte MOVDQA initialiser at 0x18013ffa1 from + _DAT_1801f66a0 = 56 01 00 00 | 00 00 00 00 | ... so level is CONSTANT 0. + * only two functions in the DLL carry the 0x326 (totalCredits) immediate: + FUN_180127300 (DiscardCard) and FUN_1801279c0 (DiscardCardByRes). Both store + to obj+0x28 with a plain MOV, never a read-modify-write. + +THIS BATCH + A. who READS obj+0x28 on the DiscardCard server-call object? dump the class + vtable at 0x180220488 (slot +0x08 == 0x180127300 confirms the base) and + decompile every slot; likewise the DiscardCardByRes vtable located by + searching .rdata for the qword 0x1801279c0. + B. the FUT manager singleton: FUN_18011d780 writes DAT_1802e6398. find the + concrete vtable, dump slots 0x9c0..0xa90, decompile 0xa08 / 0xa30 / 0xa48 + and every neighbour whose body mentions a credit-looking field. + C. who reads item+0x38 and item+0x3c? enumerate every function that both + (i) references the item registration entry point mgr->vt[0xa08] target and + (ii) contains a +0x38 / +0x3c memory operand. Also print the a08 target. + D. Q3 bulk discard: resolve the class that owns FUN_180126f40 by searching + .rdata for that qword, dump its vtable and decompile its url builder and + response deserialiser, so the bulk request/response shape is read off code. + E. print the raw .rdata around each RS4 discard literal so the class list is + visible. + +CONTROLS + * vtable slot +0x08 of 0x180220488 must equal 0x180127300 (already observed). + * the .rdata qword search must find 0x180127300 at 0x180220490 (already + observed via xrefs_to) -- same syntactic form as the searches for + 0x1801279c0 and 0x180126f40, so a hit there validates the method. +""" +import traceback, os, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/qs/" +os.makedirs(OUT, exist_ok=True) + + +def dump(tag, va, path=None): + try: + src = dec(va) + except Exception as e: + src = "// decompile threw %r" % (e,) + print("=" * 78) + print("%s %#x fname=%s len(src)=%d (FULL)" % (tag, va, fname(va), len(src))) + print("=" * 78) + print(src) + if path: + open(OUT + path, "w").write(src) + return src + + +def q(a): + return struct.pack(" price 800 ; rating 94 -> 752 ; rating 75 -> 600 + cardtype 6, level 1, rare 0 -> price 5 ; rating 55 -> 3 + cardtype 6, level 3, rare 0 -> price 40 ; rating 95 -> 38 +So the query MUST have been issued with level = 3 / 1 / 3, never 0. + +But batch 2's instruction scan of FUN_18013fe00 found EXACTLY ONE reference to +[RBP + 0x1b4] (the slot the "level" argument is loaded from) and it is a READ at +0x180141099; the only write covering that slot appeared to be the 16-byte +MOVDQA at 0x18013ffa1. Something is wrong with that conclusion -- this is exactly +the absence trap. Find the write. + +HYPOTHESES TO TEST, in order + H1 the MOVDQA source is not _DAT_1801f66a0, or that constant is not + 56 01 00 00 | 00 00 00 00 | ... + H2 part of the atom switch lives outside the address set Ghidra assigned to + FUN_18013fe00, so the body-only instruction walk missed an arm. Test by + walking the whole address range 0x18013fe00..0x180141400 instruction by + instruction, ignoring function boundaries. + H3 the slot is written through a register-based pointer (LEA RAX,[RBP+0x160] + style) rather than an RBP displacement. + +CONTROL: the same range-walk must find the KNOWN write to [RBP + 0x1ac] +(cardtype) at 0x180140e16 and the KNOWN write to [RBP + 0x1b8] (rareflag) at +0x180140cc5. Both are plain RBP-displacement MOVs, the same syntactic form as +the write being hunted, so finding them proves the walk sees this form. +""" +import traceback + +try: + print("##### H1: the initialiser constant #####") + for a in (0x1801f66a0, 0x1801f66b0): + print(" %#x = %s" % (a, read_bytes(a, 16).hex())) + print(" asm 0x18013fe00..0x18013fff0:") + p = 0x18013fe00 + while p < 0x18013fff0: + i = listing.getInstructionAt(addr(p)) + if i is None: + p += 1 + continue + print(" %#x %s" % (p, str(i))) + p += i.getLength() + + print("\n##### H2/H3: whole-range instruction walk 0x18013fe00..0x180141400 #####") + want = ("0x1b4", "0x1b0", "0x1ac", "0x1b8", "0x214", "0x198", "0x19c") + p = 0x18013fe00 + n = 0 + hits = {w: [] for w in want} + lea160 = [] + while p < 0x180141400: + i = listing.getInstructionAt(addr(p)) + if i is None: + p += 1 + continue + t = str(i) + n += 1 + for w in want: + if w in t: + hits[w].append((p, t)) + if "RBP + 0x160]" in t or "RBP + 0x1" in t and t.startswith("LEA"): + lea160.append((p, t)) + p += i.getLength() + print(" walked %d instructions" % n) + for w in want: + print(" --- '%s' : %d ---" % (w, len(hits[w]))) + for a, t in hits[w]: + print(" %#x %s" % (a, t)) + print(" --- LEA of frame slots ---") + for a, t in lea160: + print(" %#x %s" % (a, t)) + + print("\n##### the atom-0x191 (level) question #####") + # find every immediate 0x191 anywhere in the range, in any form + p = 0x18013fe00 + while p < 0x180141400: + i = listing.getInstructionAt(addr(p)) + if i is None: + p += 1 + continue + t = str(i) + if "0x191" in t or "0x18a" in t or "0x192" in t: + print(" %#x %s" % (p, t)) + p += i.getLength() + + print("\n##### callers of FUN_18013fe00 (maybe one pre-fills level) #####") + for frm, typ, fn, ent in xrefs_to(0x18013fe00): + print(" %#x %s %s %#x" % (frm, typ, fn, ent)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_qs_5.py b/fifa17-recon/tools/ghidra_queries/q_st_qs_5.py new file mode 100644 index 0000000..1292ea2 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_qs_5.py @@ -0,0 +1,90 @@ +"""D2 QUICK SELL, batch 5: find the SECOND fcc_discardcoins computation site. + +THE CONTRADICTION THIS RESOLVES. +Measured live (read-only, pid 134663): persistent FUT item objects carry, at +item+0x3c, exactly round_half_up(item_rating * price / 100) where price comes from +the fcc_discardcoins row (cardtype = item+0x4c, level = item+0x54, rare = item+0x58). +Two items with identical cardtype 6 / rare 0 but item+0x54 = 1 and 3 got 3 and 38, +which is only explicable if the level key really varies per item. +BUT inside FUN_18013fe00 the slot that feeds the "level" argument, [RBP+0x1b4], is +provably never written: a whole-DLL byte-pattern search for a modrm with +mod=10 rm=101 disp32=0x000001b4 finds exactly one operand in that function and it +is the LOAD at 0x18014109a (44 8b 8d b4 01 00 00). So a SECOND site must exist. + +SEARCHES (all four dispatch forms considered; this is a byte/immediate search, not +a "== 0x" grep) + A. every function containing the divide-by-100 magic B8 1F 85 EB 51 + (MOV EAX,0x51eb851f) or 0x51eb851f in any instruction, cross-referenced with + whether it also calls the db-query wrappers. + B. xrefs to the four literals "price" 0x1802231e4, "level" 0x180207848, + "cardtype" 0x180223208, "rare" 0x18022315c, "fcc_discardcoins" 0x1802231f0. + C. every caller of the db wrappers FUN_1801a0000 (from-table) and FUN_18019ff00 + (where) and FUN_1801a0080 (get cell). + D. what writes item+0x54? search the whole .text for a dword store with + disp8/disp32 0x54 is hopeless, so instead: decompile the manager entry + mgr->vt[0xa08] target reached from FUN_18013fe00 and look for a level/tier + computation, and decompile the tier helper FUN_1800a9fe0 that an earlier + agent found returning 1/2/3. + +CONTROL: search A must report FUN_18013fe00 (it contains MOV EAX,0x51eb851f at +0x180141123). Search B must report the single known xref 0x18014106d for +fcc_discardcoins. If either control misses, the search is broken. +""" +import traceback + +try: + print("##### CONTROL + A: functions containing the /100 magic 0x51eb851f #####") + hits = {} + it = fm.getFunctions(True) + n = 0 + while it.hasNext(): + f = it.next() + n += 1 + ii = listing.getInstructions(f.getBody(), True) + got = [] + while ii.hasNext(): + i = ii.next() + t = str(i) + if "0x51eb851f" in t: + got.append((int(i.getAddress().getOffset()), t)) + if got: + hits[int(f.getEntryPoint().getOffset())] = (f.getName(), got) + print(" scanned %d functions, %d contain the magic" % (n, len(hits))) + print(" FUN_18013fe00 present: %s" % (0x18013fe00 in hits)) + for e in sorted(hits): + print(" %#x %s (%d sites)" % (e, hits[e][0], len(hits[e][1]))) + + print("\n##### B: xrefs to the query literals #####") + for nm, a in (("price", 0x1802231e4), ("level", 0x180207848), + ("cardtype", 0x180223208), ("rare", 0x18022315c), + ("fcc_discardcoins", 0x1802231f0)): + xs = xrefs_to(a) + print(" %-18s %#x : %d xrefs" % (nm, a, len(xs))) + for frm, typ, fn, ent in xs: + print(" %#x %s %s %#x" % (frm, typ, fn, ent)) + + print("\n##### C: callers of the db wrappers #####") + for nm, a in (("from-table 0x1801a0000", 0x1801a0000), + ("where 0x18019ff00", 0x18019ff00), + ("getcell 0x1801a0080", 0x1801a0080), + ("rowcount 0x1801a00a0", 0x1801a00a0), + ("select 0x1801a0280", 0x1801a0280)): + xs = xrefs_to(a) + fns = sorted({(ent, fn) for frm, typ, fn, ent in xs if ent}) + print(" %-24s %d xrefs from %d functions" % (nm, len(xs), len(fns))) + for ent, fn in fns: + print(" %#x %s" % (ent, fn)) + + print("\n##### D: tier helper and the registration entry #####") + for a in (0x1800a9fe0,): + src = dec(a) + print("=" * 78) + print("FUN_%x len=%d" % (a, len(src))) + print("=" * 78) + print(src) + print(" xrefs to %#x:" % a) + for frm, typ, fn, ent in xrefs_to(a): + print(" %#x %s %s %#x" % (frm, typ, fn, ent)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_qs_6.py b/fifa17-recon/tools/ghidra_queries/q_st_qs_6.py new file mode 100644 index 0000000..a3244e0 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_qs_6.py @@ -0,0 +1,73 @@ +"""D2 QUICK SELL, batch 6: FUN_180141660, the derived-field filler. + +RESOLUTION OF THE CONTRADICTION. [RBP+0x1b4] (item+0x54, the "level" query key) is +never written through an RBP displacement, but at 0x180141019/0x180141020 the code +does + + LEA RCX,[RBP + 0x160] ; = the item struct base + CALL 0x180141660 + CMP dword ptr [RBP + 0x198],0x0 ; the discardValue guard, immediately after + +so FUN_180141660 receives a POINTER to the item and can write item+0x54 through +RCX, which no RBP-displacement search could ever see. It also carries four xrefs +to the "rare" literal 0x18022315c, i.e. it does its own db lookups. + +DUMP IT AND EVERYTHING IT CALLS, in full. + +CONTROL: FUN_180141660 must contain at least one store to [RCX/RAX/RBX + 0x54] +or an equivalent; and the known-good sibling fact is that the caller loads +item+0x54 at 0x180141099 straight afterwards. Also decompile FUN_1801356c0, the +other function referencing "rare". +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/qs/" + + +def dump(tag, va, path=None): + try: + src = dec(va) + except Exception as e: + src = "// threw %r" % (e,) + print("=" * 78) + print("%s %#x fname=%s len(src)=%d (FULL)" % (tag, va, fname(va), len(src))) + print("=" * 78) + print(src) + if path: + open(OUT + path, "w").write(src) + return src + + +try: + src = dump("derived-field filler", 0x180141660, "qs_141660.txt") + print("\n--- raw asm of FUN_180141660 ---") + f = func(0x180141660) + it = listing.getInstructions(f.getBody(), True) + n = 0 + while it.hasNext(): + i = it.next() + print(" %#x %s" % (int(i.getAddress().getOffset()), str(i))) + n += 1 + print(" (%d instructions)" % n) + + print("\n--- callees ---") + seen = set() + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + i = it.next() + t = str(i) + if t.startswith("CALL 0x"): + try: + tgt = int(t.split()[1], 16) + except Exception: + continue + if tgt not in seen: + seen.add(tgt) + for t in sorted(seen): + dump("callee", t, "qs_141660_callee_%x.txt" % t) + + print("\n--- other 'rare' consumer ---") + dump("FUN_1801356c0", 0x1801356c0, "qs_1356c0.txt") + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_qs_7.py b/fifa17-recon/tools/ghidra_queries/q_st_qs_7.py new file mode 100644 index 0000000..dfbbb92 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_qs_7.py @@ -0,0 +1,101 @@ +"""D2 QUICK SELL, batch 7: Q2 (assign vs add) and Q3 (bulk discard shape). + +Q2 CONTEXT. FutDiscardCardServerResponse is a 0x38-byte object freshly allocated +per request by FUN_180127160 ("RS4:FutDiscardCardServerResponse", size 0x38, +vtable 0x180220488). Its deserialiser stores totalCredits with a plain +MOV dword [obj+0x28] and the item id with MOV qword [obj+0x30]; there is no +read-modify-write anywhere in the deser, and only two functions in the whole DLL +carry the 0x326 immediate. What remains is: who READS obj+0x28, and does that +consumer assign or accumulate into the wallet? Route to it: FUN_180127290 (vtable +slot +0xa0) dispatches to a delegate stored at servercall+0x50 / +0x60. + +Q3 CONTEXT. FUN_180126f40 emits {"itemId":[, ...]} using atom 0x16d. Find +which of the three discard actions owns it (DiscardCard 0x1802cb230 factory +0x180123cd0, DiscardCardByRes 0x1802cb260 factory 0x180123ce0, DiscardACard +0x1802cb290 factory 0x180123cc0) and what url/method that action uses. + +CONTROL for Q3: factory 0x180123cd0 must produce an object whose vtable slot ++0x08 is the request-body/url set that includes 0x180127570 (the "/%llu" single-id +url builder we have already seen on the wire as DELETE /ut/game/fifa17/item/). +""" +import traceback, os, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/qs/" + + +def dump(tag, va, path=None): + try: + src = dec(va) + except Exception as e: + src = "// threw %r" % (e,) + print("=" * 78) + print("%s %#x fname=%s len(src)=%d (FULL)" % (tag, va, fname(va), len(src))) + print("=" * 78) + print(src) + if path: + open(OUT + path, "w").write(src) + return src + + +try: + print("##### Q3: the three discard action factories #####") + for nm, a in (("DiscardACard", 0x180123cc0), ("DiscardCard", 0x180123cd0), + ("DiscardCardByRes", 0x180123ce0)): + dump("factory " + nm, a, "qs_fac_%x.txt" % a) + + print("\n##### Q3: the pointer table around 0x1801f3118 #####") + for off in range(-0x40, 0x100, 8): + a = 0x1801f3118 + off + try: + v = qword(a) + except Exception: + continue + f = fm.getFunctionAt(addr(v)) if 0x180000000 <= v < 0x181000000 else None + print(" %#x -> %#x %s" % (a, v, f.getName() if f else "")) + + print("\n##### Q3: url/method providers #####") + for a in (0x180126f00, 0x1801277c0, 0x180127800, 0x180068320, 0x180127890, + 0x180122420, 0x18011f940): + dump("provider", a, "qs_prov_%x.txt" % a) + + print("\n##### Q3: the url-suffix table entry 0x0d / 0x0e / 0x0f #####") + # the action rows point at a url index; print the table of url format strings + for i in range(0x28): + try: + p = qword(0x1801f2f00 + i * 8) + print(" idx %#04x -> %#x %r" % (i, p, rd_str(p, 60) if p else "")) + except Exception as e: + print(" idx %#04x ERR %r" % (i, e)) + + print("\n##### Q2: every RS4 class with 'Credit' or 'User' in the name #####") + for h in find_all(b"RS4:Fut"): + s = rd_str(h, 90) + if "Credit" in s or "UserData" in s or "UserInfo" in s: + print(" %#x %r" % (h, s)) + for frm, typ, fn, ent in xrefs_to(h - 4): + print(" xref %#x %s %s %#x" % (frm, typ, fn, ent)) + + print("\n##### Q2: functions containing the credits atom 0xc0 as a compare #####") + it = fm.getFunctions(True) + n = 0 + found = [] + while it.hasNext(): + f = it.next() + n += 1 + ii = listing.getInstructions(f.getBody(), True) + got = [] + while ii.hasNext(): + i = ii.next() + t = str(i) + if ("CMP" in t or "SUB" in t) and (",0xc0" in t): + got.append((int(i.getAddress().getOffset()), t)) + if got: + found.append((int(f.getEntryPoint().getOffset()), f.getName(), got)) + print(" scanned %d functions, %d contain a CMP/SUB with 0xc0" % (n, len(found))) + for e, nm, got in found: + print(" %#x %s" % (e, nm)) + for a, t in got: + print(" %#x %s" % (a, t)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_qs_8.py b/fifa17-recon/tools/ghidra_queries/q_st_qs_8.py new file mode 100644 index 0000000..2eb52a3 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_qs_8.py @@ -0,0 +1,66 @@ +"""D2 QUICK SELL, batch 8: which of item+0x38 / item+0x3c does the client READ? + +WHY IT MATTERS. In FUN_18013fe00 the two are mutually exclusive: + item+0x38 = discardValue exactly as the server sent it (atom 0xd7) + item+0x3c = the locally computed fallback, written ONLY when item+0x38 == 0 +So if the UI reads +0x3c alone, serving a non-zero discardValue would make the +quick-sell figure render as 0. If it reads +0x38 alone, our current seed of 0 would +render 0 -- which contradicts the live club items, which all carry a correct value +in +0x3c and 0 in +0x38. The likely shape is a getter "return +0x38 ? +0x38 : +0x3c" +or a caller that ORs them. Find it. + +METHOD. Scan every function; keep the ones whose instruction text contains BOTH a +"+ 0x38]" and a "+ 0x3c]" memory operand. Print the small ones in full. This is a +text search over decoded operands, so it catches loads through ANY base register, +which is the form an accessor uses -- unlike an RBP-displacement search. + +CONTROL: FUN_18013fe00 itself must appear in the list (it has the store to +0x198 +and +0x19c, but those are RBP+0x198 not "+ 0x38", so instead the control is +FUN_180141660, which is known to touch obj+0x54/+0x58/+0xb4 through RCX and must +show up in an equivalent scan for "+ 0x54]" and "+ 0x58]"). Both scans are printed. +""" +import traceback + +try: + def scan(a_txt, b_txt, maxins=60): + out = [] + it = fm.getFunctions(True) + while it.hasNext(): + f = it.next() + ii = listing.getInstructions(f.getBody(), True) + n = 0 + ha = hb = False + while ii.hasNext(): + t = str(ii.next()) + n += 1 + if a_txt in t: + ha = True + if b_txt in t: + hb = True + if ha and hb: + out.append((int(f.getEntryPoint().getOffset()), f.getName(), n)) + return out + + print("##### CONTROL scan: '+ 0x54]' and '+ 0x58]' #####") + ctl = scan("+ 0x54]", "+ 0x58]") + print(" %d functions; FUN_180141660 present: %s" + % (len(ctl), any(e == 0x180141660 for e, _, _ in ctl))) + + print("\n##### TARGET scan: '+ 0x38]' and '+ 0x3c]' #####") + tgt = scan("+ 0x38]", "+ 0x3c]") + print(" %d functions" % len(tgt)) + small = [t for t in tgt if t[2] <= 40] + print(" %d of them are <= 40 instructions" % len(small)) + for e, nm, n in sorted(small, key=lambda x: x[2]): + src = dec(e) + print("=" * 78) + print("%#x %s %d instructions len(src)=%d" % (e, nm, n, len(src))) + print("=" * 78) + print(src) + print("\n --- larger candidates (names only) ---") + for e, nm, n in sorted(tgt, key=lambda x: x[2]): + if n > 40: + print(" %#x %s %d ins" % (e, nm, n)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_qs_9.py b/fifa17-recon/tools/ghidra_queries/q_st_qs_9.py new file mode 100644 index 0000000..bb58d25 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_qs_9.py @@ -0,0 +1,89 @@ +"""D2 QUICK SELL, batch 9: the wallet. Q2 assign-vs-add, final attempt. + +PLAN. Find the credit-carrying response classes and their deserialisers: + RS4:FutUserCreditsServerResponse @ 0x18021dc18 + RS4:FutUpdateCreditsServerResponse @ 0x18022cc10 + RS4:FutDiscardCardServerResponse @ 0x180220540 (vtable 0x180220488, deser 0x180127300) +Resolve each by find_all(b"RS4:"+name) then xrefs_to(hit-4) -> factory -> the +.rdata vtable it installs -> slot +0x08. Decompile all three deserialisers and +every virtual they invoke on the FUT manager singleton, so the wallet field and +its writers are visible. Then enumerate every writer of that field. + +CONTROL: the discard chain must resolve to 0x180127300, which is already known +independently (qword 0x180127300 sits at 0x180220490 = vtable+0x08). If the same +mechanism yields a plausible deser for the two credits classes, it is working. +""" +import traceback, os, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/qs/" + + +def dump(tag, va, path=None): + try: + src = dec(va) + except Exception as e: + src = "// threw %r" % (e,) + print("=" * 78) + print("%s %#x fname=%s len(src)=%d (FULL)" % (tag, va, fname(va), len(src))) + print("=" * 78) + print(src) + if path: + open(OUT + path, "w").write(src) + return src + + +def resolve(name): + out = [] + for h in find_all(b"RS4:" + name.encode() + b"\x00"): + for frm, typ, fn, ent in xrefs_to(h - 4): + if ent: + out.append((h, frm, ent, fn)) + return out + + +try: + for nm in ("FutDiscardCardServerResponse", "FutUserCreditsServerResponse", + "FutUpdateCreditsServerResponse"): + print("##### %s #####" % nm) + for h, frm, ent, fn in resolve(nm): + print(" literal %#x factory %#x %s (ref at %#x)" % (h, ent, fn, frm)) + src = dump("factory", ent, "qs_r_fac_%x.txt" % ent) + # find the PTR_FUN_ vtable it installs + import re + for m in re.finditer(r"PTR_FUN_([0-9a-f]+)", src): + vt = int(m.group(1), 16) + print(" vtable %#x, slot+0x08 = %#x %s" + % (vt, qword(vt + 8), fname(qword(vt + 8)))) + dump("deser", qword(vt + 8), "qs_r_deser_%x.txt" % qword(vt + 8)) + for off, tgt, fnm in vtable(vt, 24): + print(" +%#05x %#x %s" % (off, tgt, fnm)) + print() + + print("##### the FUT manager slots used by the discard deser #####") + # the deser calls (**(code**)(*plVar4 + 0xa30))(plVar4, id) after parsing an id + # find every function that calls a virtual at +0xa30 / +0xa08 / +0xa48 and the + # ones that call slots near them, so a credit setter can be spotted by name. + it = fm.getFunctions(True) + want = ("0xa08", "0xa30", "0xa48", "0x9f8", "0xa00", "0xa10", "0xa18", "0xa20", + "0xa28", "0xa38", "0xa40", "0xa50", "0xa58", "0xa60") + tally = {} + while it.hasNext(): + f = it.next() + ii = listing.getInstructions(f.getBody(), True) + got = [] + while ii.hasNext(): + i = ii.next() + t = str(i) + if t.startswith("CALL qword ptr [") and any(w in t for w in want): + got.append((int(i.getAddress().getOffset()), t)) + if got: + tally[int(f.getEntryPoint().getOffset())] = (f.getName(), got) + print(" %d functions call one of those slots" % len(tally)) + for e in sorted(tally): + nm, got = tally[e] + print(" %#x %s" % (e, nm)) + for a, t in got: + print(" %#x %s" % (a, t)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_unop_1.py b/fifa17-recon/tools/ghidra_queries/q_st_unop_1.py new file mode 100644 index 0000000..e8366f2 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_unop_1.py @@ -0,0 +1,59 @@ +"""DIMENSION 5 / unopenedPacks -- pass 1: dump the four deserializers end to end. + +HYPOTHESIS: (a) FutCreateUser deser 0x18014cc60 has arms for starterPack(0x2e5) and +bonusPacks(0x5d) that call dedicated sub-deserializers; (b) userInfo 0x18013ec10 has an +arm for unopenedPacks(0x35e) that calls a sub-deser and then a singleton vtbl slot; +(c) pack element 0x18013af30 has an arm for packContentInfo(0x20c) calling a sub-deser +that holds unopened(0x35d). + +CONTROL: 0x18013c6d0 (settings deser) is a deserializer of the SAME family with a +KNOWN answer -- exactly one key `configs`(0xa2). If the dump/parse pipeline is sound, +the settings dump must show 0xa2 and nothing else in its key ladder. Same syntactic +form family (ladder), so it controls the extraction, not just the decompile. + +NO ABSENCE CLAIMS FROM THIS PASS: it only dumps. Full text goes to disk, lengths are +printed so truncation is visible. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store" + +try: + os.makedirs(OUT, exist_ok=True) + targets = { + "createuser_18014cc60": 0x18014cc60, + "userinfo_18013ec10": 0x18013ec10, + "packelem_18013af30": 0x18013af30, + "settings_18013c6d0": 0x18013c6d0, # CONTROL + } + for name, a in targets.items(): + src = dec(a, 300) + p = os.path.join(OUT, "dec_%s.c" % name) + with open(p, "w") as f: + f.write(src) + f2 = func(a) + print("== %s @ %#x fn=%s body=%#x-%#x declen=%d" % ( + name, a, f2.getName() if f2 else "?", + int(f2.getEntryPoint().getOffset()) if f2 else 0, + int(f2.getBody().getMaxAddress().getOffset()) if f2 else 0, + len(src))) + print(" written %s" % p) + + # Raw instruction-level immediate enumeration for each target, so the ladder / + # switch / sub-dec forms are all visible regardless of how Ghidra renders them. + for name, a in targets.items(): + f2 = func(a) + if f2 is None: + print("!! no function at %#x" % a) + continue + lines = [] + it = listing.getInstructions(f2.getBody(), True) + while it.hasNext(): + ins = it.next() + lines.append("%#x %s" % (int(ins.getAddress().getOffset()), str(ins))) + p = os.path.join(OUT, "asm_%s.txt" % name) + with open(p, "w") as fh: + fh.write("\n".join(lines)) + print("== asm %s: %d instructions -> %s" % (name, len(lines), p)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_unop_2.py b/fifa17-recon/tools/ghidra_queries/q_st_unop_2.py new file mode 100644 index 0000000..745ad3a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_unop_2.py @@ -0,0 +1,72 @@ +"""DIMENSION 5 pass 2: the pieces the CreateUser / unopenedPacks answers depend on. + +HYPOTHESES + H1 FUN_18011a830 returns the model singleton (DAT_1802e6398 or similar), so its + vtable can be resolved LIVE and slot 0x4e0 identified. + H2 FUN_180142470 (the userData 0x36d arm of CreateUser) takes only the reader, so + it parses into a global, not into the response record. + H3 FUN_180141ee0 is the shared "read FIELD_NAME -> atom, advance to value" helper; + return 6 means "no value / null" and suppresses dispatch. + H4 The primitive getters 0x1801c7620 (BOOL) / 0x1801c79d0 (INT) / 0x180135ff0 + (SKIP) determine whether a container fed to a scalar arm desyncs the reader. + +CONTROL for the vtable slot question: slot 0x480 is used by the squadList(0x2d4) arm +of the SAME deserializer and is LIVE-CONFIRMED WORKING (MY SQUADS renders). Whatever +method resolves 0x4e0 must also resolve 0x480 to something sane; if it cannot resolve +0x480 the method is broken, not the target. + +CONTROL for the stack-struct offset rule (record_off = 0x268 - N in FUN_18013af30): +displayGroupAssetId(0xda)->local_238 must give 0x30 and +displayGroupUseDefaultImage(0xdb)->local_230 must give 0x38, which is what an earlier +independent pass recorded for those two fields. Both are printed below from the asm. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store" + +try: + for name, a in [ + ("singleton_18011a830", 0x18011a830), + ("userdata_180142470", 0x180142470), + ("keyhelper_180141ee0", 0x180141ee0), + ("bool_1801c7620", 0x1801c7620), + ("int_1801c79d0", 0x1801c79d0), + ("skip_180135ff0", 0x180135ff0), + ("item_18013fe00", 0x18013fe00), + ("storeroot_1801234e0", 0x1801234e0), + ]: + src = dec(a, 300) + p = os.path.join(OUT, "dec_%s.c" % name) + open(p, "w").write(src) + print("== %s @ %#x declen=%d -> %s" % (name, a, len(src), p)) + + print("\n---- singleton getter, short ones printed inline ----") + for a in (0x18011a830, 0x180141ee0, 0x1801c7620): + s = dec(a, 300) + if len(s) < 2600: + print("\n### %#x (len=%d)\n%s" % (a, len(s), s)) + else: + print("\n### %#x len=%d (see file)" % (a, len(s))) + + # ---- CONTROL: stack offsets in FUN_18013af30 from the raw asm ---------- + print("\n---- FUN_18013af30 stack-displacement control ----") + f = func(0x18013af30) + it = listing.getInstructions(f.getBody(), True) + want = {} + while it.hasNext(): + ins = it.next() + t = str(ins) + for d in ("0xcd", "0xb4", "0x30", "0x38", "0xce", "0xcc"): + pass + want.setdefault("all", []).append((int(ins.getAddress().getOffset()), t)) + # print the instructions immediately around each of the four atom arms + for label, site in (("0x35d unopened", 0x18013b7b0), ("0xda dGAssetId", 0x0), + ("0xdb dGUseDefImg", 0x0)): + pass + # simpler: dump every instruction that writes a byte to a stack slot + for addr_i, t in want["all"]: + if ("MOV byte ptr [RSP" in t or "MOV byte ptr [RBP" in t + or "MOV dword ptr [RSP" in t): + print(" %#x %s" % (addr_i, t)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_unop_3.py b/fifa17-recon/tools/ghidra_queries/q_st_unop_3.py new file mode 100644 index 0000000..59bfc2b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_unop_3.py @@ -0,0 +1,45 @@ +"""DIMENSION 5 pass 3: the unopenedPacks consumer, and the tokenizer's EOF behaviour. + +Resolved LIVE (read-only probe, pid 134663): model singleton = *DAT_1802e6398, +its vtable = static 0x18021c2a0, and + slot 0x4e0 -> 0x18011e120 <- the unopenedPacks(0x35e) consumer + slot 0x480 -> 0x18011baf0 <- CONTROL, the squadList(0x2d4) consumer, which is + live-confirmed working (MY SQUADS renders) +Both land inside CardsDLL, so both are analysable. If 0x18011baf0 does not look like +a squad-roster accessor, the vtable resolution is wrong and 0x4e0 means nothing. + +HYPOTHESES + H1 0x18011e120 stores the pack count into a model field; xrefs to that field give + the UI consumer. + H2 the tokenizer 0x1801c7f10 returns a specific token at end of input; that value + decides whether a trailing re-dispatch terminates or spins (the 0x1801c7f1a + freeze). +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store" + +try: + for name, a in [ + ("consumer_18011e120", 0x18011e120), + ("control_18011baf0", 0x18011baf0), + ("nexttok_1801c7f10", 0x1801c7f10), + ]: + src = dec(a, 300) + open(os.path.join(OUT, "dec_%s.c" % name), "w").write(src) + print("\n#### %s @ %#x len=%d\n%s" % (name, a, len(src), src if len(src) < 6000 else "(see file)")) + + print("\n---- asm of 0x18011e120 ----") + f = func(0x18011e120) + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + ins = it.next() + print(" %#x %s" % (int(ins.getAddress().getOffset()), str(ins))) + + print("\n---- vtable static 0x18021c2a0 slots 0x470..0x500 ----") + for s in range(0x470, 0x508, 8): + t = qword(0x18021c2a0 + s) + fn = fm.getFunctionAt(addr(t)) if 0x180000000 <= t < 0x181000000 else None + print(" +%#05x -> %#x %s" % (s, t, fn.getName() if fn else "")) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_unop_4.py b/fifa17-recon/tools/ghidra_queries/q_st_unop_4.py new file mode 100644 index 0000000..b9ab105 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_unop_4.py @@ -0,0 +1,59 @@ +"""DIMENSION 5 pass 4: who READS the unopened-pack count, and who consumes the +CreateUser response record. + +Established so far (this run): + userInfo 0x35e arm -> model+0x20950 = preOrderPacks + recoveredPacks, then raises + event 0x273d. Writers of +0x20950: 0x18010e06a and 0x18011e12f. Reader: 0x18011c200 + (= model vtable slot 0x4d8). 0x273d compared at 0x18007e85f and 0x1800b3944, raised + again at 0x180199e07. + +HYPOTHESIS: the 0x4d8 getter is called from UI/flow code that decides whether a +pending-packs tile exists, and one of the 0x273d handlers is the refresh path. + +CONTROL: every call site is filtered by whether its containing function reaches the +model singleton FUN_18011a830 / DAT_1802e6398. Call sites on OTHER classes' vtables +that happen to use offset 0x4d8 must be rejected by that filter; if the filter rejects +nothing it is not filtering. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store" + +SITES_4D8 = [0x1800160c0, 0x180019816, 0x18007136b, 0x18007e498, 0x1800ad460, + 0x1800aeccf, 0x1800aece6, 0x1800af449, 0x1800b1d48] +SITES_4E0 = [0x1800173d1, 0x180019861, 0x18007137b, 0x1800ad498, 0x1800bcb71, + 0x18013f222] +OTHER = [0x18010e06a, 0x18007e85f, 0x1800b3944, 0x180199e07, 0x18011c200] + +try: + seen = {} + for label, sites in (("get4d8", SITES_4D8), ("call4e0", SITES_4E0), ("other", OTHER)): + print("\n===== %s =====" % label) + for s in sites: + f = func(s) + if f is None: + print(" %#x -> no function" % s) + continue + ent = int(f.getEntryPoint().getOffset()) + src = seen.get(ent) + if src is None: + src = dec(ent, 300) + seen[ent] = src + uses_model = ("FUN_18011a830" in src) or ("DAT_1802e6398" in src) + print(" site %#x fn %s @ %#x len=%d model=%s" % ( + s, f.getName(), ent, len(src), uses_model)) + open(os.path.join(OUT, "dec_fn_%x.c" % ent), "w").write(src) + + # The CreateUser response object: find its class literal, vtable and consumers. + print("\n===== FutCreateUserServerResponse =====") + for lit in find_all(b"RS4:FutCreateUserServerResponse\x00"): + print(" literal @ %#x" % lit) + for frm, typ, fn, ent in xrefs_to(lit): + print(" xref from %#x (%s) in %s @ %#x" % (frm, typ, fn, ent)) + if ent: + src = dec(ent, 300) + open(os.path.join(OUT, "dec_createuser_factory_%x.c" % ent), "w").write(src) + print(" -> dec_createuser_factory_%x.c len=%d" % (ent, len(src))) + print(src[:1800]) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_unop_5.py b/fifa17-recon/tools/ghidra_queries/q_st_unop_5.py new file mode 100644 index 0000000..de39935 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_unop_5.py @@ -0,0 +1,55 @@ +"""DIMENSION 5 pass 5: (a) the CreateUser response object's own handler, which is what +consumes starterPack/bonusPacks; (b) the other writer of model+0x20950; (c) the two +0x273d event handlers; (d) callers of FUN_180017390 (the second caller of the count +setter). + +Correction carried into this pass: pass 4's "model=False" filter was TOO NARROW. The +userInfo deser reaches the model through the raw singleton FUN_18011a830, but UI code +reaches the SAME object through the ref-counted service locator +FUN_180009c80(&out, FUN_1800d7170()). Both then call slots 0x160 / 0x4d8 / 0x4e0 / +0x530 on it, so the locator form is the same class. Do not read pass 4's False column +as "not the model". + +CONTROL for the response-vtable walk: slot +0x08 of the resolved vtable must be the +known deserializer 0x18014cc60. If it is not, the vtable is the wrong one and every +other slot read from it is meaningless. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store" + +try: + print("### FUN_18014c810 (CreateUser response ctor)") + src = dec(0x18014c810, 300) + print(src) + open(os.path.join(OUT, "dec_ctor_18014c810.c"), "w").write(src) + + # find the vtable it installs: any .rdata address referenced whose +8 is 0x18014cc60 + print("\n### hunting the response vtable (control: slot +0x08 == 0x18014cc60)") + hits = find_all((0x18014cc60).to_bytes(8, "little"), blocks=(".rdata", ".data")) + for h in hits: + vt = h - 8 + print(" candidate vtable %#x (slot+8 = deser)" % vt) + for i in range(0, 0x60, 8): + t = qword(vt + i) + fn = fm.getFunctionAt(addr(t)) if 0x180000000 <= t < 0x181000000 else None + print(" +%#04x -> %#x %s" % (i, t, fn.getName() if fn else "")) + for frm, typ, fn, ent in xrefs_to(vt): + print(" vtable xref from %#x in %s @ %#x" % (frm, fn, ent)) + + print("\n### callers of FUN_180017390 (second caller of the 0x4e0 count setter)") + for frm, typ, fn, ent in xrefs_to(0x180017390): + print(" from %#x (%s) in %s @ %#x" % (frm, typ, fn, ent)) + + print("\n### FUN_180199cc0 (raises 0x273d)") + s = dec(0x180199cc0, 300) + print(s) + open(os.path.join(OUT, "dec_fn_180199cc0.c"), "w").write(s) + + for a, nm in ((0x18010cdc0, "writer2_18010cdc0"), (0x18007e7f0, "evt_18007e7f0"), + (0x1800b3900, "evt_1800b3900")): + s = dec(a, 300) + open(os.path.join(OUT, "dec_%s.c" % nm), "w").write(s) + print("\n### %s len=%d -> file" % (nm, len(s))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_unop_6.py b/fifa17-recon/tools/ghidra_queries/q_st_unop_6.py new file mode 100644 index 0000000..5b84c74 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_unop_6.py @@ -0,0 +1,31 @@ +"""DIMENSION 5 pass 6: does the CreateUser response's starterPack vector reach the +model's 0x18-stride "claim" vector (model vtable slot 0x160), the one whose +non-emptiness pops FUT_CLAIM_NEW_ITEM_POPUP? + +Class-specific slots on the CreateUser response vtable 0x1802251f8 are +0x00 +(0x18014c990) and +0x40 (0x18014c950); every other slot is shared 0x18016c### / +0x180122420 boilerplate, so the response handler is one of those two. + +CONTROL: slot +0x08 of 0x1802251f8 is 0x18014cc60, the deserializer established in +pass 1 by an independent route (the RS4: literal xref from the factory). It matches, +so this vtable is the right object's. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store" + +try: + for a, nm in ((0x18014c950, "resp_slot40_18014c950"), + (0x18014c990, "resp_slot00_18014c990")): + s = dec(a, 300) + open(os.path.join(OUT, "dec_%s.c" % nm), "w").write(s) + print("\n### %s @ %#x len=%d\n%s" % (nm, a, len(s), s if len(s) < 5000 else "(file)")) + + t160 = qword(0x18021c2a0 + 0x160) + print("\n### model vtable slot 0x160 -> %#x" % t160) + print(dec(t160, 300)) + + print("\n### rest of FUN_180199cc0") + print(dec(0x180199cc0, 300)[1500:]) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_unop_7.py b/fifa17-recon/tools/ghidra_queries/q_st_unop_7.py new file mode 100644 index 0000000..51f5662 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_unop_7.py @@ -0,0 +1,25 @@ +"""DIMENSION 5 pass 7: close Q2. FUN_1800af3a0 returns 0x1c when the unopened-pack +count is > 0 and the model's claim vector (model+0x5a38, slot 0x160) is empty. What +consumes that return value, and can that path originate an HTTP request? + +CONTROL: the same function returns 0x29 when the claim vector is NON-empty and 0xe +otherwise, so whatever consumes the value must treat all three as the same kind of +token (a state/screen id). If the caller uses it as something else (a count, a bool) +the "state id" reading is wrong. +""" +import traceback, os +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store" +try: + print("### xrefs to FUN_1800af3a0") + for frm, typ, fn, ent in xrefs_to(0x1800af3a0): + print(" from %#x (%s) in %s @ %#x" % (frm, typ, fn, ent)) + if ent: + s = dec(ent, 300) + open(os.path.join(OUT, "dec_af3a0_caller_%x.c" % ent), "w").write(s) + print(" len=%d -> dec_af3a0_caller_%x.c" % (len(s), ent)) + if len(s) < 4000: + print(s) + print("\n### FUN_1801a4cd0 (the message poster used by the 0x273d handlers)") + print(dec(0x1801a4cd0, 300)[:1500]) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_st_unop_8.py b/fifa17-recon/tools/ghidra_queries/q_st_unop_8.py new file mode 100644 index 0000000..f5eff2a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_st_unop_8.py @@ -0,0 +1,33 @@ +"""DIMENSION 5 pass 8: the pack-record -> view-model translator, which is the consumer +of unopened(0x35d). + +Found in the raw disassembly, not the decompiler: at 0x18002c7ea..0x18002c816 a +function copies BYTE [rbp+0xce] -> [rsi+0xb8] and BYTE [rbp+0xcd] -> [rsi+0x69], +where rbp is the 0x158-byte parsed pack record (it also reads +0x144/+0x148/+0x14c/ ++0x150/+0x154, all inside 0x158) and rsi is a wider destination struct. + +Per pass 1's offset rule (record_off = 0x268 - N in FUN_18013af30, controlled twice by +displayGroupAssetId->+0x30 and displayGroupUseDefaultImage->+0x38), +0xcd is +unopened(0x35d) and +0xce is isPremium(0x176). + +CONTROL: the same routine must also move a field whose meaning is already known from +the deserializer, so the "rbp is the pack record" reading is testable. start(0x2e3) is +at record+0xb4 and the routine moves DWORD [rbp+0xb4] -> [rsi+0x84]; sortPriority +(0x2cb) is at record+0x7c and displayGroupAssetId at +0x30. +""" +import traceback, os +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store" +try: + f = func(0x18002c7ea) + ent = int(f.getEntryPoint().getOffset()) + print("containing function %s @ %#x body %#x-%#x" % ( + f.getName(), ent, ent, int(f.getBody().getMaxAddress().getOffset()))) + s = dec(ent, 300) + open(os.path.join(OUT, "dec_translator_%x.c" % ent), "w").write(s) + print("declen=%d -> dec_translator_%x.c" % (len(s), ent)) + print(s if len(s) < 12000 else s[:12000]) + print("\n### callers") + for frm, typ, fn, e2 in xrefs_to(ent): + print(" from %#x (%s) in %s @ %#x" % (frm, typ, fn, e2)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_zver_3.py b/fifa17-recon/tools/ghidra_queries/q_zver_3.py new file mode 100644 index 0000000..7c16633 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_zver_3.py @@ -0,0 +1,91 @@ +"""ADVERSARIAL VERIFY BATCH 3 (namespaced q_zver_* -- another agent is clobbering q_adv_*). + +Targets: + T1 dim4: the four claimed duplicateItemIdList consumers really accept atom 0xec and + really run the identical fixup reading ONLY record qword0 and qword2. + 0x180162880 CreatePack, 0x18013bd40 purchased/massinfo, 0x1801293d0 FutViewCards, + 0x18013e7f0 IS-list. + Ladder decode (raw instructions, catches sub/dec ladders and reports switch bounds) + + full decompile of each. + T2 dim4: FUN_18009bc40 branches on item+0x10 and suppresses PUT /item when set. + T3 dim5 claim 12: FUN_1801340e0 is a COPY CONSTRUCTOR of one 0x158 record, not a + vector-grow with element size 0x108. Full decompile + the tail of FUN_18013af30. + T4 dim5: pack element record offset rule (0x268 - N) and the 0x35d/0x2e3 nesting. +CONTROL for the ladder decoder: reproduced {0xeb,0xed,0x16d,0x16f} on 0x180138e10 and +{0x1f2,0x1f3,0x1f4,0x383} on 0x180142470 and {0x5d,0x1a5,0x2cd,0x2e5,0x36d} on +0x18014cc60 in batch 2 -- all three are sub/dec ladders, the SAME form as the targets. +""" +import traceback + + +def ladder(entry, note=""): + f = func(entry) + if f is None: + print("!! no function at %#x" % entry) + return + print("=" * 90) + print("### LADDER %#x %s body=%s" % (entry, note, f.getBody())) + acc = {} + hits = [] + n = 0 + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + ins = it.next(); n += 1 + m = str(ins.getMnemonicString()).upper() + a = int(ins.getAddress().getOffset()) + ops = [str(ins.getDefaultOperandRepresentation(i)) for i in range(ins.getNumOperands())] + def imm(i): + try: + sc = ins.getScalar(i) + return None if sc is None else int(sc.getUnsignedValue()) + except Exception: + return None + if m in ("CMP", "SUB", "ADD") and len(ops) == 2: + v = imm(1) + if v is not None: + r = ops[0] + if m == "CMP": + hits.append((a, m, r, (acc.get(r, 0) + v) & 0xFFFFFFFF)) + elif m == "SUB": + acc[r] = (acc.get(r, 0) + v) & 0xFFFFFFFF; hits.append((a, m, r, acc[r])) + else: + acc[r] = (acc.get(r, 0) - v) & 0xFFFFFFFF; hits.append((a, m, r, acc[r])) + elif m in ("DEC", "INC") and len(ops) == 1: + r = ops[0] + acc[r] = (acc.get(r, 0) + (1 if m == "DEC" else -1)) & 0xFFFFFFFF + hits.append((a, m, r, acc[r])) + elif m == "JMP" and ops and "[" in ops[0]: + hits.append((a, "SWITCHJMP", ops[0], 0)) + elif m in ("MOV", "MOVZX", "MOVSX", "MOVSXD", "LEA", "XOR", "POP"): + if ops: + acc.pop(ops[0], None) + elif m == "CALL": + for r in ("EAX", "RAX", "ECX", "RCX", "EDX", "RDX", "R8D", "R9D", "R10D", "R11D"): + acc.pop(r, None) + print(" instructions: %d" % n) + for a, k, r, v in hits: + if k == "SWITCHJMP": + print(" %#x SWITCHJMP %s <<< JUMP TABLE, case labels NOT in this list" % (a, r)) + else: + print(" %#x %-4s %-28s -> atom %#x (%d)" % (a, k, r, v, v)) + + +try: + for a, t in [(0x180162880, "CreatePack deser"), + (0x18013bd40, "purchased/massinfo body"), + (0x1801293d0, "FutViewCards"), + (0x18013e7f0, "IS-list body")]: + ladder(a, t) + print() + for a, t in [(0x180162880, "CreatePack deser"), + (0x18013e7f0, "IS-list body"), + (0x18009bc40, "claimed loan-sign completion"), + (0x1801340e0, "claimed copy-ctor of 0x158 pack record")]: + s = dec(a) + print("=" * 100) + print("### DECOMPILE %s %#x len=%d" % (t, a, len(s))) + print("=" * 100) + print(s) + print("### END %#x len=%d PRINTED IN FULL" % (a, len(s))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_zver_4.py b/fifa17-recon/tools/ghidra_queries/q_zver_4.py new file mode 100644 index 0000000..a7e2369 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_zver_4.py @@ -0,0 +1,50 @@ +"""ADVERSARIAL VERIFY BATCH 4. + + T1 dim4 claim 4: exactly six HAS_DUPLICATE code sites; five publish (item+0x10 != 0), + one hardcodes 0. My independent objdump census found SIX sites: + 0x1800439e4, 0x18008481d (direct lea on literal 0x1801f6510) + 0x1800943ce, 0x180094c24, 0x18009661c, 0x180097400 (via ptr slot 0x1802a1ff8) + Note 0x18009bc40 is NOT among them, yet the claim lists FUN_18009bc40 as a + HAS_DUPLICATE publisher. Resolve containing functions and settle it. + T2 dim4 claim 5: FUN_180127cc0 pile serializer -- record+0x08 switch 5/6/else, + atoms 0x16b/0x15c/0x226/0x2fe/0x331/0x330/0x262/0x87. Ladder + full decompile. + T3 dim5 claim 5: the three claimed consumers of model vt+0x4d8 (0x18011c200). + Enumerate ALL xrefs to 0x18011c200 and to the accessor address, and list callers. + T4 dim5 claim 6 ATTACK: who writes model+0x5a38's begin/end? dim5 says unknown and + guesses starterPack. Enumerate callers of the vt+0x160 accessor 0x18011b780 and + check whether the CreatePack/purchased deserializers push into it. +""" +import traceback +try: + print("=== T1 containing functions of the six HAS_DUPLICATE sites ===") + for a in (0x1800439e4, 0x18008481d, 0x1800943ce, 0x180094c24, 0x18009661c, 0x180097400): + f = func(a) + print(" %#x -> %s @ %#x" % (a, f.getName() if f else "NONE", + int(f.getEntryPoint().getOffset()) if f else 0)) + f = func(0x18009bc40) + print(" FUN_18009bc40 body = %s" % (f.getBody() if f else None)) + print(" any HAS_DUPLICATE site inside FUN_18009bc40? ", + any(f.getBody().contains(addr(a)) for a in + (0x1800439e4, 0x18008481d, 0x1800943ce, 0x180094c24, 0x18009661c, 0x180097400))) + + print() + print("=== T3 xrefs to the vt+0x4d8 accessor 0x18011c200 (count getter) ===") + for frm, typ, fn, ent in xrefs_to(0x18011c200): + print(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + print("=== T4 xrefs to the vt+0x160 accessor 0x18011b780 (claim vector) ===") + xs = xrefs_to(0x18011b780) + print(" total xrefs: %d" % len(xs)) + for frm, typ, fn, ent in xs: + print(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent)) + + print() + for a, t in [(0x180127cc0, "T2 pile/itemData request serializer"), + (0x18013bd40, "shared purchased/massinfo body (does it push into vt+0x160?)")]: + s = dec(a) + print("=" * 100) + print("### DECOMPILE %s %#x len=%d" % (t, a, len(s))) + print("=" * 100) + print(s) + print("### END %#x len=%d PRINTED IN FULL" % (a, len(s))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_zver_5.py b/fifa17-recon/tools/ghidra_queries/q_zver_5.py new file mode 100644 index 0000000..af03750 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_zver_5.py @@ -0,0 +1,44 @@ +"""ADVERSARIAL VERIFY BATCH 5 -- attacking dim5 claim 5. + +dim5 claim 5: "model+0x20950 ... is read only through model vtable slot 0x4d8 +(accessor 0x18011c200). Its THREE consumers render a notification badge +(FUN_1800b1d00), a tab counter (FUN_1800aeb90), and a flow-state selector +(FUN_1800af3a0). No HTTP request is originated on any of those paths." + +My independent objdump census of `call QWORD PTR [reg+0x4d8]` over the whole PE +found NINE sites, and of `call [reg+0x4e0]` (the SETTER) found SIX: + 0x4d8: 1800160c0 180019816 18007136b 18007e498 1800ad460 1800aeccf 1800aece6 + 1800af449 1800b1d48 + 0x4e0: 1800173d1 180019861 18007137b 1800ad498 1800bcb71 18013f222 +Three of the 0x4d8 sites are immediately followed by a 0x4e0 site in the same +function -- a read-modify-WRITE of the counter that dim5 did not mention. +Resolve every containing function, and decompile the ones dim5 never examined. +CONTROL: 0x18013f222 must resolve to the userInfo deser 0x18013ec10 and 0x1800173d1 +to FUN_180017390 -- the two sites dim5 DID identify. Same method for all nine. +""" +import traceback +try: + S48 = [0x1800160c0, 0x180019816, 0x18007136b, 0x18007e498, 0x1800ad460, + 0x1800aeccf, 0x1800aece6, 0x1800af449, 0x1800b1d48] + S4E = [0x1800173d1, 0x180019861, 0x18007137b, 0x1800ad498, 0x1800bcb71, 0x18013f222] + print("=== containing functions ===") + news = [] + for tag, lst in (("GET +0x4d8", S48), ("SET +0x4e0", S4E)): + for a in lst: + f = func(a) + e = int(f.getEntryPoint().getOffset()) if f else 0 + print(" %s %#x -> %s @ %#x" % (tag, a, f.getName() if f else "NONE", e)) + if e and e not in news: + news.append(e) + KNOWN = {0x1800b1d00, 0x1800aeb90, 0x1800af3a0, 0x180017390, 0x18013ec10} + todo = [e for e in news if e not in KNOWN] + print("\nfunctions dim5 never examined: %s" % ", ".join("%#x" % e for e in todo)) + for e in todo: + s = dec(e) + print("=" * 100) + print("### DECOMPILE %#x len=%d" % (e, len(s))) + print("=" * 100) + print(s) + print("### END %#x len=%d PRINTED IN FULL" % (e, len(s))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index b356b85..2434fa4 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -800,6 +800,11 @@ MOVE_BODY = os.environ.get("FUT_MOVE_BODY", "ack") # "unknown", so the live-proven value is now on. See _pack_body. STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP", "1") == "1" +# FUT_STORE_GROUPID: give each pack a DISTINCT displayGroupAssetId so the grouped +# layout that FUT_STORE_DISPLAYGROUP switched on has something to separate packs by. +# Default off. See the long note at the send site in _pack_body. +STORE_GROUPID = os.environ.get("FUT_STORE_GROUPID", "0") == "1" + # FUT_QUICKSELL: serve the SINGLE-CARD quick sell, which we have never served. # @@ -2626,6 +2631,31 @@ def _pack_body(p, idx): # for exactly that reason, and the live test buys a pack to prove the buy path # still works. body["displayGroup"] = {"value": p["name"]} + # FUT_STORE_GROUPID. The risk flagged above ACTUALLY HAPPENED, live 2026-08-05: + # sending displayGroup did switch the store to a grouped render path, all three + # packs collapsed into ONE group, and drilling into any of the three group tiles + # rendered the same single Premium Gold pack. Two of three packs became + # unbuyable. Cosmetic tile names were bought with two thirds of the store. + # + # displayGroupAssetId (0xda) is the obvious thing to group BY, and it is real: + # case 0xda in 0x18013af30 calls the INT getter 0x1801c79d0 and lands in the + # 0x158-byte pack record at +0x30 (the record is copy-constructed out of the + # stack frame at the tail of the deser, via FUN_1801340e0 / FUN_180132180). + # Omitting it presumably leaves every pack on the same default, hence one group. + # + # This is a hypothesis with a mechanism, not a proven fix. The consumer that + # builds group membership was NOT located: it is reached from the packed + # FIFA17.exe side and chasing it costs far more than the live test does. + # Type fidelity is not the risk here (a scalar into an INT getter is the safe + # direction; the freeze that started all this came from sending displayGroup as + # an ARRAY where a flat object was expected), so the cheap experiment is sound. + # + # Default OFF until a launch shows three separately buyable tiles. + # If it does NOT work, the correct fallback is FUT_STORE_DISPLAYGROUP=0, which + # restores the ungrouped layout: tiles read "unknown" but all three are buyable. + # Ugly and working beats pretty and unbuyable. + if STORE_GROUPID: + body["displayGroupAssetId"] = p["id"] return body