From e578443d73f7205e651e9e0a2fd4f57c38a6ea80 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 6 Aug 2026 10:07:01 -0700 Subject: [PATCH] fifa17-recon: tradingEnabled is 0, and that is why the transfer options are greyed out Card-subsystem pass, 11 agents plus three adversarial verifiers. Full writeup in docs/plan-2026-08-06-card-subsystem.md. Two of the results below correct things I committed earlier today. THE GREYED-OUT TRANSFER OPTIONS ARE EXPLAINED. "Place on Transfer List" and "List on Transfer Market" have been disabled in the reveal screen and nobody knew why. TO_TRADE_PILE (FUN_1801a7260) requires BOTH item+0x49 tradeable AND a service gate at vtable slot +0x270. That slot is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the tradingEnabled gate byte. Read live and reproduced independently: slot +0x2b0 friendlySeasons disp 0x1fd3a VALUE=1 slot +0x2c8 draftMode disp 0x1fd3d VALUE=1 slot +0x2e0 packOpeningAnim disp 0x1fd45 VALUE=1 slot +0x270 tradingEnabled disp 0x1fd2e VALUE=0 tradingEnabled is the FIRST gate byte found that is not 1. This partly rehabilitates the settings work from this morning: that plan died because every gate it targeted already read 1, and the conclusion drawn was that the settings array does not matter. It does. It matters for a flag nobody was looking at, and tradingEnabled is ALREADY in _SETTINGS_KEEP, plumbed and never sent because _SETTINGS_MODE defaults to off. So the fix is two things, not one: FUT_SETTINGS=keep AND untradeable false. Shipping only the boolean would look like the finding failed. THE DISCARD "MISS" NEVER EXISTED, which corrects e3092ca. fcc_discardcoins is resident and complete, the client lookup runs and is correct, and it lands at item+0x3c. The tile simply binds +0x38, which is OUR value, and nothing falls back to +0x3c. So the client was not failing a lookup; it was faithfully displaying the 0 we sent. Same observable, completely different mechanism, and the version in e3092ca is wrong. FUT_DISCARD_SEND remains exactly the right fix, now for the right reason. WHAT FUT PAYS FOR STAFF IS NO LONGER UNKNOWN. Same formula, but the rating input is the table `value` column: gkcoachcards 9000081 value 66 gives 36, and the client's own +0x3c reads 36. That closes the gap I flagged in e3092ca as not-guessed. CLUB ITEM SUBTYPES, the standing unknown in CARD_SYSTEM.md, are settled: kit 9, stadium 10, badge 11 are cardtype 7 (not 9), ball 30, league logo 31 by elimination. All five constants in fut_clubitems.FAMILIES are wrong and all five currently sit in the TROPHY block 0x91..0x96. Note the probe route the doc preferred could never have answered this: probe_shelf()'s candidate set lacks 9, 10 and 11, so it would have spent a launch and returned nothing for three of five families. THE CARD MODEL FIELD MAP now exists, 28 rows, every field we send with the byte it lands on and whether the client keeps it. Built by diffing what we serve against the parsed records in the live heap (stride 0x180, anchored by a satellite back-pointer rather than by assuming the +0x38 offset). Corrections that change what we serve: +0x54 is the discard LEVEL not itemType, +0x49 is untradeable INVERTED, +0x5c is itemState, definitionId is not an atom at all. A HIGH-CONFIDENCE ABSENCE CLAIM WAS REFUTED IN VERIFICATION: playStyle IS stored, at +0x88. Its controls were raw scalars while playStyle is a DECODED scalar, so the control was the wrong FORM. That is a new variant of the absence trap, which has now cost six wrong verdicts, and it is recorded in the doc. FIX TO MY OWN PATCH from e3092ca: purchased() and last_pack() lacked the _with_discard wrapper that items() had, so the pending pile, which is the one place a quick-sell value is actually read, served unstamped cards. Found by verification, not testing. All three read paths now stamp. Correcting an overstatement in e3092ca: "turning the flag off is a true revert" holds for the read paths, which copy, but NOT for cards minted while armed, because _item() stamps at creation and those persist (9 items currently). Kept deliberately: the pack reveal serves itemList straight from open_pack(), not through purchased(), so removing creation-stamping would leave the screen that matters unstamped. Persisted values are correct and self-heal, since every read recomputes and overwrites. Nothing here has been on screen. Six patches are proposed in the doc as pasteable text, env-flagged, defaulting off, none applied. Live: 439 contract checks, 414 card-family checks, market suite, all pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/plan-2026-08-06-card-subsystem.md | 1031 +++++++++++++++++ fifa17-recon/tools/fut_store.py | 11 +- .../tools/ghidra_queries/q_adv_dv_1.py | 104 ++ .../tools/ghidra_queries/q_adv_dv_10.py | 20 + .../tools/ghidra_queries/q_adv_dv_2.py | 118 ++ .../tools/ghidra_queries/q_adv_dv_3.py | 100 ++ .../tools/ghidra_queries/q_adv_dv_4.py | 120 ++ .../tools/ghidra_queries/q_adv_dv_5.py | 62 + .../tools/ghidra_queries/q_adv_dv_6.py | 52 + .../tools/ghidra_queries/q_adv_dv_7.py | 62 + .../tools/ghidra_queries/q_adv_dv_8.py | 42 + .../tools/ghidra_queries/q_adv_dv_9.py | 31 + .../tools/ghidra_queries/q_adv_v1_1.py | 74 ++ .../tools/ghidra_queries/q_adv_v1_2.py | 124 ++ .../tools/ghidra_queries/q_adv_v1_3.py | 92 ++ .../tools/ghidra_queries/q_adv_v_1.py | 116 ++ .../tools/ghidra_queries/q_adv_v_2.py | 88 ++ .../tools/ghidra_queries/q_adv_v_3.py | 42 + .../tools/ghidra_queries/q_adv_v_4.py | 79 ++ .../tools/ghidra_queries/q_cd_clubitem_1.py | 58 + .../tools/ghidra_queries/q_cd_clubitem_10.py | 56 + .../tools/ghidra_queries/q_cd_clubitem_11.py | 37 + .../tools/ghidra_queries/q_cd_clubitem_12.py | 76 ++ .../tools/ghidra_queries/q_cd_clubitem_13.py | 55 + .../tools/ghidra_queries/q_cd_clubitem_14.py | 41 + .../tools/ghidra_queries/q_cd_clubitem_15.py | 67 ++ .../tools/ghidra_queries/q_cd_clubitem_16.py | 35 + .../tools/ghidra_queries/q_cd_clubitem_17.py | 58 + .../tools/ghidra_queries/q_cd_clubitem_18.py | 33 + .../tools/ghidra_queries/q_cd_clubitem_19.py | 89 ++ .../tools/ghidra_queries/q_cd_clubitem_2.py | 85 ++ .../tools/ghidra_queries/q_cd_clubitem_20.py | 26 + .../tools/ghidra_queries/q_cd_clubitem_21.py | 47 + .../tools/ghidra_queries/q_cd_clubitem_22.py | 31 + .../tools/ghidra_queries/q_cd_clubitem_23.py | 40 + .../tools/ghidra_queries/q_cd_clubitem_24.py | 31 + .../tools/ghidra_queries/q_cd_clubitem_25.py | 53 + .../tools/ghidra_queries/q_cd_clubitem_26.py | 39 + .../tools/ghidra_queries/q_cd_clubitem_27.py | 28 + .../tools/ghidra_queries/q_cd_clubitem_28.py | 29 + .../tools/ghidra_queries/q_cd_clubitem_3.py | 61 + .../tools/ghidra_queries/q_cd_clubitem_4.py | 46 + .../tools/ghidra_queries/q_cd_clubitem_5.py | 69 ++ .../tools/ghidra_queries/q_cd_clubitem_6.py | 48 + .../tools/ghidra_queries/q_cd_clubitem_7.py | 42 + .../tools/ghidra_queries/q_cd_clubitem_8.py | 26 + .../tools/ghidra_queries/q_cd_clubitem_9.py | 32 + .../tools/ghidra_queries/q_cd_discard_1.py | 95 ++ .../tools/ghidra_queries/q_cd_discard_2.py | 134 +++ .../tools/ghidra_queries/q_cd_discard_3.py | 132 +++ .../tools/ghidra_queries/q_cd_discard_4.py | 51 + .../tools/ghidra_queries/q_cd_discard_5.py | 85 ++ .../tools/ghidra_queries/q_cd_discard_6.py | 80 ++ .../tools/ghidra_queries/q_cd_discard_7.py | 69 ++ .../tools/ghidra_queries/q_cd_discard_8.py | 89 ++ .../tools/ghidra_queries/q_cd_discard_9.py | 41 + .../tools/ghidra_queries/q_cd_rating_1.py | 51 + .../tools/ghidra_queries/q_cd_rating_2.py | 72 ++ .../tools/ghidra_queries/q_cd_rating_3.py | 44 + .../tools/ghidra_queries/q_cd_rating_4.py | 59 + .../tools/ghidra_queries/q_cd_rating_5.py | 77 ++ .../tools/ghidra_queries/q_cd_rating_6.py | 46 + .../tools/ghidra_queries/q_cd_rating_7.py | 37 + .../tools/ghidra_queries/q_cd_rating_8.py | 16 + .../tools/ghidra_queries/q_cd_rating_9.py | 19 + .../tools/ghidra_queries/q_cd_route_1.py | 50 + .../tools/ghidra_queries/q_cd_route_2.py | 79 ++ .../tools/ghidra_queries/q_cd_route_3.py | 51 + .../tools/ghidra_queries/q_cd_route_4.py | 54 + .../tools/ghidra_queries/q_cd_route_5.py | 44 + .../tools/ghidra_queries/q_cd_state_1.py | 196 ++++ .../tools/ghidra_queries/q_cd_state_10.py | 21 + .../tools/ghidra_queries/q_cd_state_11.py | 53 + .../tools/ghidra_queries/q_cd_state_12.py | 22 + .../tools/ghidra_queries/q_cd_state_2.py | 167 +++ .../tools/ghidra_queries/q_cd_state_3.py | 170 +++ .../tools/ghidra_queries/q_cd_state_4.py | 139 +++ .../tools/ghidra_queries/q_cd_state_5.py | 142 +++ .../tools/ghidra_queries/q_cd_state_6.py | 129 +++ .../tools/ghidra_queries/q_cd_state_7.py | 97 ++ .../tools/ghidra_queries/q_cd_state_8.py | 123 ++ .../tools/ghidra_queries/q_cd_state_9.py | 18 + 82 files changed, 6456 insertions(+), 2 deletions(-) create mode 100644 fifa17-recon/docs/plan-2026-08-06-card-subsystem.md create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_dv_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_dv_10.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_dv_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_dv_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_dv_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_dv_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_dv_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_dv_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_dv_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_dv_9.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_v1_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_v1_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_v1_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_v_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_v_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_v_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_adv_v_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_10.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_11.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_12.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_13.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_14.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_15.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_16.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_17.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_18.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_19.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_20.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_21.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_22.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_23.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_24.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_25.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_26.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_27.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_28.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_clubitem_9.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_discard_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_discard_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_discard_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_discard_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_discard_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_discard_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_discard_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_discard_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_discard_9.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_rating_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_rating_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_rating_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_rating_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_rating_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_rating_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_rating_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_rating_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_rating_9.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_route_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_route_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_route_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_route_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_route_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_state_1.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_state_10.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_state_11.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_state_12.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_state_2.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_state_3.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_state_4.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_state_5.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_state_6.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_state_7.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_state_8.py create mode 100644 fifa17-recon/tools/ghidra_queries/q_cd_state_9.py diff --git a/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md b/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md new file mode 100644 index 0000000..3cba09d --- /dev/null +++ b/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md @@ -0,0 +1,1031 @@ +# The card subsystem: the item record, club-item subtypes, and the card lifecycle + +Written 2026-08-06. Six parallel reversing passes over the shared item +deserializer, the club-item families, the discard lookup, rating authority, the +club route table and the live heap, plus three adversarial verification rounds +that refuted four claims, corrected fourteen more and closed two gaps the +original passes had declared unresolvable. FIFA 17 was running throughout as pid +183351, in Ultimate Team, and was read strictly read-only. No server was +restarted, no server code was changed, and `tools/fifa17_profile.json` was never +opened for write. + +Slide used for every live read: `live = static - 0x180000000 + 0x6ffffc140000`, +re-derived from `/proc/183351/maps` by five agents independently rather than +asserted, and proved each time against bytes read from the on-disk PE in two +different sections. Controls were the FNV hasher prologue at `0x180180d00` +(`.text`) and a single-occurrence `.rdata` literal, each agent picking its own. +One verifier added a third control that is worth stealing: the `.data` pointer at +`0x1802d2760` deliberately MISMATCHES between file and memory, and the delta is +exactly the slide, so the relocation itself confirms the arithmetic. The live +addresses die with the process; the static ones do not. + +One methodological note before anything else, because it changed a verdict. The +key hash is **FNV-1** (multiply, then xor), not FNV-1a. A verifier's first pass +used FNV-1a against all 907 dictionary names and scored 0/907; the positive +control caught it immediately. `docs/plan-2026-08-05-store-subsystem.md` calls +`0x811c9dc5` "the FNV-1a offset basis". The basis is shared; the round order is +not. + +--- + +## 1. What we now know that we did not know this morning + +**The greyed-out transfer options are explained, both gates are identified, and +one of them was measured at 0 in the live client today.** This has been an +unexplained live observation for two days and it is now a two-row server fix. +`FUN_18003e370` publishes eight per-card action booleans to Flash, and the fourth +is `TO_TRADE_PILE`. Its predicate is `FUN_1801a7260`, which returns 1 only if +**both** of two conditions hold: the item's tradeable byte at `+0x49` is +non-zero, and slot `+0x270` of the `0xed84b12` service returns non-zero. We fail +both. We fail the first because `_item()` hard-codes `"untradeable": True`, and +the deserializer stores that field **inverted** (`local_140 = CONCAT11(cVar6 == +'\0', ...)`), so our every card has `+0x49 == 0`. We fail the second because that +slot is `movzx eax, byte [rcx+0x1fd2e]; ret`, and `0x1fd2e` is the +`tradingEnabled` gate byte that `docs/ENDPOINT_MAP.md` already maps to settings +field `[10]`. + +That second half was the open gap in the source material, and it is now measured +rather than argued: + +``` +pid 183351 base 0x6ffffc140000 slide 0x6ffe7c140000 +CONTROL A .text FNV prologue: MATCH +DAT_1802e6398 -> 0xb7b4b920 vtable static 0x18021c2a0 + slot +0x270 -> 0x18011c670 stub=0fb6812efd0100c3 disp=0x1fd2e VALUE=0 + slot +0x2b0 -> 0x18011c500 stub=0fb6813afd0100c3 disp=0x1fd3a VALUE=1 + slot +0x2c8 -> 0x18011c4b0 stub=0fb6813dfd0100c3 disp=0x1fd3d VALUE=1 + slot +0x2e0 -> 0x18011c590 stub=0fb68145fd0100c3 disp=0x1fd45 VALUE=1 +``` + +The three controls are the gate bytes `tools/gate_byte_probe.py` already measured +as 1 on two earlier launches. They come back 1 here, in the same walk, off the +same object, with each displacement decoded from its own accessor stub rather +than taken from a table. So the walk works and the zero is a fact about trading, +not an artefact. `GET /settings` on the live server still answers `{"configs": +[]}`, which I fetched read-only to confirm. + +This also settles something the settings-gate document left ambiguous. That +document's correction established that the applier runs even with an empty +configs array and that the struct it is handed defaults several fields to 1. +Trading is not one of them: its default is 0. So `tradingEnabled` is not a flag +we have been overwriting, it is a flag nobody has ever sent, and +`tools/utas_server.py` **already has the row plumbed** -- `tradingEnabled` is in +`_SETTINGS_KEEP`, reachable today with `FUT_SETTINGS=keep`. The transfer-list fix +is one existing env flag plus one boolean, and section 6 has both. + +Be precise about what this does and does not claim. It explains why the menu +entry is greyed. It does not promise that the transfer market works behind it. + +**The card model now exists as a document.** Section 2 is the reference artifact +this project has never had: every field the server sends, whether the client +parses it, the byte it lands on, who wins when the client's own database +disagrees, and a grade with an address. Twenty-eight rows, of which twenty-one +are CONFIRMED with an address, four are INFERRED, and three are honestly UNKNOWN. +It was built the only way it could have been: a live bijection over 22 resident +records to find the offsets, and the deserializer's own stack-slot arithmetic to +name the ones the live data could not separate. + +**Club-item subtypes are settled, and the starting premise was wrong for three of +the five families.** Kits, stadia and badges are **cardtype 7**, subtypes 9, 10 +and 11. Only balls (30) and league logos (31) are cardtype 9. The 0x91..0x96 +block that `CARD_SYSTEM.md` and `tools/fut_clubitems.py` assign to club +customisation is **trophies**. Every one of the five subtype constants currently +in `FAMILIES` is wrong, and all five sit inside the trophy block. Section 3 has +the evidence and the one probe still outstanding. + +**The discard "miss" was never a miss.** The premise that has been carried in +`fut_store.py`'s own comments -- "that lookup returns no row for our cards" -- is +false. `fcc_discardcoins` is resident in the running client with all 141 rows, +dumped independently by two agents and byte-identical to the repo's +reconstruction both times. Every one of the 22 resident records keys a real row. +On the two records where our `discardValue` guard did not fire, the client's own +stored answer at `+0x3c` reproduces the table evaluation **to the unit**: the +gkcoach card at rating 66 computes 36, the chemistry style at rating 95 computes +38. The lookup runs, hits, and is right. + +The real cause is a consumer split. `FUN_1800eb850` pushes two separate named +properties to Flash, `DISCARD_CREDITS` from `+0x38` (ours) and +`CALCULATED_DISCARD_CREDITS` from `+0x3c` (the client's), and no native code +anywhere in CardsDLL selects between them, sums them, or falls back from one to +the other. Each getter has exactly one call site, confirmed by two agents using +two different methods. The quick-sell tile binds `DISCARD_CREDITS`, so a card we +do not price renders 0 while the correct number sits one dword away, unread. That +is a negative result and it deletes work: there is no card field we get wrong, no +table row to add, and nothing to fix in the formula. `FUT_DISCARD_SEND=1` is not +a workaround for a data defect, it is the only wire input the tile reads, and it +should stop being described as a workaround. + +**What FUT pays for staff is no longer unknown.** The brief listed this as an +open question and the fallback in `discard_value()` documents it as a guess not +worth shipping. It is the same formula, and the rating input is the staff table's +own `value` column. The gkcoach card 9000081 has `value: 66` in +`data/tables/gkcoachcards.json`; the merge writes that column into `+0xb4`; +`round_half_up(66 * 55 / 100) = 36`; and the client's independently computed +`+0x3c` reads exactly 36. One data point, but it is an exact one, and the +mechanism is read out of the merge rather than curve-fitted. Section 6 has the +patch, which is server-side arithmetic with no wire change at all. + +**Two of the eleven items in the pending pile are unpriced, and the cause is one +missing wrapper rather than a missing formula.** `Profile.items()` stamps +`discardValue` on the way out; `Profile.purchased()` returns the raw pile. I ran +the live pile through the server's own `discard_value()` read-only: the chemistry +style 100000283 comes back **38**, matching the client's own computation to the +unit, and it is simply not on the wire. The staff card returns `None` because it +carries no rating, which the previous finding fixes. + +**A confident absence claim was wrong, and the way it was wrong is worth more +than the fact.** One pass established at HIGH that `playStyle` is stored nowhere +in the item record, and did it properly: an exhaustive bijection over every +offset at four widths across 22 records, with same-value constant controls +(`contract == 7`, `fitness == 99`) that the same pass found. The controls passed. +The claim is still false. `playStyle` lands at `+0x88`, written by deser case +`0x23f`, and the search could never have found it because the value passes +through `FUN_180136480`, a switch accepting only `0xfb..0x111` with `default: +return 0`. We send 250, one below the first case, so the client stores 0 and no +search for the literal 250 can succeed. The controls were **raw scalars written +straight through**; the target was a **decoded scalar**. The control did not match +the target's form. That is the absence trap in a costume nobody had seen: not a +grep-versus-switch mismatch this time, but a same-form control that was the wrong +form for a reason invisible in the live data. Add it to the list: when testing for +a field's presence by value, first ask whether the value you sent is the value the +client would store. + +**`amount` is also not dropped, and the correction is corroborated by a screenshot +we already have.** Ground truth recorded `amount` as absent from the record. It +lands at `+0xbe` for chemistry styles (subtypes 250..273) and `+0xbf` for other +consumable classes, written by `FUN_18013f4d0` as a single **byte**. Live: item +100000283, we sent `amount: 5`, `+0xbe == 5`. The original sweep looked for a u32 +and explicitly dismissed the stray single bytes. Independently, `CARD_SYSTEM.md` +already records consumables drawing correct "+5 / +10 / +15" badges and attributes +that to "atom 0x1b reaching record+0xbf" -- a live screen observation from +2026-08-05 that agrees with the static reading for the other consumable classes. +Two routes, same answer. + +Three smaller things worth carrying forward. + +**`definitionId` is not an atom.** It is absent from the 907-row dictionary at +`0x1802D2760`. I checked the repo's own `docs/fut_atoms.tsv` directly rather than +taking it on report: `awk` for `definitionId` returns nothing while every +neighbour resolves. A verifier went further and walked the **live** key map, +recovering 908 nodes, scoring 907/907 on the dictionary as a positive control, and +showing `definitionId`'s hash collides with nothing -- so it cannot even be +silently misrouted onto another field. `_item()` sends it on every card. It is +pure wire cost and dropping it is free. + +**`itemType` is parsed and thrown away.** Case `0x173` copies the string into a +heap buffer that lives *below* the record base in the frame, so it is outside the +0x180-byte record by construction, and nothing copies it in. This matters mainly +because we send `"itemType": "player"` on a chemistry-style consumable, which is +wrong and harmless, but which also made the `+0x54` question look undecidable +when it was not. + +**The client does not hold the club.** A 2.80 GiB sweep found only 31 of 249 +served item ids resident: 22 as full records (the 11-item pending pile plus an +11-item club page) and 9 as bare u32. The nine were initially read as squad slot +references; a base-rate test refutes it. Background density in that id range +predicts 22.8 noise hits in our window and we observed 9, which is *below* noise, +and eight of the nine appear exactly once in memory where every genuine record id +appears three to ten times. So `/club` paging is real and any future probe that +expects to find a 246-item array will fail for residency reasons, not structural +ones. + +--- + +## 2. The card model, field by field + +This is the reference the project has not had. "Where it lands" is an offset into +the 0x180-byte parsed item record built by `FUN_18013fe00`. Two independent +methods produced it and they agree everywhere they overlap: + +- **Live bijection.** 22 resident records at `0xb7b61a78..0xb7b639f8`, every + offset tried at widths 1/2/4/8, each field correlated only over the records that + carried it. Reproduced from scratch by three agents with the same result. Rows + it produced alone are marked *(live diff)*. +- **Frame arithmetic.** `FUN_18013fe00` builds the record as a stack struct and + hands `&local_188` to the merge, so `record_offset = 0x188 - X` for every + Ghidra local `local_X`. This is structural, not heuristic, and it was validated + against nine fields with independent corroboration before being used to settle + anything. It is what decides the fields the live data cannot, because every + resident record carries the same value. + +Authority means: who wins when our JSON and the client's local database disagree. +**OURS** = server-authoritative, the wire value reaches the screen. **CLIENT** = +overwritten or ignored, sending it changes nothing. **CONDITIONAL** = depends on +the card family or on whether we send zero. + +### Fields we send today + +| field (atom) | parsed | lands at | authority | grade | address | +|---|---|---|---|---|---| +| `id` (0x15c) | yes | `+0x08` u64, dup at `-0x08` | OURS | CONFIRMED | live diff, 22/22 | +| `resourceId` (0x287) | yes | `+0x18` u32, version byte `+0x24` | OURS, and it is the only identity that matters | CONFIRMED | `FUN_180166ca0` | +| `assetId` (0x23) | yes | `+0x20` u32 | CONDITIONAL: dead for families 1–5 and 10; live elsewhere; **required** for stadium (subtype 10), whose caption is `StadiumName_` | CONFIRMED | `FUN_180119bd0`, `FUN_180135890` | +| `cardassetid` (0x6b) | yes | `+0x1c` u32 | CONDITIONAL: clobbered for players by `rec[0x1c] = rec[0x18] & 0xffffff`; **live for every family the merge has no arm for**, which is 6,7,8,9 and the club families | CONFIRMED | `FUN_180135890` line 1; merge arms 1,2,3,4,5,10 only | +| `definitionId` | **no** | nowhere | not an atom at all | CONFIRMED | absent from `fut_atoms.tsv` and from the live key map, 907/907 control | +| `cardsubtypeid` (0x6c) | yes | `+0x50` u32; family `+0x4c` = `FUN_1800d8330(subtype)` | OURS, and it selects everything downstream | CONFIRMED | live diff 22/22; `FUN_1800d8330` read in full | +| `itemType` (0x173) | yes, then discarded | nowhere in the record | ignored | CONFIRMED | case 0x173 writes a heap string below the record base | +| `rareflag` (0x271) | yes | `+0x58` u32 | OURS | CONFIRMED | live diff 21/21; discard key `uStack_130 & 0xffffffff` | +| `rating` (0x274) | yes | `+0xb4` u8 | CONDITIONAL: **OURS for family 1**; overwritten from the DB `value` column for families 2,3,4,5,10 | CONFIRMED | `FUN_1801a87f0`; merge staff arms | +| `preferredPosition` (0x24a) | yes | `+0x146` u16 | OURS | CONFIRMED | live diff, exact 11-way bijection | +| `nation` (0x1d1) | yes | `+0x148` (family 1), `+0xde` (family 2), dropped otherwise | OURS **when non-zero**; the merge fills it only `if (rec[0x148] == 0)` | CONFIRMED | deser tail lines 714–720 | +| `teamid` (0x306) | yes | `+0x94` u32 | OURS **when non-zero**; merge fills only if 0. **Required** for kit (9) and badge (11): the caption is `TeamName_Abbr15_` | CONFIRMED | live diff 20/20; `FUN_180119bd0` | +| `leagueId` (0x18a) | yes | `+0xe0` u16 | **Dead for players** -- the merge writes `+0x154` unconditionally on the DB-hit branch, and `+0xe0` is swallowed by the `+0xdd` commonname buffer. **OURS for managers** | CONFIRMED | `FUN_1801a8540`; manager merge `FUN_1801356c0` writes only `+0xb8`; live manager cards already draw "LaLiga Santander" from our JSON | +| `playStyle` (0x23f) | yes, **decoded** | `+0x88` u32 | OURS, but only for values `251..273`; anything else stores 0 | CONFIRMED | case 0x23f → `FUN_180136480`; `FUN_1801a85c0` | +| `attributeList` (0x31) | yes | `+0x98..+0xac`, 6 × u32, index order | OURS for family 1; the staff arms overwrite it from the DB | CONFIRMED | `FUN_1801a8450`, live diff 20/20 | +| `itemState` (0x172) | yes, string → enum | `+0x5c` u32 | OURS | CONFIRMED | case 0x172 → `FUN_180166660`, table `0x180229cc0` | +| `owners` (0x207) | yes | `+0x48` u8 | OURS. Constructor default is **0**, so omitting is not the same as sending 1 | CONFIRMED | case 0x207; `FUN_1801a89f0` | +| `untradeable` (0x361) | yes, **inverted** | `+0x49` u8, holds *tradeable* | OURS. Constructor default is **1**, so omitting is equivalent to sending `false` | CONFIRMED | case 0x361 `CONCAT11(cVar6 == '\0', ...)`; `FUN_1801a7260` | +| `contract` (0xb8) | yes | `+0x8c` u32 | OURS | CONFIRMED | live diff | +| `fitness` (0x128) | yes | `+0xb0` u32, dup u8 at `+0x64` | OURS | CONFIRMED | live diff | +| `discardValue` (0xd7) | yes | `+0x38` u32; client's own answer at `+0x3c` | OURS, and it is the **only** value the quick-sell tile reads | CONFIRMED | guard `0x180141025`; `FUN_1800eb850` | +| `amount` (0x1b) | yes | `+0xbe` u8 for subtypes 250–273, `+0xbf` u8 otherwise, `+0x98` for cardtype 9 | OURS | CONFIRMED | `FUN_18013f4d0`; live `+0xbe == 5`; corroborated by the "+5/+10/+15" badges observed 2026-08-05 | +| `pile` (0x226) | **no** | n/a | ignored on itemData. `+0x60` is set by the owning list, not the wire | CONFIRMED | no 0x226 arm in `FUN_18013fe00`, checked in all four dispatch forms | +| `rating` on staff | see above | n/a | ignored on the wire, but **load-bearing server-side** as the discard input | CONFIRMED | merge staff arms; `discard_value()` | + +### Fields we do not send and could + +| field (atom) | lands at | why it is worth knowing | grade | +|---|---|---|---| +| `lastSalePrice` (0x185) | `+0x34` u32 | published to Flash as `BOUGHT_FOR`. We leave it at 0 today. Upgraded from MEDIUM to HIGH by a verifier who resolved the arm | CONFIRMED | +| `localizedName` (0x19c) | `+0xd9` (0x38 bytes, cardtype 9); `+0xbc` (0x1f bytes, cardtype 7) | cardtype 9 has no DB resolver, so a ball's displayed name can only come from the wire | CONFIRMED (offset), UNKNOWN (safe to send) | +| `description` (0xd1) | `+0x111` (cardtype 9); `+0x10f` (cardtype 7) | see the unsettled note below | CONFIRMED (offset), UNKNOWN (does anything read it) | +| `loans` (0x19b) | `+0x90` | **do not send.** `loans > 0` with `contract == 0` is the client's definition of an expired loan and it greys out `MODIFY` | CONFIRMED | +| `injuryGames` (0x167) | `+0x145` | **do not send non-zero.** It kills `TO_TRADE_PILE` for players independently of everything in section 4 | CONFIRMED | +| `value` (0x377) | n/a | **never send.** It is an object member elsewhere (`displayGroup {"value": ...}`) and is the prime suspect for the 2026-08-05 crash | established prior work | + +### Record fields that are not wire fields + +`+0x25` is the item's index inside the `itemData` array that built it. `+0x30` is +a client-generated timestamp written by `FUN_1800d84e0()` in the deser tail with +no wire input -- one agent reads it as a boot-relative millisecond tick, another as +rdtsc-derived, and the disagreement does not matter because nothing we send +reaches it. `+0x54` is the discard **level** (3/2/1 by thresholding `+0xb4` at +0x4b/0x41), written unconditionally at the merge tail; the previously recorded +"itemType enum 3=player 2=staff" is refuted, see below. `+0x60` is the pile, +assigned by the owning list. `+0x70` is a vtable pointer that differs between the +club and purchased lists. `+0xb8`/`+0xc8`/`+0xdd` are inline name buffers filled +from the local player DB. `+0x14c` is the position group, recomputed from +`+0x146` by the client's own ladder (0 → GK, 1–8 → DEF, 9–19 → MID, 20–27 → ATT). + +### The +0x54 conflict, and why it is settled + +Two passes graded contradictory claims about `+0x54` at HIGH: one said the discard +level, one said an itemType enum with a "perfect bijection {player:3, staff:2}". +The bijection is real and it is degenerate. Over all 22 resident records the two +hypotheses predict the same value on 22 of 22, because every resident player is +rated 75 or above and the one staff card is rated 66. Worse, the tie is partly our +own doing: we serve `"itemType": "player"` on the chemistry-style consumable, so +even the one record that should have discriminated does not look like it does. + +Static evidence settles it four ways. `FUN_180141660`'s tail has exactly one write +to `+0x54` and it is the rating ladder at `0x180141e8a..0x180141ea3`. That tail is +reached on every path: the switch on `+0x4c` has arms for 1,2,3,4,5,10 and no +default, so every other family falls straight through, and the only escape is the +`item+8 == 0` entry guard. No dispatch arm of `FUN_18013fe00` writes the stack half +that maps to `+0x54` -- all 52 case labels plus the three `==` arms and the three +range splits were enumerated. And the deser tail passes `+0x54` to the +`fcc_discardcoins` query as the **`level`** column, whose semantics are already +independently established. `+0x54` is the level. `itemType` is not stored at all. + +I would bet on the level reading without reservation, and the falsifier is one +screen: serve a player rated 60 and `+0x54` must read 1. The save already holds +twenty sub-75 players (100000161 at 53, 100000162 at 59, 100000159 at 65) that +would decide it if they were paged in. + +--- + +## 3. Club item subtypes + +**The answer, for four of five families with certainty and the fifth by +elimination:** + +| family | cardsubtypeid | cardtype | resolver | what it additionally needs | +|---|---|---|---|---| +| kit | **9** | 7 | `FUN_180119bd0` → `FUT_UC_KITS` + `TeamName_Abbr15_` | `teamid` | +| stadium | **10** | 7 | `FUN_180119bd0` → `Stadium` + `StadiumName_` | `assetId` | +| badge | **11** | 7 | `FUN_180119bd0` → `Badge` + `TeamName_Abbr15_` | `teamid` | +| ball | **30** (0x1e) | 9 | none; `FUT_UC_BALL` caption only | `localizedName` | +| league logo | **31** (0x1f) | 9 | `FUN_180098f20` keyed on leagueid | `localizedName`, probably | + +The premise that all five live in cardtype 9 is wrong, and the root fact is not an +inference from a call site. `FUN_1800d8330`, read in full at 714 chars by two +agents, contains `case 9: case 10: case 0xb: return 7;`. Kits, stadia and badges +are cardtype 7, which **does** have a resolver, reached from `FUN_1800f6c40` only +when `item+0x4c == 7`, called with `(subtype, teamid, assetId)`. The manager vtable +slot was verified from both disk and live memory: qword at `0x18021c2a0 + 0x498` +is `0x180119bd0` on disk, and the same value comes back from a read-only deref of +`DAT_1802e6398` in the running client. + +Four independent lines agree on kit = 9. The resolver's own arms. The card-detail +builder `FUN_1801bfac0`, which switches on a verified `cardsubtypeid` accessor +(`FUN_1801a8640` is literally `return *(u32*)(*(long*)(p+0x18) + 0x50)`) and gives +subtype 9 the `FUT_UC_KITS` / `FUT_ThirdKit` / `KitBioAwayDescription` family, 10 +the `Stadium` / `StadiumDetailDesc` family and 11 the `Badge` / +`badgeBioDescription` family with a leagueteamlinks lookup. An `IS_KIT_%d` flag in +`FUN_1800f6c40` set when `item+0x50 == 9`. And the deserializer itself, which for +cardtype 7 with cardsubtypeid 9 defaults `cardassetid` to **0x23 = 35** -- which is +exactly the `cardassetid` carried by every one of the 1482 rows of +`fcc_kitcards`. + +**0x91..0x96 are trophies, not club customisation.** `FUN_180108c00` deserializes +`tournamentType` (0x32f) and computes `subtype = value + 0x91`, then picks +`TOURNY_LOC_%d` for `0x91 <= s < 0x95` and `SEASON_LOC_%d` for `0x95 <= s < 0x97`. +`FUN_1800fed90` is the only function in the binary whose switch case set is exactly +`{0x91..0x96}` -- established by enumerating all 8767 `caseD_` symbols with +`FUN_1800d8330`'s own jump-table labels present as the control -- and it maps them +to (tournament, 0..3) and (season, 0..1). `FUN_1801017e0` builds +`FUT::TournamentInfo` objects and maps a 0..3 enum onto `0x91..0x94`. So +`tools/fut_clubitems.py` currently assigns all five club families ids inside the +trophy block, and `probe_shelf()`'s candidate set `{30,31,145..150}` **cannot find +the answer for kits, stadia or badges**, because 9, 10 and 11 are not in it. That +probe would have burned a launch and returned nothing for three of five families. + +**The near-miss that would have produced a sixth wrong verdict.** The enum table at +`0x180229ab0` reads `... physio=9, badge=0xa, kit=0xb, leagueLogo=0xc, ..., stadium=0x15, +ball=0x16`. For players and staff it is exactly `cardsubtypeid + 1`, which makes +badge 9, kit 10 and leagueLogo 11 look like the answer. It is not: that table feeds +the transfermarket `&cat=%s` query parameter in `FUN_180162c90`, and reading it as +a subtype map swaps badge and kit and loses stadium entirely. Two agents found this +table; one of them nearly published it. + +### The one probe still outstanding + +League logo = 31 is by **elimination**, and the elimination is airtight on its +premises but the premises are exhaustive-search results rather than a caption. +`FUN_1800d8330`'s cardtype-9 set is exactly `{0x1e, 0x1f, 0x91..0x96, 0xe7..0xe9, +0xec}`; the `0xe7..0xec` block is matched exactly by `fcc_misccards`' cardsubtype +column `{231,232,233,236}`; `0x91..0x96` are trophies; `0x1e` has the `FUT_UC_BALL` +caption. That leaves one slot. There is no `FUT_UC_LEAGUELOGO` literal anywhere in +the DLL -- a verifier enumerated all 19 `FUT_UC_*` strings by regex over the whole +file -- so no caption can confirm it. + +**The minimal probe, specified to run without further thought.** One item, one +family, no unestablished extras: + +```json +{"itemData": [{ + "id": 960000001, + "resourceId": 8010001, + "assetId": 8010001, + "cardassetid": 40, + "cardsubtypeid": 31, + "itemState": "free", + "owners": 1, + "untradeable": false, + "localizedName": "PROBE LEAGUE LOGO", + "description": "PROBE" +}]} +``` + +Served on `GET club?type=leaguelogos`, with `resourceId` taken from row 0 of +`data/tables/fcc_leaguelogos.json` rather than the literal above. Expected: the +league crest draws and the name is the string sent. If a ball caption appears, 30 +and 31 are swapped. If nothing draws at all, league logos are not cardtype 9 and +the elimination is unsound. Cost is one launch and it can ride along with any +other club-item test. + +Note two things about that body. `untradeable: false` is deliberate and free -- the +constructor default for `+0x49` is already 1, so sending `false` matches the +default rather than departing from it. And `localizedName` and `description` are +the one part of the probe that is *not* established as safe: the parser reads both +as strings (deser cases 0x19c and 0xd1, both STR), but per this project's own rule, +"the parser reads X" is not "sending X is safe". They are included because a +cardtype-9 item has no other source of a display name, so a probe without them +cannot tell "wrong subtype" from "right subtype, no name". + +### An unsettled disagreement, recorded as unsettled + +One pass concluded that a ball needs both `localizedName` and `description`; a +verifier showed that the ball's *subtitle* accessor `FUN_1801a8560` reads `+0xba`, +while the `description` atom demonstrably lands at `+0x111` for cardtype 9. Either +something else writes `+0xba`, or the ball subtitle is fed by a slot no wire atom +reaches. The title half is agreed: `FUN_1801a8570` reads `+0xd9`, which is where +`localizedName` lands. **I would bet on the verifier**, because its offsets come +from the deserializer's own frame arithmetic rather than from inferring backwards +from an accessor. So: send `localizedName` and expect it to show; send +`description` and do not be surprised if nothing changes. The same `+0xba` also +holds the unresolved kit-variant selector, so these two gaps may be one gap. + +--- + +## 4. The card lifecycle + +### The complete itemState vocabulary + +Twelve entries in one NUL-terminated table at `0x180229cc0`, stride 0x10, `{const +char* name, u32 value}`, walked in full by three agents from both disk and live +memory. `FUN_180166660` is a linear walk over it and returns `0xffffffff` for +anything not in the table. + +| string | value | what it permits | +|---|---|---| +| `invalid` | 0 | no consumer found. **This is the value an item gets when we omit `itemState`** | +| `free` | 1 | the normal owned state; accepted by the squad builder; written back on unequip | +| `WAITING_FOR_GAME` | 2 | alias of `inGame` | +| `inGame` | 2 | accepted by the squad builder | +| `forSale` | 5 | **never tested anywhere in CardsDLL** | +| `offered` | 6 | **never tested anywhere in CardsDLL** | +| `activeBadge` | 100 | equipped; drives the `IS_ACTIVE` tick | +| `activeHomeKit` | 101 | equipped; additionally read by the kit swap, which needs more (below) | +| `activeAwayKit` | 102 | equipped; ditto | +| `activeBall` | 103 | equipped; the unequip path writes `free` back over it | +| `activeStadium` | 104 | equipped | +| `active` | 255 | no consumer found | + +The previous record in `CARD_SYSTEM.md` starts this table at `0x180229d20`, which +is the middle of it, and therefore misses `invalid`, `free`, `WAITING_FOR_GAME`, +`inGame`, `forSale` and `offered`. Immediately *before* the table, at +`0x180229c30..0x180229cb0`, sits the itemType vocabulary (`any=-1, player=1, +staff=2, clubInfo=3, training=4, development=5, stadium=6, ball=7`), which is +exactly the table a reader arriving from the wrong direction would confuse with +this one. + +**Omitting `itemState` is not the same as sending `free`.** The constructor +initialises the 16 bytes covering `+0x50..+0x5f` from `_DAT_1801f66a0`, read as +`56010000 00000000 00000000 00000000` from both disk and memory, so the default is +0 = `invalid`, and an item left at 0 fails the squad builder's `state == 1 || +state == 2` acceptance test. Always send it. + +`forSale` and `offered` being untested survived a hard attack and it is worth +recording how, because the original argument could not have established it. A scan +that collects compare *immediates* cannot evaluate a compare against a *register*, +and there are two such compares on `+0x5c`. A verifier resolved both: at +`0x1800d7588` the register holds `(param_4 != 2) + 0x65`, and at `0x1801b3894` it +holds a constant 1 loaded once and never reassigned. Neither can be 5 or 6. The +same verifier found four *write* sites storing literal 5 and 6 into `[reg+0x5c]` in +`FUN_180147070` and had to open it to establish that it is a different struct +entirely. That is exactly the shape of hit that has produced wrong verdicts here +before, and it was caught only by reading it. + +### The eight action flags + +`FUN_18003e370` publishes eight per-card booleans to Flash. The link from those +eight names to the eight bytes filled by `FUN_1800e2a40` was originally anchored +only semantically; a verifier closed it by GUID. `FUN_180018bd0` requests service +`0x10c80b95` and casts to interface `0x10c80b96`; the cast stub for that interface +is at `0x1800e1660`; its only pointer sits at `0x180215b28`; the cast helper is +vtable slot `+0x18`, so the vtable base is `0x180215b10`; and slot `+0x40` of that +base is `FUN_1800e2a40`, which is the slot `FUN_18003e370` calls. The arithmetic +was checked against a second interface as a control. + +| flag | rule | can the server move it | +|---|---|---| +| `DISCARD` | 0 only when `+0xb5` dream set and `+0xb6` clear | no (we send no dream) | +| `MODIFY` | 0 on that dream condition, or `loans > 0 && contract == 0` | yes -- by not sending `loans` | +| `TO_ACTIVE_SQUAD` | family in {1,2} and squad count < 0x17 and not already in the squad | indirectly | +| `TO_TRADE_PILE` | service gate **and** `+0x49` tradeable **and** (players: `injuryGames == 0`, statsList[4] and [5] zero; others: subtype not in `{0xe7,0xe8,0xe9,0xec}`) | **yes, see below** | +| `TO_STICKER_BOOK` | item valid, family not in {0,-1}, `+0x10 == 0` | no | +| `MAY_BE_REMOVED` | constant 1 | no | +| `QUICK_SEARCH` | dream card or expired loan | no | +| `DREAM_REPLACE` | `loans > 0 && contract == 0` | yes, by omission | + +`itemState` is consulted by none of the eight. If you want the transfer menu back, +`itemState` is the wrong lever. + +### Why "Place on Transfer List" is greyed, completely + +Both conditions fail and both are ours to fix: + +1. `+0x49` is 0 on every card, because `_item()` sends `"untradeable": True` and + the deserializer stores the negation. +2. The service gate at slot `+0x270` reads the `tradingEnabled` byte `0x1fd2e`, + measured **0** in the live client today, with three control gates reading 1 in + the same walk. `GET /settings` still answers `{"configs": []}`. + +Doing only the first will look like the finding failed. Section 6 does both. + +Two side effects of flipping `untradeable`, neither a blocker but neither +predicted by the original claim that `+0x49` has exactly two consumers. There is a +third, `FUN_1800bc580`, which walks 11 squad slots and counts untradeable members; +that count is published to Flash as `UNTRADABLE_COUNT` and gates squad submission +in `FUN_1800bba10`, which currently takes the `couldNotSubmitSquad` branch. Both +effects move in the permissive direction. There is also a second escape hatch in +that gate -- `svc->0x308()` on service `0xed80ed8` -- that nobody resolved, so if +squad submission behaves oddly afterwards, that is where to look. + +**"List on Transfer Market" as a separate menu entry was not found.** The eight +flags contain `TO_TRADE_PILE` and no listing action. `FUN_18003e550` publishes +`DURATION` / `START_PRICE` / `ASKING_PRICE`, which is the listing panel, but +whether it has its own enable predicate was not chased. The likely explanation is +that listing is only reachable from the trade pile, so both entries share one root +cause, but that is an inference and it is not established. + +### Equipping club items + +`itemState` really is the equip mechanism for the `IS_ACTIVE` tick: +`FUN_180084720` and `FUN_180094220` each test `+0x5c` against `0x64..0x68` and +publish the result, and the ball equip path `FUN_180113870` writes `0x67` on equip +and `1` on unequip. But do not present it as a working kit swap. `FUN_1801c3480` +gates the home/away kit read on `family == 7 && *(int*)(item+0x60) == 4`, and +`+0x60` is not wire-derived: there is no `pile` arm in the item deserializer, and +live it reads 1 for every `/club` item and 6 for every `/purchased` item. We +cannot produce 4 on any route we know. So `activeHomeKit` will light the tick and +will not change the kit. + +--- + +## 5. Remaining unknowns, with the cheapest experiment for each + +### Needs decompiling only + +**Who writes item `+0x60`.** It gates the kit swap at value 4 and we can produce 1 +and 6. Both attempts to scan for it drowned: `+0x60` returns 1688 and 4144 +instructions depending on method. The narrower anchor is the `/club` and +`/purchased` response handlers -- find the list-insert that assigns it, read the +constants. This is the single blocker between "we can mark a kit equipped" and "we +can equip a kit". + +**The kit variant selector.** `FUN_1801bfac0` distinguishes home, away and third +kits from `FUN_1801a8800` (`+0xba`, u16) and `FUN_1801a8040` (`+0xbf`, signed +byte). Which wire atom sets it is unknown, so we cannot serve a specific kit +deliberately. Note `+0xba` is the same slot as the unresolved ball subtitle. + +**`FUN_1801aa190`.** The one unopened link inside the eight-flag chain: it is +claimed to resolve `statsList[4]` and `[5]` at `+0x104 + idx*4`. It changes no +action today because we send no `statsList`, but it is two minutes of work and it +would close the chain. + +**The `BOUGHT_FOR` consumer.** `+0x34` = atom `0x185 lastSalePrice` is resolved. +What remains is whether the field is visible anywhere worth populating. + +**`FUN_1800fed90` has zero direct xrefs.** The trophy subtype mapping does not +depend on it (`FUN_180108c00` carries the same mapping independently), but the +dispatch table that reaches it was not identified, and trophies are a whole +unimplemented family. + +**Case sensitivity of the `itemState` string match.** Almost certainly +unresolvable statically: `FUN_180008190` is a single indirect call through +`DAT_1802ddfd8 + 0x248`, a runtime-populated service pointer. Send the exact +casing from the table and do not experiment on the live save. + +### Needs a live probe (read-only, no launch) + +**Resolve `DAT_1802ddfd8 + 0x248`** in the running process and identify the string +comparator. That answers the casing question without a launch. + +**Re-read `+0x30` after a refetch** to decide between "monotonic clock" and +"sequence counter". Low value; nothing we send reaches it. + +**Confirm the FUT roster database is loaded.** The `fcc_discardcoins` result +proves `g_db` is loaded and complete; it says nothing about the separate database +behind `LoadFUTDatabase` / `.dbFUTVer` / `DL_FUT_LIVEDB`, whose strings live in +FIFA17.exe and not in CardsDLL. These are different databases and they should stop +being conflated. + +### Needs a launch the user must drive -- ranked, and short + +1. **Transfer list.** `FUT_SETTINGS=keep` plus `FUT_TRADEABLE=1`. Read the card + action menu. This is first because it is the only item on the list that fixes a + thing the user can see is broken, both halves are one-line changes, and one + half is already measured. Falsifier if it fails: `+0x49` should read 1 and + `0x1fd2e` should read 1; if both are 1 and the entry is still greyed, the + remaining gate is `svc->0x308()` on `0xed80ed8`. +2. **Club items, one family at a time, kits first.** Kits have the loudest failure + mode: a wrong `teamid` produces a visibly wrong team abbreviation rather than + silence, which is the opposite of the cardtype-9 families and is why this + ordering is not arbitrary. Then badges, then stadia, then balls, then the + league-logo probe from section 3. +3. **The `+0x54` discriminator.** Serve one sub-65 player on the visible club page. + Predicts `+0x54 == 1` and a bronze face. Free if it rides along with anything + else; not worth a launch of its own, since the static case is already four-deep. +4. **`playStyle 251`.** Predicts `+0x88 == 1` and a visible chemistry badge, and + confirms the decoder bounds on real data. Also rides along. + +Everything below rank 2 should ride along with something above it. The scarce +resource is menu trips, not tests. + +--- + +## 6. Proposed patches + +None of these are applied. All are env-flagged and default to the current +behaviour, per the house rule that a flag defaults to the live-proven value. + +### P1 -- `FUT_TRADEABLE`: make cards listable + +Two halves, and the first is already implemented. Half one is an environment +change only: run with `FUT_SETTINGS=keep`, which emits the existing +`_SETTINGS_KEEP` rows including `tradingEnabled`. Half two, in +`tools/fut_store.py`: + +```python +# FUT_TRADEABLE: send untradeable=false so the client's tradeable byte is set. +# +# "Place on Transfer List" is greyed on every card and BOTH of its gates are ours. +# FUN_1801a7260, the TO_TRADE_PILE predicate published by FUN_18003e370, returns 1 +# only if the service gate at vtable+0x270 of 0xed84b12 is non-zero AND item+0x49 +# is non-zero. The deserializer stores untradeable INVERTED -- case 0x361 does +# `CONCAT11(cVar6 == '\0', ...)` -- so untradeable:true writes 0 and kills the flag. +# +# The service gate is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the +# tradingEnabled gate byte (ENDPOINT_MAP, FutGetSettingsServerResponse). Measured +# live 2026-08-06 as 0, with three control gate bytes reading 1 in the same walk. +# So this flag alone is NOT sufficient: it needs FUT_SETTINGS=keep beside it. +# +# Freeze risk: NONE beyond what we already send. untradeable is atom 0x361, read by +# the BOOL primitive FUN_1801c7620, and we already send the key on every card -- only +# the value changes. Every freeze on this project has come from feeding a container +# where a scalar was expected; this does not change the shape of anything. +# +# Side effects, which are real but permissive. item+0x49 has a third consumer, +# FUN_1800bc580, which counts untradeable squad members; that count is published as +# UNTRADABLE_COUNT and gates squad submission in FUN_1800bba10, which today takes the +# couldNotSubmitSquad branch. Both move toward "more allowed", not less. +TRADEABLE = os.environ.get("FUT_TRADEABLE", "0") == "1" +``` + +and in `_item()`, replacing the hard-coded `"untradeable": True`: + +```python + "untradeable": not TRADEABLE, +``` + +Club items in `tools/fut_clubitems.py` already send `"untradeable": False` and +need no change. + +**Type fidelity.** BOOL where a BOOL is read. The constructor default for `+0x49` +is 1, i.e. tradeable, so `false` moves the field *toward* the client's own default +rather than away from it. + +### P2 -- stamp `discardValue` on the purchased pile + +In `tools/fut_store.py`, `Profile.purchased()`: + +```python + def purchased(self): + """Items still held in the purchased/unassigned pile (returned by + GET /purchased/items); they move to the club via FutMoveCard (PUT /item). + + Stamped on the way out exactly as items() is. Without this the pending pile + renders 0 for anything whose discardValue is not already persisted in the + save: the quick-sell tile binds the Flash property DISCARD_CREDITS, which is + item+0x38, which is ONLY ever written from the wire. The client's own correct + answer lands at +0x3c under the name CALCULATED_DISCARD_CREDITS and no native + code in CardsDLL falls back from one to the other. Verified live 2026-08-06: + the pile's chemistry style 100000283 evaluates to 38 through discard_value() + and the client's own +0x3c holds 38, and the tile reads neither because the + key is absent from the wire.""" + its = self.load().get("purchased", []) + return [_with_discard(dict(it)) for it in its] if DISCARD_SEND else its +``` + +**Freeze risk: none.** `discardValue` is atom `0xd7`, a plain INT already on the +wire for 20 of 22 resident items. Not stamped, not persisted, so turning +`FUT_DISCARD_SEND` off is still a true revert. No new flag: this is a bug in the +existing flag's coverage. + +### P3 -- `FUT_DISCARD_STAFF`: price staff cards from the table `value` column + +The unrated-card fallback in `discard_value()` documents the staff price as +unknown. It is not. The merge writes the staff table's `value` column into +`+0xb4`, and `round_half_up(value * price / 100)` reproduces the client's own +answer exactly. In `tools/fut_store.py`: + +```python +# FUT_DISCARD_STAFF: price staff cards using the game table's `value` column as the +# rating input. +# +# discard_value() bails on a card with no `rating` key, which is every staff card we +# serve, so the pile's gkcoach 100000282 goes out unpriced and its tile shows 0. The +# rating a staff card USES is the `value` column of its own cards table -- the merge +# arms for families 2,3,4,5,10 all do `rec[0xb4] = row["value"]` -- so the number was +# always available server-side. Confirmed to the unit, live 2026-08-06: carddbid +# 9000081 has value 66 in gkcoachcards.json, cardtype 10 (subtype 6), rare 0, level 2 +# -> price 55 -> round_half_up(66*55/100) = 36, and the client's own computation at +# record +0x3c reads 36. +# +# Freeze risk: NONE. This changes no wire shape at all. It only supplies a rating to +# a server-side arithmetic function; the `rating` key is not added to the JSON, +# because on a staff card the merge overwrites +0xb4 from the DB regardless and +# sending it would be inert. +DISCARD_STAFF = os.environ.get("FUT_DISCARD_STAFF", "0") == "1" and DISCARD_SEND + +_STAFF_VALUE_TABLES = ("headcoachcards.json", "gkcoachcards.json", + "physiocards.json", "fitnesscoachcards.json", + "managercards.json") +_STAFF_VALUE = None + + +def _staff_value(resource_id): + """carddbid -> the table's `value` column, or None. Built once, lazily.""" + global _STAFF_VALUE + if _STAFF_VALUE is None: + _STAFF_VALUE = {} + base = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "data", "tables") + for fn in _STAFF_VALUE_TABLES: + try: + with open(os.path.join(base, fn)) as f: + rows = json.load(f).get("rows") or [] + except (IOError, ValueError): + continue + for r in rows: + cid, val = r.get("carddbid"), r.get("value") + if cid and val: + _STAFF_VALUE.setdefault(cid, val) + return _STAFF_VALUE.get(resource_id & 0xffffff) +``` + +and, inside `discard_value()`, replacing the early bail: + +```python + r = item.get("rating") + if not r and DISCARD_STAFF: + r = _staff_value(int(item.get("resourceId") or 0)) + if not r: + return None +``` + +**One caveat, stated rather than buried.** This is confirmed on one card. The +mechanism is read out of the merge, not curve-fitted, and the arithmetic matched +to the unit, but a second staff family would make it two. + +### P4 -- `FUT_CLUBITEMS`: correct the five subtypes + +In `tools/fut_clubitems.py`, the `FAMILIES` table and the probe set. This is a +data correction, not a new capability, and it should still be exercised one family +at a time through the existing `FUT_CLUBITEMS` machinery. + +```python +# (table, art id, stat id, stat name, cardsubtypeid) +# +# CORRECTED 2026-08-06. Every previous value was inside the 0x91..0x96 block, which +# is TROPHIES (FUN_180108c00 computes subtype = tournamentType + 0x91; FUN_1800fed90 +# is the only function in the binary whose case set is exactly {0x91..0x96}). +# +# Kits, stadia and badges are NOT cardtype 9. FUN_1800d8330 has +# `case 9: case 10: case 0xb: return 7`, and cardtype 7 DOES have a resolver: +# manager vtable +0x498 = FUN_180119bd0, reached from FUN_1800f6c40 when +# item+0x4c == 7, called with (subtype, teamid, assetId). So the docstring's +# "a wrong id cannot announce itself" is false for these three -- a wrong teamid +# produces a visibly wrong TeamName_Abbr15_ caption, which is why kits go first. +FAMILIES = [ + ("balls", "fcc_balls.json", 37, 0x1E, "balls", 30), + ("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 10), + ("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 11), + ("kits", "fcc_kitcards.json", 35, 0x28, "kits", 9), + ("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 31), +] + +# The candidate set for probe_shelf(). The old set {30,31,145..150} could not have +# answered the question for kits, stadia or badges, because 9, 10 and 11 were not +# in it -- it would have burned a launch and returned nothing for three of five. +CARDTYPE9_SUBTYPES = (9, 10, 11, 30, 31) +``` + +and, in `_item()`, the per-family fields the cardtype-7 resolver requires: + +```python +def _item(item_id, carddbid, cardassetid, subtype, teamid=None, extra=None): + it = { + "id": item_id, + "resourceId": carddbid, + "assetId": carddbid, + "cardassetid": cardassetid, # THE ART ID, never a copy of resourceId + "cardsubtypeid": subtype, + "itemState": "free", + "owners": 1, + "untradeable": False, + } + # KIT (9) and BADGE (11) display as + TeamName_Abbr15_, so + # without teamid the name comes out as the caption alone. STADIUM (10) reads + # StadiumName_, which resourceId already supplies. teamid is atom 0x306, + # read with the INT primitive FUN_1801c79d0 and stored at record +0x94 -- an + # established scalar field, not a new shape. + # + # BE HONEST ABOUT THE 2026-08-05 CRASH: teamid was one of the three extras in the + # response that crashed the client. It was never bisected. `value` is the + # established suspect (it is an OBJECT member elsewhere, and a scalar where an + # object is expected is the 0x1801c7f1a busy loop), and that response also carried + # 30 items across FIVE wrong subtypes at once. This adds teamid ALONE, to ONE + # family, with the subtypes now correct. That is the narrow test the crash denied us. + if teamid is not None and subtype in (9, 11): + it["teamid"] = teamid + if extra: + it.update(extra) + return it +``` + +**`itemType` should be dropped from this builder** while it is being touched. It +currently sends `"itemType": "club"`, marked UNOBSERVED in the docstring, and the +deserializer parses `itemType` into a heap string below the record base and never +copies it in. It is inert, but it is also one of the few unobserved strings we +still emit. + +**Freeze risk: low, and lower than the last attempt.** `teamid` is an INT read by +the scalar primitive; the subtype change alters an integer's value, not its type. +The blast radius is bounded by serving one family per test, which the existing +`FUT_CLUBITEMS` machinery already enforces and `equippables` still answers empty. + +### P5 -- `FUT_CLUB_POSTAB`: answer the DEF/MID/ATT tabs + +`club_route`'s filter keeps only `cardsubtypeid not in (0,1,2,3)` for any type +outside `player` and `custom`, so `type=playerdefender`, `playermidfielder`, +`playerforward` and `any` all return an empty list against a save whose items are +all subtype 0. The three position tabs are real traffic: `FUN_18012ddf0` remaps +request field `*(req+0x14)` values `0x1c/0x1d/0x1e` into type codes `0x1b/0x1c/0x1d` +and suppresses the `position=` parameter, so the MY CLUB position tabs arrive +exactly as those three strings. + +```python +# The client's OWN position grouping, read out of FUN_180135890's recompute of +# record+0x14c from record+0x146: 0 -> GK, 1..8 -> DEF, 9..19 -> MID, 20..27 -> ATT. +# Not invented here; this is the ladder the client applies to its own records. +_POS_GROUP = {"playerdefender": (0, 8), "playermidfielder": (9, 19), + "playerforward": (20, 27)} +``` + +and in `club_route`, before the existing `if kind and kind not in ("player", "custom")`: + +```python + if kind in _POS_GROUP: + lo, hi = _POS_GROUP[kind] + items = [i for i in items + if i.get("cardsubtypeid", 0) in (0, 1, 2, 3) + and lo <= int(i.get("preferredPosition") or 0) <= hi] + log(" CLUB: type=%s -> %d item(s) (position group %d..%d)" + % (kind, len(items), lo, hi)) + return 200, {"itemData": items} + if kind == "any": + log(" CLUB: type=any -> %d item(s) (unfiltered)" % len(items)) + return 200, {"itemData": items} +``` + +**The one guess, named.** GK is folded into DEF because the observed tabs are +DEF/MID/ATT and there is no fourth. If the DEF tab comes back without goalkeepers +in it, the guess is wrong and the fix is to change `(0, 8)` to `(1, 8)`. Nothing +else in the patch is a guess. + +**Freeze risk: none.** Server-side filtering only; the response shape is the +`{"itemData": [...]}` the route already returns everywhere. + +### P6 -- drop `definitionId` + +One line in `_item()`. `definitionId` is not in the 907-entry key dictionary, so +it hashes to an unregistered key and its value goes to the value-SKIP handler +`FUN_180135ff0`. Verified three ways: absent from `docs/fut_atoms.tsv`; absent +from the live key map walked in the running client with all 907 dictionary names +passing as a positive control; and colliding with no registered hash, so it cannot +be misrouted onto another field. **Freeze risk: none** -- removing a key the parser +skips strictly reduces executed code. Low value, zero cost, and it removes a field +that three documents describe as if it did something. + +--- + +## 7. Proposed corrections to existing documents + +### `docs/CARD_SYSTEM.md` + +**Replace the "STILL UNKNOWN, AND NOT GUESSED" section entirely.** It is answered. +Kit 9, stadium 10, badge 11, ball 30, league logo 31 (the last by elimination). +Route (a) of its own two proposals is what paid off: the consumer is the manager +vtable slot `+0x498` = `FUN_180119bd0`. Route (b), `FUT_CLUBITEMS=probe:`, +would have failed for three of the five families because its candidate set did not +contain 9, 10 or 11. Keep the residual league-logo probe from section 3 above. + +**Correct item 1 of "VERIFIED IN BINARY".** It says `FUN_1800d8330` returns +cardtype 9 for `0x1e, 0x1f, 0x91..0x96, 0xe7..0xe9, 0xec` and that this "leaves +0x1e, 0x1f and 0x91..0x96 for badges, kits, stadia, balls and league logos". The +first half is right; the inference is wrong. `0x91..0x96` are trophies, and three +of the five families are cardtype 7, not 9. + +**Correct item 2.** The itemState enum table starts at `0x180229cc0`, not +`0x180229d20`. The full vocabulary is the twelve rows in section 4 above; the +recorded ten are missing `invalid`, `free`, `WAITING_FOR_GAME`, `inGame`, +`forSale` and `offered`. Note that `WAITING_FOR_GAME` and `inGame` are genuine +aliases, both 2, and that omitting the key yields 0 = `invalid`, which is not +`free`. + +**Update the card view-model paragraph.** It states that every rendered field is +read from a resolved definition record at `item+0x10` and "NEVER from our item +JSON". That was true of the generic-card era and is no longer the whole story: +rating, attributes, position, contract, fitness, rareflag and playStyle are read +by thin accessors straight off the item record for family 1, and `CARD_SYSTEM.md`'s +own later sections (real squads, managers painting `+0xde`/`+0xe0`, consumables +drawing `+5/+10/+15`) already contradict the earlier text. Add a pointer to the +field table in section 2 here rather than rewriting the history. + +**Add the field-map corrections** as a dated block: + +``` +CORRECTED 2026-08-06 (live diff + deserializer frame arithmetic, record_off = 0x188 - X): + +0x34 lastSalePrice (atom 0x185), published to Flash as BOUGHT_FOR + +0x48 owners (atom 0x207, u8; constructor default 0) + +0x49 TRADEABLE (atom 0x361 untradeable, u8, stored INVERTED; default 1) + +0x54 discard LEVEL (3/2/1 by rating >= 0x4b / >= 0x41), NOT an itemType enum + +0x5c itemState (atom 0x172 via FUN_180166660, u32) + +0x88 playStyle (atom 0x23f via FUN_180136480; only 0xfb..0x111 map to 1..0x17) + +0x90 loans (atom 0x19b) -- do not send; loans>0 with contract 0 greys MODIFY + +0xbe amount (atom 0x1b, u8) for cardsubtypeid 250..273 (chemistry styles) + +0xbf amount (atom 0x1b, u8) for the other consumable classes [already recorded] + +0xd9 localizedName (atom 0x19c, 0x38 bytes) for cardtype 9; +0xbc (0x1f) for cardtype 7 + +0x111 description (atom 0xd1, 0x1f bytes) for cardtype 9; +0x10f for cardtype 7 + +0x30 is a CLIENT timestamp from FUN_1800d84e0(), not a wire field + +0x60 pile is assigned by the owning list, not parsed; there is no 0x226 arm + itemType (atom 0x173) is parsed into a heap string and never stored + definitionId is NOT AN ATOM +``` + +**Correct the consumables section's route claim.** It says the observed route is +"not the `/consumables/%s` template in .rdata, which the client has still never +used". It is exactly that template: action row 9 `ConsumablesSearch` carries base +index 3 = `ut/%s/club`, and `FUN_1801308c0` appends `/consumables/%s`. The base was +`ut/%s/club` all along. The same sentence appears in commit `ccb736f`. + +**Correct the `discard_value()` docstring premise** in `tools/fut_store.py` while +you are at it: "WHY its lookup misses is still UNKNOWN" and "that lookup returns no +row for our cards" are both false. It does not miss. The tile reads a different +property. + +### `docs/ENDPOINT_MAP.md` + +**The Club section's URLs are wrong for four routes.** Rows 12, 13 and 16 give +`GET ut/game/fifa17/item?type=`, `GET ut/game/fifa17/...` and `GET +ut/game/fifa17/...`. The client can emit exactly four request families on the +`ut/%s/club` base, and the binding is a table, not an inference: the 125-row action +table at `0x1802caa20` indexes the 48-entry URL-base table at `0x18021df80` through +column 1, and base index 3 = `ut/%s/club` is carried by exactly four rows. + +``` +| ClubSearch | FUN_18012ddf0 | GET ut/%s/club? | FutStickerBookSearchServerResponse | +| ClubStats | FUN_18012f4f0 | GET ut/%s/club/stats/[/] | FutStickerBookStats2ServerResponse | +| StaffStats | thunk 0x18012b080| GET ut/%s/club/stats/staff | FutStaffBonusServerResponse | +| ConsumablesSearch | FUN_1801308c0 | GET ut/%s/club/consumables/| FutConsumablesSearchServerResponse | +``` + +**Add the club query grammar**, which is complete and ordered: `?year=2017` +(always, hardcoded), then `type`, `start` (omitted at 0), `count` (omitted at +100), `filter`, then either the filter block (`position, formation, state, level, +rare, nation, country, league, playStyle, team, sort`) or a comma-joined `defId=` +list, never both. Live control: the one ProtoHttp club search in the log is +`GET /ut/game/fifa17/club?year=2017&type=equippables&count=11&level=any&sort=desc`, +which matches the predicted order and every suppression rule. Sub-vocabularies: +`filter` = available/base/exact/any; `level` = bronze/silver/gold/any; `sort` = +asc/desc; `rare` = the literal string `SP`, not a boolean; `state` = the itemState +names plus `any`, and note that the request spells it `onSale` where the response +value is `forSale`. + +**Add the complete `?type=` vocabulary**, 30 values from `FUN_18012ec50` (29 cases +plus a default of `any`), and record that `/club/stats` has exactly seven forms: +`club`, `year`, `country/`, `league/`, `newcards`, `consumables`, and the +separately-dispatched `staff`. **There is no `/club/stats/team/`**, verified +twice: the switch has six cases with no such arm, and an exhaustive string scan of +the PE finds no literal containing `stats/team`. `utas_server.py`'s handling of a +`team` stats mode is dead code. + +**Two holes in the base table are worth recording** so nobody re-derives them as +findings. Base index 43 = `ut/v2/%s/store` is carried by no action row and has zero +references in `.text`, yet `ut/v2/store` is live-proven; base index 9 = +`ut/%s/activeMessage` is a second hole of the same kind. So at least one route is +composed outside CardsDLL, most likely inside the packed exe, and every "the table +bounds it" statement is bounded to CardsDLL. + +**Under `FutGetSettingsServerResponse`**, add that `tradingEnabled` field `[10]`, +gate byte `0x1fd2e`, was measured **0** in the live client on 2026-08-06 while +`friendlySeasonsEnabled`, `enableDraftMode` and `packOpeningAnimationEnabled` all +read 1 in the same walk. The struct defaults are not uniform: some fields default +to 1 and trading defaults to 0. Gate byte `0x1fd2e` is read by vtable slot `+0x270` += `FUN_18011c670`, and that slot is the service half of the `TO_TRADE_PILE` +predicate. + +--- + +## Coverage, honestly + +Five of the six dimensions came back deep and two adversarial rounds attacked the +claims that change what we serve. The live-heap dimension and the club-subtype +dimension are the strongest: both were re-derived from scratch by a verifier with +independent controls, and the disagreements between them were resolved by a third +method (the deserializer's frame arithmetic) rather than by preferring an author. + +Two areas are thin and should be treated as single-source. The **auction-house +field routing** -- `tradeId`, `tradeState`, `expires`, `bidState` and friends +belonging to the auction element `FUN_18013e410` rather than to `itemData` -- rests +on one agent's census and was explicitly not re-attacked; the verifier confirmed +only the negative half, that those atoms have no arm in the item deserializer. +The **club query emission order** in section 7 likewise rests on one decompile +plus one live log line, though all 17 atom transcriptions were independently +re-resolved and none was wrong. + +Nothing in this document was tested on screen. Every "the card will display X" is +a prediction from record contents and code, not an observation of pixels, with the +sole exception of the gate-byte measurement quoted in section 1, which is a +measurement of a byte and not of a menu. The client is bound to port 8099 and the +safety rules forbid reconfiguring it, so the live work here was read-only heap +reads, read-only GETs, and static analysis. + +--- + +## The next action + +Run the client with `FUT_SETTINGS=keep FUT_TRADEABLE=1` and open a card's action +menu. That single launch tests both halves of the transfer-list finding at once, +it is the only outstanding item that fixes something the user has already seen +broken, and it is unusually well instrumented: if the entry lights up, two gates +and an inverted boolean are confirmed together; if it stays grey, `gate_byte_probe.py` +extended to slot `+0x270` says immediately whether `0x1fd2e` flipped to 1, and +`+0x49` on any resident record says whether the boolean landed, so the failure +localises to one of three named places rather than to "it did not work". Bring the +kits along on the same trip if the club-item subtype patch is in -- one family, +`teamid` alone, subtype 9 -- because a wrong `teamid` announces itself in the +caption and that is the loudest failure mode any club family has. diff --git a/fifa17-recon/tools/fut_store.py b/fifa17-recon/tools/fut_store.py index e32e7d1..2f0ca9e 100644 --- a/fifa17-recon/tools/fut_store.py +++ b/fifa17-recon/tools/fut_store.py @@ -460,9 +460,14 @@ class Store: return moved def purchased(self): + # Stamped on read exactly like items(). Leaving this out was a real defect: + # the pending pile is the ONE place a quick-sell value is actually read, so + # the club showed real numbers while the reveal screen showed 0 for anything + # already sitting in the pile. Found by a verification pass, not by testing. """Items still held in the purchased/unassigned pile (returned by GET /purchased/items); they move to the club via FutMoveCard (PUT /item).""" - return self.load().get("purchased", []) + pur = self.load().get("purchased", []) + return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur def active_squad(self): sq = self.load()["squads"] @@ -557,7 +562,9 @@ class Store: return items def last_pack(self): - return self.load().get("purchased", []) + # Same stamping as purchased(); this is the reveal-screen read path. + pur = self.load().get("purchased", []) + return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_dv_1.py b/fifa17-recon/tools/ghidra_queries/q_adv_dv_1.py new file mode 100644 index 0000000..5cb4950 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_dv_1.py @@ -0,0 +1,104 @@ +"""ADVERSARIAL BATCH 1. + +HYPOTHESES UNDER ATTACK (all from another agent, assumed WRONG until reproduced): + H1 item+0x49 = (untradeable == false), written by atom 0x361 in FUN_18013fe00. + H2 FUN_1801a7260 (TO_TRADE_PILE) requires item+0x49 != 0, and the eight flags are + ENABLE flags. + H3 FUN_18003e370 publishes 8 names in the order DISCARD, MODIFY, TO_ACTIVE_SQUAD, + TO_TRADE_PILE, ... and FUN_1800e2a40 fills those 8 bytes in that order. + H4 item+0x54 is the discard LEVEL written at 0x180141e8a..0x180141ea3, not itemType. + H5 the itemState table starts at 0x180229cc0 with 12 entries. + H6 FUN_180166660 has exactly one caller. + H7 FUN_1801a8620 (+0x38) and FUN_1801a8090 (+0x3c) have exactly one xref each. + +CONTROL: for every "exactly one caller" claim I also run the SAME xrefs_to call on a +function that is known to have many callers (FUN_180135ff0, the value-SKIP, ~134) and +on the FNV hasher 0x180180d00, so a zero/one result cannot be a broken scan. +Everything is printed IN FULL; no truncation. +""" +import traceback, sys + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q1_raw.txt" +try: + f = open(OUT, "w") + + def P(*a): + s = " ".join(str(x) for x in a) + f.write(s + "\n") + + P("=" * 30, "CONTROL: xrefs machinery works", "=" * 30) + for nm, a in (("FUN_180135ff0 value-SKIP", 0x180135FF0), + ("FUN_180180d00 FNV hasher", 0x180180D00), + ("FUN_1801c7620 BOOL prim", 0x1801C7620)): + xr = xrefs_to(a) + ents = sorted(set(e for _, t, _, e in xr if "CALL" in t and e)) + P("%s: %d refs, %d distinct calling funcs" % (nm, len(xr), len(ents))) + + P() + P("=" * 30, "H7 discard getters", "=" * 30) + for nm, a in (("FUN_1801a8620 (+0x38 DISCARD_CREDITS?)", 0x1801A8620), + ("FUN_1801a8090 (+0x3c CALCULATED?)", 0x1801A8090), + ("FUN_1801a80c0 (CARD_LEVEL?)", 0x1801A80C0)): + P("---", nm) + fn = fm.getFunctionAt(addr(a)) + P(" function at addr:", fn.getName() if fn else None) + for frm, t, cf, e in xrefs_to(a): + P(" ref %#x %s in %s@%#x" % (frm, t, cf, e)) + P(" BODY:") + P(dec(a)) + + P() + P("=" * 30, "H6 FUN_180166660 callers", "=" * 30) + for frm, t, cf, e in xrefs_to(0x180166660): + P(" ref %#x %s in %s@%#x" % (frm, t, cf, e)) + P(dec(0x180166660)) + + P() + P("=" * 30, "H5 itemState table walk from 0x180229c00", "=" * 30) + a = 0x180229C00 + for i in range(40): + p = qword(a + i * 0x10) + q = qword(a + i * 0x10 + 8) + s = "" + if 0x180000000 <= p < 0x181000000: + try: + s = rd_str(p, 60) + except Exception: + s = "?" + P(" %#x p=%#018x q=%#018x %r" % (a + i * 0x10, p, q, s)) + + P() + P("=" * 30, "H2 TO_TRADE_PILE predicate + siblings", "=" * 30) + for a in (0x1801A7260, 0x1801A8940, 0x1801A71C0, 0x1801A7210, 0x1801A7250, + 0x1801A7180, 0x1801A7320, 0x1801A71E0, 0x1801A8900, 0x1801A89F0): + fn = fm.getFunctionAt(addr(a)) + P("### %#x %s xrefs=%d" % (a, fn.getName() if fn else "NO FUNC", len(xrefs_to(a)))) + for frm, t, cf, e in xrefs_to(a): + P(" ref %#x %s in %s@%#x" % (frm, t, cf, e)) + P(dec(a)) + P() + + P() + P("=" * 30, "H3 publisher + filler, FULL", "=" * 30) + for a in (0x18003E370, 0x1800E2A40): + P("### %#x len-of-decompile follows" % a) + d = dec(a) + P(" len(src) =", len(d)) + P(d) + P() + + P() + P("=" * 30, "H4 level write at 0x180141e60..0x180141ec0 raw disasm", "=" * 30) + ins = listing.getInstructions(addr(0x180141E40), True) + n = 0 + while ins.hasNext() and n < 60: + i = ins.next() + if int(i.getAddress().getOffset()) > 0x180141EC0: + break + P(" %#x %s" % (int(i.getAddress().getOffset()), i)) + n += 1 + + f.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_dv_10.py b/fifa17-recon/tools/ghidra_queries/q_adv_dv_10.py new file mode 100644 index 0000000..97362d4 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_dv_10.py @@ -0,0 +1,20 @@ +"""BATCH 10: disassemble the undefined thunk at 0x18011c670 (slot +0x270 of the +0xed84b12 service = the second gate on TO_TRADE_PILE).""" +import traceback +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q10_raw.txt" +try: + f = open(OUT, "w") + def P(*a): f.write(" ".join(str(x) for x in a) + "\n") + P("bytes at 0x18011c670:", read_bytes(0x18011C670, 64).hex()) + it = listing.getInstructions(addr(0x18011C670), True) + n = 0 + while it.hasNext() and n < 40: + i = it.next(); a = int(i.getAddress().getOffset()) + if a > 0x18011C6F0: break + P(" %#x %s" % (a, i)); n += 1 + P() + for t in (0x18011C4C0, 0x18011C500): + P("### %#x" % t); P(dec(t)); P() + f.close(); print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_dv_2.py b/fifa17-recon/tools/ghidra_queries/q_adv_dv_2.py new file mode 100644 index 0000000..88ca5d8 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_dv_2.py @@ -0,0 +1,118 @@ +"""ADVERSARIAL BATCH 2 -- the ABSENCE claims, re-tested with a DIFFERENT method. + +The other agent tested "+0x49 is compared in exactly two places" and "itemState 5/6 +are never tested" with a LOAD/COMPARE-PAIR scan keyed on displacement. That method +has a structural blind spot: a compare performed on a value RETURNED BY AN ACCESSOR +never shows the displacement at the compare site. FUN_1801a8940 is exactly such an +accessor for +0x49 and it has a caller (FUN_1800bc580) the agent never opened. + +MY METHOD (different): enumerate EVERY instruction in .text whose textual form +contains the displacement, with no filter on opcode class at all -- so ==, !=, switch +case labels and sub/dec ladders are all caught at the LOAD, and the containing +function is then read. Plus a byte-pattern census of the two-instruction accessor +shape 48 8b 4x 18 / which finds getters my displacement scan would +attribute to the getter rather than to its caller. + +CONTROLS (same syntactic form as the targets -- a raw displacement load): + 0x38 and 0x3c : known-live fields, must come back non-zero + 0x4c : the other agent reported 37 pairs, must come back >= 37 + 0xdeadbe : impossible displacement, must come back 0 (proves the scan can + return zero for a real absence rather than always finding noise) +""" +import re, traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q2_raw.txt" +try: + f = open(OUT, "w") + + def P(*a): + f.write(" ".join(str(x) for x in a) + "\n") + + TARGETS = [0x38, 0x3c, 0x48, 0x49, 0x4c, 0x54, 0x58, 0x5c, 0x60, 0x88, 0x90] + pats = {d: re.compile(r"\+\s*0x%x\s*\]" % d) for d in TARGETS} + impossible = re.compile(r"\+\s*0xdeadbe\s*\]") + + hits = {d: [] for d in TARGETS} + imp = [] + n = 0 + it = listing.getInstructions(True) + while it.hasNext(): + i = it.next() + s = i.toString() + n += 1 + for d, p in pats.items(): + if p.search(s): + hits[d].append((int(i.getAddress().getOffset()), s)) + if impossible.search(s): + imp.append(int(i.getAddress().getOffset())) + P("instructions scanned:", n) + P("IMPOSSIBLE-DISPLACEMENT CONTROL 0xdeadbe hits:", len(imp), "(must be 0)") + P() + for d in TARGETS: + fns = {} + for a, s in hits[d]: + fn = fm.getFunctionContaining(addr(a)) + k = (fn.getName(), int(fn.getEntryPoint().getOffset())) if fn else ("?", 0) + fns.setdefault(k, []).append((a, s)) + P("### displacement +0x%02x : %d instructions in %d functions" % (d, len(hits[d]), len(fns))) + if d in (0x49, 0x48): + for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]): + P(" %s @%#x (%d)" % (nm, e, len(lst))) + for a, s in lst: + P(" %#x %s" % (a, s)) + elif d == 0x5c: + P(" functions:") + for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]): + P(" %s @%#x n=%d" % (nm, e, len(lst))) + P() + + P("=" * 30, "+0x5c FULL instruction list (itemState 5/6 absence retest)", "=" * 30) + for a, s in hits[0x5C]: + fn = fm.getFunctionContaining(addr(a)) + P(" %#x %-52s %s" % (a, s, fn.getName() if fn else "?")) + P() + + P("=" * 30, "ACCESSOR CENSUS: byte pattern 48 8b 4x 18 followed by a load", "=" * 30) + seen = {} + for reg in (0x41, 0x51, 0x49, 0x59, 0x71, 0x79): + pat = bytes([0x48, 0x8B, reg, 0x18]) + for a in find_all(pat, blocks=(".text",)): + try: + nxt = read_bytes(a + 4, 8) + except Exception: + continue + seen.setdefault(a, nxt) + P("call-shape candidates:", len(seen)) + interest = {} + for a, nxt in seen.items(): + disp = None + if nxt[0] == 0x8B and (nxt[1] & 0xC0) == 0x40: + disp = nxt[2] + elif nxt[0] == 0x0F and nxt[1] in (0xB6, 0xB7) and (nxt[2] & 0xC0) == 0x40: + disp = nxt[3] + elif nxt[0] == 0x83 and (nxt[1] & 0xC0) == 0x40: + disp = nxt[2] + elif nxt[0] == 0x8A and (nxt[1] & 0xC0) == 0x40: + disp = nxt[2] + if disp in (0x38, 0x3C, 0x48, 0x49, 0x4C, 0x54, 0x58, 0x5C, 0x60, 0x88, 0x90): + fn = fm.getFunctionContaining(addr(a)) + interest.setdefault(disp, []).append((a, fn.getName() if fn else "?", + int(fn.getEntryPoint().getOffset()) if fn else 0)) + for d in sorted(interest): + P("### accessor-shape loads of +0x%02x : %d" % (d, len(interest[d]))) + for a, nm, e in sorted(interest[d], key=lambda x: x[2]): + P(" %#x in %s @%#x" % (a, nm, e)) + if e: + nc = [(fr, t, cf, ce) for fr, t, cf, ce in xrefs_to(e) if "CALL" in t] + P(" callers: %d -> %s" % (len(nc), sorted(set(cf for _, _, cf, _ in nc)))) + P() + + P("=" * 30, "THE UNOPENED +0x49 CONSUMER: FUN_1800bc580", "=" * 30) + d = dec(0x1800BC580) + P("len(src) =", len(d)) + P(d) + + f.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_dv_3.py b/fifa17-recon/tools/ghidra_queries/q_adv_dv_3.py new file mode 100644 index 0000000..9119cab --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_dv_3.py @@ -0,0 +1,100 @@ +"""ADVERSARIAL BATCH 3. + +My batch-2 displacement census turned up FOUR +0x5c sites the other agent's +constant-collecting scan did not report, including MOV dword [RDI+0x5c],0x5 and +MOV dword [RDI+0x5c],0x6 in FUN_180147070 -- i.e. the client WRITES forSale and +offered. Their claim "forSale(5) and offered(6): NEVER TESTED ANYWHERE" and the +action "nothing reads them" are under direct attack here. + +Also under attack: + - "no other code path can produce the greyout from wire data": FUN_1800bc580 is a + THIRD +0x49 consumer (it counts untradeable squad members). What uses that count? + - the FUN_1800e2a40 <-> FUN_18003e370 vtable link the agent flagged as a gap. + - the +0x23f playStyle mapper, the 0x226 pile mapper, and the record-offset anchor + inside FUN_18013fe00 (printed IN FULL, with len). + +CONTROL for the vtable hunt: I search for the 8-byte pointer to FUN_1800e2a40 AND, +in the same pass, for the pointer to FUN_1801a7260 (which the agent reported has NO +8-byte pointer, only 4-byte .pdata RVAs) and to FUN_18003e370. A hunt that finds all +three or none tells me the search itself is sound. +""" +import traceback, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q3_raw.txt" +try: + f = open(OUT, "w") + + def P(*a): + f.write(" ".join(str(x) for x in a) + "\n") + + P("=" * 25, "A. itemState WRITERS/READERS the other scan missed", "=" * 25) + for a in (0x180147070, 0x1801A6FC0, 0x1800A47B0, 0x18011DC50, 0x1800D73D0): + d = dec(a) + P("### %#x len=%d xrefs:" % (a, len(d))) + for frm, t, cf, e in xrefs_to(a): + P(" %#x %s in %s@%#x" % (frm, t, cf, e)) + P(d) + P() + + P("=" * 25, "B. the third +0x49 consumer: who calls FUN_1800bc580", "=" * 25) + for frm, t, cf, e in xrefs_to(0x1800BC580): + P(" %#x %s in %s@%#x" % (frm, t, cf, e)) + P("--- FUN_1801a8890 (the sibling predicate counted into param_2):") + P(dec(0x1801A8890)) + P("--- FUN_1801a80a0:") + P(dec(0x1801A80A0)) + + P() + P("=" * 25, "C. vtable link FUN_1800e2a40 <- FUN_18003e370 slot 0x40", "=" * 25) + for nm, a in (("FUN_1800e2a40", 0x1800E2A40), ("FUN_1801a7260", 0x1801A7260), + ("FUN_18003e370", 0x18003E370), ("FUN_1800eb850", 0x1800EB850)): + pat = struct.pack("" % (i * 8, v)) + break + fn = fm.getFunctionAt(addr(v)) + P(" +%#04x %#x %s%s" % (i * 8, v, fn.getName() if fn else "", + " <== TARGET" if v == a else "")) + + P() + P("=" * 25, "D. FUN_18013fe00 FULL", "=" * 25) + d = dec(0x18013FE00, timeout=600) + P("len(src) =", len(d)) + P(d) + + P() + P("=" * 25, "E. mappers", "=" * 25) + for nm, a in (("playStyle FUN_180136480", 0x180136480), + ("pile FUN_180142650", 0x180142650), + ("owners helper FUN_1800d7b50", 0x1800D7B50), + ("BOUGHT_FOR mapper FUN_1800d7b30", 0x1800D7B30), + ("family FUN_1800d8330", 0x1800D8330)): + P("### " + nm) + dd = dec(a) + P(" len=%d" % len(dd)) + P(dd) + P() + + f.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_dv_4.py b/fifa17-recon/tools/ghidra_queries/q_adv_dv_4.py new file mode 100644 index 0000000..95d0b87 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_dv_4.py @@ -0,0 +1,120 @@ +"""ADVERSARIAL BATCH 4 -- the remaining serve-changing and absence claims. + + - FUN_180141660: is the +0x54 level write really on the COMMON tail, or only on the + "DB Error" path? If only on the error path the whole level story changes. + - FUN_1801b3640: CMP dword [RAX+0x5c],R15D -- a REGISTER compare the other agent's + constant-collecting scan could not evaluate. If R15D can be 5 or 6 their + "forSale/offered are never tested" absence claim dies. + - FUN_18003e550: the listing panel. Does "List on Transfer Market" have its own + enable predicate the eight-flag array does not cover? + - FUN_1800eb850: are DISCARD_CREDITS / CALCULATED_DISCARD_CREDITS really the two + names, pushed from 0x1801a8620 / 0x1801a8090? + - 0x226 pile census, re-tested by xrefs to the mapper FUN_180142650 (a DIFFERENT + method from decompiling all 134 skip-callers). + - itemState string-writer absence, re-tested by xrefs to every one of the 12 string + literals, with the ITEM-TYPE table strings ('player','staff') as a control that + has known extra users. + - FUN_180008190: resolve the indirect string compare through the global vtable. +""" +import traceback, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q4_raw.txt" +try: + f = open(OUT, "w") + + def P(*a): + f.write(" ".join(str(x) for x in a) + "\n") + + P("=" * 25, "A. FUN_180141660 -- is the level write a common tail?", "=" * 25) + fn = fm.getFunctionAt(addr(0x180141660)) + body = fn.getBody() + P("body:", body, " min %#x max %#x" % (int(body.getMinAddress().getOffset()), + int(body.getMaxAddress().getOffset()))) + # every RET in the function, and every branch target landing at/after 0x180141e77 + rets, brs = [], [] + it = listing.getInstructions(body, True) + while it.hasNext(): + i = it.next() + m = i.getMnemonicString() + a = int(i.getAddress().getOffset()) + if m == "RET": + rets.append(a) + if m.startswith("J"): + for r in i.getFlows(): + t = int(r.getOffset()) + if 0x180141E70 <= t <= 0x180141EB0: + brs.append((a, m, t)) + P("RET sites:", [hex(x) for x in rets]) + P("branches into the tail 0x180141e70..0x180141eb0:") + for a, m, t in brs: + P(" %#x %s -> %#x" % (a, m, t)) + P() + P("FUN_180141660 decompile:") + d = dec(0x180141660, timeout=600) + P("len =", len(d)) + P(d) + + P() + P("=" * 25, "B. FUN_1801b3640 -- the register compare on +0x5c", "=" * 25) + ins = listing.getInstructions(addr(0x1801B3860), True) + n = 0 + while ins.hasNext() and n < 90: + i = ins.next() + a = int(i.getAddress().getOffset()) + if a > 0x1801B38E0: + break + P(" %#x %s" % (a, i)) + n += 1 + P() + P("R15 setup search 0x1801b3640..0x1801b3894:") + ins = listing.getInstructions(addr(0x1801B3640), True) + while ins.hasNext(): + i = ins.next() + a = int(i.getAddress().getOffset()) + if a > 0x1801B3894: + break + s = i.toString() + if "R15" in s: + P(" %#x %s" % (a, s)) + P() + d = dec(0x1801B3640, timeout=600) + P("FUN_1801b3640 len =", len(d)) + P(d) + + P() + P("=" * 25, "C. FUN_18003e550 listing panel + FUN_1800eb850 discard push", "=" * 25) + for a in (0x18003E550, 0x1800EB850): + d = dec(a, timeout=600) + P("### %#x len=%d" % (a, len(d))) + P(d) + P() + + P("=" * 25, "D. pile mapper xrefs (different method for the 0x226 census)", "=" * 25) + for frm, t, cf, e in xrefs_to(0x180142650): + P(" %#x %s in %s@%#x" % (frm, t, cf, e)) + + P() + P("=" * 25, "E. itemState string literals: every xref", "=" * 25) + names = ["invalid", "free", "WAITING_FOR_GAME", "inGame", "forSale", "offered", + "activeBadge", "activeHomeKit", "activeAwayKit", "activeBall", + "activeStadium", "active", + "player", "staff"] # last two = CONTROL, known to be used elsewhere + for nm in names: + hits = find_all(nm.encode() + b"\x00", blocks=(".rdata", ".data")) + P("### %-18s literal hits: %s" % (nm, [hex(h) for h in hits])) + for h in hits: + for frm, t, cf, e in xrefs_to(h): + P(" ref %#x %s in %s@%#x" % (frm, t, cf, e)) + + P() + P("=" * 25, "F. FUN_180008190 indirect compare + FUN_180130d10 + FUN_1801c3480", "=" * 25) + for a in (0x180008190, 0x180130D10, 0x1801C3480): + d = dec(a, timeout=600) + P("### %#x len=%d" % (a, len(d))) + P(d) + P() + + f.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_dv_5.py b/fifa17-recon/tools/ghidra_queries/q_adv_dv_5.py new file mode 100644 index 0000000..ba8079c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_dv_5.py @@ -0,0 +1,62 @@ +"""ADVERSARIAL BATCH 5 -- consequences of the one serve-changing action, and the +service gate the other agent left open. + + 1. untradeable:false flips item+0x49 to 1 on EVERY card. Besides TO_TRADE_PILE that + byte feeds FUN_1800bc580, which counts untradeable members of the 11-slot active + squad. Who consumes that count, and does flipping it change anything else? + 2. FUN_1801a7260's other gate: slot +0x270 of the service FUN_180009c80 resolves. + Identify the service vtable and that slot if possible. +""" +import traceback, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q5_raw.txt" +try: + f = open(OUT, "w") + + def P(*a): + f.write(" ".join(str(x) for x in a) + "\n") + + P("=" * 25, "1. consumers of the untradeable-squad count", "=" * 25) + for a in (0x1800BB2A0, 0x1800BBA10): + d = dec(a, timeout=600) + P("### %#x len=%d" % (a, len(d))) + P(d) + P() + + P("=" * 25, "2. the service locator used by FUN_1801a7260", "=" * 25) + for nm, a in (("FUN_1800d7170", 0x1800D7170), ("FUN_180009c80", 0x180009C80), + ("FUN_180018bd0", 0x180018BD0), ("FUN_180009b60", 0x180009B60)): + P("### " + nm) + P(dec(a)) + P() + + P("=" * 25, "3. any vtable with >= 0x280 bytes containing plausible slot 0x270", "=" * 25) + # find .rdata runs of >= 0x50 consecutive .text pointers; report those long enough + for b in mem.getBlocks(): + if b.getName() != ".rdata" or not b.isInitialized(): + continue + s = int(b.getStart().getOffset()) + e = int(b.getEnd().getOffset()) + a = (s + 7) & ~7 + run_start = None + while a + 8 <= e: + try: + v = qword(a) + except Exception: + break + ok = 0x180001000 <= v < 0x1801E5000 + if ok and run_start is None: + run_start = a + elif not ok and run_start is not None: + ln = a - run_start + if ln >= 0x280: + P(" vtable-ish run %#x..%#x len %#x slot+0x270 -> %#x %s" % + (run_start, a, ln, qword(run_start + 0x270), + (lambda fn: fn.getName() if fn else "")(fm.getFunctionAt(addr(qword(run_start + 0x270)))))) + run_start = None + a += 8 + + f.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_dv_6.py b/fifa17-recon/tools/ghidra_queries/q_adv_dv_6.py new file mode 100644 index 0000000..06ab298 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_dv_6.py @@ -0,0 +1,52 @@ +"""ADVERSARIAL BATCH 6 -- pin the service behind GUID 0xed84b11/0xed84b12 whose +vtable slot +0x270 is the OTHER gate on TO_TRADE_PILE. If that gate is an online / +transfer-market-availability check it may block the menu even with untradeable:false, +which is the single biggest risk to the headline recommendation. + +METHOD: the class that implements an interface references the same GUID constant when +it registers. Scan .text for the 4-byte immediates and report every function. +CONTROL: the same scan for 0x10c80b95 (the CardInventory-ish service FUN_18003e370 +uses) and 0xed80ed8 -- if those come back with registrars and 0xed84b11 does not, the +absence is about this GUID and not about the scan. +""" +import traceback, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q6_raw.txt" +try: + f = open(OUT, "w") + + def P(*a): + f.write(" ".join(str(x) for x in a) + "\n") + + for g in (0xED84B11, 0xED84B12, 0x10C80B95, 0x10C80B96, 0xED80ED8): + pat = struct.pack(" + FUN_1800e2a40 link the other agent could only infer semantically. + (b) 0xed84b12 appears as data at 0x180113f52. Whatever class that belongs to is the + service FUN_1801a7260 calls slot +0x270 on. Find its vtable and read slot 0x270. +""" +import traceback, struct + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q7_raw.txt" +try: + f = open(OUT, "w") + + def P(*a): + f.write(" ".join(str(x) for x in a) + "\n") + + for a in (0x1800E1662, 0x180113F52): + fn = fm.getFunctionContaining(addr(a)) + P("### data GUID at %#x -> containing function %s @%#x" % + (a, fn.getName() if fn else None, + int(fn.getEntryPoint().getOffset()) if fn else 0)) + if fn: + e = int(fn.getEntryPoint().getOffset()) + P(dec(e)) + hits = find_all(struct.pack(" slot of this fn relative to first non-stub: +%#x" % (h - base)) + for i in range(0, 90): + v = qword(base + i * 8) + if not (0x180001000 <= v < 0x1801E5000): + break + f2 = fm.getFunctionAt(addr(v)) + mark = "" + if i * 8 == 0x270: + mark = " <== SLOT 0x270" + if i * 8 == 0x40: + mark = " <== SLOT 0x40" + P(" +%#05x %#x %s%s" % (i * 8, v, f2.getName() if f2 else "", mark)) + P() + + f.close() + print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_dv_8.py b/fifa17-recon/tools/ghidra_queries/q_adv_dv_8.py new file mode 100644 index 0000000..6940ad3 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_dv_8.py @@ -0,0 +1,42 @@ +"""BATCH 8: the two GUID-returning stubs are undefined functions. Read them as raw +instructions and find the vtable that holds them. CONTROL: both stubs must decode to +'mov eax, ; ret' -- if they do not, my reading of them as interface-id getters +is wrong and I say so.""" +import traceback, struct +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q8_raw.txt" +try: + f = open(OUT, "w") + def P(*a): f.write(" ".join(str(x) for x in a) + "\n") + for lo, hi in ((0x1800E1650, 0x1800E1690), (0x180113F40, 0x180113F80)): + P("### raw %#x..%#x" % (lo, hi)) + P(" bytes:", read_bytes(lo, hi - lo).hex()) + it = listing.getInstructions(addr(lo), True) + while it.hasNext(): + i = it.next() + a = int(i.getAddress().getOffset()) + if a >= hi: break + P(" %#x %s" % (a, i)) + P() + for cand in (0x1800E1660, 0x180113F50, 0x180113F4C, 0x180113F40): + hits = find_all(struct.pack(" base 0x180215b10 -> slot +0x40 must be + FUN_1800e2a40 if the FUN_18003e370 link is real. (CONTROL for the arithmetic.) + - 0xed84b12 class: stub ptr at 0x18021c2b8 -> base 0x18021c2a0 -> slot +0x270 is the + other gate on TO_TRADE_PILE. +""" +import traceback +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q9_raw.txt" +try: + f = open(OUT, "w") + def P(*a): f.write(" ".join(str(x) for x in a) + "\n") + for nm, base, slots in (("iface 0x10c80b96 (CONTROL)", 0x180215B10, (0x18, 0x40)), + ("iface 0xed84b12", 0x18021C2A0, (0x18, 0x270, 0x290, 0x2b0, 0x308, 0x148))): + P("### %s vtable base %#x" % (nm, base)) + for s in slots: + v = qword(base + s) + fn = fm.getFunctionAt(addr(v)) + P(" +%#05x -> %#x %s" % (s, v, fn.getName() if fn else "")) + P() + for a in (0x1801B1CE0,): + pass + v = qword(0x18021C2A0 + 0x270) + P("=== slot 0x270 body ===") + P(dec(v, timeout=300)) + P("=== xrefs to it ===") + for frm, t, cf, e in xrefs_to(v): + P(" %#x %s in %s@%#x" % (frm, t, cf, e)) + f.close(); print("WROTE", OUT) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_v1_1.py b/fifa17-recon/tools/ghidra_queries/q_adv_v1_1.py new file mode 100644 index 0000000..9db581d --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_v1_1.py @@ -0,0 +1,74 @@ +"""ADVERSARIAL BATCH 1. + +HYPOTHESES UNDER TEST (all from another agent, assumed WRONG until reproduced): + H1 FUN_1800d8330 maps cardsubtypeid -> cardtype and returns 9 for exactly + {0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec}; and returns 7 for 9,10,11. + H2 FUN_180119bd0 arms: 9 -> KITS, 10 -> Stadium, 0xb -> Badge, else "". + H3 FUN_1801a8640 == *(u32*)(*(u64*)(param_1+0x18)+0x50) i.e. cardsubtypeid. + H4 FUN_1800f6c40 calls vtable+0x498 only when item+0x4c == 7, args + (item+0x50, item+0x94, item+0x20); and sets IS_KIT_%d when item+0x50==9. + H5 FUN_180141660 tail writes item+0x54 = level(rating@+0xb4): 3 if >=0x4b, + else 2 - (rating < 0x41). <-- CONTRADICTS the live-map "+0x54 = itemType". + +CONTROL: FUN_1800d8330 must decompile non-empty and its case labels must be +recoverable; it is a jump table, which is the form that DEFEATED an earlier scan. +Every decompile is written to disk IN FULL with its length printed, so no claim +here can rest on a truncated body. + +Output: /tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/ +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv" + +try: + os.makedirs(OUT, exist_ok=True) + + TARGETS = { + "FUN_1800d8330": 0x1800d8330, + "FUN_180119bd0": 0x180119bd0, + "FUN_1801a8640": 0x1801a8640, + "FUN_1800f6c40": 0x1800f6c40, + "FUN_180141660": 0x180141660, + "FUN_1801a8570": 0x1801a8570, + "FUN_1801a8560": 0x1801a8560, + "FUN_1801a8800": 0x1801a8800, + "FUN_1801a8040": 0x1801a8040, + "FUN_180136480": 0x180136480, + } + for name, a in TARGETS.items(): + src = dec(a) + p = os.path.join(OUT, name + ".c") + open(p, "w").write(src) + print("WROTE %-16s len=%6d -> %s" % (name, len(src), p)) + + print() + print("=== small functions printed IN FULL ===") + for name in ("FUN_1800d8330", "FUN_1801a8640", "FUN_1801a8570", "FUN_1801a8560", + "FUN_1801a8800", "FUN_1801a8040", "FUN_180119bd0"): + src = open(os.path.join(OUT, name + ".c")).read() + print("\n----------8<---------- %s (len=%d) ----------" % (name, len(src))) + print(src) + + print() + print("=== CONTROL: case labels of FUN_1800d8330 via the listing ===") + f = func(0x1800d8330) + print("entry 0x%x body %s" % (int(f.getEntryPoint().getOffset()), f.getBody())) + it = listing.getInstructions(f.getBody(), True) + n = 0 + while it.hasNext(): + ins = it.next() + n += 1 + print("instruction count: %d" % n) + # enumerate caseD_ labels inside the body + st = prog.getSymbolTable() + labs = [] + rng = f.getBody() + for sym in st.getAllSymbols(True): + a2 = sym.getAddress() + if a2 is not None and rng.contains(a2) and str(sym.getName()).startswith("caseD_"): + labs.append((str(sym.getName()), int(a2.getOffset()))) + print("caseD_ labels in FUN_1800d8330: %d -> %s" % (len(labs), sorted(set(l[0] for l in labs)))) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_v1_2.py b/fifa17-recon/tools/ghidra_queries/q_adv_v1_2.py new file mode 100644 index 0000000..927abf8 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_v1_2.py @@ -0,0 +1,124 @@ +"""ADVERSARIAL BATCH 2. + +MAIN ATTACK: the claim "cardtype 9 has NO resolver at all, so ball and leaguelogo +display strings must come off the wire (localizedName + description)". That claim +CHANGES WHAT WE SERVE, so it is priority 1. + +Counter-evidence to chase: .rdata at 0x1802041d0 holds 'fcc_leaguelogos' and +0x1802041e0 holds 'LeagueName_Abbr_15_%d', sitting immediately beside 'FUT_UC_KITS' +(0x180204180) which IS a resolver literal. If some function formats +LeagueName_Abbr_15_%d for a league logo, the "must come off the wire" claim is wrong. + + H6 vtable+0x490 = FUN_18011a860 is a GENERIC name resolver taking + (cardtype@+0x4c, cardsubtypeid@+0x50, resourceId@+0x18). Does it have a + cardtype-9 arm? + H7 'fcc_leaguelogos' / 'LeagueName_Abbr_15_%d' are referenced by some function. + H8 FUN_18012ee20 has EXACTLY ONE caller (the club URL builder). [absence claim] + H9 FUN_1800fed90 is the ONLY function whose switch case set is exactly + {0x91..0x96}. [absence claim -- re-tested here by a DIFFERENT method than + the original caseD_ symbol enumeration: I enumerate switch tables from the + instruction/flow side via getBasicBlocks + scalar operands, AND repeat the + symbol method, and compare the two.] + H10 FUN_180141660 (the merge) is called on every deserialized item. + +CONTROL for the xref questions: 'FUT_UC_KITS' at 0x180204180 MUST come back with +>=1 referencing function (we already know FUN_180119bd0 uses it). If the xref +method returns 0 for FUT_UC_KITS the method is broken and every negative is void. +Same syntactic form (a .rdata string address referenced by a LEA) as the targets. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv" + +try: + print("=== CONTROL + targets: xrefs to .rdata string addresses ===") + STRS = { + "FUT_UC_KITS (CONTROL)": 0x180204180, + "FUT_UC_BALL": 0x180239120, + "fcc_leaguelogos": 0x1802041d0, + "LeagueName_Abbr_15_%d": 0x1802041e0, + "leagues": 0x1802041b0, + "Badge (0x1802041b8)": 0x1802041b8, + "countryid": 0x1802041c0, + "fcc_myclubs": 0x180204190, + "TeamName_Abbr15_%d?": None, + } + for name, a in STRS.items(): + if a is None: + continue + try: + xs = xrefs_to(a) + except Exception as e: + print(" %-24s XREF ERROR %s" % (name, e)); continue + fns = sorted(set((x[2], x[3]) for x in xs)) + print(" %-24s 0x%x %d refs, funcs: %s" % + (name, a, len(xs), ["%s@0x%x" % (n, e) for n, e in fns])) + + print() + print("=== find TeamName_Abbr15_%d and StadiumName_%d addresses then xref ===") + for lit in (b"TeamName_Abbr15_%d\x00", b"StadiumName_%d\x00", b"LeagueName_Abbr_15_%d\x00", + b"fcc_leaguelogos\x00", b"fcc_balls\x00", b"fcc_stadium\x00", + b"fcc_badgecards\x00", b"fcc_kitcards\x00", b"fcc_misccards\x00"): + hits = find_all(lit, blocks=(".rdata", ".data", ".text")) + print(" %-26s %d hit(s) at %s" % (lit.rstrip(b"\x00").decode(), len(hits), + [hex(h) for h in hits])) + for h in hits: + xs = xrefs_to(h) + fns = sorted(set((x[2], x[3]) for x in xs)) + print(" -> %d refs: %s" % (len(xs), ["%s@0x%x" % (n, e) for n, e in fns])) + + print() + print("=== H6: generic resolver FUN_18011a860 (vtable +0x490) FULL ===") + src = dec(0x18011a860) + open(os.path.join(OUT, "FUN_18011a860.c"), "w").write(src) + print("len=%d" % len(src)) + print(src) + + print() + print("=== H8: callers of FUN_18012ee20 (itemState code -> atom) ===") + for fa in (0x18012ee20, 0x180141660, 0x180166660, 0x1800fed90): + try: + cs = callers(fa) + except Exception: + cs = [(x[0], x[2], x[3]) for x in xrefs_to(fa)] + print(" FUN_%x callers: %s" % (fa, cs)) + + print() + print("=== H9: switch case-set enumeration, TWO methods ===") + st = prog.getSymbolTable() + # method 1: caseD_ symbols grouped by containing function + import collections + bysym = collections.defaultdict(set) + n = 0 + for sym in st.getAllSymbols(True): + nm = str(sym.getName()) + if not nm.startswith("caseD_"): + continue + n += 1 + a2 = sym.getAddress() + f = fm.getFunctionContaining(a2) + if f is None: + continue + try: + v = int(nm.split("_")[-1], 16) + except ValueError: + continue + bysym[int(f.getEntryPoint().getOffset())].add(v) + print(" method1: %d caseD_ symbols over %d functions" % (n, len(bysym))) + TARGET = set(range(0x91, 0x97)) + exact = [hex(k) for k, v in bysym.items() if v == TARGET] + superset = [hex(k) for k, v in bysym.items() if TARGET <= v and v != TARGET] + overlap = [hex(k) for k, v in bysym.items() if (TARGET & v) and not (TARGET <= v)] + print(" functions with case set EXACTLY {0x91..0x96}: %s" % exact) + print(" functions whose case set is a SUPERSET: %s" % superset) + print(" functions with PARTIAL overlap: %s" % overlap) + print(" CONTROL FUN_1800d8330 present in method1? %s -> %s" % + (0x1800d8330 in bysym, sorted(hex(x) for x in bysym.get(0x1800d8330, [])))) + + print() + print("=== H10: callers of the merge FUN_180141660 ===") + xs = xrefs_to(0x180141660) + print(" %d refs: %s" % (len(xs), sorted(set("%s@0x%x" % (x[2], x[3]) for x in xs)))) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_v1_3.py b/fifa17-recon/tools/ghidra_queries/q_adv_v1_3.py new file mode 100644 index 0000000..b0b9da3 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_v1_3.py @@ -0,0 +1,92 @@ +"""ADVERSARIAL BATCH 3. + +PRIORITY-1 ATTACK: FUN_180098f20 is the ONLY referencer of both 'fcc_leaguelogos' +and 'LeagueName_Abbr_15_%d'. If it resolves a league-logo display name from the DB, +then the claim "cardtype 9 has no resolver at all, so ball and leaguelogo need +localizedName + description off the wire" is WRONG, and that claim changes what we +serve. + +ALSO: + H11 FUN_180108c00 deserializes atom 0x32f (tournamentType) and computes + subtype = value + 0x91. (the trophy claim) + H12 FUN_1801bfac0 arm iVar5 == 0x1e -> FUT_UC_BALL, and the 0x1f arm. + H13 DAT_18022315c is the string "rare" (supports low-dword-of-uStack_130 = rareflag) + H14 the deser's stack struct -> record copy: which stack slot becomes record+0x58. + +CONTROL for the "who calls X" questions: FUN_180119bd0 must come back with >=1 +caller (we already proved FUN_1800f6c40 calls it through vtable slot +0x498 -- +though that is an INDIRECT call, so a direct-xref method may legitimately return 0; +that is exactly why the control matters and why a 0 here is NOT an absence). +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv" + +try: + print("=== H13: strings at the DAT_ addresses used as DB column names ===") + for a in (0x18022315c, 0x1801eeeb0, 0x1802ef590, 0x18021ce7c, 0x18021ce7f, 0x1801e9caf): + try: + print(" 0x%x -> %r" % (a, rd_str(a, 40))) + except Exception as e: + print(" 0x%x -> ERR %s" % (a, e)) + + print() + print("=== PRIORITY 1: FUN_180098f20 FULL (the fcc_leaguelogos referencer) ===") + src = dec(0x180098f20) + open(os.path.join(OUT, "FUN_180098f20.c"), "w").write(src) + print("len=%d" % len(src)) + print(src) + + print() + print("=== who calls FUN_180098f20 ? ===") + for fa, label in ((0x180098f20, "leaguelogo resolver"), + (0x180119bd0, "CONTROL kit/stadium/badge resolver (indirect-only expected)"), + (0x18011a860, "generic resolver +0x490"), + (0x180094580, "third FUT_UC_KITS user"), + (0x1800991a0, "fcc_myclubs user"), + (0x180099490, "leagues/countryid/Badge user")): + xs = xrefs_to(fa) + print(" 0x%x %-52s %d refs: %s" % + (fa, label, len(xs), sorted(set("%s@0x%x" % (x[2], x[3]) for x in xs)))) + + print() + print("=== H11: FUN_180108c00 FULL (tournamentType -> subtype 0x91+) ===") + src = dec(0x180108c00) + open(os.path.join(OUT, "FUN_180108c00.c"), "w").write(src) + print("len=%d" % len(src)) + print(src[:9000]) + if len(src) > 9000: + print("... [remainder in FUN_180108c00.c]") + + print() + print("=== FUN_1800fed90 FULL (the 0x91..0x96 switch) ===") + src = dec(0x1800fed90) + open(os.path.join(OUT, "FUN_1800fed90.c"), "w").write(src) + print("len=%d" % len(src)) + print(src) + + print() + print("=== re-decompile the item deser MYSELF (do not trust the other agent's copy) ===") + src = dec(0x18013fe00, timeout=600) + p = os.path.join(OUT, "FUN_18013fe00.c") + open(p, "w").write(src) + print("len=%d -> %s" % (len(src), p)) + # print only the lines that matter for H14 + for i, ln in enumerate(src.splitlines(), 1): + if ("uStack_130" in ln or "local_100" in ln or "FUN_180141660" in ln + or "local_13c" in ln or "local_138" in ln): + print(" %4d: %s" % (i, ln)) + + print() + print("=== also dump the card-detail builder for the 0x1e / 0x1f arms ===") + src = dec(0x1801bfac0, timeout=600) + open(os.path.join(OUT, "FUN_1801bfac0.c"), "w").write(src) + print("len=%d" % len(src)) + for i, ln in enumerate(src.splitlines(), 1): + if ("0x1e" in ln or "0x1f" in ln or "FUT_UC_BALL" in ln or "FUN_1801a8640" in ln + or "Stadium" in ln or "Badge" in ln or "FUT_UC_KITS" in ln + or "LeagueName" in ln or "fcc_" in ln): + print(" %4d: %s" % (i, ln)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_v_1.py b/fifa17-recon/tools/ghidra_queries/q_adv_v_1.py new file mode 100644 index 0000000..7da69fb --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_v_1.py @@ -0,0 +1,116 @@ +"""ADVERSARIAL VERIFICATION BATCH 1. + +HYPOTHESES UNDER ATTACK (from the D4 report): + H-A record+0x54 is card LEVEL derived from rating by an unconditional ladder in + the tail of FUN_180141660, NOT itemType. + H-B FUN_1801a87f0 is a one-byte read of record+0xb4 and all four OVERALL_RATING + publishers call it. + H-C playStyle lands at record+0x88, FUN_180136480 accepts only 0xfb..0x111. + H-D atom 0x173 itemType never becomes an int. + +CONTROLS. + * For every "no such thing" statement I enumerate case labels, `== 0x`, `!= 0x` + AND sub/dec ladders, and I state which form the positive control used. + * Positive control for the dispatch enumeration: atoms 0x274 (rating) and 0x287 + (resourceId), both known-present, must be found by the SAME enumerator. + * Positive control for the literal-xref method: a literal whose xref count is + independently known. +Everything is written to files; nothing is truncated. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/" + +try: + import re + + def dump(name, s): + p = OUT + name + open(p, "w").write(s) + print("WROTE %s (%d chars)" % (p, len(s))) + + targets = { + "merge_141660": 0x180141660, + "deser_13fe00": 0x18013FE00, + "playersmerge_135890": 0x180135890, + "acc_rating_1a87f0": 0x1801A87F0, + "acc_cardlevel_1a80c0": 0x1801A80C0, + "acc_playstyle_1a85c0": 0x1801A85C0, + "acc_league_1a8550": 0x1801A8550, + "acc_attr_1a8450": 0x1801A8450, + "acc_dream_1a8830": 0x1801A8830, + "acc_assetid_1a8010": 0x1801A8010, + "acc_asset2_1a8020": 0x1801A8020, + "mapper_playstyle_136480": 0x180136480, + "family_d8330": 0x1800D8330, + "resid_166ca0": 0x180166CA0, + } + blob = [] + src = {} + for nm, a in targets.items(): + f = func(a) + s = dec(a, 600) + src[nm] = s + blob.append("=" * 78) + blob.append("### %s @ %#x ghidra_fn=%s entry=%#x len=%d" % ( + nm, a, f.getName() if f else "NONE", + int(f.getEntryPoint().getOffset()) if f else 0, len(s))) + blob.append(s) + dump("v1_bodies.txt", "\n".join(blob)) + + # ---- dispatch-form enumeration over the item deser, ALL FOUR FORMS + d = src["deser_13fe00"] + print("\n--- deser FUN_18013fe00 len=%d ---" % len(d)) + cases = sorted(set(int(x, 16) for x in re.findall(r"case\s+0x([0-9a-fA-F]+)", d))) + cases += sorted(set(int(x) for x in re.findall(r"case\s+(\d+)", d))) + eq = sorted(set(int(x, 16) for x in re.findall(r"==\s*0x([0-9a-fA-F]+)", d))) + ne = sorted(set(int(x, 16) for x in re.findall(r"!=\s*0x([0-9a-fA-F]+)", d))) + lt = sorted(set(int(x, 16) for x in re.findall(r"<\s*0x([0-9a-fA-F]+)", d))) + sub = sorted(set(int(x, 16) for x in re.findall(r"-\s*0x([0-9a-fA-F]+)", d))) + print("case labels (%d): %s" % (len(cases), [hex(c) for c in cases])) + print("== 0x (%d): %s" % (len(eq), [hex(c) for c in eq])) + print("!= 0x (%d): %s" % (len(ne), [hex(c) for c in ne])) + print("< 0x (%d): %s" % (len(lt), [hex(c) for c in lt])) + print("- 0x ladders (%d): %s" % (len(sub), [hex(c) for c in sub])) + for probe, label in [(0x274, "rating CONTROL"), (0x287, "resourceId CONTROL"), + (0x173, "itemType"), (0x23F, "playStyle"), + (0x172, "itemState"), (0x207, "owners"), + (0x361, "untradeable"), (0x1B, "amount"), + (0x226, "pile"), (0x6B, "cardassetid"), (0x23, "assetId"), + (0x18A, "leagueId"), (0x1D1, "nation"), (0x6C, "cardsubtypeid")]: + forms = [] + if probe in cases: + forms.append("case") + if probe in eq: + forms.append("==") + if probe in ne: + forms.append("!=") + print(" atom %#x %-18s dispatch forms: %s" % (probe, label, forms or "NONE FOUND")) + + # ---- who writes offset 0x54 anywhere in the two functions? + print("\n--- textual writes to +0x54 / 0x54 in merge and deser ---") + for nm in ("merge_141660", "deser_13fe00", "playersmerge_135890"): + for ln_no, ln in enumerate(src[nm].split("\n")): + if "0x54" in ln or "0xb4" in ln: + print(" %-20s %4d| %s" % (nm, ln_no, ln.strip())) + + # ---- OVERALL_RATING literal: locate it MYSELF, then xref + print("\n--- OVERALL_RATING literal census ---") + hits = find_all(b"OVERALL_RATING\x00") + print("occurrences of 'OVERALL_RATING\\0':", [hex(h) for h in hits]) + for h in hits: + xs = xrefs_to(h) + print(" %#x xrefs=%d" % (h, len(xs))) + for frm, t, fn, ent in xs: + print(" from %#x %s in %s @%#x" % (frm, t, fn, ent)) + # control: a literal with an obviously different xref profile + for lit in (b"CARD_LEVEL\x00", b"PLAY_STYLE\x00", b"LEAGUE_ID\x00", + b"ATTRIBUTE_VALUE\x00", b"IS_DREAM_PLAYER\x00", b"ASSET_ID\x00"): + hs = find_all(lit) + print("\n%s occurrences: %s" % (lit, [hex(x) for x in hs])) + for h in hs: + xs = xrefs_to(h) + print(" %#x xrefs=%d -> %s" % (h, len(xs), sorted(set(x[2] for x in xs)))) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_v_2.py b/fifa17-recon/tools/ghidra_queries/q_adv_v_2.py new file mode 100644 index 0000000..fba0c16 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_v_2.py @@ -0,0 +1,88 @@ +"""ADVERSARIAL VERIFICATION BATCH 2. + +Q1 COMPLETENESS GAP the D4 report admitted: are there raw, non-accessor reads of + record+0xb4 anywhere in the binary? 0xb4 cannot be encoded as a signed disp8, + so EVERY [reg+0xb4] reference must carry the literal disp32 bytes b4 00 00 00. + Scanning .text for those four bytes and decoding the containing instruction is + therefore an EXHAUSTIVE search, not a sample. Same scan for 0x54 and 0x88. + Positive control: the scan must find FUN_1801a87f0 (+0xb4), FUN_180141660's + ladder (+0xb4 and +0x54) and FUN_1801a85c0 (+0x88). + +Q2 FUN_18013f4d0 -- the family-6 handler the deser tail calls with (record, + resourceId, AMOUNT). If it stores amount in the record, the standing + "amount is dropped" verdict is wrong. + +Q3 the +0xe0 mystery: FUN_1801a8540, FUN_1800e5940 (manager publisher), + FUN_1800e6e20 (player publisher) in full. + +Q4 FUN_180166660 itemState mapper, FUN_1800d7b50/b30/b10/af0 value readers. + +Q5 who calls FUN_18013fe00 and FUN_180141660 (is the ladder really on every path). +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/" + +try: + def scan_disp(off): + pat = bytes([off & 0xFF, (off >> 8) & 0xFF, (off >> 16) & 0xFF, (off >> 24) & 0xFF]) + hits = find_all(pat, blocks=(".text",)) + rows = [] + for h in hits: + ins = listing.getInstructionContaining(addr(h)) + if ins is None: + continue + a = int(ins.getAddress().getOffset()) + txt = str(ins) + if ("0xb4]" in txt or "0x54]" in txt or "0x88]" in txt or + hex(off) in txt.lower()): + f = fm.getFunctionContaining(ins.getAddress()) + rows.append((a, txt, f.getName() if f else "?")) + return rows + + for off, label in ((0xB4, "record+0xb4 rating"), + (0x54, "record+0x54 disputed"), + (0x88, "record+0x88 playStyle")): + rows = scan_disp(off) + print("\n==== EXHAUSTIVE disp32 scan for [reg+%#x] (%s): %d instructions" + % (off, label, len(rows))) + seen = {} + for a, txt, fn in rows: + seen.setdefault(fn, []).append((a, txt)) + for fn in sorted(seen): + print(" %-24s" % fn, ["%#x %s" % (a, t) for a, t in seen[fn]]) + + bodies = [] + for nm, a in (("f_13f4d0_family6", 0x18013F4D0), + ("acc_1a8540", 0x1801A8540), + ("acc_1a86b0", 0x1801A86B0), + ("acc_1a8590_nation", 0x1801A8590), + ("acc_1a86a0_team", 0x1801A86A0), + ("pub_mgr_1800e5940", 0x1800E5940), + ("pub_player_1800e6e20", 0x1800E6E20), + ("itemstate_166660", 0x180166660), + ("rd_d7b50", 0x1800D7B50), ("rd_d7b30", 0x1800D7B30), + ("rd_d7b10", 0x1800D7B10), ("rd_d7af0", 0x1800D7AF0), + ("stamp_d84e0", 0x1800D84E0)): + f = func(a) + s = dec(a, 600) + bodies.append("=" * 78) + bodies.append("### %s @ %#x len=%d" % (nm, a, len(s))) + bodies.append(s) + open(OUT + "v2_bodies.txt", "w").write("\n".join(bodies)) + print("\nWROTE v2_bodies.txt") + + print("\n==== callers ====") + for nm, a in (("FUN_18013fe00 item deser", 0x18013FE00), + ("FUN_180141660 merge", 0x180141660), + ("FUN_180135890 players merge", 0x180135890), + ("FUN_1801a87f0 rating acc", 0x1801A87F0), + ("FUN_1801a80c0 cardlevel acc", 0x1801A80C0), + ("FUN_1801a85c0 playstyle acc", 0x1801A85C0), + ("FUN_1801a8550 league acc", 0x1801A8550), + ("FUN_1801a8540", 0x1801A8540)): + xs = xrefs_to(a) + cs = sorted(set("%s@%#x" % (x[2], x[3]) for x in xs if x[1].startswith("UNCONDITIONAL_CALL") or "CALL" in x[1])) + print("%-30s xrefs=%d callers=%s" % (nm, len(xs), cs)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_v_3.py b/fifa17-recon/tools/ghidra_queries/q_adv_v_3.py new file mode 100644 index 0000000..ea81c0c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_v_3.py @@ -0,0 +1,42 @@ +"""ADVERSARIAL BATCH 3: exhaustive [reg+disp32] scan, tightened. + +0xb4 / 0x54 / 0x88 / 0xe0 cannot be a signed disp8, so every [reg+off] reference +must carry the disp32 bytes literally. The scan is therefore exhaustive over .text. +Filter: keep only instructions whose printed operand ends in "+ 0x]", drop LEA +and the unwind-stub noise. +Positive controls that MUST appear: FUN_1801a87f0 (+0xb4 read), +FUN_180141660 (+0xb4 read and +0x54 write), FUN_1801a85c0 (+0x88 read), +FUN_1801a80c0 (+0x54 read and write). +""" +import traceback + +try: + for off in (0xB4, 0x54, 0x88, 0xE0): + pat = bytes([off, 0, 0, 0]) + hits = find_all(pat, blocks=(".text",)) + rows = [] + for h in hits: + ins = listing.getInstructionContaining(addr(h)) + if ins is None: + continue + txt = str(ins) + if ("+ %s]" % hex(off)) not in txt: + continue + mn = txt.split()[0] + if mn in ("LEA", "NOP"): + continue + f = fm.getFunctionContaining(ins.getAddress()) + fn = f.getName() if f else "?" + if fn.startswith("Unwind") or fn.startswith("_guard"): + continue + rows.append((int(ins.getAddress().getOffset()), txt, fn)) + rows = sorted(set(rows)) + print("\n==== [reg+%#x] exhaustive disp32 scan: %d non-LEA, non-unwind instructions" + % (off, len(rows))) + byf = {} + for a, t, fn in rows: + byf.setdefault(fn, []).append((a, t)) + for fn in sorted(byf): + print(" %-26s %s" % (fn, "; ".join("%#x %s" % x for x in byf[fn]))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_adv_v_4.py b/fifa17-recon/tools/ghidra_queries/q_adv_v_4.py new file mode 100644 index 0000000..37e5f2f --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_adv_v_4.py @@ -0,0 +1,79 @@ +"""ADVERSARIAL BATCH 4. + +CARD SIDE + A. FUN_1801aa7f0 and FUN_1800e6410 read [reg+0xb4] as a byte but sit OUTSIDE the + accessor range [0x1801a7000,0x1801a9000) the D4 report swept. Do they read an + item record? If so the "OVERALL_RATING has exactly four publishers, all through + FUN_1801a87f0" completeness argument has a hole. + B. FUN_1801356c0 -- the family-2 (manager) merge. Does it clobber +0xdd..+0xfb the + way the players merge does? That decides whether leagueId at +0xe0 survives for + managers. + C. FUN_180134cb0 -- writes +0xfc..+0x101, which FUN_1801a86b0 reads as the + per-attribute chemistry delta. + D. disp8 scan for [reg+0x54]: 0x54 fits a signed disp8 so the disp32 trick does + NOT apply; iterate EVERY instruction in .text instead. Positive control: + FUN_180141660 and FUN_1801a80c0 must appear. + +ROUTE SIDE + E. FUN_18012ec50 club ?type= switch, FUN_18012f4f0 club/stats switch, + FUN_1801308c0 consumables suffix, FUN_18012ddf0 query builder -- full, so the + "exactly 30 / exactly 7 / no /stats/team" absences can be re-tested against + case labels AND == AND != AND ladders. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/" + +try: + import re + bodies = [] + src = {} + for nm, a in (("rating_reader_1aa7f0", 0x1801AA7F0), + ("rating_reader_e6410", 0x1800E6410), + ("mgr_merge_1356c0", 0x1801356C0), + ("chem_134cb0", 0x180134CB0), + ("clubtype_12ec50", 0x18012EC50), + ("clubstats_12f4f0", 0x18012F4F0), + ("consum_1308c0", 0x1801308C0), + ("clubsearch_12ddf0", 0x18012DDF0)): + s = dec(a, 600) + src[nm] = s + bodies.append("=" * 78) + bodies.append("### %s @ %#x len=%d" % (nm, a, len(s))) + bodies.append(s) + open(OUT + "v4_bodies.txt", "w").write("\n".join(bodies)) + print("WROTE v4_bodies.txt") + + for nm in ("clubtype_12ec50", "clubstats_12f4f0"): + s = src[nm] + cases = re.findall(r"case\s+(0x[0-9a-fA-F]+|\d+):", s) + eq = re.findall(r"==\s*(0x[0-9a-fA-F]+|\d+)", s) + ne = re.findall(r"!=\s*(0x[0-9a-fA-F]+|\d+)", s) + sub = re.findall(r"-\s*(0x[0-9a-fA-F]+|\d+)U?\s*<", s) + print("\n%s len=%d cases=%d %s\n ==%s !=%s ladders=%s" + % (nm, len(s), len(cases), cases, eq, ne, sub)) + + # ---- D: exhaustive instruction walk for [reg+0x54] + print("\n==== EVERY instruction in .text referencing [reg + 0x54] ====") + blk = [b for b in mem.getBlocks() if b.getName() == ".text"][0] + it = listing.getInstructions(blk.getStart(), True) + n = 0 + found = [] + while it.hasNext(): + ins = it.next() + if ins.getAddress().getOffset() > int(blk.getEnd().getOffset()): + break + n += 1 + t = str(ins) + if "+ 0x54]" in t: + f = fm.getFunctionContaining(ins.getAddress()) + found.append((int(ins.getAddress().getOffset()), t, + f.getName() if f else "?")) + print("instructions walked: %d ; hits: %d" % (n, len(found))) + byf = {} + for a, t, fn in found: + byf.setdefault(fn, []).append("%#x %s" % (a, t)) + for fn in sorted(byf): + print(" %-26s %s" % (fn, "; ".join(byf[fn]))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_1.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_1.py new file mode 100644 index 0000000..5ac5580 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_1.py @@ -0,0 +1,58 @@ +"""Q1 recon: the itemState enum table and the club?type= strings. + +HYPOTHESIS: the itemState enum table at 0x180229d20 (stride 0x10, 10 entries) is +referenced by (a) a string->enum mapper in the deserializer and (b) an equip path +that WRITES activeBadge/activeHomeKit/... The equip path is the place most likely +to switch on cardsubtypeid for cardtype 9. + +CONTROL: the table dump itself. The doc states the ten names; if the dump does not +reproduce WAITING_FOR_GAME, inGame, forSale, offered, activeBadge, activeHomeKit, +activeAwayKit, activeBall, activeStadium, active in that order, my table read is +wrong and every conclusion downstream is void. + +Also: locate the literals for club?type= singular names (stadium/ball/equippables) +and the family caption keys, with occurrence counts, so later queries can pick a +unique anchor. +""" +import traceback + +try: + print("=== A: itemState enum table 0x180229d20, stride 0x10, 14 entries ===") + T = 0x180229D20 + for i in range(14): + e = T + i * 0x10 + q0 = qword(e) + q1 = qword(e + 8) + s = "" + if 0x180000000 <= q0 < 0x181000000: + try: + s = rd_str(q0, 64) + except Exception: + s = "?" + print(" [%2d] %#x: q0=%#018x %-24r q1=%#x" % (i, e, q0, s, q1)) + + print() + print("=== B: xrefs to the table start and to each row ===") + for i in range(12): + e = T + i * 0x10 + xs = xrefs_to(e) + if xs: + print(" row %d @%#x:" % (i, e)) + for frm, typ, fn, ent in xs: + print(" from %#x %s in %s(%#x)" % (frm, typ, fn, ent)) + + print() + print("=== C: string literals of interest, all occurrences ===") + pats = [ + b"activeBadge", b"activeHomeKit", b"activeAwayKit", b"activeBall", + b"activeStadium", b"itemState", b"forSale", b"inGame", + b"equippables", b"stadium", b"Stadium", b"ball", b"Ball", + b"badge", b"Badge", b"kit", b"Kit", b"clubLogo", b"leagueLogo", + b"CLUBLOGO", b"LEAGUELOGO", b"BADGE", b"STADIUM", b"BALL", b"KIT", + ] + for p in pats: + hits = find_all(p) + print(" %-16r n=%d %s" % (p.decode(), len(hits), + " ".join("%#x" % h for h in hits[:12]))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_10.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_10.py new file mode 100644 index 0000000..e949bed --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_10.py @@ -0,0 +1,56 @@ +"""Q10: the fcc_ table vocabulary and the classifier's neighbourhood. + +Q8/Q9 changed the picture: inside the item deserializer, cardtype 9 items whose +cardsubtypeid is in [0x91,0x95) take a custom-image path, and a SEPARATE +deserializer FUN_180108c00 computes subtype = wireValue + 0x91 and then picks the +loc format by range: + 0x91 <= s < 0x95 -> "TOURNY_LOC_%d" + 0x95 <= s < 0x97 -> "SEASON_LOC_%d" +so 0x91..0x96 look like TROPHIES, not badges/kits/stadia/balls. Also, cardtype 7 +(subtypes 9,10,11) has an arm that defaults a field to 0x23 = 35, and 35 is the +kit cardassetid recorded in tools/fut_clubitems.py. + +This query gathers the vocabulary needed to test that: + A. every "fcc_" table name literal in the binary, with the function that queries + it -- the merge's per-family table map; + B. every literal starting "cardsubtype" / "cardtype" (column names); + C. the small helpers around the classifier: FUN_1800d84e0 (called right after it + in the deser), FUN_1800d7b30/b50/af0/b10, FUN_1800d7170. + +CONTROL: "fcc_discardcoins" must appear in A, and its query site must be +FUN_18013fe00 (line 784 of the Q8 decompile). If it does not, the literal scan is +not seeing the same code the decompiler is. +""" +import traceback + +try: + print("=== A: fcc_ table literals ===") + seen = set() + for h in find_all(b"fcc_"): + s = rd_str(h, 64) + if not s or s in seen: + continue + seen.add(s) + xs = xrefs_to(h) + who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs})) + print(" %#x %-28r <- %s" % (h, s, who or "-")) + print(" total distinct: %d" % len(seen)) + + print() + print("=== B: cardtype / cardsubtype column literals ===") + for pat in (b"cardtype", b"cardsubtype", b"carddbid", b"cardassetid"): + for h in find_all(pat): + s = rd_str(h, 64) + xs = xrefs_to(h) + who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs})) + print(" %#x %-28r <- %s" % (h, s, who or "-")) + + print() + print("=== C: helpers ===") + for a in (0x1800D84E0, 0x1800D7B30, 0x1800D7B50, 0x1800D7AF0, 0x1800D7B10): + src = dec(a) + print("-" * 70) + print("FUN_%x len=%d" % (a, len(src))) + print(src if len(src) < 2500 else src[:2500] + "\n...[TRUNCATED, len above]") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_11.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_11.py new file mode 100644 index 0000000..ca93848 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_11.py @@ -0,0 +1,37 @@ +"""Q11: dump the candidate functions to files for local analysis. + +Rationale: the interesting functions are 3k-27k chars each and printing them all to +the transcript is wasteful. Write each decompile to +/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec/FUN_.c and print only name+length here. + +Set chosen from Q10: + FUN_180098f20 queries fcc_leaguelogos AND uses carddbid + cardassetid + FUN_180098560 / FUN_1800989f0 / FUN_180042440 / FUN_180043350 fcc_myclubscategories + FUN_1800991a0 fcc_myclubs + FUN_180141660 the merge (carddbid) + FUN_18011a860 / FUN_1801356c0 / FUN_1801362e0 other carddbid users + FUN_18013fe00 the shared item deserializer (full, for local grep) + FUN_18011e9d0 the <0x95 callback from Q9 + FUN_18013af30 the remaining scan hit + +CONTROL: FUN_18013fe00 must come out at 26234 chars, the length Q8 measured. A +different length means a different function or a different decompiler setting. +""" +import os +import traceback + +OUT = ("/tmp/claude-1000/-home-alex-Documents-OpenFUT/" + "8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec") + +try: + os.makedirs(OUT, exist_ok=True) + for a in (0x180098F20, 0x180098560, 0x1800989F0, 0x180042440, 0x180043350, + 0x1800991A0, 0x180141660, 0x18011A860, 0x1801356C0, 0x1801362E0, + 0x18013FE00, 0x18011E9D0, 0x18013AF30, 0x180096670, 0x1801017E0): + src = dec(a) + p = os.path.join(OUT, "FUN_%x.c" % a) + with open(p, "w") as f: + f.write(src) + print(" %-14s len=%d -> %s" % ("FUN_%x" % a, len(src), p)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_12.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_12.py new file mode 100644 index 0000000..b678f16 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_12.py @@ -0,0 +1,76 @@ +"""Q12: the UI group tables around 0x180203260 and every family caption key. + +Known: consumables group table at 0x180203260 (7 rows, stride 0x18, indexed by the +switch in FUN_180096670 case 0xb) and staff at 0x180203310 (5 rows, case 8). The +club-item claim "there is no equivalent table" is exactly the kind of absence this +project keeps getting wrong, so walk the WHOLE region 0x180203100..0x180203700 as +stride-0x18 triples and print anything string-shaped, then xref each candidate +table start. + +Also print every .rdata literal containing BADGE / STADIUM / BALL / KIT / LOGO / +TROPHY (upper case, i.e. loc keys) with its xrefs. Q4 of the brief. + +CONTROL: the consumables table at 0x180203260 must come out as the seven rows +already recorded (TRAINING/CONTRACT/FITNESS/HEALING/PLAYSTYLE/MANAGER_LEAGUE/ +TACTIC_TRAINING with codes 0,1,4,3,0x17,0x18,0x11). If the walk does not reproduce +it, the stride/layout assumption is wrong and nothing else in this query counts. +""" +import traceback + + +def walk(lo, hi, stride): + a = lo + while a < hi: + cells = [] + for k in range(0, stride, 8): + try: + q = qword(a + k) + except Exception: + q = 0 + s = "" + if 0x180000000 <= q < 0x181000000: + try: + t = rd_str(q, 80) + if t and all(0x20 <= ord(c) < 0x7F for c in t): + s = t + except Exception: + pass + cells.append("%#x%s" % (q, (" %r" % s) if s else "")) + print(" %#x %s" % (a, " | ".join(cells))) + a += stride + + +try: + print("=== stride-0x18 walk 0x180203200..0x180203460 ===") + walk(0x180203200, 0x180203460, 0x18) + print() + print("=== xrefs to plausible table starts ===") + for a in range(0x180203200, 0x180203460, 8): + xs = xrefs_to(a) + if xs: + print(" %#x:" % a) + for frm, typ, fn, ent in xs: + print(" %#x %s in %s(%#x)" % (frm, typ, fn, ent)) + print() + print("=== upper-case family loc keys ===") + seen = set() + for pat in (b"BADGE", b"STADIUM", b"BALL", b"KIT", b"LOGO", b"TROPHY"): + for h in find_all(pat): + # walk back to the start of the C string + p = h + for _ in range(80): + try: + if mem.getByte(addr(p - 1)) & 0xFF == 0: + break + except Exception: + break + p -= 1 + s = rd_str(p, 120) + if p in seen or len(s) < 4: + continue + seen.add(p) + xs = xrefs_to(p) + who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs})) + print(" %#x %-52r <- %s" % (p, s, who or "-")) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_13.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_13.py new file mode 100644 index 0000000..4a11e5d --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_13.py @@ -0,0 +1,55 @@ +"""Q13: every FUT_MYCLUB_ loc key, and the table row that carries it. + +Q12 reproduced the consumables table (control passed) and showed the staff table's +middle column IS the cardtype (manager 2, headcoach 3, fitnesscoach 4, gkcoach 0xa, +physio 5 -- exactly the merge's switch arms), and a trophies pair: + 0x180203380 {0x05, 0, FUT_MYCLUB_OFFLINE_TROPHIES_EARNED} + 0x180203398 {0x15, 1, FUT_MYCLUB_ONLINE_TROPHIES_EARNED} + +If a badges/kits/stadia/balls row exists in the same shape, its middle column is the +answer. Enumerate EVERY FUT_MYCLUB_ literal, find the pointer to it in .rdata/.data, +and print the 0x18-byte row it sits in for all three possible cell positions, plus +the rows either side. + +CONTROL: FUT_MYCLUB_CONSUMABLES_TRAINING_EARNED must resolve to the row +{0, ptr, 'training'} at 0x180203260. Any layout guess that cannot reproduce that row +is wrong. +""" +import traceback + +try: + keys = [] + for h in find_all(b"FUT_MYCLUB_"): + s = rd_str(h, 120) + keys.append((h, s)) + keys.sort() + print("=== %d FUT_MYCLUB_ literals ===" % len(keys)) + for h, s in keys: + print(" %#x %r" % (h, s)) + print() + print("=== pointer rows ===") + for h, s in keys: + ptrs = find_all(h.to_bytes(8, "little"), blocks=(".rdata", ".data")) + if not ptrs: + print(" %-46r no pointer" % s) + continue + for pa in ptrs: + ctx = [] + for off in (-0x18, -0x10, -8, 0, 8, 0x10, 0x18): + try: + q = qword(pa + off) + except Exception: + continue + t = "" + if 0x180000000 <= q < 0x181000000: + try: + u = rd_str(q, 80) + if u and all(0x20 <= ord(c) < 0x7F for c in u): + t = u + except Exception: + pass + ctx.append("%+#5x:%#x%s" % (off, q, (" %r" % t) if t else "")) + print(" %-46r @%#x" % (s, pa)) + print(" " + " ".join(ctx)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_14.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_14.py new file mode 100644 index 0000000..a458ca2 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_14.py @@ -0,0 +1,41 @@ +"""Q14: the club URL format strings and their builders. + +Q6 found 'type=%s' at 0x180224c7a and 0x180224ff9 with no direct xref, which means +each is the TAIL of a longer literal whose start is what the code references. Dump +every C string in 0x180224a00..0x180225300 and 0x18021e200..0x18021e800 with xrefs, +so the club request builder can be identified and decompiled. + +Also dump 0x180228400..0x18022b200 for the transfermarket/club parameter strings. + +CONTROL: '&cat=%s' at 0x1802285b8 is already known to be referenced by +FUN_180162c90; it must show that xref here too. +""" +import traceback + + +def dump(lo, hi, tag): + print("=== %s %#x..%#x ===" % (tag, lo, hi)) + p = lo + while p < hi: + try: + b = mem.getByte(addr(p)) & 0xFF + except Exception: + p += 1 + continue + if 0x20 <= b < 0x7F: + s = rd_str(p, 160) + if len(s) >= 3: + who = ",".join(sorted({"%s(%#x)" % (fn, ent) + for _f, _t, fn, ent in xrefs_to(p)})) + print(" %#x %-66r %s" % (p, s, who)) + p += max(1, len(s)) + 1 + else: + p += 1 + + +try: + dump(0x180224A00, 0x180225300, "club/url block") + dump(0x18021E200, 0x18021E800, "route table") + dump(0x180228400, 0x180229000, "params block") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_15.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_15.py new file mode 100644 index 0000000..75f25ce --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_15.py @@ -0,0 +1,67 @@ +"""Q15: find the equip path by the atoms it must name. + +The itemState vocabulary is also in the atom table: + 0xc activeAwayKit 0xd activeBadge 0xe activeBall 0x10 activeHomeKit + 0x12 activeStadium 0xa active 0x12e free 0x164 inGame 0x1e5 offered +and the club ?type= taxonomy switch FUN_18012ec50 shows how a name reaches the wire: +FUN_180180cd0(atom) returns the atom's name string. So whatever chooses which of the +five active* states to send must call FUN_180180cd0 with 0xc/0xd/0xe/0x10/0x12, and +the choice is made from the item's family. That is the mapping the brief wants. + +Method: enumerate every caller of FUN_180180cd0, decompile each once, and report the +call sites whose literal argument is one of the atoms of interest: + equip states 0xc 0xd 0xe 0x10 0x12 + club families 0x49 badge, 0x179 kit, 0x2d8 stadium, 0x4d ball, + 0x18d leaguelogos, 0x10a equippables, 0x4b badges, 0x4f balls, + 0x17c kits, 0x18e leagueLogos, 0x2d7 stadia +Print the matching lines with context so the surrounding switch is visible. + +CONTROL: FUN_18012ec50 is a known caller and must be reported with its family atoms +(0x49, 0x179, 0x2d8, 0x4d, 0x18d, 0x10a). If it is not in the output, the caller +enumeration or the literal matching is broken. +""" +import re +import traceback + +WANT = {0xC: "activeAwayKit", 0xD: "activeBadge", 0xE: "activeBall", + 0x10: "activeHomeKit", 0x12: "activeStadium", 0xA: "active", + 0x12E: "free", 0x164: "inGame", 0x1E5: "offered", + 0x49: "badge", 0x179: "kit", 0x2D8: "stadium", 0x4D: "ball", + 0x18D: "leaguelogos", 0x10A: "equippables", 0x4B: "badges", + 0x4F: "balls", 0x17C: "kits", 0x18E: "leagueLogos", 0x2D7: "stadia"} + +try: + ents = {} + for frm, typ, fn, ent in xrefs_to(0x180180CD0): + if ent: + ents[ent] = fn + print("callers of FUN_180180cd0: %d" % len(ents)) + pat = re.compile(r"FUN_180180cd0\((0x[0-9a-f]+|\d+)\)") + nhit = 0 + for ent, fn in sorted(ents.items()): + src = dec(ent) + lines = src.splitlines() + found = [] + for i, l in enumerate(lines): + for m in pat.finditer(l): + v = int(m.group(1), 0) + if v in WANT: + found.append((i, v)) + if not found: + continue + nhit += 1 + print("=" * 70) + print("%s @%#x len=%d atoms=%s" % + (fn, ent, len(src), + sorted({"%#x=%s" % (v, WANT[v]) for _i, v in found}))) + shown = set() + for i, _v in found: + for j in range(max(0, i - 4), min(len(lines), i + 2)): + if j in shown: + continue + shown.add(j) + print(" %4d: %s" % (j, lines[j].strip())) + print(" ---") + print("functions with hits: %d" % nhit) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_16.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_16.py new file mode 100644 index 0000000..09776df --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_16.py @@ -0,0 +1,35 @@ +"""Q16: the equip path -- callers of the itemState serializer. + +Q15 found FUN_18012ee20: itemState code -> atom name, with + 1->free 2->inGame 5->0x1f8 6->offered 100->activeBadge 0x65->activeHomeKit + 0x66->activeAwayKit 0x67->activeBall 0x68->activeStadium 0xff->active +Whoever CALLS it with 0x64..0x68 is the equip path, and the code that picks which of +those five to pass must know the item's family. + +Dump: every caller of FUN_18012ee20 in full, plus FUN_18012ddf0 (the club URL +builder) in full, plus FUN_18012ec50's caller chain context. + +CONTROL: FUN_18012ddf0 must contain the five-way if/else on *(param_1+0x30) that +Q15 printed (0xa badge, 0xb kit, 0x15 stadium, 0x16 ball, else equippables). If the +full decompile lacks it, this is not the same function. +""" +import traceback + +try: + ents = {} + for frm, typ, fn, ent in xrefs_to(0x18012EE20): + if ent: + ents[ent] = fn + print("callers of FUN_18012ee20 (itemState->atom): %d -> %s" % + (len(ents), ["%s(%#x)" % (v, k) for k, v in ents.items()])) + for ent in sorted(ents): + src = dec(ent) + print("=" * 78) + print("CALLER %s @%#x len=%d" % (ents[ent], ent, len(src))) + print(src) + print("=" * 78) + src = dec(0x18012DDF0) + print("CLUB URL BUILDER FUN_18012ddf0 len=%d" % len(src)) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_17.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_17.py new file mode 100644 index 0000000..60917c1 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_17.py @@ -0,0 +1,58 @@ +"""Q17: enumerate EVERY switch case label in the binary, then find the ones that +distinguish club subtypes. + +Q5's scalar scan failed its control because jump-table case labels are not +instruction immediates. Ghidra, however, names them: it creates symbols of the form +switchD__caseD_ (and caseD_) at each case target. Walking the symbol +table therefore enumerates switch dispatch in the one form a scalar scan cannot see. + +Report every function whose case-value set intersects the club-subtype candidates +{0x1e,0x1f,9,10,11,0x91..0x96} and print the full case set for each. + +CONTROL: FUN_1800d8330 must appear with case labels including 0x1e, 0x1f, 0x91..0x96, +0xe7..0xe9 and 0xec. If it does not, the symbol-based enumeration is broken and no +absence claim may be made from it. +""" +import re +import traceback + +try: + st = prog.getSymbolTable() + it = st.getAllSymbols(True) + pat = re.compile(r"caseD_([0-9a-fA-F]+)$") + per = {} + n = 0 + while it.hasNext(): + s = it.next() + m = pat.search(s.getName()) + if not m: + continue + n += 1 + try: + v = int(m.group(1), 16) + except ValueError: + continue + f = fm.getFunctionContaining(s.getAddress()) + key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0) + per.setdefault(key, set()).add(v) + print("case labels found: %d in %d functions" % (n, len(per))) + + CAND = {0x1E, 0x1F, 9, 10, 11, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96} + print() + print("=== functions whose case set meets the club-subtype candidates ===") + rows = [] + for (name, ent), vals in per.items(): + inter = vals & CAND + if len(inter) >= 2: + rows.append((len(inter), name, ent, vals)) + rows.sort(reverse=True) + for k, name, ent, vals in rows: + print(" %-26s %#x hits=%d cases=%s" % + (name, ent, k, sorted("%#x" % v for v in vals))) + print() + print("=== control: FUN_1800d8330 ===") + for (name, ent), vals in per.items(): + if ent == 0x1800D8330: + print(" YES cases=%s" % sorted("%#x" % v for v in vals)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_18.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_18.py new file mode 100644 index 0000000..26295ab --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_18.py @@ -0,0 +1,33 @@ +"""Q18: FUN_1800fed90 -- a switch whose case set is EXACTLY {0x91..0x96}. + +Q17's case-label enumeration (control passed on FUN_1800d8330) found exactly one +function whose switch discriminates the six high club subtypes and nothing else: +FUN_1800fed90. If 0x91..0x96 are trophies, this is where each one is turned into a +concrete thing, and the six arms should be distinguishable. + +Also decompile FUN_1800f4bc0 and FUN_1800f2f70 (case sets 0xa..0x14, i.e. they +distinguish 10 and 11, the other two cardtype-7 subtypes) and FUN_1800d8260 / +FUN_1800d86c0 / FUN_1800d8b50 (small enum->string helpers next to the classifier). + +CONTROL: FUN_1800d8b50 is called by the club URL builder FUN_18012ddf0 to render a +value for query key atom 0x243; it should decompile to a code->string table, which +is a known shape. If it comes out as something else, my reading of the URL builder +is wrong. +""" +import traceback + +try: + for a in (0x1800FED90, 0x1800F4BC0, 0x1800F2F70, 0x1800D8260, 0x1800D86C0, + 0x1800D8B50): + src = dec(a) + print("=" * 78) + print("FUN_%x len=%d" % (a, len(src))) + print(src if len(src) < 9000 else src[:9000] + "\n...[cut at 9000, len above]") + print("=" * 78) + print("=== callers ===") + for a in (0x1800FED90, 0x1800F4BC0): + print(" callers of %#x:" % a) + for frm, typ, fn, ent in xrefs_to(a): + print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_19.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_19.py new file mode 100644 index 0000000..35b4487 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_19.py @@ -0,0 +1,89 @@ +"""Q19: every constant the code compares a +0x50 (cardsubtypeid) or +0x4c (cardtype) +field against. + +The parsed item record has cardsubtypeid at +0x50 and cardtype at +0x4c. Instead of +searching for a constant (which misses jump tables) or for a syntactic form (which +misses != and ladders), search for the FIELD ACCESS and then collect every immediate +that touches the loaded register within the next 8 instructions, whatever the +mnemonic. Both the direct form (CMP dword [reg+0x50], imm) and the load-then-test +form (MOV r32,[reg+0x50]; SUB r32,imm; CMP r32,imm) are covered. + +CONTROL: FUN_18011e3c0 is known to do `*(int *)(x + 0x50) - 0x91U < 6`, so it must +appear with 0x91 (and 6) attached to a +0x50 access. If the control is absent the +scan is broken and nothing may be concluded from what it does not find. +""" +import traceback + +try: + block = None + for b in mem.getBlocks(): + if b.getName() == ".text": + block = b + break + per = {} + it = listing.getInstructions(block.getStart(), True) + window = [] # [(reg_name, remaining_instrs)] + n = 0 + while it.hasNext(): + ins = it.next() + n += 1 + txt = str(ins) + # 1) direct: memory operand with disp 0x50/0x4c and an immediate + for disp in ("0x50", "0x4c"): + if ("+ " + disp + "]") in txt or ("+" + disp + "]") in txt: + imms = [] + for i in range(ins.getNumOperands()): + for o in ins.getOpObjects(i): + try: + imms.append(int(o.getValue())) + except Exception: + pass + f = fm.getFunctionContaining(ins.getAddress()) + key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0) + rec = per.setdefault(key, {"direct": set(), "near": set()}) + for v in imms: + if v not in (0x50, 0x4C) and 0 <= v < 0x1000: + rec["direct"].add((disp, v)) + # start a window: whatever register this instruction defines + for r in ins.getResultObjects(): + window.append([str(r), 8, key, disp]) + # 2) decay window and attach immediates that touch the tracked register + nxt = [] + for w in window: + reg, left, key, disp = w + if left <= 0: + continue + if reg in txt: + for i in range(ins.getNumOperands()): + for o in ins.getOpObjects(i): + try: + v = int(o.getValue()) + except Exception: + continue + if 0 <= v < 0x1000: + per.setdefault(key, {"direct": set(), "near": set()}) + per[key]["near"].add((disp, v)) + w[1] = left - 1 + nxt.append(w) + window = nxt[-40:] + + print("instructions scanned: %d" % n) + print() + CAND = {9, 10, 11, 0x1E, 0x1F, 7, 0x91} + print("=== functions whose +0x50 / +0x4c constants meet {9,10,11,0x1e,0x1f,7,0x91} ===") + for (name, ent), rec in sorted(per.items()): + vals = rec["direct"] | rec["near"] + hit = {v for _d, v in vals} & CAND + if not hit: + continue + print(" %-24s %#x hits=%s" % (name, ent, sorted("%#x" % h for h in hit))) + print(" direct=%s" % sorted("%s:%#x" % (d, v) for d, v in rec["direct"])) + print(" near =%s" % sorted("%s:%#x" % (d, v) for d, v in rec["near"])[:40]) + print() + print("=== control FUN_18011e3c0 ===") + for (name, ent), rec in per.items(): + if ent == 0x18011E3C0: + print(" direct=%s" % sorted("%s:%#x" % (d, v) for d, v in rec["direct"])) + print(" near =%s" % sorted("%s:%#x" % (d, v) for d, v in rec["near"])) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_2.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_2.py new file mode 100644 index 0000000..7bcb0f3 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_2.py @@ -0,0 +1,85 @@ +"""Q2: two string clusters that look like family-name tables. + +Q1 found 'badge' 0x18022a220, 'kit' 0x18022a228, 'leagueLogo' 0x18022a230, +'ball' 0x18022a278 packed together, and a second cluster 'badge' 0x1802303c8, +'ball' 0x1802303dc, 'equippables' 0x180230f48, 'leagueLogo' 0x180231608. + +HYPOTHESIS: cluster 1 is the value list of a {name -> code} enum table like the +itemState one (stride 0x10: char* then int). Cluster 2 is the club?type= route +vocabulary. + +CONTROL: the itemState table itself. My Q1 read started mid-table (row0 = +activeBadge with code 0x64, while the doc's list starts at WAITING_FOR_GAME), so +this query re-walks BACKWARDS from 0x180229d20 to find the real table start and +prints it in full. If the ten documented names do not appear in order, my table +walker is wrong. + +Then: for every string in each cluster, find the .rdata qword that points at it +(the table row) and print the row's neighbours, plus xrefs. +""" +import traceback + + +def dump_strings(lo, hi, label): + print("=== strings %s %#x..%#x ===" % (label, lo, hi)) + p = lo + while p < hi: + try: + b = mem.getByte(addr(p)) & 0xFF + except Exception: + p += 1 + continue + if 0x20 <= b < 0x7F: + s = rd_str(p, 96) + if len(s) >= 2: + print(" %#x %r" % (p, s)) + p += max(1, len(s)) + 1 + else: + p += 1 + + +def walk_table(start, n, back=0): + print("--- table walk from %#x, %d rows (stride 0x10) ---" % (start, n)) + for i in range(-back, n): + e = start + i * 0x10 + try: + q0, q1 = qword(e), qword(e + 8) + except Exception: + continue + s = "" + if 0x180000000 <= q0 < 0x181000000: + try: + s = rd_str(q0, 64) + except Exception: + s = "?" + print(" [%3d] %#x ptr=%#x %-26r val=%#x" % (i, e, q0, s, q1)) + + +try: + walk_table(0x180229D20, 8, back=14) + print() + dump_strings(0x18022A200, 0x18022A380, "cluster1") + print() + dump_strings(0x180230300, 0x180230420, "cluster2a") + print() + dump_strings(0x180230F00, 0x180230FA0, "cluster2b") + print() + dump_strings(0x180231380, 0x180231680, "cluster2c") + print() + print("=== xrefs / pointer-rows for cluster strings ===") + for name, a in [("badge", 0x18022A220), ("kit", 0x18022A228), + ("leagueLogo", 0x18022A230), ("ball", 0x18022A278), + ("badge2", 0x1802303C8), ("ball2", 0x1802303DC), + ("equippables", 0x180230F48), ("leagueLogo2", 0x180231608), + ("itemState", 0x180231490)]: + print(" %s @%#x" % (name, a)) + for frm, typ, fn, ent in xrefs_to(a): + print(" xref from %#x %s in %s(%#x)" % (frm, typ, fn, ent)) + ptrs = find_all(a.to_bytes(8, "little"), blocks=(".rdata", ".data")) + for pa in ptrs[:8]: + print(" ptr-row @%#x next-q=%#x prev-q=%#x" % + (pa, qword(pa + 8), qword(pa - 8))) + for frm, typ, fn, ent in xrefs_to(pa): + print(" row xref %#x %s in %s(%#x)" % (frm, typ, fn, ent)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_20.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_20.py new file mode 100644 index 0000000..7d7fad8 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_20.py @@ -0,0 +1,26 @@ +"""Q20: the functions that test cardtype==7 / cardsubtypeid in {9,10,11}. + +Q19 (control passed: it recovered FUN_18011e3c0's 0x91/0x94/0x96 on a +0x50 field) +flagged: + FUN_1800f6c40 direct [+0x4c]==7 AND [+0x50]==9 + FUN_180084720 direct [+0x50]==9 and [+0x50]==0xb + FUN_180094580 near [+0x50] 9 / 0xb / 3 + FUN_18015fa80 direct [+0x50]==9 + FUN_1801362e0 direct [+0x4c] 1 / 2 / 7 +Decompile each. Whatever these do with subtypes 9/10/11 is the club-family +behaviour, and a loc key or asset id in any arm names the family. + +CONTROL: FUN_1801362e0 is one of the merge's arms (called from FUN_180141660 case 2, +the manager arm) so it must be a DB lookup on carddbid; if it is not, the +0x4c +attribution is on a different struct and these hits are noise. +""" +import traceback + +try: + for a in (0x1800F6C40, 0x180084720, 0x180094580, 0x18015FA80, 0x1801362E0): + src = dec(a) + print("=" * 78) + print("FUN_%x len=%d" % (a, len(src))) + print(src if len(src) < 12000 else src[:12000] + "\n...[cut, len above]") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_21.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_21.py new file mode 100644 index 0000000..a0329ec --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_21.py @@ -0,0 +1,47 @@ +"""Q21: the FUT data-manager vtable slots that name a club item. + +FUN_1800f6c40 (the pack/award tile builder) does: + if (item+0x4c == 1) -> ITEM_RARITY / ITEM_LEVEL + else if (item+0x50 == 9) -> "IS_KIT_%d" = 1 <-- names subtype 9 + name = mgr->vt[0x490](out, item+0x4c cardtype, item+0x50 subtype, item+0x18) + if (name empty && item+0x4c == 7) + name = mgr->vt[0x498](out, item+0x50 subtype, item+0x94 teamid, item+0x20) +where mgr = FUN_18011a830(). Slots 0x490 and 0x498 are therefore the club-item name +resolvers and must switch on the subtype. + +Resolve the manager's vtable, then decompile slots 0x490, 0x498, 0xa08, 0xa38, 0xa40. + +CONTROL: slot 0xa08 is the one the item deserializer calls to file a parsed item +(FUN_18013fe00 line 825), and slot 0xa40 is the lookup FUN_18011e3c0 uses with a +resourceId. If the resolved vtable's 0xa08/0xa40 are not functions, the vtable +resolution is wrong. +""" +import traceback + +try: + src = dec(0x18011A830) + print("=== FUN_18011a830 (manager accessor) len=%d ===" % len(src)) + print(src) + # find the vtable it installs / the object's class + print() + print("=== candidate vtables referenced from FUN_18011a830 and its callees ===") + f = func(0x18011A830) + cands = set() + for ad in f.getBody().getAddresses(True): + ins = listing.getInstructionAt(ad) + if ins is None: + continue + for r in ins.getReferencesFrom(): + t = int(r.getToAddress().getOffset()) + if 0x1801E5000 <= t <= 0x1802891FF: + cands.add(t) + for t in sorted(cands): + try: + v0, v1 = qword(t), qword(t + 8) + except Exception: + continue + print(" %#x -> %#x %#x (%s / %s)" % + (t, v0, v1, fname(v0) if 0x180000000 <= v0 < 0x181000000 else "-", + fname(v1) if 0x180000000 <= v1 < 0x181000000 else "-")) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_22.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_22.py new file mode 100644 index 0000000..fbc1302 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_22.py @@ -0,0 +1,31 @@ +"""Q22: resolve the manager object's vtable through the global DAT_1802e6398. + +FUN_18011a830 just returns DAT_1802e6398, so the vtable is installed wherever that +global is written. Find the writers, decompile the smallest, and read the vtable it +stores. Then dump slots 0x490 / 0x498 / 0xa08 / 0xa38 / 0xa40. + +CONTROL: the recovered vtable's slot 0xa08 and 0xa40 must both be real functions +(the item deserializer calls 0xa08 to file an item; FUN_18011e3c0 calls 0xa40 with a +resourceId). If either is not a function, the vtable is wrong. +""" +import traceback + +try: + print("=== writers/readers of DAT_1802e6398 ===") + ents = {} + for frm, typ, fn, ent in xrefs_to(0x1802E6398): + ents.setdefault(ent, []).append((frm, typ, fn)) + for ent, lst in sorted(ents.items()): + print(" %s(%#x) n=%d types=%s" % + (lst[0][2], ent, len(lst), sorted({t for _f, t, _n in lst}))) + # the constructor is a function that WRITES it + writers = [e for e, lst in ents.items() + if any(t == "WRITE" for _f, t, _n in lst)] + print("writers: %s" % ["%#x" % w for w in writers]) + for w in writers: + src = dec(w) + print("=" * 70) + print("writer FUN_%x len=%d" % (w, len(src))) + print(src if len(src) < 6000 else src[:6000] + "\n...[cut]") +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_23.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_23.py new file mode 100644 index 0000000..204fb54 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_23.py @@ -0,0 +1,40 @@ +"""Q23: callers of the manager setter FUN_18011d780 -> the manager's vtable. + +CONTROL: the vtable found must have real functions at slots 0xa08 and 0xa40. +""" +import traceback + +try: + for frm, typ, fn, ent in xrefs_to(0x18011D780): + print("caller %s(%#x) via %#x %s" % (fn, ent, frm, typ)) + ents = {ent for _f, _t, _n, ent in xrefs_to(0x18011D780) if ent} + for ent in ents: + src = dec(ent) + print("=" * 70) + print("FUN_%x len=%d" % (ent, len(src))) + print(src if len(src) < 7000 else src[:7000] + "\n...[cut]") + f = func(ent) + cands = set() + for ad in f.getBody().getAddresses(True): + ins = listing.getInstructionAt(ad) + if ins is None: + continue + for r in ins.getReferencesFrom(): + t = int(r.getToAddress().getOffset()) + if 0x1801E5000 <= t <= 0x1802891FF: + cands.add(t) + print("--- .rdata refs, checked for vtable shape ---") + for t in sorted(cands): + try: + v0, v1 = qword(t), qword(t + 8) + s90, s98 = qword(t + 0x490), qword(t + 0x498) + a08, a40 = qword(t + 0xA08), qword(t + 0xA40) + except Exception: + continue + ok = all(fm.getFunctionAt(addr(x)) is not None + for x in (v0, v1) if 0x180000000 <= x < 0x181000000) + print(" %#x v0=%s v1=%s | +0x490=%s +0x498=%s +0xa08=%s +0xa40=%s%s" % + (t, fname(v0), fname(v1), fname(s90), fname(s98), + fname(a08), fname(a40), " <== VTABLE?" if ok else "")) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_24.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_24.py new file mode 100644 index 0000000..ce20914 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_24.py @@ -0,0 +1,31 @@ +"""Q24: FUN_180119bd0 -- the cardtype-7 name resolver. This should BE the mapping. + +The manager vtable was read out of the live process (read-only): DAT_1802e6398 -> +object -> vtable static 0x18021c2a0, with + +0x490 = FUN_18011a860 (cardtype switch 1,2,3,4,5,10 -- no club arm) + +0x498 = FUN_180119bd0 (called ONLY when +0x490 returned empty AND cardtype==7, + with args (cardsubtypeid, teamid, assetId)) + +0xa08 = FUN_18011cca0 (file a parsed item) + +0xa38 = FUN_180113e40 (register trophy: (tournamentId, subtype, name)) + +0xa40 = FUN_18011bf40 (lookup by resourceId) + +Decompile 0x498, 0xa38, 0xa40 and 0xa08. + +CONTROL: FUN_18011a860 must be the same function Q11 dumped (12905 chars) with the +cardtype switch; that is what makes the 0x498 fallback meaningful. +""" +import traceback + +try: + for a, tag in [(0x180119BD0, "+0x498 club-item name resolver"), + (0x180113E40, "+0xa38 trophy register"), + (0x18011BF40, "+0xa40 lookup by resourceId"), + (0x18011CCA0, "+0xa08 file parsed item")]: + src = dec(a) + print("=" * 78) + print("%s FUN_%x len=%d" % (tag, a, len(src))) + print(src if len(src) < 14000 else src[:14000] + "\n...[cut, len above]") + print("=" * 78) + print("control: FUN_18011a860 len=%d" % len(dec(0x18011A860))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_25.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_25.py new file mode 100644 index 0000000..e25e550 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_25.py @@ -0,0 +1,53 @@ +"""Q25: is there a cardtype-9 sibling of FUN_180119bd0 for balls and league logos? + +FUN_180119bd0 settles cardtype 7: 9 -> "FUT_UC_KITS"+TeamName_Abbr15_ + 10 -> "Stadium"+StadiumName_ + 11 -> "Badge"+TeamName_Abbr15_ +That leaves 0x1e and 0x1f (the only other cardtype-9 subtypes besides trophies +0x91..0x96 and misc 0xe7..0xec) for ball and league logo. + +Dump the loc-key string neighbourhood the resolver draws from (0x1801ec700.. +0x1801ed400 holds 'Stadium'/'Ball' literals) with xrefs, and xref the exact literals +"Stadium", "Badge", "FUT_UC_KITS" to find any sibling resolver. A function that +references a ball or league-logo caption is the cardtype-9 equivalent. + +CONTROL: the literals "Stadium" and "Badge" must show FUN_180119bd0 as an xref. If +they do not, I am looking at different copies of those strings. +""" +import traceback + +try: + print("=== atoms 0xd1 and 0x19c (the two club-item wire strings) ===") + for a in (0xD1, 0x19C): + print(" %#x = %d" % (a, a)) + + print() + print("=== string dump 0x1801ec700..0x1801ed400 ===") + p = 0x1801EC700 + while p < 0x1801ED400: + try: + b = mem.getByte(addr(p)) & 0xFF + except Exception: + p += 1 + continue + if 0x20 <= b < 0x7F: + s = rd_str(p, 120) + if len(s) >= 3: + who = ",".join(sorted({"%s(%#x)" % (fn, ent) + for _f, _t, fn, ent in xrefs_to(p)})) + print(" %#x %-46r %s" % (p, s, who)) + p += max(1, len(s)) + 1 + else: + p += 1 + + print() + print("=== exact-literal xrefs ===") + for lit in (b"Stadium\x00", b"Badge\x00", b"FUT_UC_KITS\x00", b"Ball\x00", + b"BallName", b"LeagueLogo", b"leaguelogo", b"FUT_UC_"): + for h in find_all(lit): + s = rd_str(h, 80) + who = ",".join(sorted({"%s(%#x)" % (fn, ent) + for _f, _t, fn, ent in xrefs_to(h)})) + print(" %-14r %#x %-30r <- %s" % (lit.rstrip(b"\x00").decode(), h, s, who or "-")) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_26.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_26.py new file mode 100644 index 0000000..9c26ed0 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_26.py @@ -0,0 +1,39 @@ +"""Q26: FUN_1801bfac0 -- the one function that names ALL the club families. + +It references 'Stadium', 'Badge', 'FUT_UC_KITS' and 'FUT_UC_BALL' (and the GK +attribute captions), and Q10 showed it queries fcc_matches. If it switches on +cardsubtypeid it will name the ball subtype, which FUN_180119bd0 (cardtype 7 only) +cannot. + +Also dump the string cluster 0x180239000..0x180239180 which holds FUT_UC_BALL, +'Stadium' and 'badge' close together, with xrefs. + +CONTROL: FUN_180119bd0 must appear as an xref of 'Stadium' 0x18021ce80 and 'Badge' +0x1802041b8 -- it did in Q25, so the literal identification is sound. +""" +import traceback + +try: + src = dec(0x1801BFAC0) + print("=== FUN_1801bfac0 len=%d ===" % len(src)) + print(src if len(src) < 20000 else src[:20000] + "\n...[cut, len above]") + print() + print("=== strings 0x180238f80..0x180239200 ===") + p = 0x180238F80 + while p < 0x180239200: + try: + b = mem.getByte(addr(p)) & 0xFF + except Exception: + p += 1 + continue + if 0x20 <= b < 0x7F: + s = rd_str(p, 120) + if len(s) >= 3: + who = ",".join(sorted({"%s(%#x)" % (fn, ent) + for _f, _t, fn, ent in xrefs_to(p)})) + print(" %#x %-40r %s" % (p, s, who)) + p += max(1, len(s)) + 1 + else: + p += 1 +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_27.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_27.py new file mode 100644 index 0000000..4bfc566 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_27.py @@ -0,0 +1,28 @@ +"""Q27: dump FUN_1801bfac0 in FULL to disk (it is >20k chars and was cut in Q26). + +It is the card-detail builder and the only function referencing FUT_UC_KITS, +'Stadium', 'Badge' AND FUT_UC_BALL, so its club arms should name every family +including the ball subtype that FUN_180119bd0 (cardtype 7 only) cannot reach. + +Also dump FUN_180094580 (references FUT_UC_KITS, and Q19 flagged it testing +item+0x50 against 9 and 0xb) and FUN_180099490 ('Badge'). + +No truncation: written to files, lengths printed here. +""" +import os +import traceback + +OUT = ("/tmp/claude-1000/-home-alex-Documents-OpenFUT/" + "8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec") + +try: + os.makedirs(OUT, exist_ok=True) + for a in (0x1801BFAC0, 0x180094580, 0x180099490, 0x18015FA80, 0x180102790, + 0x1800F6C40, 0x180084720): + src = dec(a) + p = os.path.join(OUT, "FUN_%x.c" % a) + with open(p, "w") as f: + f.write(src) + print(" FUN_%x len=%d" % (a, len(src))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_28.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_28.py new file mode 100644 index 0000000..5fb445c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_28.py @@ -0,0 +1,29 @@ +"""Q28: verify the accessor that FUN_1801bfac0 switches on IS cardsubtypeid, and +read the deserializer arms for the club-item string fields. + +FUN_1801bfac0 does `iVar5 = FUN_1801a8640(local_78)` and then + iVar5 == 9 -> FUT_UC_KITS (+ FUT_ThirdKit / KitBioAwayDescription variants) + iVar5 == 10 -> "Stadium" + StadiumName_%d + StadiumDetailDesc + iVar5 == 0xb-> "Badge" + TeamName_Abbr15_%d + badgeBioDescription + iVar5 == 0x1e -> "FUT_UC_BALL" + iVar5 == 0x1f -> league-derived id, no generic asset string + iVar5 - 0xe7U < 2 / 0xe9 / 0xec -> misc +That reading only holds if FUN_1801a8640 returns the item's +0x50 cardsubtypeid. +Decompile it and its neighbours FUN_1801a8570 / FUN_1801a8560 / FUN_1801a8020 / +FUN_1801a86a0 / FUN_1801a8800 / FUN_1801a87f0 / FUN_1801a8040. + +CONTROL: FUN_1801a86a0 is used in the badge arm as the argument to +TeamName_Abbr15_%d, so it must return the +0x94 teamid. If it returns something +else, my field-offset map for these accessors is wrong. +""" +import traceback + +try: + for a in (0x1801A8640, 0x1801A8570, 0x1801A8560, 0x1801A8020, 0x1801A86A0, + 0x1801A8800, 0x1801A87F0, 0x1801A8040): + src = dec(a) + print("-" * 70) + print("FUN_%x len=%d" % (a, len(src))) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_3.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_3.py new file mode 100644 index 0000000..d8f8543 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_3.py @@ -0,0 +1,61 @@ +"""Q3: walk the whole enum-table block around 0x180229a00..0x180229e00. + +Q2 found row @0x180229b50 = {'badge', 0xa}, 0x180229b60 = {'kit', 0xb}, +0x180229b70 = {'leagueLogo', 0xc}, and 0x180229c10 = {'ball', 0x16} -- i.e. a +{name -> numeric code} table that NAMES THE CLUB FAMILIES. That is exactly the +mapping the brief asks for, IF the codes are cardsubtypeids. + +HYPOTHESIS: one of these tables is the cardsubtypeid vocabulary. Codes 0xa/0xb/0xc +are NOT in the cardtype-9 subtype set (0x1e,0x1f,0x91..0x96), so either it is a +different axis (an "item sub-family" enum) or the mapping is indirect. + +CONTROL: the itemState table at 0x180229cc0 (invalid/free/WAITING_FOR_GAME/...) +must reappear intact inside the same walk, with the same codes Q2 printed. + +Dump every 0x10 row from 0x180229800 to 0x180229f00, printing ptr, string, value. +Then xref every table start candidate (a row whose predecessor is not a valid +string row) to find the lookup function. +""" +import traceback + +try: + LO, HI = 0x180229800, 0x180229F00 + rows = [] + a = LO + while a < HI: + try: + q0, q1 = qword(a), qword(a + 8) + except Exception: + a += 0x10 + continue + s = None + if 0x180000000 <= q0 < 0x181000000: + try: + t = rd_str(q0, 64) + if t and all(0x20 <= ord(c) < 0x7F for c in t): + s = t + except Exception: + pass + rows.append((a, q0, s, q1)) + a += 0x10 + + print("=== enum row walk %#x..%#x ===" % (LO, HI)) + prev_ok = False + starts = [] + for (a, q0, s, q1) in rows: + mark = "" + ok = s is not None + if ok and not prev_ok: + mark = " <== TABLE START?" + starts.append(a) + prev_ok = ok + print(" %#x ptr=%#018x %-28r val=%#-10x%s" % (a, q0, s or "", q1, mark)) + + print() + print("=== xrefs to each candidate table start ===") + for a in starts: + print(" start %#x" % a) + for frm, typ, fn, ent in xrefs_to(a): + print(" from %#x %s in %s(%#x)" % (frm, typ, fn, ent)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_4.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_4.py new file mode 100644 index 0000000..2b01f49 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_4.py @@ -0,0 +1,46 @@ +"""Q4: the enum converter helpers and their callers. + +Q3 established two request-side vocabularies: + table 0x180229ab0 "subtype filter": any=-1 playerGK=1..physio=9 badge=0xa kit=0xb + leagueLogo=0xc playerTraining=0xd GKTraining=0xe position=0xf playStyle=0x10 + managerLeagueModifier=0x11 contract=0x12 fitness=0x13 healing=0x14 + stadium=0x15 ball=0x16 + table 0x180229c30 "type filter": any=-1 player=1 staff=2 clubInfo=3 training=4 + development=5 stadium=6 ball=7 + table 0x180229cc0 "itemState": invalid=0 free=1 WAITING_FOR_GAME=2 inGame=2 + forSale=5 offered=6 activeBadge=0x64 .. activeStadium=0x68 active=0xff + +HYPOTHESIS: the converter functions FUN_180166300 (subtype), FUN_180166340 (type), +FUN_180166660 (itemState) are string<->code helpers; their CALLERS are the request +builder and the equip path. The equip path must choose 0x64..0x68 from the item, and +that choice is the subtype->family mapping we want. + +CONTROL: FUN_1800d8330, decompiled in full here, must reproduce the documented +cardtype-9 subtype set {0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec}. If it does not, my +project copy is not the analysed one. +""" +import traceback + +try: + for a, tag in [(0x1800D8330, "CONTROL FUN_1800d8330 cardsubtype->cardtype"), + (0x180166300, "subtype-enum helper"), + (0x180166340, "type-enum helper"), + (0x180166660, "itemState helper A"), + (0x1801666F0, "itemState/other helper B"), + (0x180166790, "helper C")]: + src = dec(a) + print("=" * 78) + print("%s @%#x len=%d" % (tag, a, len(src))) + print(src) + print("=" * 78) + print("=== callers ===") + for a in (0x180166300, 0x180166340, 0x180166660, 0x1801666F0, 0x180166790): + print(" callers of %#x:" % a) + seen = set() + for frm, typ, fn, ent in xrefs_to(a): + if ent in seen: + continue + seen.add(ent) + print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_5.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_5.py new file mode 100644 index 0000000..56eef69 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_5.py @@ -0,0 +1,69 @@ +"""Q5: who consumes cardtype 9 / the club subtypes? + +Two prongs. + +A. FUN_180162c90 calls BOTH code->string helpers (type at 0x180162cec, subtype at + 0x180163071), so it is the request builder for club?type=...&... Decompile it in + full: it names the query parameters and shows which enum feeds which parameter. + This answers Q3 directly. + +B. THE ABSENCE TRAP GUARD. Rather than grep for "== 0x91", scan EVERY instruction in + .text for a scalar operand in the club-subtype set {0x1e,0x1f,0x91..0x96} and + group by containing function, regardless of mnemonic (cmp / sub / mov / lea / + switch-index arithmetic all count), then report functions that carry three or more + DISTINCT members of the set. A dispatch written as a sub-ladder or a jump table + still shows up because the constants themselves must exist somewhere -- and if a + jump table is used, the case labels live in .rdata, so also scan .rdata/.data for + the byte-pattern of a switch index table. + +CONTROL for the scan: FUN_1800d8330 is known to contain all eight of 0x1e,0x1f, +0x91..0x96 as switch case labels. If the scan does not list FUN_1800d8330, the scan +is broken and any "no other consumer" conclusion is void. +""" +import traceback + +try: + src = dec(0x180162C90) + print("=== A: FUN_180162c90 request builder, len=%d ===" % len(src)) + print(src) + + print() + print("=== B: scalar scan over .text for {0x1e,0x1f,0x91..0x96} ===") + TARGET = {0x1E, 0x1F, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96} + per = {} + block = None + for b in mem.getBlocks(): + if b.getName() == ".text": + block = b + break + it = listing.getInstructions(block.getStart(), True) + n = 0 + while it.hasNext(): + ins = it.next() + if ins.getAddress().getOffset() > block.getEnd().getOffset(): + break + n += 1 + for i in range(ins.getNumOperands()): + for o in ins.getOpObjects(i): + try: + v = int(o.getValue()) + except Exception: + continue + if v in TARGET: + f = fm.getFunctionContaining(ins.getAddress()) + key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0) + per.setdefault(key, {}).setdefault(v, []).append( + int(ins.getAddress().getOffset())) + print(" instructions scanned: %d" % n) + rank = sorted(per.items(), key=lambda kv: -len(kv[1])) + for (name, ent), d in rank: + if len(d) < 3: + continue + print(" %-28s %#x distinct=%d %s" % + (name, ent, len(d), sorted("%#x" % k for k in d))) + print(" --- control present? FUN_1800d8330 ---") + for (name, ent), d in per.items(): + if ent == 0x1800D8330: + print(" YES: distinct=%d %s" % (len(d), sorted("%#x" % k for k in d))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_6.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_6.py new file mode 100644 index 0000000..643e80c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_6.py @@ -0,0 +1,48 @@ +"""Q6: the club query taxonomy switch and the club URL builder. + +Prior work (tools/fut_club_stats.py header) records FUN_18012ec50 as the 30-arm +atom switch that turns a club ?type= name into a code, with 11 badge, 12 kit, +13 stadium, 14 ball, 15 equippables, 16 leaguelogos. + +HYPOTHESIS: something downstream of that code either (a) adds a &cardsubtype= +filter, which would give the mapping outright, or (b) buckets the RESPONSE items +by cardsubtypeid for the combined equippables view. + +NOTE ON THE FAILED CONTROL IN Q5: the scalar scan did NOT find FUN_1800d8330, +whose 0x1e/0x1f/0x91..0x96 case labels are a jump table, so scalar scanning cannot +see switch dispatch here. Every "not found" from that scan is void. This query +therefore works from call graph and strings only. + +CONTROL here: FUN_18012ec50 must decompile to a switch whose arms match the 30 +names already recorded. If it does not, the recorded note (and my starting point) +is wrong. +""" +import traceback + +try: + for a, tag in [(0x18012EC50, "CONTROL club ?type= taxonomy switch"), + (0x180096670, "UI group-table switch")]: + src = dec(a) + print("=" * 78) + print("%s @%#x len=%d" % (tag, a, len(src))) + print(src) + + print("=" * 78) + print("=== callers of FUN_18012ec50 ===") + for frm, typ, fn, ent in xrefs_to(0x18012EC50): + print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ)) + + print() + print("=== url-ish literals ===") + for p in [b"/club?", b"/club", b"club?type", b"type=%s", b"&cat=%s", + b"&cardsubtype=%d", b"&count=%d", b"/item", b"/purchased"]: + hits = find_all(p) + print(" %-18r n=%d" % (p.decode(), len(hits))) + for h in hits[:14]: + s = rd_str(h, 120) + fs = [] + for frm, typ, fn, ent in xrefs_to(h): + fs.append("%s(%#x)" % (fn, ent)) + print(" %#x %-58r <- %s" % (h, s, ",".join(fs) or "-")) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_7.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_7.py new file mode 100644 index 0000000..119a3d5 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_7.py @@ -0,0 +1,42 @@ +"""Q7: every caller of the cardtype classifier, and how each treats cardtype 9. + +FUN_1800d8330(cardsubtypeid) -> cardtype. Anything that needs to know "this is a +club item" must either call it or test cardsubtypeid directly. Enumerate its +callers, decompile each, and print EVERY line mentioning a 9 in any comparison or +switch form (case 9:, ==9, !=9, -9, <9, >9), so an arm written as != does not hide. + +Also decompile the shared item deserializer FUN_18013fe00 fully? No: too long for +one batch. Instead print its length and the lines around the FUN_1800d8330 call and +around the itemState helper call at 0x1801406e3. + +CONTROL: FUN_1800d8330 itself is excluded; the caller list must be non-empty and +must include the item deserializer FUN_18013fe00 (which is documented to compute +the family into item+0x4c). If FUN_18013fe00 is absent from the caller list, the +xref enumeration is broken. +""" +import re +import traceback + +try: + seen = {} + for frm, typ, fn, ent in xrefs_to(0x1800D8330): + if ent and ent != 0x1800D8330: + seen.setdefault(ent, fn) + print("=== callers of FUN_1800d8330: %d ===" % len(seen)) + pat = re.compile(r"(case\s+9\s*:|[=!<>]=\s*9\b|==\s*9\b|!=\s*9\b|\b9\s*[=!<>]|-\s*9\b)") + for ent, fn in sorted(seen.items()): + src = dec(ent) + lines = src.splitlines() + hits = [(i, l.strip()) for i, l in enumerate(lines) if pat.search(l)] + print("-" * 70) + print("%s @%#x len=%d nine-lines=%d" % (fn, ent, len(src), len(hits))) + for i, l in hits[:40]: + print(" %4d: %s" % (i, l)) + print("=" * 70) + src = dec(0x18013FE00) + print("item deser FUN_18013fe00 len=%d" % len(src)) + for i, l in enumerate(src.splitlines()): + if "1800d8330" in l or "180166660" in l or "1801666f0" in l: + print(" %4d: %s" % (i, l.strip())) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_8.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_8.py new file mode 100644 index 0000000..ec38100 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_8.py @@ -0,0 +1,26 @@ +"""Q8: the cardtype-9 arms inside the shared item deserializer. + +Q7: FUN_1800d8330 has exactly ONE caller, FUN_18013fe00, and the decompile has +`if (local_13c == 9)` at line 729 and `if ((int)local_138 == 9)` at line 754, where +local_138 is the cardsubtypeid fed to the classifier and local_13c is the cardtype. + +Print lines 640..900 of that decompile verbatim (no truncation claim will be made +from a window: the window is stated as a window). Also print lines 380..470 which +contain the itemState conversion calls, and the function tail (last 80 lines), +because the brief warns that locals that look dead are often copied out at the tail. +""" +import traceback + +try: + src = dec(0x18013FE00) + lines = src.splitlines() + print("total lines=%d chars=%d" % (len(lines), len(src))) + for lo, hi, tag in [(380, 470, "itemState region"), + (640, 900, "cardtype-9 region"), + (len(lines) - 90, len(lines), "tail")]: + print("=" * 70) + print("--- %s: lines %d..%d ---" % (tag, lo, hi)) + for i in range(max(0, lo), min(len(lines), hi)): + print("%5d: %s" % (i, lines[i])) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_9.py b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_9.py new file mode 100644 index 0000000..79ea5ae --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_clubitem_9.py @@ -0,0 +1,32 @@ +"""Q9: the five functions that carry club-subtype immediates individually. + +Q5's scalar scan cannot see jump-table switches (its control, FUN_1800d8330, was +missed), but everything it DID find is a real immediate in an instruction. Those +functions are: + FUN_1801017e0 0x1e 0x91 0x92 0x93 0x94 + FUN_180108c00 0x1e 0x91 0x94 0x96 + FUN_18011e3c0 0x91 0x94 0x96 + FUN_18011e9d0 0x91 0x94 0x96 + FUN_18013af30 0x1e 0x1f 0x94 + FUN_180067d00 0x94 0x95 0x96 +Plus the range test found in Q8 inside the item deserializer at line 738: + (int)cardsubtypeid - 0x91U < 4 -> {0x91,0x92,0x93,0x94} take a custom-image path. + +Decompile all six in full. Anything that ties one of these constants to a loc key, +an asset path or a UI slot is the mapping. + +CONTROL: FUN_18013af30 must contain 0x1e/0x1f/0x94 somewhere in its text; if a +decompile comes back without the constants the scan attributed to it, the scan's +function attribution is wrong. +""" +import traceback + +try: + for a in (0x1801017E0, 0x180108C00, 0x18011E3C0, 0x18011E9D0, 0x18013AF30, + 0x180067D00): + src = dec(a) + print("=" * 78) + print("FUN_%x len=%d" % (a, len(src))) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_discard_1.py b/fifa17-recon/tools/ghidra_queries/q_cd_discard_1.py new file mode 100644 index 0000000..84c0fdc --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_discard_1.py @@ -0,0 +1,95 @@ +"""Q1 scaffolding: locate the fcc_discardcoins query site and its containing +function(s), and print the raw instruction stream around the three bind sites. + +HYPOTHESIS: the SQL at 0x1802231e4.. is built and bound inside one function whose +frame holds the item pointer; cardtype/level/rare come from three distinct item +struct offsets, and the result is stored at item+0x3c. + +CONTROL: 0x18013fe00 (the shared ITEM deserializer, known function) and +0x1800d8330 (the known cardsubtypeid->cardtype mapper) must both resolve to real +functions with sane sizes. If they do not, the project copy is wrong and every +other answer here is void. + +Absence discipline: nothing in this file claims absence. It only prints. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q1_out.txt" + +try: + lines = [] + def P(*a): + s = " ".join(str(x) for x in a) + lines.append(s) + + LITS = { + 0x1802231E4: "sql frag A", + 0x1802231F0: "sql frag B", + 0x1802231F4: "fcc_discardcoins", + 0x180223208: "sql frag C", + 0x180207848: "sql frag D", + 0x18022315C: "sql frag E", + } + P("=== LITERALS ===") + for a, tag in sorted(LITS.items()): + try: + P("%#x %-18s %r" % (a, tag, rd_str(a, 120))) + except Exception as e: + P("%#x %-18s READ FAIL %s" % (a, tag, e)) + + P("") + P("=== XREFS TO LITERALS ===") + for a, tag in sorted(LITS.items()): + xs = xrefs_to(a) + P("%#x %s -> %d refs" % (a, tag, len(xs))) + for frm, typ, fn, ent in xs: + P(" from %#x %-14s in %s @ %#x" % (frm, typ, fn, ent)) + + P("") + P("=== FUNCTION IDENTITY ===") + for a in (0x18013FE00, 0x1800D8330, 0x180141025, 0x180141119, 0x180141140, + 0x180141660, 0x180141E8A, 0x180140F00): + f = func(a) + if f is None: + P("%#x -> NO FUNCTION" % a) + continue + b = f.getBody() + P("%#x -> %s entry=%#x body=[%#x..%#x] size=%d" + % (a, f.getName(), int(f.getEntryPoint().getOffset()), + int(b.getMinAddress().getOffset()), int(b.getMaxAddress().getOffset()), + int(b.getNumAddresses()))) + + P("") + P("=== RAW INSTRUCTIONS 0x180140f80 .. 0x180141200 ===") + p = 0x180140F80 + while p < 0x180141200: + ins = listing.getInstructionAt(addr(p)) + if ins is None: + P("%#x " % p) + p += 1 + continue + P("%#x %s" % (p, ins)) + p += ins.getLength() + + P("") + P("=== RAW INSTRUCTIONS 0x180141e40 .. 0x180141f00 (level derivation) ===") + p = 0x180141E40 + while p < 0x180141F00: + ins = listing.getInstructionAt(addr(p)) + if ins is None: + P("%#x " % p) + p += 1 + continue + P("%#x %s" % (p, ins)) + p += ins.getLength() + + src = dec(0x180141025) + P("") + P("=== FULL DECOMPILE of function containing 0x180141025, len=%d ===" % len(src)) + P(src) + + with open(OUT, "w") as fh: + fh.write("\n".join(lines)) + print("wrote %s (%d lines)" % (OUT, len(lines))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_discard_2.py b/fifa17-recon/tools/ghidra_queries/q_cd_discard_2.py new file mode 100644 index 0000000..b35d391 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_discard_2.py @@ -0,0 +1,134 @@ +"""Q: the client's fcc_discardcoins lookup does NOT miss (proved live). So why did +every card render "Quick Sell 0" when we omitted discardValue? + +HYPOTHESIS H1: the consumer of the price reads item+0x38 (the wire discardValue) +only, and NEVER item+0x3c (where the client's own computation lands). That would +explain both halves of the observation: 0 with the field omitted, exact with it +sent. + +RIVAL H2: the consumer reads +0x38 if non-zero else +0x3c. H2 predicts the +omitted-field test should have shown the right numbers, which it did not, but H2 +survives if the failing observation was mis-made, so decide it on code. + +METHOD: enumerate EVERY instruction in .text whose memory operand is a dword at +displacement 0x38 or 0x3c, tabulate by containing function, and then look at the +functions that touch BOTH (a candidate "sent else computed" selector) versus +functions that touch only one. + +CONTROL: FUN_18013fe00 must appear in both tables (it provably reads +0x38 at +0x180141025 and writes +0x3c at 0x180141140). If the scan does not find those two +exact addresses, the scan is broken and its silence means nothing. + +Also: FUN_180141660 in full, plus every string literal it references, for the +"does the miss degrade other screens" question. + +Absence discipline: this scan enumerates operands, not immediates, so it is +immune to the ==/!=/switch/ladder trap; but it is scoped to CardsDLL only and +says nothing about FIFA17.exe. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q2_out.txt" + +try: + lines = [] + def P(*a): + lines.append(" ".join(str(x) for x in a)) + + # ---------------------------------------------------------------- 1. operand scan + from ghidra.program.model.lang import OperandType + + hits38, hits3c = {}, {} + n_ins = 0 + it = listing.getInstructions(True) + while it.hasNext(): + ins = it.next() + n_ins += 1 + txt = str(ins) + if "0x38]" not in txt and "0x3c]" not in txt: + continue + a = int(ins.getAddress().getOffset()) + f = fm.getFunctionContaining(ins.getAddress()) + key = (f.getName() if f else "?", + int(f.getEntryPoint().getOffset()) if f else 0) + if "0x38]" in txt: + hits38.setdefault(key, []).append((a, txt)) + if "0x3c]" in txt: + hits3c.setdefault(key, []).append((a, txt)) + + P("instructions scanned: %d" % n_ins) + P("functions touching a +0x38 operand: %d" % len(hits38)) + P("functions touching a +0x3c operand: %d" % len(hits3c)) + + P("") + P("=== CONTROL ===") + ctl = [t for k, v in hits38.items() for t in v if t[0] == 0x180141025] + P("read of +0x38 at 0x180141025 found: %s" % (ctl or "NO -- SCAN BROKEN")) + ctl2 = [t for k, v in hits3c.items() for t in v if t[0] == 0x180141140] + P("write of +0x3c at 0x180141140 found: %s" % (ctl2 or "NO -- SCAN BROKEN")) + + both = sorted(set(hits38) & set(hits3c), key=lambda k: k[1]) + P("") + P("=== FUNCTIONS TOUCHING BOTH +0x38 AND +0x3c (%d) ===" % len(both)) + for k in both: + P(" %s @ %#x" % k) + for a, t in sorted(hits38[k]): + P(" 38 %#x %s" % (a, t)) + for a, t in sorted(hits3c[k]): + P(" 3c %#x %s" % (a, t)) + + only3c = sorted(set(hits3c) - set(hits38), key=lambda k: k[1]) + P("") + P("=== FUNCTIONS TOUCHING +0x3c ONLY (%d) ===" % len(only3c)) + for k in only3c: + P(" %s @ %#x" % k) + for a, t in sorted(hits3c[k]): + P(" %#x %s" % (a, t)) + + # ---------------------------------------------------------------- 2. tiny accessors + P("") + P("=== TINY ACCESSOR BYTE PATTERNS IN .text ===") + pats = { + "mov eax,[rcx+0x38]; ret": b"\x8b\x41\x38\xc3", + "mov eax,[rcx+0x3c]; ret": b"\x8b\x41\x3c\xc3", + "mov eax,[rcx+0x38]": b"\x8b\x41\x38", + "mov eax,[rcx+0x3c]": b"\x8b\x41\x3c", + } + for name, pat in pats.items(): + hs = find_all(pat, blocks=(".text",)) + P(" %-26s %d hits" % (name, len(hs))) + for h in hs[:40]: + f = fm.getFunctionContaining(addr(h)) + P(" %#x in %s" % (h, f.getName() if f else "?")) + + # ---------------------------------------------------------------- 3. FUN_180141660 + src = dec(0x180141660) + P("") + P("=== FUN_180141660 FULL DECOMPILE, len=%d ===" % len(src)) + P(src) + + P("") + P("=== STRING LITERALS REFERENCED BY FUN_180141660 ===") + f = func(0x180141660) + seen = set() + for ad in f.getBody().getAddresses(True): + ins = listing.getInstructionAt(ad) + if ins is None: + continue + for r in ins.getReferencesFrom(): + t = int(r.getToAddress().getOffset()) + if t in seen or not (0x1801E5000 <= t <= 0x180290000): + continue + seen.add(t) + try: + s = rd_str(t, 80) + except Exception: + continue + if s and all(32 <= ord(c) < 127 for c in s) and len(s) >= 3: + P(" %#x from %#x %r" % (t, int(ad.getOffset()), s)) + + with open(OUT, "w") as fh: + fh.write("\n".join(lines)) + print("wrote %s (%d lines)" % (OUT, len(lines))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_discard_3.py b/fifa17-recon/tools/ghidra_queries/q_cd_discard_3.py new file mode 100644 index 0000000..6f7d1a0 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_discard_3.py @@ -0,0 +1,132 @@ +"""Q: who READS the parsed item record's discard fields, item+0x38 (the wire +discardValue) and item+0x3c (the client's own fcc_discardcoins result)? + +WHY q2's CONTROL FAILED, and why that was the control's fault: inside +FUN_18013fe00 the item is a STACK STRUCT at RBP+0x160, so the guard reads +[RBP+0x198] and the store writes [RBP+0x19c]. A scan for the displacements 0x38 +and 0x3c can never see them. The control was invalid, not the scan. + +NEW METHOD -- FINGERPRINT THE STRUCT, NOT THE OFFSET. The item record has several +displacements that are rare in general code: +0x146 (preferredPosition, u16), ++0x148 (nation), +0x154 (leagueId), +0x94 (teamid), +0xb4 (rating). Any function +that dereferences a pointer at two or more of those is handling an item record. +Collect the displacement set per function from the instruction text, select the +item handlers, and then report their +0x38 / +0x3c usage. + +SECOND TEST, independent of the fingerprint: find every place in .text where a +dword is READ at [reg+0x38] and, within 0x40 bytes and off the SAME base +register, a dword is READ at [reg+0x3c]. That is the shape of a "server value +else computed value" selector. H1 (consumer reads +0x38 only) predicts no such +selector on an item; H2 predicts one. + +CONTROL for this run: the fingerprint must select FUN_18013fe00 itself when the +frame register RBP is allowed, because that function demonstrably touches +RBP+0x2a6 (0x146+0x160), RBP+0x2a8, RBP+0x2b4 and RBP+0x214. I print the +frame-relative fingerprint hits separately for exactly that reason. +""" +import re +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q3_out.txt" +RE_MEM = re.compile(r"\[(R[A-Z0-9]+) \+ (-?0x[0-9a-f]+)\]") + +try: + lines = [] + def P(*a): + lines.append(" ".join(str(x) for x in a)) + + ITEM_MARKS = (0x146, 0x148, 0x154, 0x94, 0xb4, 0x8c) + FRAME_MARKS = tuple(m + 0x160 for m in ITEM_MARKS) + + per_fn = {} # entry -> {'name', 'disp': {d: [(addr, base, txt)]}} + reads38 = [] # (addr, base, fnentry, txt) + reads3c = [] + n_ins = 0 + + it = listing.getInstructions(True) + while it.hasNext(): + ins = it.next() + n_ins += 1 + txt = str(ins) + if "[" not in txt: + continue + ms = RE_MEM.findall(txt) + if not ms: + continue + a = int(ins.getAddress().getOffset()) + f = fm.getFunctionContaining(ins.getAddress()) + ent = int(f.getEntryPoint().getOffset()) if f else 0 + rec = per_fn.setdefault(ent, {"name": f.getName() if f else "?", "disp": {}}) + for base, dtxt in ms: + d = int(dtxt, 16) + rec["disp"].setdefault(d, []).append((a, base, txt)) + if d == 0x38 and txt.startswith("MOV E") and "dword ptr [" + base in txt: + reads38.append((a, base, ent, txt)) + if d == 0x3c and txt.startswith("MOV E") and "dword ptr [" + base in txt: + reads3c.append((a, base, ent, txt)) + + P("instructions scanned: %d ; functions with memory operands: %d" % (n_ins, len(per_fn))) + + # ---- CONTROL: the frame-relative fingerprint must select FUN_18013fe00 + P("") + P("=== CONTROL: frame-relative item fingerprint (marks + 0x160) ===") + ctl = [] + for ent, rec in per_fn.items(): + got = [m for m in FRAME_MARKS if m in rec["disp"]] + if len(got) >= 3: + ctl.append((ent, rec["name"], [hex(g) for g in got])) + for ent, nm, got in sorted(ctl): + P(" %-18s %#x marks %s %s" % (nm, ent, got, + "<== FUN_18013fe00" if ent == 0x18013FE00 else "")) + P(" control %s" % ("PASS" if any(e == 0x18013FE00 for e, _, _ in ctl) + else "FAIL -- fingerprint cannot see the known item handler")) + + # ---- pointer-relative fingerprint: the real search + P("") + P("=== ITEM HANDLERS BY POINTER-RELATIVE FINGERPRINT (>=2 of %s) ===" + % [hex(m) for m in ITEM_MARKS]) + cands = [] + for ent, rec in per_fn.items(): + got = [] + for m in ITEM_MARKS: + for (a, base, txt) in rec["disp"].get(m, []): + if base not in ("RSP", "RBP"): + got.append(m) + break + if len(got) >= 2: + cands.append((ent, rec["name"], got)) + P("candidates: %d" % len(cands)) + for ent, nm, got in sorted(cands): + rec = per_fn[ent] + h38 = [(a, b, t) for (a, b, t) in rec["disp"].get(0x38, []) if b not in ("RSP", "RBP")] + h3c = [(a, b, t) for (a, b, t) in rec["disp"].get(0x3c, []) if b not in ("RSP", "RBP")] + P("") + P(" %-18s %#x marks %s +0x38:%d +0x3c:%d" + % (nm, ent, [hex(g) for g in got], len(h38), len(h3c))) + for a, b, t in h38: + P(" 38 %#x %s" % (a, t)) + for a, b, t in h3c: + P(" 3c %#x %s" % (a, t)) + + # ---- selector shape + P("") + P("=== SELECTOR SHAPE: dword read [reg+0x38] then dword read [SAME reg+0x3c] within 0x40 ===") + idx3c = {} + for a, base, ent, txt in reads3c: + idx3c.setdefault(base, []).append((a, ent, txt)) + nsel = 0 + for a, base, ent, txt in reads38: + for a2, ent2, txt2 in idx3c.get(base, []): + if 0 < a2 - a <= 0x40: + nsel += 1 + nm = per_fn.get(ent, {}).get("name", "?") + P(" %s @ %#x : %#x %s -> %#x %s" % (nm, ent, a, txt, a2, txt2)) + P(" selectors found: %d" % nsel) + P(" (reads at +0x38: %d, reads at +0x3c: %d, over the whole .text)" + % (len(reads38), len(reads3c))) + + with open(OUT, "w") as fh: + fh.write("\n".join(lines)) + print("wrote %s (%d lines)" % (OUT, len(lines))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_discard_4.py b/fifa17-recon/tools/ghidra_queries/q_cd_discard_4.py new file mode 100644 index 0000000..12f684c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_discard_4.py @@ -0,0 +1,51 @@ +"""Q: identify the CONSUMER of the item record's discard fields. + +The parsed stack struct is local_188 (RBP+0x160) and it is handed to +`FUN_18011a830()->vtbl[0xa08](mgr, existingItem, &parsed)`. The heap records our +live probe walked have the SAME layout (discardValue at +0x38, client-computed at ++0x3c, both confirmed live), so a consumer reads [reg+0x38] / [reg+0x3c]. + +H1: no consumer reads +0x3c off an item; the display reads +0x38 only. +H2: some consumer reads +0x38 and falls back to +0x3c. + +q3 found 40 "read 0x38 then read 0x3c off the same base" sites DLL-wide, three of +which sit in functions carrying an item fingerprint. Decompile those and decide. + +CONTROL: FUN_18013fe00 is a known item handler and FUN_1800d8330 a known +cardsubtypeid mapper; both must decompile to something recognisable. Also resolve +FUN_18011a830's vtable slot 0xa08, which is the known sink, as a positive check +that vtable resolution works here at all. + +Absence discipline: any "no consumer reads +0x3c" statement below is scoped to +CardsDLL's .text only. FIFA17.exe is Denuvo-packed and is NOT searched, so a +consumer living there cannot be excluded by this query. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q4_out.txt" + +try: + lines = [] + def P(*a): + lines.append(" ".join(str(x) for x in a)) + + P("=== SINK: FUN_18011a830 and vtable slot 0xa08 ===") + src = dec(0x18011A830) + P("FUN_18011a830 decompile, len=%d" % len(src)) + P(src) + + for tgt in (0x1800AA440, 0x18007C5F0, 0x18007BF00, 0x1800D7920): + s = dec(tgt) + P("") + P("=" * 100) + P("=== FUN_%x FULL DECOMPILE, len=%d ===" % (tgt, len(s))) + P(s) + P("--- callers of %#x ---" % tgt) + for frm, typ, fn, ent in xrefs_to(tgt): + P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent)) + + with open(OUT, "w") as fh: + fh.write("\n".join(lines)) + print("wrote %s (%d lines)" % (OUT, len(lines))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_discard_5.py b/fifa17-recon/tools/ghidra_queries/q_cd_discard_5.py new file mode 100644 index 0000000..288fb45 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_discard_5.py @@ -0,0 +1,85 @@ +"""Q: which value does the UI actually receive for the quick-sell price? + +LEAD: the UI layer is bound by NAME. q4 showed calls of the shape + (**(code **)(*view + 0x48))(view, "LATEST_GAMES_WON", value) +so every number the client renders is pushed through a named setter. If the +quick-sell price is pushed under a name, finding that name finds the reader, and +the reader tells us whether it reads item+0x38 (the wire discardValue) or +item+0x3c (the client's own fcc_discardcoins result). + +METHOD: enumerate every printable string in .rdata/.data whose text contains +DISCARD / discard / QUICK / Quick / SELL / Sell / COIN / Coin, print it with its +xrefs and the containing function, then decompile the functions that push a +discard-ish name. + +CONTROL: the search must find the strings we already know exist -- the SQL +fragments "fcc_discardcoins" (0x1802231f0) and "price" (0x1802231e4) both +contain the target substrings, and the known UI name "LATEST_GAMES_WON" must +show up under COIN?? no -- it must show up in a separate positive check that the +string enumerator sees UI names at all. Both checks are printed explicitly. If +either fails the enumerator is broken and its silence means nothing. +""" +import re +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q5_out.txt" + +try: + lines = [] + def P(*a): + lines.append(" ".join(str(x) for x in a)) + + NEEDLES = ("DISCARD", "discard", "Discard", "QUICK", "Quick", "quick", + "SELL", "Sell", "sell", "COIN", "Coin", "coin") + + # gather printable C strings out of .rdata/.data + blocks = {} + for b in mem.getBlocks(): + if b.getName() in (".rdata", ".data") and b.isInitialized(): + s = int(b.getStart().getOffset()) + n = int(b.getEnd().getOffset()) - s + 1 + blocks[b.getName()] = (s, n) + P("blocks: %s" % {k: (hex(v[0]), v[1]) for k, v in blocks.items()}) + + RE_STR = re.compile(rb"[ -~]{4,120}\x00") + found = [] + for bn, (s, n) in blocks.items(): + off = 0 + CH = 1 << 20 + while off < n: + ln = min(CH, n - off) + data = read_bytes(s + off, ln) + for m in RE_STR.finditer(data): + txt = m.group()[:-1].decode("ascii") + if any(x in txt for x in NEEDLES): + found.append((s + off + m.start(), bn, txt)) + off += ln - 130 if ln == CH else ln + # dedupe + seen = set() + found = [f for f in found if not (f[0] in seen or seen.add(f[0]))] + P("strings matching %s: %d" % (list(NEEDLES), len(found))) + + P("") + P("=== CONTROL 1: the two known SQL literals must be in the hit list ===") + hits = {a for a, _, _ in found} + P(" 0x1802231f0 'fcc_discardcoins' present: %s" % (0x1802231F0 in hits)) + P(" 0x1802231e4 'price' present (should be False, no needle): %s" % (0x1802231E4 in hits)) + + P("") + P("=== CONTROL 2: the enumerator sees UI names -- LATEST_GAMES_WON ===") + la = find_all(b"LATEST_GAMES_WON\x00", blocks=(".rdata", ".data")) + P(" LATEST_GAMES_WON found at %s" % [hex(x) for x in la]) + + P("") + P("=== HITS WITH XREFS ===") + for a, bn, txt in sorted(found): + xs = xrefs_to(a) + fns = sorted({(fn, ent) for _, _, fn, ent in xs if ent}) + P("%#x [%s] %-46r xrefs=%d %s" + % (a, bn, txt, len(xs), ", ".join("%s@%#x" % f for f in fns[:8]))) + + with open(OUT, "w") as fh: + fh.write("\n".join(lines)) + print("wrote %s (%d lines)" % (OUT, len(lines))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_discard_6.py b/fifa17-recon/tools/ghidra_queries/q_cd_discard_6.py new file mode 100644 index 0000000..611a9a0 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_discard_6.py @@ -0,0 +1,80 @@ +"""Q: FUN_1800eb850 pushes BOTH "DISCARD_CREDITS" (3 refs) and +"CALCULATED_DISCARD_CREDITS" (1 ref) to the UI. Which item offset feeds each? + +H1 predicts DISCARD_CREDITS <- item+0x38 (the wire discardValue) and +CALCULATED_DISCARD_CREDITS <- item+0x3c (the client's fcc_discardcoins result), +two independent UI properties with no native fallback between them. If so, which +one the card tile shows is decided in the Flash/Scaleform asset, not in native +code, and the "Quick Sell 0" observation means the tile binds DISCARD_CREDITS. + +METHOD: full decompile plus the raw instruction stream around every reference to +the two names, so the register feeding the third argument is visible rather than +inferred from decompiler variable naming. Also the callers of FUN_1800eb850, and +the same treatment for the sibling names DISCARD / COINS_AWARDED. + +CONTROL: FUN_18013fe00's own two sites are the reference semantics: the guard +reads [RBP+0x198] (= item+0x38) and the store writes [RBP+0x19c] (= item+0x3c). +Any offsets this query reports must be interpretable against a struct base held +in a register; I print the full function so the base can be traced, rather than +quoting a fragment. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q6_out.txt" + +try: + lines = [] + def P(*a): + lines.append(" ".join(str(x) for x in a)) + + for nm, a in (("DISCARD_CREDITS", 0x1802161C8), + ("CALCULATED_DISCARD_CREDITS", 0x1802161D8), + ("DISCARD", 0x1801F4D28)): + P("=== xrefs to %r (%#x) ===" % (nm, a)) + for frm, typ, fn, ent in xrefs_to(a): + P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent)) + P("") + + f = func(0x1800EB850) + b = f.getBody() + lo = int(b.getMinAddress().getOffset()) + hi = int(b.getMaxAddress().getOffset()) + P("FUN_1800eb850 body [%#x..%#x] size=%d" % (lo, hi, int(b.getNumAddresses()))) + + src = dec(0x1800EB850) + P("") + P("=== FUN_1800eb850 FULL DECOMPILE, len=%d ===" % len(src)) + P(src) + + P("") + P("=== FUN_1800eb850 FULL DISASSEMBLY ===") + p = lo + while p <= hi: + ins = listing.getInstructionAt(addr(p)) + if ins is None: + P("%#x " % p) + p += 1 + continue + extra = "" + for r in ins.getReferencesFrom(): + t = int(r.getToAddress().getOffset()) + if 0x1801E5000 <= t <= 0x180290000: + try: + s = rd_str(t, 60) + except Exception: + s = "" + if s: + extra = " ; %r" % s + P("%#x %s%s" % (p, ins, extra)) + p += ins.getLength() + + P("") + P("=== CALLERS OF FUN_1800eb850 ===") + for frm, typ, fn, ent in xrefs_to(0x1800EB850): + P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent)) + + with open(OUT, "w") as fh: + fh.write("\n".join(lines)) + print("wrote %s (%d lines)" % (OUT, len(lines))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_discard_7.py b/fifa17-recon/tools/ghidra_queries/q_cd_discard_7.py new file mode 100644 index 0000000..58c5382 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_discard_7.py @@ -0,0 +1,69 @@ +"""Q: which struct offsets do the two UI getters read? + +FUN_1800eb850 pushes: + "DISCARD_CREDITS" <- FUN_1801a8620(handle) + "CALCULATED_DISCARD_CREDITS" <- FUN_1801a8090(handle) + "CARD_LEVEL" <- FUN_1801a80c0(handle) + "CARD_RARITY" <- FUN_1801a8880 || FUN_1801a88c0 + +H1 predicts FUN_1801a8620 reads +0x38 and FUN_1801a8090 reads +0x3c, i.e. the +wire value and the client's own computation are exposed to the UI as two +SEPARATE named properties with no native fallback between them. + +BONUS CONTROL, and it is a strong one: the live probe showed item+0x54 holding +1/2/3 exactly tracking rating (3 if >=75, 2 if >=65, else 1) on all 22 resident +records, which contradicts the existing field map's "itemType 3=player 2=staff". +If FUN_1801a80c0 ("CARD_LEVEL") reads +0x54, that independently settles it as +the fcc_discardcoins `level`. + +Also print the callers of FUN_1800eb850 to name the screen. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q7_out.txt" + +TARGETS = [0x1801A8620, 0x1801A8090, 0x1801A80C0, 0x1801A8880, 0x1801A88C0, + 0x1801A8850, 0x1801A8810, 0x1801A7140, 0x1801A78F0] + +try: + lines = [] + def P(*a): + lines.append(" ".join(str(x) for x in a)) + + for t in TARGETS: + f = func(t) + if f is None: + P("%#x NO FUNCTION" % t) + continue + b = f.getBody() + lo, hi = int(b.getMinAddress().getOffset()), int(b.getMaxAddress().getOffset()) + P("=" * 92) + P("FUN_%x body [%#x..%#x] size=%d" % (t, lo, hi, int(b.getNumAddresses()))) + P("--- disassembly (full) ---") + p = lo + while p <= hi: + ins = listing.getInstructionAt(addr(p)) + if ins is None: + P(" %#x " % p) + p += 1 + continue + P(" %#x %s" % (p, ins)) + p += ins.getLength() + s = dec(t) + P("--- decompile, len=%d ---" % len(s)) + P(s) + P("") + + P("=" * 92) + P("=== CALLERS OF FUN_1800eb850 ===") + for frm, typ, fn, ent in xrefs_to(0x1800EB850): + P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent)) + if ent: + for f2, t2, n2, e2 in xrefs_to(ent): + P(" <- %#x %s @ %#x" % (f2, n2, e2)) + + with open(OUT, "w") as fh: + fh.write("\n".join(lines)) + print("wrote %s (%d lines)" % (OUT, len(lines))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_discard_8.py b/fifa17-recon/tools/ghidra_queries/q_cd_discard_8.py new file mode 100644 index 0000000..d017195 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_discard_8.py @@ -0,0 +1,89 @@ +"""Final batch. + +Q-A How many places consume each getter? + FUN_1801a8620 = item+0x38 (wire discardValue, UI name DISCARD_CREDITS) + FUN_1801a8090 = item+0x3c (client fcc_discardcoins result, UI name + CALCULATED_DISCARD_CREDITS) + If +0x3c has exactly ONE consumer and it is the UI property push, then no + native code ever falls back from the wire value to the computed one, and + which number the tile shows is a Flash-asset decision. That is H1. + +Q-B What is FUN_1800eb850 registered as? Its only xrefs are DATA slots at + 0x180216130 / 0x1802658a0 / 0x1802f85ec, so it sits in a dispatch table. + Dump the qwords either side and resolve any string pointers to name it. + +Q-C item+0x34 feeds the UI property "BOUGHT_FOR" and is NOT on the field map. + Print the deserializer sites that write +0x194 (= 0x34 + 0x160, the frame + form inside FUN_18013fe00) so the wire atom can be identified later. + +CONTROL: FUN_1801a8620 and FUN_1801a8090 are 8-byte leaf functions whose bodies +were printed in q7, so their identity is not in doubt; the xref counts below are +the only new claim. A getter with zero xrefs would mean the xref index is stale, +which is checkable against FUN_1801a80c0 (CARD_LEVEL), known to be called from +FUN_1800eb850. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q8_out.txt" + +try: + lines = [] + def P(*a): + lines.append(" ".join(str(x) for x in a)) + + P("=== Q-A getter consumers ===") + for nm, a in (("+0x38 DISCARD_CREDITS getter FUN_1801a8620", 0x1801A8620), + ("+0x3c CALCULATED getter FUN_1801a8090", 0x1801A8090), + ("+0x54 CARD_LEVEL getter FUN_1801a80c0", 0x1801A80C0), + ("+0x58 rare==1 getter FUN_1801a88c0", 0x1801A88C0), + ("+0x58 rare==0xc getter FUN_1801a8880", 0x1801A8880)): + xs = xrefs_to(a) + P("%-44s %d xrefs" % (nm, len(xs))) + for frm, typ, fn, ent in xs: + P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent)) + + P("") + P("=== Q-B dispatch-table context around the FUN_1800eb850 slots ===") + for slot in (0x180216130, 0x1802658A0, 0x1802F85EC): + P("-- slot %#x --" % slot) + for off in range(-0x40, 0x48, 8): + a = slot + off + try: + v = qword(a) + except Exception: + P(" %#x " % a) + continue + tag = "" + f = fm.getFunctionAt(addr(v)) if 0x180000000 <= v < 0x181000000 else None + if f: + tag = "-> FUNC %s" % f.getName() + elif 0x1801E5000 <= v <= 0x180290000: + try: + s = rd_str(v, 60) + except Exception: + s = "" + if s and all(32 <= ord(c) < 127 for c in s): + tag = "-> %r" % s + P(" %#x %016x %s%s" % (a, v, tag, " <== the slot" if off == 0 else "")) + P("") + + P("=== Q-C writes to the parsed item's +0x34 (frame form RBP+0x194) ===") + f = func(0x18013FE00) + lo = int(f.getBody().getMinAddress().getOffset()) + hi = int(f.getBody().getMaxAddress().getOffset()) + p = lo + while p <= hi: + ins = listing.getInstructionAt(addr(p)) + if ins is None: + p += 1 + continue + t = str(ins) + if "0x194]" in t or "0x190]" in t or "0x198]" in t or "0x19c]" in t: + P(" %#x %s" % (p, t)) + p += ins.getLength() + + with open(OUT, "w") as fh: + fh.write("\n".join(lines)) + print("wrote %s (%d lines)" % (OUT, len(lines))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_discard_9.py b/fifa17-recon/tools/ghidra_queries/q_cd_discard_9.py new file mode 100644 index 0000000..fd9ec3c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_discard_9.py @@ -0,0 +1,41 @@ +"""Side finding follow-up: the parsed item's +0x34 is pushed to the UI as +"BOUGHT_FOR" (FUN_1800eb850, from local_38+0x34) and it is NOT on the field map. +It is written once in the deserializer, at 0x180140885 (MOV [RBP+0x194],EAX). + +Q: which wire atom writes it? Print the instruction stream from 0x180140780 to +0x1801408c0 so the dispatch arm and its atom immediate are visible, in whatever +form the compiler chose (cmp / sub-ladder / switch), rather than grepping for +one form. + +CONTROL: the same window must also show the neighbouring known write +0x180140e43 -> [RBP+0x190] (item+0x30) or, failing that, at least one arm whose +atom is already in docs/fut_atoms.tsv, so that the arm-reading method is shown +to work on a known case in the same function. +""" +import traceback + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q9_out.txt" + +try: + lines = [] + def P(*a): + lines.append(" ".join(str(x) for x in a)) + + for lo, hi, tag in ((0x180140760, 0x1801408D0, "around the +0x34 (BOUGHT_FOR) write"), + (0x180140DC0, 0x180140E60, "around the +0x30 write, as a control")): + P("=== %s : %#x..%#x ===" % (tag, lo, hi)) + p = lo + while p < hi: + ins = listing.getInstructionAt(addr(p)) + if ins is None: + p += 1 + continue + P(" %#x %s" % (p, ins)) + p += ins.getLength() + P("") + + with open(OUT, "w") as fh: + fh.write("\n".join(lines)) + print("wrote %s (%d lines)" % (OUT, len(lines))) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_rating_1.py b/fifa17-recon/tools/ghidra_queries/q_cd_rating_1.py new file mode 100644 index 0000000..a97966f --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_rating_1.py @@ -0,0 +1,51 @@ +"""Q1: WHERE DOES THE DISPLAYED RATING COME FROM? + +HYPOTHESIS: the card face is painted from an "attribute publisher" that reads the +0x180-stride parsed item record and pushes named ATTRIB_* keys to the UI layer. +CARD_SYSTEM.md already names one such function, FUN_1800e5940, which reads +record+0xe3 and publishes ATTRIB_CONTRACT_NEGOTIATION. If that function also +publishes a rating key, and it reads it from the record (offset 0xb4 per the live +field map) rather than re-querying the local players table, then the displayed +rating is OURS. + +CONTROL (same syntactic form): the publisher must also emit the six attribute keys +from +0x98..+0xac. Those are KNOWN-OURS -- CARD_SYSTEM.md records invented +attributes rendering on screen (SILVA 72/44/58/60/62/61). If my read of the +publisher shows the attributes coming from somewhere OTHER than the record, my +read of the publisher is wrong, not the doc, and every rating conclusion in this +file is void. + +Also enumerate every ATTRIB_* literal in .rdata with its xrefs, which is the raw +material for the Q2 "ours vs client's" table. +""" +import traceback + +try: + print("=== A: every ATTRIB_ literal in .rdata ===") + hits = find_all(b"ATTRIB_") + print("n=%d" % len(hits)) + seen = {} + for h in hits: + s = rd_str(h, 96) + seen.setdefault(s, []).append(h) + for s in sorted(seen): + print(" %-46s %s" % (s, " ".join("%#x" % a for a in seen[s]))) + + print() + print("=== B: xrefs to each ATTRIB_ literal ===") + for s in sorted(seen): + for a in seen[s]: + xs = xrefs_to(a) + if not xs: + continue + print(" %-46s @%#x" % (s, a)) + for frm, typ, fn, ent in xs: + print(" from %#x %-12s %s(%#x)" % (frm, typ, fn, ent)) + + print() + print("=== C: FUN_1800e5940 in full ===") + src = dec(0x1800E5940) + print("len=%d" % len(src)) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_rating_2.py b/fifa17-recon/tools/ghidra_queries/q_cd_rating_2.py new file mode 100644 index 0000000..1c36cd6 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_rating_2.py @@ -0,0 +1,72 @@ +"""Q2: the card-accessor family and the OTHER card publishers. + +FUN_1800e5940 is the MANAGER card publisher (gated on CARD_TYPE, emits +ManagerCardBio / TACTICAL_KNOWLEDGE / ATTRIB_TEAM_TALKS). It reads every value it +publishes through a family of tiny accessors at 0x1801a8xxx taking a 4-qword stack +wrapper, and pushes them to a UI sink via param_3 vtable slots +0x08 (bool), ++0x10 (int), +0x20 (string). + +HYPOTHESIS: those accessors are thin field reads off the 0x180-stride parsed item +record, so FUN_1801a87f0 (OVERALL_RATING) reads record+0xb4, which is where atom +0x274 rating is stored. If so the DISPLAYED rating is OUR wire value. + +CONTROL, same syntactic form: FUN_1801a8540 is published as LEAGUE_ID. LEAGUE_ID +is KNOWN to be client-derived -- FUN_180135890 "always recomputes leagueid +0x154" +on a DB hit. And FUN_1801a8480 is CONTRACT_REMAINING, known-ours (+0x8c, we send +contract 7 and the manager card printed "CONTRACT 7"). So the accessor family must +show BOTH kinds reading the SAME record: that proves the accessors are record +reads and that "ours vs theirs" is decided upstream in the merge, not here. If the +league accessor instead performs a DB query while the rating one does a field read, +that is the opposite answer and it is equally visible. + +Also: enumerate every function in [0x1801a7000,0x1801a9000) with its decompiled +body, which is the whole accessor table, and find the sibling publishers by xref +on the UI key literals. +""" +import traceback + +try: + print("=== A: UI key literals and their xrefs (finds sibling publishers) ===") + keys = [b"OVERALL_RATING", b"CARD_LEVEL", b"CARD_RARITY", b"CARD_TYPE", + b"ASSET_ID", b"LEAGUE_ID", b"TEAM_ID", b"NATIONALITY", + b"CONTRACT_REMAINING", b"FIRST_NAME", b"LAST_NAME", b"KNOWN_AS", + b"PREFERRED_POSITION", b"POSITION", b"FITNESS", b"PLAYSTYLE", + b"PLAY_STYLE", b"CHEMISTRY", b"ATTRIBUTE", b"ATTR", b"MORALE", + b"TRAINING", b"DISCARD", b"QUICK_SELL", b"UNTRADEABLE", + b"RESOURCE_ID", b"DEFINITION_ID", b"CARD_ASSET", b"ITEM_STATE", + b"ITEM_TYPE", b"SUBTYPE", b"RARE", b"PHOTO", b"HEADSHOT", b"IS_"] + for k in keys: + for a in find_all(k): + s = rd_str(a, 64) + if not s.startswith(k.decode()): + continue + xs = xrefs_to(a) + if not xs: + continue + print(" %-34s @%#x" % (s, a)) + for frm, typ, fn, ent in xs: + print(" from %#x %-10s %s(%#x)" % (frm, typ, fn, ent)) + + print() + print("=== B: every function in [0x1801a7000,0x1801a9000) decompiled ===") + it = fm.getFunctions(addr(0x1801A7000), True) + n = 0 + while it.hasNext(): + f = it.next() + e = int(f.getEntryPoint().getOffset()) + if e >= 0x1801A9000: + break + n += 1 + src = dec(e) + body = " ".join(src.split()) + print("--- %s @%#x len=%d" % (f.getName(), e, len(src))) + print(" %s" % (body[:900])) + print("total functions in range: %d" % n) + + print() + print("=== C: FUN_1801a78f0 (builds the wrapper) in full ===") + s = dec(0x1801A78F0) + print("len=%d" % len(s)) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_rating_3.py b/fifa17-recon/tools/ghidra_queries/q_cd_rating_3.py new file mode 100644 index 0000000..6a2b073 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_rating_3.py @@ -0,0 +1,44 @@ +"""Q3: the PLAYER card publisher, the attribute publisher, and the merge. + +FUN_1800e6e20 emits OVERALL_RATING, CARD_LEVEL, CARD_RARITY, LEAGUE_ID, TEAM_ID, +LAST_NAME, PREFERRED_POSITION(+_ID), PLAY_STYLE, NATIONALITY(+ABBR/ABBR15/ASSET_ID), +CONTRACT_REMAINING and ASSET_ID -- that is the player card face. Get it in full and +map each published key to the accessor and therefore to a record offset. + +FUN_1800e65d0 / FUN_1800e96f0 emit ATTRIBUTE_NAME / _ABBR / _VALUE: the six bars. +FUN_1800ea400 and FUN_1800e86f0 and FUN_1800eb850 are the other publishers. + +HYPOTHESIS: every one of these is a pure field read off the same 0x180 record, so +the "ours vs the client's" split is decided ONLY in the merge FUN_180141660 / +FUN_180135890, never at draw time. + +CONTROL: FUN_1800e6e20 must publish LAST_NAME from record+0xc8 (an inline char +array the merge WRITES from the local DB) and OVERALL_RATING from record+0xb4 (a +field the merge is documented never to touch). Both in the same function, same +call form. If they do not both appear as record reads, my model of the publisher +is wrong. + +Also dump FUN_180135890 (players merge, the Q4 miss path) and FUN_180141660 in +full, plus the CARD_TYPE getter FUN_18003b800 and FUN_18003cbf0. +""" +import traceback + +FUNCS = [ + ("FUN_1800e6e20 PLAYER card publisher", 0x1800E6E20), + ("FUN_1800e65d0 attribute publisher A", 0x1800E65D0), + ("FUN_1800e96f0 attribute publisher B", 0x1800E96F0), + ("FUN_1800ea400 publisher", 0x1800EA400), + ("FUN_1800e86f0 publisher", 0x1800E86F0), + ("FUN_1800eb850 publisher", 0x1800EB850), + ("FUN_18015fa80 publisher", 0x18015FA80), + ("FUN_1800e80f0 publisher", 0x1800E80F0), +] + +try: + for label, ea in FUNCS: + src = dec(ea) + print("=" * 70) + print("### %s @%#x len=%d" % (label, ea, len(src))) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_rating_4.py b/fifa17-recon/tools/ghidra_queries/q_cd_rating_4.py new file mode 100644 index 0000000..50ccc30 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_rating_4.py @@ -0,0 +1,59 @@ +"""Q4: the deserializer's atom->offset table, the merge, and the blank-card path. + +Three questions, one batch. + +(a) THE WIRE SIDE. FUN_18013fe00 is the shared ITEM element deserializer. Print it + IN FULL (never truncated -- see the absence trap) so the atom dispatch can be + enumerated in every form: == 0xNN, != 0xNN, switch case labels and running-sum + ladders. Atoms of interest: 0x274 rating, 0x287 resourceId, 0x23 assetId, + 0x6b cardassetid, 0x6c cardsubtypeid, 0x23f playStyle, 0x24a preferredPosition, + 0x31 attributeList, 0x271 rareflag, 0x172 itemState, 0x173 itemType. + HYPOTHESIS: rating 0x274 -> record+0xb4, and playStyle 0x23f has NO arm (the + live sweep found no 0xfa anywhere in a record although we send playStyle 250, + and the player publisher reads PLAY_STYLE from +0x88). + CONTROL: the same enumeration must FIND 0x274 and 0x287, which are known to be + parsed. An enumeration that finds neither is a broken enumeration, not an + absent atom. + +(b) THE MERGE. FUN_180141660 (dispatch on record+0x4c) and FUN_180135890 (players + branch). Q4 asks what exactly the client failed to find for a blank card and + what the minimum viable identity is. + +(c) THE ACCESSORS the player publisher uses, in full and untruncated, so each + published UI key gets an exact record offset. +""" +import traceback + +ACC = [0x1801A8010, 0x1801A8020, 0x1801A8090, 0x1801A80A0, 0x1801A80C0, + 0x1801A8100, 0x1801A8110, 0x1801A8120, 0x1801A8130, 0x1801A8140, + 0x1801A8460, 0x1801A8480, 0x1801A8490, 0x1801A84A0, 0x1801A8550, + 0x1801A8590, 0x1801A85C0, 0x1801A85D0, 0x1801A8600, 0x1801A8620, + 0x1801A8640, 0x1801A8660, 0x1801A86A0, 0x1801A86B0, 0x1801A87F0, + 0x1801A8890, 0x1801A88D0, 0x1801A8950, 0x1801A8940] + +try: + print("=== C: accessors in full ===") + for a in ACC: + s = dec(a) + print("--- %#x len=%d" % (a, len(s))) + print(s) + + print() + print("=== B: merge dispatch FUN_180141660 ===") + s = dec(0x180141660) + print("len=%d" % len(s)) + print(s) + + print() + print("=== B2: players merge FUN_180135890 ===") + s = dec(0x180135890) + print("len=%d" % len(s)) + print(s) + + print() + print("=== A: item deserializer FUN_18013fe00 IN FULL ===") + s = dec(0x18013FE00, 600) + print("len=%d" % len(s)) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_rating_5.py b/fifa17-recon/tools/ghidra_queries/q_cd_rating_5.py new file mode 100644 index 0000000..a2f7048 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_rating_5.py @@ -0,0 +1,77 @@ +"""Q5: resourceId decomposition, the value mappers, and who reads assetId (+0x20). + +Established so far, and the reason for each question here: + * FUN_180141660's TAIL, reached on EVERY family including the no-merge default, + does record+0x54 = 3 if record+0xb4 >= 0x4b else 2 if >= 0x41 else 1. So +0x54 + is the bronze/silver/gold CARD LEVEL derived from rating, not itemType. + * The players merge queries `players` by record+0x18 & 0xffffff and, on 0 rows, + OVERWRITES rating=0x32, attrs=1, teamid=0x78d, nation=0xe, position=2, name=" ". + * The item deser routes atom 0x1d1 nation to +0x148 for family 1 and +0xde for + family 2 and NOWHERE otherwise, and atom 0x18a leagueId always to +0xe0. + +Remaining: + (a) FUN_180166ca0(resourceId, &out_byte, buf, &out_dword) -- the resourceId + decomposition. out_dword lands at record+0x18 and out_byte at record+0x24, + and +0x24 gates the "p%d.dds" FUTPlayerHeads photo registration. This decides + Q3: what resourceId actually has to look like. + CONTROL: DAT_7364642e642570 must read as "p%d.dds"; if it does not, my read of + the photo path is wrong. + (b) FUN_180136480 playStyle mapper, FUN_180166810 preferredPosition mapper, + FUN_180166660 itemState mapper, FUN_1801666f0 injuryType mapper. + (c) WHO READS record+0x20 (assetId)? Accessors FUN_1801a8020 and FUN_1801a8990 + read it. Enumerate their callers. HYPOTHESIS: only the staff/manager card + art path, which the merge itself fills from the local DB, so a wire assetId + is dead for every family. + CONTROL: the same enumeration run on FUN_1801a87f0 (+0xb4 rating) must return + the publishers we already found. An enumeration that returns nothing for + rating is broken. + (d) FUN_1800d84e0 -- the value written to record+0x30. + (e) FUN_1801356c0 manager merge, for the family table. +""" +import traceback + +try: + print("=== CONTROL: the format string at 0x1801eaf90..0x1801eafa8 region ===") + for a in (0x1801EAF98,): + print(" %#x -> %r" % (a, rd_str(a, 32))) + hits = find_all(b"p%d.dds") + print(" 'p%%d.dds' occurrences: %s" % " ".join("%#x" % h for h in hits)) + for h in find_all(b"FUTPlayerHeads"): + print(" FUTPlayerHeads @%#x" % h) + + print() + print("=== (a) FUN_180166ca0 resourceId decomposition ===") + print(dec(0x180166CA0)) + + print("=== (b) mappers ===") + for lbl, a in (("playStyle FUN_180136480", 0x180136480), + ("preferredPosition FUN_180166810", 0x180166810), + ("itemState FUN_180166660", 0x180166660), + ("injuryType FUN_1801666f0", 0x1801666F0)): + s = dec(a) + print("--- %s len=%d" % (lbl, len(s))) + print(s) + + print("=== (c) callers of the accessors ===") + for lbl, a in (("+0x20 assetId FUN_1801a8020", 0x1801A8020), + ("+0x20/+0x4c FUN_1801a8990", 0x1801A8990), + ("+0x1c cardassetid FUN_1801a8010", 0x1801A8010), + ("+0x18 resourceId FUN_1801a80a0", 0x1801A80A0), + ("CONTROL +0xb4 rating FUN_1801a87f0", 0x1801A87F0), + ("+0x54 level FUN_1801a80c0", 0x1801A80C0), + ("+0x54 FUN_1801a8870", 0x1801A8870), + ("+0x54 FUN_1801a88f0", 0x1801A88F0)): + print("--- %s" % lbl) + for frm, typ, fn, ent in xrefs_to(a): + print(" %#x %-10s %s(%#x)" % (frm, typ, fn, ent)) + + print() + print("=== (d) FUN_1800d84e0 (record+0x30) ===") + print(dec(0x1800D84E0)) + + print("=== (e) FUN_1801356c0 manager merge ===") + s = dec(0x1801356C0) + print("len=%d" % len(s)) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_rating_6.py b/fifa17-recon/tools/ghidra_queries/q_cd_rating_6.py new file mode 100644 index 0000000..89d1e46 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_rating_6.py @@ -0,0 +1,46 @@ +"""Q6: loose ends. + +(a) _DAT_1801f66a0 -- the qword the deser uses to initialise {cardsubtypeid(+0x50), + +0x54}. If +0x54 is the card LEVEL and no atom writes it, its pre-merge value + is whatever this constant carries. +(b) Does ANY writer of record+0x54 exist besides FUN_180141660's tail? Enumerate + every function that references the level accessors and, more directly, look for + the constant 0x4b/0x41 rating-tier ladder elsewhere. +(c) FUN_18003b800 -- the CARD_TYPE getter whose value 0x12 gates the manager + publisher. What is the enum? +(d) FUN_1801a7dd0 / FUN_1801a78f0 -- how the publisher's wrapper gets its record + pointer, to confirm wrapper+0x18 IS the 0x180 item record and not a copy. + CONTROL: FUN_1801a7100 already visibly does param_1[3] = *(param_2+0x10), i.e. + wrapper+0x18 = arg+0x10; if FUN_1801a7dd0 disagrees my offsets are off. +""" +import traceback + +try: + print("=== (a) _DAT_1801f66a0 ===") + q = qword(0x1801F66A0) + print(" qword %#018x -> low dword %#x (+0x50 init) high dword %#x (+0x54 init)" + % (q, q & 0xFFFFFFFF, q >> 32)) + + print() + print("=== (b) other rating-tier ladders: functions comparing a byte to 0x4b ===") + for lbl, a in (("FUN_180141660 tail", 0x180141660), + ("FUN_1801a80c0 accessor", 0x1801A80C0)): + print(" known: %s" % lbl) + print(" callers of FUN_1800d8330 (family map), for context:") + for frm, typ, fn, ent in xrefs_to(0x1800D8330): + print(" %#x %-10s %s(%#x)" % (frm, typ, fn, ent)) + + print() + print("=== (c) FUN_18003b800 CARD_TYPE getter ===") + print(dec(0x18003B800)) + print("=== FUN_18003b9d0 and FUN_18003cbf0 (manager publisher gates) ===") + print(dec(0x18003B9D0)) + print(dec(0x18003CBF0)) + + print("=== (d) wrapper construction ===") + for a in (0x1801A7DD0, 0x1801A78F0, 0x1801A7100): + s = dec(a) + print("--- %#x len=%d" % (a, len(s))) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_rating_7.py b/fifa17-recon/tools/ghidra_queries/q_cd_rating_7.py new file mode 100644 index 0000000..73d3c5a --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_rating_7.py @@ -0,0 +1,37 @@ +"""Q7: completeness check on the headline claim. + +CLAIM: the displayed rating is record+0xb4 and nothing else. +The evidence so far is that the literal OVERALL_RATING has exactly 4 xrefs and all 4 +functions call FUN_1801a87f0 (+0xb4). That is only airtight if OVERALL_RATING is the +ONLY key under which a rating reaches the UI. Enumerate every .rdata literal that +contains "RATING" or "OVR" and report its xrefs, so a second rating key cannot hide. + +CONTROL: the enumeration must re-find OVERALL_RATING with its 4 known xrefs. +""" +import traceback +try: + seen = {} + for pat in (b"RATING", b"OVR", b"Rating"): + for a in find_all(pat): + # walk back to the start of the C string + s = a + while s > a - 64: + try: + if mem.getByte(addr(s - 1)) & 0xFF == 0: + break + except Exception: + break + s -= 1 + txt = rd_str(s, 96) + if txt: + seen.setdefault(txt, set()).add(s) + for txt in sorted(seen): + for a in sorted(seen[txt]): + xs = xrefs_to(a) + if not xs: + continue + print("%-44s @%#x" % (txt[:44], a)) + for frm, typ, fn, ent in xs: + print(" %#x %-10s %s(%#x)" % (frm, typ, fn, ent)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_rating_8.py b/fifa17-recon/tools/ghidra_queries/q_cd_rating_8.py new file mode 100644 index 0000000..deb0217 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_rating_8.py @@ -0,0 +1,16 @@ +"""Q8: the four publishers of the bare "RATING" key -- do any bypass record+0xb4? + +If one of them reads a rating from the local DB instead of the item record, the +headline claim ("the displayed rating is ours") needs qualifying by screen. +CONTROL: FUN_18003ded0 also publishes FIRST_NAME/LAST_NAME, so it is card-shaped and +is the most likely counterexample. Print all four in full. +""" +import traceback +try: + for a in (0x18003DED0, 0x180053AA0, 0x1800F0970, 0x18005F910): + s = dec(a) + print("=" * 60) + print("### %#x len=%d" % (a, len(s))) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_rating_9.py b/fifa17-recon/tools/ghidra_queries/q_cd_rating_9.py new file mode 100644 index 0000000..5ceb26c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_rating_9.py @@ -0,0 +1,19 @@ +"""Q9: why is record+0xe0 zero although atom 0x18a leagueId writes local_a8? + +The struct-offset formula (offset = 0x188 - localnum) is validated empirically on +NINE fields (contract +0x8c, rating +0xb4, discardValue +0x38, cardsubtypeid +0x50, +teamid +0x94, nation +0x148, owners +0x48, cardassetid +0x1c, assetId +0x20), yet a +live read shows +0xe0 == 0 on all 22 records while we send leagueId 13..353. +Candidate: FUN_180134cb0, called at the tail of FUN_180135890, clears the manager +block for family 1. +CONTROL: whatever it does must NOT clear +0xb4/+0x94/+0x148, which the live read +shows surviving. +""" +import traceback +try: + for a in (0x180134CB0,): + s = dec(a) + print("### %#x len=%d" % (a, len(s))) + print(s) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_route_1.py b/fifa17-recon/tools/ghidra_queries/q_cd_route_1.py new file mode 100644 index 0000000..d488bd3 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_route_1.py @@ -0,0 +1,50 @@ +"""q_cd_route_1 -- the club route family. + +HYPOTHESIS + The 125-row action table at 0x1802caa20 binds each client-originated request to a + URL base out of the 48-row base table at 0x18021df80 (16-byte rows: char* template, + char* symbolic name). Exactly four rows carry base index 3 = "ut/%s/club": + ClubSearch (fn 0x180123a60), ClubStats (0x180123a70), StaffStats (0x1801247e0), + ConsumablesSearch (0x180123a80). If that is right, those four factories are the + COMPLETE set of club-route request builders and everything the client can emit on + /club is one of them. + +CONTROL + Decompile a NON-club factory from the same table in the same pass (PurchasedItems + 0x180124260, base 26 = ut/%s/purchased) and confirm it resolves to a different + request class with a different suffix builder. If the four club factories and the + control all decompile to the same shape, the shape is real; if the control comes + back empty while the targets do too, the method is broken, not the answer. + + Second control: the /stats/%s, /stats/staff and /consumables/%s literals were found + by RIP-relative displacement scan of .text on the ON-DISK PE, at 0x18012f5b5, + 0x18012b086 and 0x18013090e. Those three functions must turn out to be the URL + builders of three of the four classes. If they are not, the base-index reading of + column 1 is wrong. +""" +import traceback + +try: + TARGETS = [ + ("ClubSearch.factory", 0x180123a60), + ("ClubStats.factory", 0x180123a70), + ("StaffStats.factory", 0x1801247e0), + ("ConsumablesSearch.factory", 0x180123a80), + ("CONTROL PurchasedItems.factory", 0x180124260), + ("uses /stats/%s", 0x18012f5b5), + ("uses /stats/staff", 0x18012b086), + ("uses /consumables/%s", 0x18013090e), + ("uses both enum tables", 0x180166306), + ] + for label, ea in TARGETS: + f = func(ea) + print("=" * 78) + print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC")) + if f is None: + continue + print(" entry %s" % f.getEntryPoint()) + src = dec(f) + print(" len(src) = %d" % len(src)) + print(src) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_route_2.py b/fifa17-recon/tools/ghidra_queries/q_cd_route_2.py new file mode 100644 index 0000000..d6ffbaf --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_route_2.py @@ -0,0 +1,79 @@ +"""q_cd_route_2 -- the ClubSearch (FutStickerBookSearch) query-string builder. + +HYPOTHESIS + The club item list is the "StickerBookSearch" request (RS4 literal at 0x180221e48). + Its literal block holds "%s%s=%s" 0x180221e78, "%s%s=%d" 0x180221e80, "%s%s=" + 0x180221e94, "2017", "desc", "asc", so the query string is assembled key by key with + a separator+name+value printf, and the key names come out of the atom reverse + lookup FUN_180180cd0 exactly as they do in the /stats/%s builder FUN_18012f4f0 + (already decompiled: 6 cases, atoms 0x87 club / 0x389 year / 0xbd country / + 0x189 league / 0x1d7 newcards / 0xa5 consumables). + A RIP-displacement scan of .text on the on-disk PE puts every use of those three + printf formats in 0x18012deb0..0x18012e5bd, one contiguous region, plus two strays + at 0x180169357/0x1801693ac and one at 0x180163a1c. + +CONTROL + FUN_18012f4f0 (/stats/%s) decompiled cleanly in q_cd_route_1 through the same + helpers, so a NO FUNC or an empty body here is a fact about this address, not about + the harness. Also included: the two enum reverse-lookups FUN_180166300 (table + 0x180229ab0) and the sibling at 0x180166340 (table 0x180229c30), and their callers, + because those tables are the candidate ?type= vocabularies and a table with no + caller inside a URL builder proves nothing about the wire. + + Absence discipline: the atom-id switch in a builder is a `switch` with case labels, + so it is enumerated by reading the decompile in full, not by grepping "== 0x". +""" +import traceback + +try: + def show(label, ea, full=True): + f = func(ea) + print("=" * 78) + print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC")) + if f is None: + return None + print(" entry %s body %s" % (f.getEntryPoint(), f.getBody())) + src = dec(f) + print(" len(src) = %d" % len(src)) + if full: + print(src) + return f + + seen = set() + for ea in (0x18012deb0, 0x18012df5c, 0x18012e087, 0x18012e19f, 0x18012e202, + 0x18012e256, 0x18012e2d0, 0x18012e322, 0x18012e5bd, + 0x18012dfb0, 0x18012e003, 0x18012e36d, 0x18012e3b8, 0x18012e403, + 0x18012e44e, 0x18012e499, 0x18012e0db): + f = fm.getFunctionContaining(addr(ea)) + if f is None: + print("### query-fmt use @ %s -> NO FUNC" % hex(ea)) + continue + k = int(f.getEntryPoint().getOffset()) + if k in seen: + continue + seen.add(k) + show("query-fmt user (via %s)" % hex(ea), k) + + for ea in (0x180169357, 0x180163a1c): + f = fm.getFunctionContaining(addr(ea)) + if f is None: + print("### stray fmt use @ %s -> NO FUNC" % hex(ea)) + continue + k = int(f.getEntryPoint().getOffset()) + if k not in seen: + seen.add(k) + show("stray query-fmt user (via %s)" % hex(ea), k) + + show("enum revlookup table 0x180229ab0", 0x180166300) + show("enum revlookup table 0x180229c30", 0x180166340) + for ea in (0x180166300, 0x180166340): + f = fm.getFunctionContaining(addr(ea)) + if f is None: + continue + print("--- callers of %s:" % hex(ea)) + for c in sorted(set(int(x.getEntryPoint().getOffset()) for x in f.getCallingFunctions(mon))): + print(" %s %s" % (hex(c), fm.getFunctionAt(addr(c)).getName())) + + show("StaffStats suffix (/stats/staff user)", 0x18012b086) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_route_3.py b/fifa17-recon/tools/ghidra_queries/q_cd_route_3.py new file mode 100644 index 0000000..c72fe84 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_route_3.py @@ -0,0 +1,51 @@ +"""q_cd_route_3 -- the ?type= vocabulary itself, and the remaining club value maps. + +HYPOTHESIS + FUN_18012ddf0 (the ClubSearch / FutStickerBookSearch query builder, decompiled in + q_cd_route_2) writes the type= parameter from FUN_18012ec50(code), where code is + *(req+0x10) after a 3-case remap of *(req+0x14). FUN_18012ec50 is therefore the + COMPLETE code->string map for club?type=, and enumerating its cases (switch labels + AND == AND != AND any sub/dec ladder, read from a full-length decompile) closes the + vocabulary. The one branch that bypasses it is code 0xf, which picks + badge/kit/stadium/ball/equippables from *(req+0x30). + +CONTROLS + * FUN_18012ec50 is decompiled alongside three sibling value maps used by the same + builder for other parameters -- FUN_1800d8b50 (position=), FUN_180166620 + (formation=), FUN_18012ee20 (state=). If FUN_18012ec50 came back as a stub while + those three came back as real tables, the stub is a fact; if all four are stubs + the method is at fault. + * Both enum reverse-lookups over the two candidate tables are re-printed with their + callers, so "table 0x180229c30 is/is not the type= vocabulary" is decided by a + caller edge and not by the table's contents looking plausible. + * len(src) is printed for every function and every body is printed in full, so no + absence claim here rests on a truncated decompile. +""" +import traceback + +try: + def show(label, ea): + f = fm.getFunctionContaining(addr(ea)) if ea else None + print("=" * 78) + print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC")) + if f is None: + return + print(" entry %s body %s" % (f.getEntryPoint(), f.getBody())) + src = dec(f) + print(" len(src) = %d" % len(src)) + print(src) + try: + cs = sorted(set(int(x.getEntryPoint().getOffset()) + for x in f.getCallingFunctions(mon))) + print(" callers: %s" % ", ".join(hex(c) for c in cs)) + except Exception as e: + print(" callers: <%s>" % e) + + show("type= value map FUN_18012ec50", 0x18012ec50) + show("position= value map FUN_1800d8b50", 0x1800d8b50) + show("formation= value map FUN_180166620", 0x180166620) + show("state= value map FUN_18012ee20", 0x18012ee20) + show("enum revlookup 0x180229ab0", 0x180166300) + show("enum revlookup 0x180229c30", 0x180166340) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_route_4.py b/fifa17-recon/tools/ghidra_queries/q_cd_route_4.py new file mode 100644 index 0000000..547e8cc --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_route_4.py @@ -0,0 +1,54 @@ +"""q_cd_route_4 -- close the bound: how base+suffix are actually composed. + +HYPOTHESIS + Column 1 of each 48-byte row of the 125-row action table at 0x1802caa20 is an index + into the 48-row URL-base table at 0x18021df80, and the URL is base-template then the + class's own suffix/query builder. Exactly four rows carry index 3 = "ut/%s/club". + If that is right, the club route family is closed by the table. + + KNOWN SOFT SPOT, the reason for this query: no row carries index 43 = "ut/v2/%s/store", + yet ut/v2/%s/store is a live-proven route. So either the base index can be overridden + at runtime, or the base table is indexed from somewhere else as well. Until that is + settled the "exactly four" bound is a strong default, not an absolute. + +CONTROL + The one RIP-relative reference to the action table found by scanning .text of the + on-disk PE is at 0x180123f46. Decompiling its container should show the row layout + being read (name, base index, upper name, flag, factory) -- if the field it reads at + +0x08 is NOT used as a table index, the base-index reading is wrong and every + "exactly four" statement in this dimension has to be withdrawn. + + Second target: 0x18012ea74 is the only reference to the literal "club" at + 0x180221e40, which sits inside the FutStickerBookSearch literal block. Printing it + says whether the club-search class composes its own base (which would make the base + table irrelevant for it) or uses the literal for something else entirely. + + Everything is printed with len(src) and in full; no absence claim rests on a + truncated body. +""" +import traceback + +try: + def show(label, ea): + f = fm.getFunctionContaining(addr(ea)) + print("=" * 78) + print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC")) + if f is None: + return + print(" entry %s body %s" % (f.getEntryPoint(), f.getBody())) + src = dec(f) + print(" len(src) = %d" % len(src)) + print(src) + try: + cs = sorted(set(int(x.getEntryPoint().getOffset()) + for x in f.getCallingFunctions(mon))) + print(" callers: %s" % ", ".join(hex(c) for c in cs)) + except Exception as e: + print(" callers: <%s>" % e) + + show("action-table reader (only ref to 0x1802caa20)", 0x180123f46) + show("only ref to \"club\" literal 0x180221e40", 0x18012ea74) + show("URL append helper used by every suffix builder", 0x180008020) + show("StaffStats suffix thunk (raw addr 0x18012b080)", 0x18012b080) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_route_5.py b/fifa17-recon/tools/ghidra_queries/q_cd_route_5.py new file mode 100644 index 0000000..da135d0 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_route_5.py @@ -0,0 +1,44 @@ +"""q_cd_route_5 -- is column 1 of the action row really the URL-base index? + +HYPOTHESIS + Row N of the action table 0x1802caa20 (48-byte rows) has column 1 = index into the + 16-byte-stride (template, symbolic name) table at 0x18021df80, and the four club + actions carry index 3. RIP-displacement scan of .text on the on-disk PE finds only + four references anywhere near that table: 0x180123713 (-24), 0x180123790 (-64), + 0x18012437e (+8) and 0x1801db62c (-64). Decompiling their containers should show + one of them doing table[i*2] / table[i*2+1] with i coming from the action row. + +CONTROL / FALSIFIER + ut/v2/%s/store (index 43) is a LIVE-PROVEN route and NO action row carries index 43. + So if these functions show the index arriving only from the action row, the club + bound is exact for every action in the table but the store proves some other path + exists, and the bound must be stated with that caveat. If instead they show a + runtime override (a per-request base field, or a second table), the "exactly four + club actions" claim is a default and not a closure, and this query is what says so. + + Every body is printed in full with len(src). +""" +import traceback + +try: + def show(label, ea): + f = fm.getFunctionContaining(addr(ea)) + print("=" * 78) + print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC")) + if f is None: + return + print(" entry %s body %s" % (f.getEntryPoint(), f.getBody())) + src = dec(f) + print(" len(src) = %d" % len(src)) + print(src) + try: + cs = sorted(set(int(x.getEntryPoint().getOffset()) + for x in f.getCallingFunctions(mon))) + print(" callers: %s" % ", ".join(hex(c) for c in cs)) + except Exception as e: + print(" callers: <%s>" % e) + + for ea in (0x180123713, 0x180123790, 0x18012437e, 0x1801db62c): + show("base-table ref", ea) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_state_1.py b/fifa17-recon/tools/ghidra_queries/q_cd_state_1.py new file mode 100644 index 0000000..599ab39 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_state_1.py @@ -0,0 +1,196 @@ +"""D3 Q1: the itemState vocabulary and the lifecycle-field arms in the shared item deser. + +HYPOTHESIS: FUN_18013fe00 (shared ITEM element deser) has an arm for atom 0x172 +(itemState) that reads a STR and maps it through the enum table documented at +0x180229d20 (stride 0x10: WAITING_FOR_GAME, inGame, forSale, offered, activeBadge, +activeHomeKit, activeAwayKit, activeBall, activeStadium, active). That documented +list omits "free", which we send on every card, so either the table is longer than +recorded or "free" is the default/no-match value. Also locate the arms for +pile 0x226, pileType 0x228, owners 0x207, untradeable 0x361, untradeableCount 0x362, +tradeId 0x331, loans 0x19b, itemLoans 0x16f, duplicateItemLoans 0xed, +tradeState 0x335, contract 0xa9?(unknown, resolved from tsv below). + +CONTROL (same syntactic form as the target): the item deser is known to store our +discardValue (atom 0xd7) at item+0x38. If the immediate-scan below does not find +0xd7 in FUN_18013fe00 in the SAME form (case label / cmp / ladder) then the scan is +broken and every absence claim in this batch is void. Second control: 0x172 itself +appears in docs as reaching the enum table, so xrefs_to(0x180229d20) must be +non-empty. + +ABSENCE TRAP GUARD: we do not grep "== 0x". We enumerate every immediate operand of +every instruction in the function (scalar operands of any size), which catches +`cmp ==`, `cmp !=`, jump-table `case` labels only indirectly, and running-sum +sub/dec ladders (the ladder deltas are computed and searched too). Jump tables are +handled separately by walking every switch construct Ghidra knows about. + +OUTPUT: full decompiles printed untruncated with len(src); full table dump; full +immediate census. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/" +os.makedirs(OUT, exist_ok=True) + +ATOMS = { + 0x172: "itemState", 0x226: "pile", 0x227: "pileSizeClientData", 0x228: "pileType", + 0x207: "owners", 0x361: "untradeable", 0x362: "untradeableCount", + 0x331: "tradeId", 0x332: "tradepile", 0x333: "tradePile", + 0x19b: "loans", 0x16f: "itemLoans", 0xed: "duplicateItemLoans", + 0x335: "tradeState", 0xd7: "discardValue(CONTROL)", 0x19: "allowUntradeableForSquadBuildingSets", +} + + +def dump(tag, va, echo=True): + f = func(va) + if f is None: + print("%s %#x -> NO FUNCTION" % (tag, va)) + return "" + src = dec(va) + print("=" * 78) + print("%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % (tag, va, f.getName(), len(src))) + print("=" * 78) + if echo: + print(src) + with open(OUT + "q1_%s_%x.c" % (tag, va), "w") as fh: + fh.write(src) + return src + + +def immediates(va): + """Every scalar operand of every instruction in the function containing va.""" + f = func(va) + out = {} + if f is None: + return out + body = f.getBody() + 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))) + print("[immediates] %s %#x: %d instructions, %d distinct scalars" + % (f.getName(), va, n, len(out))) + return out + + +def report_atoms(va, imms): + """Direct hits + running-sum ladder reconstruction.""" + print("--- atom census for %#x (%s)" % (va, fname(va))) + for a, name in sorted(ATOMS.items()): + hits = imms.get(a, []) + if hits: + print(" DIRECT 0x%-4x %-38s x%d first@%#x %s" + % (a, name, len(hits), hits[0][0], hits[0][1])) + # ladder reconstruction: walk instructions in address order, keep a running + # sum of sub/dec/add immediates on the dispatch register, report any partial + # sum that equals one of our atoms. + f = func(va) + if f is None: + return + it = listing.getInstructions(f.getBody(), True) + running = {} + while it.hasNext(): + ins = it.next() + m = ins.getMnemonicString().lower() + if m not in ("sub", "add", "dec", "inc", "cmp", "lea"): + continue + try: + reg = str(ins.getOpObjects(0)[0]) + except Exception: + continue + val = None + for o in ins.getOpObjects(1) if ins.getNumOperands() > 1 else []: + try: + val = int(o.getValue()) + except Exception: + pass + if m == "dec": + val = 1 + if m == "inc": + val = -1 + if val is None: + continue + if m in ("sub", "dec"): + running[reg] = running.get(reg, 0) + val + elif m in ("add", "inc"): + running[reg] = running.get(reg, 0) - val + cur = running.get(reg) + if cur in ATOMS: + print(" LADDER 0x%-4x %-38s @%#x %s (running sum on %s)" + % (cur, ATOMS[cur], int(ins.getAddress().getOffset()), ins, reg)) + + +def switch_labels(va): + """Every switch case label Ghidra resolved inside the function.""" + f = func(va) + if f is None: + return + from ghidra.program.model.symbol import FlowType # noqa + it = listing.getInstructions(f.getBody(), True) + tot = 0 + while it.hasNext(): + ins = it.next() + ft = ins.getFlowType() + if ft is not None and ft.isJump() and ft.isComputed(): + tgts = ins.getFlows() + print(" SWITCH @%#x %s -> %d targets" % (int(ins.getAddress().getOffset()), ins, len(tgts))) + tot += len(tgts) + if tot: + print(" (computed-jump targets total %d)" % tot) + + +try: + print("###### PART A: the itemState enum table at 0x180229d20") + # walk generously in both directions; entries are (char* name, ...) stride 0x10 + base = 0x180229d20 + for off in range(-0x200, 0x400, 0x10): + a = base + off + try: + p = qword(a) + q = qword(a + 8) + except Exception: + continue + s = "" + if 0x180000000 <= p < 0x181000000: + try: + s = rd_str(p, 64) + except Exception: + s = "" + print(" %#x (%+#5x) p=%#018x q=%#018x str=%r" % (a, off, p, q, s)) + + print() + print("###### PART B: who references the table") + for t in (0x180229d20,): + for frm, typ, fn, ent in xrefs_to(t): + print(" xref %#x %s in %s (%#x)" % (frm, typ, fn, ent)) + + print() + print("###### PART C: shared item deser FUN_18013fe00") + src = dump("itemdeser", 0x18013fe00) + imms = immediates(0x18013fe00) + report_atoms(0x18013fe00, imms) + switch_labels(0x18013fe00) + + print() + print("###### PART D: full sorted immediate dump for 0x18013fe00 (values < 0x1000)") + for v in sorted(k for k in imms if k < 0x1000): + print(" 0x%-4x n=%-3d %s" % (v, len(imms[v]), imms[v][0][1])) + + print() + print("###### PART E: callees of the item deser") + try: + for c in callees(0x18013fe00): + print(" callee", c) + except Exception as e: + print(" callees() failed:", e) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_state_10.py b/fifa17-recon/tools/ghidra_queries/q_cd_state_10.py new file mode 100644 index 0000000..17208e9 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_state_10.py @@ -0,0 +1,21 @@ +"""D3 Q10 (control): is FUN_1800e2a40 really vtable slot +0x40 of the object that +FUN_18003e370 calls? The whole action-flag attribution rests on that. Find every +.rdata/.data table containing the pointer and print the slot index and neighbours; +slot +0x40 = index 8. CONTROL in the same run: FUN_18013fe00 is a known vtable +member? no -- instead use FUN_1801a7260, whose two DATA xrefs we already saw, and +check the pointer search finds them.""" +import struct, traceback +try: + for tag, va in (("actionflags_1800e2a40", 0x1800e2a40), + ("gate_1801a7260(CONTROL: 2 DATA xrefs expected)", 0x1801a7260)): + hits = find_all(struct.pack(" dword %#x (RVA of 0x1801a7260 is 0x1a7260)" % (a, v)) + except Exception as e: + print(" %#x unreadable %s" % (a, e)) + + print() + print("### CONTROL: the known item vtable 0x1801eaac0") + for k in range(4): + t = qword(0x1801eaac0 + k*8) + print(" slot %+#4x %#x %s" % (k*8, t, fname(t) if 0x180000000 <= t < 0x181000000 else "")) + + print() + print("### walk back from 0x180215b50 to the start of its pointer table") + a = 0x180215b50 + start = a + while True: + p = a - 8 + try: + t = qword(p) + except Exception: + break + if not (0x180001000 <= t < 0x1801d0000): + break + start = p + a = p + if 0x180215b50 - start > 0x800: + break + print(" table start %#x, FUN_1800e2a40 is at %#x -> slot %#x (index %d)" + % (start, 0x180215b50, 0x180215b50 - start, (0x180215b50 - start)//8)) + for off in range(0, 0x180215b50 - start + 0x40, 8): + t = qword(start + off) + print(" %+#6x %#x %s" % (off, t, fname(t) if 0x180000000 <= t < 0x181000000 else "")) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_state_12.py b/fifa17-recon/tools/ghidra_queries/q_cd_state_12.py new file mode 100644 index 0000000..c403e40 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_state_12.py @@ -0,0 +1,22 @@ +"""D3 Q12: close the last gap -- is FUN_1800e2a40 the thing FUN_18003e370 calls? +Its only 8-byte pointer slot is at +0xd0 of one table, not +0x40, so either the call +goes through a thunk or the attribution is wrong. Print all callers, and print the +service constructor FUN_180018bd0 so the interface can be named. +CONTROL: the semantic anchor is independent of the vtable -- byte[2] of the output +array is computed as (isPlayer && squadHasRoom && !alreadyInSquad), which can only be +TO_ACTIVE_SQUAD, the 3rd of the eight names. If callers show a thunk, both lines of +evidence agree; if not, the semantic anchor still stands alone.""" +import traceback +try: + print("### callers of FUN_1800e2a40") + for c in callers(0x1800e2a40): + print(" ", c) + for frm, typ, fn, ent in xrefs_to(0x1800e2a40): + print(" xref %#x %s in %s (%#x)" % (frm, typ, fn, ent)) + print() + print("### FUN_180018bd0 (the service FUN_18003e370 asks for)") + print(dec(0x180018bd0)) + print("### FUN_18003cbf0 (CARD_ID extraction, 2nd arg to slot 0x40)") + print(dec(0x18003cbf0)) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_state_2.py b/fifa17-recon/tools/ghidra_queries/q_cd_state_2.py new file mode 100644 index 0000000..0337474 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_state_2.py @@ -0,0 +1,167 @@ +"""D3 Q2: the itemState string<->enum functions, and who READS the state slots. + +ESTABLISHED BEFORE THIS QUERY (q_cd_state_1 + live read, both controls passed): + the deser's stack struct base is local_188 (anchored by local_118 = &PTR_LAB_1801eaac0 + landing at record+0x70, the known item vtable), so record_off = 0x188 - N. + itemState (atom 0x172) -> FUN_180166660(str) -> record +0x5c (live: 1 == "free") + owners (atom 0x207) -> record +0x48 u8 (live: 1) + untradeable(atom 0x361)-> record +0x49 u8 = (untradeable == FALSE), a TRADEABLE flag + (live: 0 on all 22) + the enum table is at 0x180229cc0 (NOT 0x180229d20, which is mid-table): + invalid 0, free 1, WAITING_FOR_GAME 2, inGame 2, forSale 5, offered 6, + activeBadge 0x64, activeHomeKit 0x65, activeAwayKit 0x66, activeBall 0x67, + activeStadium 0x68, active 0xff, terminator {NULL, 0xffffffff} + +HYPOTHESIS: FUN_180166660 walks that table and returns a default for no-match; there is +a matching enum->string writer used when the client SENDS itemState back; and the UI +gating for "list on transfer market" / "quick sell" reads +0x49 and/or +0x5c. + +CONTROL for the reader scan (same syntactic form as the target -- a memory operand with +a small displacement): the scan must also find the KNOWN readers of +0x38/+0x3c +(discardValue) and of +0x70 (the vtable). If displacement 0x38 or 0x70 comes back with +zero hits the scan is broken and every absence below is void. + +OUTPUT: full decompiles (len printed), full table walk, full grouped reader census. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/" +os.makedirs(OUT, exist_ok=True) + +STATE_VALUES = {0, 1, 2, 5, 6, 0x64, 0x65, 0x66, 0x67, 0x68, 0xff} + + +def dump(tag, va): + f = func(va) + if f is None: + print("%s %#x -> NO FUNCTION" % (tag, va)) + return "" + src = dec(va) + print("=" * 78) + print("%s %#x %s len(src)=%d (FULL)" % (tag, va, f.getName(), len(src))) + print("=" * 78) + print(src) + with open(OUT + "q2_%s_%x.c" % (tag, va), "w") as fh: + fh.write(src) + return src + + +try: + print("###### A. string->enum helpers used by the item deser") + for tag, va in (("itemState_0x172", 0x180166660), + ("injuryType_0x168", 0x1801666f0), + ("prefPos_0x24a", 0x180166810), + ("resourceId_split_0x287", 0x180166ca0), + ("playStyle_0x23f", 0x180136480)): + dump(tag, va) + + print() + print("###### B. every xref into the itemState table block 0x180229cc0..0x180229d80") + for a in range(0x180229cc0, 0x180229d90, 8): + xs = xrefs_to(a) + for frm, typ, fn, ent in xs: + print(" table%+#5x %#x xref from %#x %s in %s (%#x)" + % (a - 0x180229cc0, a, frm, typ, fn, ent)) + + print() + print("###### C. xrefs to each itemState string literal (finds the enum->string writer)") + for name in (b"invalid\x00", b"free\x00", b"WAITING_FOR_GAME\x00", b"inGame\x00", + b"forSale\x00", b"offered\x00", b"activeBadge\x00", b"activeHomeKit\x00", + b"activeAwayKit\x00", b"activeBall\x00", b"activeStadium\x00", + b"tradepile\x00", b"club\x00", b"trade\x00", b"pile\x00"): + hits = find_all(name) + print(" %-20s occurrences=%d" % (name.decode().strip("\x00"), len(hits))) + for h in hits: + for frm, typ, fn, ent in xrefs_to(h): + print(" @%#x xref %#x %s in %s (%#x)" % (h, frm, typ, fn, ent)) + + print() + print("###### D. callers of FUN_180166660 (every itemState parse site)") + try: + for c in callers(0x180166660): + print(" caller", c) + except Exception as e: + print(" callers() failed:", e) + + print() + print("###### E. reader census: instructions with memory displacement 0x49 / 0x5c") + print(" CONTROLS in the same scan: 0x38 and 0x70") + want = {0x49: [], 0x5c: [], 0x38: [], 0x70: []} + total = 0 + it = listing.getInstructions(True) + while it.hasNext(): + ins = it.next() + total += 1 + try: + n = ins.getNumOperands() + except Exception: + continue + for i in range(n): + try: + objs = ins.getOpObjects(i) + except Exception: + continue + if len(objs) < 2: + continue + has_reg = any(hasattr(o, "getName") for o in objs) + if not has_reg: + continue + for o in objs: + v = None + try: + v = int(o.getValue()) + except Exception: + continue + if v in want: + f = fm.getFunctionContaining(ins.getAddress()) + want[v].append((int(ins.getAddress().getOffset()), + f.getName() if f else "?", + int(f.getEntryPoint().getOffset()) if f else 0, + str(ins))) + print(" scanned %d instructions" % total) + for d in (0x38, 0x70, 0x49, 0x5c): + print(" disp %#04x -> %d hits (CONTROL)" % (d, len(want[d])) + if d in (0x38, 0x70) else " disp %#04x -> %d hits" % (d, len(want[d]))) + + print() + print("###### F. functions touching [reg+0x49], grouped (this displacement is rare)") + from collections import defaultdict + g = defaultdict(list) + for a, fn, ent, txt in want[0x49]: + g[(ent, fn)].append((a, txt)) + for (ent, fn), lst in sorted(g.items()): + print(" %s (%#x) n=%d" % (fn, ent, len(lst))) + for a, txt in lst: + print(" %#x %s" % (a, txt)) + + print() + print("###### G. functions touching [reg+0x5c] that ALSO contain an itemState value") + g2 = defaultdict(list) + for a, fn, ent, txt in want[0x5c]: + g2[(ent, fn)].append((a, txt)) + print(" total distinct functions touching +0x5c: %d" % len(g2)) + for (ent, fn), lst in sorted(g2.items()): + if not ent: + continue + f = func(ent) + if f is None: + continue + imms = set() + it2 = listing.getInstructions(f.getBody(), True) + while it2.hasNext(): + ins = it2.next() + for i in range(ins.getNumOperands()): + for o in ins.getOpObjects(i): + try: + imms.add(int(o.getValue())) + except Exception: + pass + hit = imms & {0x64, 0x65, 0x66, 0x67, 0x68, 0xff, 5, 6} + if 0x64 in imms or 0x67 in imms or 0x68 in imms: + print(" *** %s (%#x) n=%d state-ish immediates=%s" + % (fn, ent, len(lst), sorted(hex(x) for x in hit))) + for a, txt in lst: + print(" %#x %s" % (a, txt)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_state_3.py b/fifa17-recon/tools/ghidra_queries/q_cd_state_3.py new file mode 100644 index 0000000..6701640 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_state_3.py @@ -0,0 +1,170 @@ +"""D3 Q3: (a) where the lifecycle atoms are parsed AT ALL, (b) who reads the state +slots on the item record, (c) who writes the pile slot +0x60. + +ESTABLISHED (q1/q2 + live, controls passed): record_off = 0x188 - N for the deser's +stack struct; itemState -> +0x5c (free==1 live), owners -> +0x48 (1 live), +!untradeable -> +0x49 (0 live), discardValue sent -> +0x38, computed -> +0x3c, +vtable -> +0x70, pile -> +0x60 (1 club / 6 purchased, NOT from the wire). + +(a) THE ABSENCE QUESTION, done soundly. Every SAX deserializer in this DLL ends its +key loop with the value-SKIP FUN_180135ff0 as the default arm. So the set of callers of +FUN_180135ff0 IS the set of deserializers -- a bounded, enumerable population. We +decompile each and look for the lifecycle atoms in ALL dispatch forms at once by +matching the decompiler's own text: `case 0xNNN:`, `== 0xNNN`, `!= 0xNNN`, and +`< 0xNNN` / `- 0xNNN` ladder steps. +CONTROL: the same scan must find 0xd7 (discardValue) and 0x172 (itemState) inside +FUN_18013fe00, both of which we have already read with our own eyes as `case` labels. +If those two do not come back, the scan is broken and no absence below counts. + +(b) publisher hunt: group every function by the set of register+displacement memory +operands it uses; a function that touches 0x38/0x3c/0x5c/0x49/0x60 together is reading +the item record. +CONTROL: the same grouping must rediscover FUN_18013fe00 itself as a heavy toucher. + +(c) pile writers: instructions storing an immediate 1 or 6 into [reg+0x60]. +""" +import traceback, os, re +from collections import defaultdict + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/" +os.makedirs(OUT, exist_ok=True) + +LIFE = {0x172: "itemState", 0x226: "pile", 0x227: "pileSizeClientData", 0x228: "pileType", + 0x207: "owners", 0x361: "untradeable", 0x362: "untradeableCount", + 0x331: "tradeId", 0x332: "tradepile", 0x333: "tradePile", + 0x19b: "loans", 0x16f: "itemLoans", 0xed: "duplicateItemLoans", + 0x335: "tradeState", 0x1c0: "maximumTradePileSize", + 0xd7: "discardValue(CONTROL)"} + +PAT = {a: re.compile(r"(case %s:|== %s\b|!= %s\b|< %s\b|- %s\b|\+ %s\b)" + % tuple([hex(a)] * 6)) for a in LIFE} + + +def dump(tag, va, echo=True): + f = func(va) + if f is None: + print("%s %#x -> NO FUNCTION" % (tag, va)) + return "" + src = dec(va) + print("=" * 78) + print("%s %#x %s len(src)=%d (FULL)" % (tag, va, f.getName(), len(src))) + print("=" * 78) + if echo: + print(src) + with open(OUT + "q3_%s_%x.c" % (tag, va), "w") as fh: + fh.write(src) + return src + + +try: + print("###### A. deserializer census (callers of the value-SKIP FUN_180135ff0)") + ents = sorted({e for (_, _, _, e) in xrefs_to(0x180135ff0) if e}) + print(" deserializer population: %d functions" % len(ents)) + rows = [] + for i, e in enumerate(ents): + try: + src = dec(e, 120) + except Exception as ex: + print(" dec failed %#x %s" % (e, ex)) + continue + hit = {} + for a, nm in LIFE.items(): + m = PAT[a].findall(src) + if m: + hit[a] = len(m) + if hit: + rows.append((e, fname(e), len(src), hit)) + print(" functions mentioning at least one lifecycle atom: %d" % len(rows)) + for e, nm, ln, hit in rows: + print(" %#x %-18s len=%-7d %s" + % (e, nm, ln, ", ".join("%s(0x%x)x%d" % (LIFE[a], a, n) + for a, n in sorted(hit.items())))) + print() + print(" --- per-atom summary over the whole deserializer population") + for a, nm in sorted(LIFE.items()): + fs = [(e, n) for e, n, _, h in rows if a in h] + print(" 0x%-4x %-24s parsed in %d deserializer(s): %s" + % (a, nm, len(fs), ", ".join("%s(%#x)" % (n, e) for e, n in fs) or "NONE")) + + print() + print("###### B. publisher hunt: displacement fingerprints over the whole .text") + disp_of = defaultdict(set) + count_of = defaultdict(lambda: defaultdict(int)) + it = listing.getInstructions(True) + tot = 0 + while it.hasNext(): + ins = it.next() + tot += 1 + f = None + for i in range(ins.getNumOperands()): + try: + objs = ins.getOpObjects(i) + except Exception: + continue + if len(objs) < 2: + continue + regs = [o for o in objs if hasattr(o, "getName")] + if not regs: + continue + if any(str(r) in ("RSP", "RBP", "ESP", "EBP") for r in regs): + continue + for o in objs: + try: + v = int(o.getValue()) + except Exception: + continue + if 0 <= v <= 0x200: + if f is None: + f = fm.getFunctionContaining(ins.getAddress()) + if f is None: + break + f = int(f.getEntryPoint().getOffset()) + disp_of[f].add(v) + count_of[f][v] += 1 + print(" scanned %d instructions, %d functions with reg+disp operands" % (tot, len(disp_of))) + KEY = {0x38, 0x3c, 0x49, 0x5c, 0x60} + cands = [(len(KEY & d), e, sorted(KEY & d)) for e, d in disp_of.items() if len(KEY & d) >= 3] + cands.sort(reverse=True) + print(" functions touching >=3 of {0x38,0x3c,0x49,0x5c,0x60}: %d" % len(cands)) + for k, e, s in cands[:60]: + print(" %#x %-18s %d/%d %s" % (e, fname(e), k, len(KEY), [hex(x) for x in s])) + print(" CONTROL: is FUN_18013fe00 in the fingerprint map?", + 0x18013fe00 in disp_of, + sorted(hex(x) for x in (KEY & disp_of.get(0x18013fe00, set())))) + + print() + print("###### C. every function whose displacement set contains 0x5c AND 0x49") + for e, d in sorted(disp_of.items()): + if 0x5c in d and 0x49 in d: + print(" %#x %-18s" % (e, fname(e))) + + print() + print("###### D. small getters: functions <= 0x20 bytes that read [reg+0x5c] or [reg+0x49] or [reg+0x60]") + for e, d in sorted(disp_of.items()): + f = func(e) + if f is None: + continue + sz = int(f.getBody().getNumAddresses()) + if sz <= 0x20 and d & {0x49, 0x5c, 0x60, 0x48}: + print(" %#x %-18s size=%d disp=%s" % (e, fname(e), sz, sorted(hex(x) for x in d))) + + print() + print("###### E. writers of the pile slot: STORE of imm into [reg+0x60]") + it = listing.getInstructions(True) + while it.hasNext(): + ins = it.next() + s = str(ins) + if "+ 0x60]" in s and ins.getMnemonicString().upper() == "MOV" and s.rstrip().endswith((",0x1", ",0x6", ",0x2", ",0x3", ",0x4", ",0x5", ",0x0")): + if "RSP" in s or "RBP" in s: + continue + f = fm.getFunctionContaining(ins.getAddress()) + print(" %#x %-46s in %s" % (int(ins.getAddress().getOffset()), s, + f.getName() if f else "?")) + + print() + print("###### F. the two byte-0x49 comparators found in q2") + for tag, va in (("cmp49_a", 0x1801a7260), ("cmp49_b", 0x1801a8940), ("wr49", 0x180130d10)): + dump(tag, va) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_state_4.py b/fifa17-recon/tools/ghidra_queries/q_cd_state_4.py new file mode 100644 index 0000000..7a2f20c --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_state_4.py @@ -0,0 +1,139 @@ +"""D3 Q4: who CONSUMES the state slots, and the transfer/quick-sell vocabulary. + +The q3 publisher hunt had a control that came back empty for a reason I can state: +FUN_18013fe00 builds the record on the STACK (RSP-relative), and I had excluded +RSP/RBP bases, so the deser cannot appear. That control was therefore uninformative, +not passed. Here the fingerprint is rebuilt around offsets that are only meaningful on +a fully built item record and that a consumer must reach through a register: + 0x3c discardValue-computed, 0xb4 rating, 0x146 preferredPosition, 0x148 nation, + 0x154 leagueId, 0x94 teamid, 0x5c itemState, 0x49 tradeable-flag, 0x60 pile +CONTROL for this scan: it must rediscover functions that touch 0xb4 AND 0x146 AND +0x148 together, because the UI certainly draws rating, position and nation from the +same object. Zero such functions => the scan is broken and nothing below counts. + +Also decompiled in full: + FUN_1800515e0 touches all five of {0x38,0x3c,0x49,0x5c,0x60} -- candidate item + copy-constructor, which if true independently re-derives the layout + FUN_180130d10 the untradeableCount deser that WRITES [reg+0x49] + FUN_180128600 / FUN_180128e30 the only two deserializers that parse `pile` 0x226 + FUN_18013e410 tradeId 0x331 + tradeState 0x335 + FUN_180148b70 the second `untradeable` 0x361 arm + FUN_180138e10 itemLoans 0x16f + duplicateItemLoans 0xed + +And a literal hunt for the transfer-market / quick-sell vocabulary with xrefs. +""" +import traceback, os +from collections import defaultdict + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/" +os.makedirs(OUT, exist_ok=True) + + +def dump(tag, va, echo=True): + f = func(va) + if f is None: + print("%s %#x -> NO FUNCTION" % (tag, va)) + return "" + src = dec(va) + print("=" * 78) + print("%s %#x %s len(src)=%d (FULL)" % (tag, va, f.getName(), len(src))) + print("=" * 78) + if echo: + print(src) + with open(OUT + "q4_%s_%x.c" % (tag, va), "w") as fh: + fh.write(src) + return src + + +try: + print("###### A. item-record consumer fingerprint over the whole .text") + disp_of = defaultdict(set) + it = listing.getInstructions(True) + tot = 0 + while it.hasNext(): + ins = it.next() + tot += 1 + f = None + for i in range(ins.getNumOperands()): + try: + objs = ins.getOpObjects(i) + except Exception: + continue + if len(objs) < 2: + continue + regs = [o for o in objs if hasattr(o, "getName")] + if not regs or any(str(r) in ("RSP", "RBP", "ESP", "EBP") for r in regs): + continue + for o in objs: + try: + v = int(o.getValue()) + except Exception: + continue + if 0 <= v <= 0x200: + if f is None: + ff = fm.getFunctionContaining(ins.getAddress()) + if ff is None: + break + f = int(ff.getEntryPoint().getOffset()) + disp_of[f].add(v) + print(" scanned %d instructions" % tot) + CTRL = {0xb4, 0x146, 0x148} + ctrl_fns = [e for e, d in disp_of.items() if CTRL <= d] + print(" CONTROL {0xb4,0x146,0x148} all present in %d functions: %s" + % (len(ctrl_fns), [("%s(%#x)" % (fname(e), e)) for e in sorted(ctrl_fns)][:20])) + ITEM = {0x3c, 0xb4, 0x146, 0x148, 0x154, 0x94, 0x5c, 0x49, 0x60, 0x38, 0x50, 0x58} + scored = sorted(((len(ITEM & d), e, sorted(ITEM & d)) for e, d in disp_of.items()), + reverse=True) + print(" top item-record consumers by fingerprint overlap:") + for k, e, s in scored[:40]: + if k < 5: + break + print(" %2d/%d %#x %-20s %s" % (k, len(ITEM), e, fname(e), [hex(x) for x in s])) + print(" of those, the ones that ALSO touch 0x5c or 0x49:") + for k, e, s in scored[:200]: + if k < 4: + break + if 0x5c in s or 0x49 in s: + print(" %2d/%d %#x %-20s %s" % (k, len(ITEM), e, fname(e), [hex(x) for x in s])) + + print() + print("###### B. full decompiles") + for tag, va in (("copyctor_cand", 0x1800515e0), + ("untradeableCount_deser", 0x180130d10), + ("pile_deser_a", 0x180128600), + ("pile_deser_b", 0x180128e30), + ("tradeId_tradeState_deser", 0x18013e410), + ("untradeable_2nd", 0x180148b70), + ("itemLoans_deser", 0x180138e10)): + dump(tag, va) + + print() + print("###### C. transfer / sell / list vocabulary in .rdata, with xrefs") + NEEDLES = [b"TRANSFER", b"Transfer", b"transfer", b"QUICK_SELL", b"QuickSell", + b"quickSell", b"DISCARD", b"Discard", b"TRADEABLE", b"tradeable", + b"Tradeable", b"UNTRADEABLE", b"LIST_ON", b"tradepile", b"TRADE_PILE", + b"canBeSold", b"isTradeable", b"AUCTION", b"auctionhouse"] + seen = set() + for nd in NEEDLES: + hits = find_all(nd, blocks=(".rdata", ".data")) + print(" needle %-14s hits=%d" % (nd.decode(), len(hits))) + for h in hits[:80]: + # back up to the start of the C string + st = h + for _ in range(96): + try: + if mem.getByte(addr(st - 1)) == 0: + break + except Exception: + break + st -= 1 + if st in seen: + continue + seen.add(st) + s = rd_str(st, 120) + xs = xrefs_to(st) + fns = sorted({(fn, ent) for _, _, fn, ent in xs if ent}) + print(" %#x %-58r xrefs=%d %s" % (st, s, len(xs), + ["%s(%#x)" % (n, e) for n, e in fns][:6])) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_state_5.py b/fifa17-recon/tools/ghidra_queries/q_cd_state_5.py new file mode 100644 index 0000000..0388954 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_state_5.py @@ -0,0 +1,142 @@ +"""D3 Q5: the UI view-model accessors over the item record, and the enum tables for +pile / tradeState / bidState. + +WHAT LED HERE. FUN_1801a8940 is a nine-byte getter `return *(*(this+0x18) + 0x49)` and +FUN_1801a89f0 is `return *(*(this+0x18) + 0x48)`: there is a wrapper class holding the +item record at +0x18 and exposing its fields one accessor at a time. FUN_1801a7260 is a +PREDICATE in the same region that reads item+0x49, item+0x4c, item+0x50 and item+0x145 +and returns 0 or 1 -- exactly the shape of a "is this menu entry enabled" test, which is +the open live question (Place on Transfer List / List on Transfer Market greyed out). + +HYPOTHESIS: enumerating every small function of the form `*(*(param_1+0x18) + N)` gives +the COMPLETE list of item fields the UI can see, and the predicates in the same region +give the gating rules. If itemState (+0x5c) has an accessor, the lifecycle table is +reachable; if it has none, itemState reaches the UI some other way and I must say so. + +CONTROL: the accessor enumeration must find offsets we already know the UI displays -- ++0x38/+0x3c (discardValue, confirmed on screen today) and +0xb4 (rating). If those come +back with no accessor the enumeration is looking at the wrong class and proves nothing. + +Also: pile string->enum FUN_180142650 (used by the PUT /item verdict deser), +tradeState FUN_180166bd0, bidState FUN_180166380, with their tables walked. +""" +import traceback, os, re +from collections import defaultdict + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/" +os.makedirs(OUT, exist_ok=True) + + +def dump(tag, va, echo=True): + f = func(va) + if f is None: + print("%s %#x -> NO FUNCTION" % (tag, va)) + return "" + src = dec(va) + print("=" * 78) + print("%s %#x %s len(src)=%d (FULL)" % (tag, va, f.getName(), len(src))) + print("=" * 78) + if echo: + print(src) + with open(OUT + "q5_%s_%x.c" % (tag, va), "w") as fh: + fh.write(src) + return src + + +def walk_table(base, tag, lo=-0x100, hi=0x200): + print(" --- table %s at %#x" % (tag, base)) + for off in range(lo, hi, 0x10): + a = base + off + try: + p = qword(a) + q = qword(a + 8) + except Exception: + continue + s = rd_str(p, 64) if 0x180000000 <= p < 0x181000000 else "" + print(" %#x (%+#5x) p=%#014x q=%#010x %r" % (a, off, p, q, s)) + + +try: + print("###### A. accessor enumeration over the whole DLL") + print(" every function <= 0x40 bytes whose decompile is a single deref of") + print(" *(param_1 + 0x18) or *(param_1 + 0x10) plus a constant offset") + pat18 = re.compile(r"\(param_1 \+ 0x18\) \+ (0x[0-9a-f]+|\d+)\)") + pat18b = re.compile(r"\*\(longlong \*\)\(param_1 \+ 0x18\)\)") + pat10 = re.compile(r"\(param_1 \+ 0x10\) \+ (0x[0-9a-f]+|\d+)\)") + acc18 = defaultdict(list) + acc10 = defaultdict(list) + n_small = 0 + fit = fm.getFunctions(True) + while fit.hasNext(): + f = fit.next() + sz = int(f.getBody().getNumAddresses()) + if sz > 0x40: + continue + n_small += 1 + ent = int(f.getEntryPoint().getOffset()) + try: + src = dec(ent, 30) + except Exception: + continue + for m in pat18.finditer(src): + acc18[int(m.group(1), 0)].append((ent, f.getName(), sz)) + for m in pat10.finditer(src): + acc10[int(m.group(1), 0)].append((ent, f.getName(), sz)) + if pat18b.search(src) and "+ 0x18" in src and not pat18.search(src): + acc18[0].append((ent, f.getName(), sz)) + print(" scanned %d small functions" % n_small) + print(" --- accessors on *(this+0x18) + N (N = item record offset)") + for off in sorted(acc18): + for ent, nm, sz in acc18[off]: + print(" +%#-6x %s (%#x) size=%d" % (off, nm, ent, sz)) + print(" CONTROL: accessors exist for +0x38/+0x3c/+0xb4? %s" + % {hex(k): len(acc18.get(k, [])) for k in (0x38, 0x3c, 0xb4)}) + print(" itemState +0x5c accessor count: %d" % len(acc18.get(0x5c, []))) + print(" --- accessors on *(this+0x10) + N") + for off in sorted(acc10): + for ent, nm, sz in acc10[off]: + print(" +%#-6x %s (%#x) size=%d" % (off, nm, ent, sz)) + + print() + print("###### B. the gate predicate and its neighbours") + for tag, va in (("gate_1801a7260", 0x1801a7260), ("helper_1801a8900", 0x1801a8900), + ("get49_1801a8940", 0x1801a8940), ("get48_1801a89f0", 0x1801a89f0), + ("f5c_1801a5a30", 0x1801a5a30), ("f5c_1801a5a50", 0x1801a5a50), + ("f5c_1801a5aa0", 0x1801a5aa0), ("f5c_1801a5ac0", 0x1801a5ac0), + ("f5c_1801a5ae0", 0x1801a5ae0), ("f5c_1801a7040", 0x1801a7040)): + dump(tag, va) + + print() + print("###### C. who references the gate predicate (vtable slot or direct call)") + for va in (0x1801a7260, 0x1801a8940, 0x1801a89f0): + print(" --- xrefs to %#x" % va) + for frm, typ, fn, ent in xrefs_to(va): + print(" %#x %s in %s (%#x)" % (frm, typ, fn, ent)) + # vtable membership: any .rdata qword equal to va + import struct + hits = find_all(struct.pack("enum") + for tag, va in (("pile_enum_180142650", 0x180142650), + ("tradeState_180166bd0", 0x180166bd0), + ("bidState_180166380", 0x180166380)): + src = dump(tag, va) + m = re.search(r"PTR_[A-Za-z_0-9]*_(1[0-9a-f]{8})", src or "") + if m: + walk_table(int(m.group(1), 16), tag, 0, 0x120) + else: + m2 = re.search(r"DAT_(1[0-9a-f]{8})", src or "") + if m2: + walk_table(int(m2.group(1), 16) - 8, tag, 0, 0x120) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_state_6.py b/fifa17-recon/tools/ghidra_queries/q_cd_state_6.py new file mode 100644 index 0000000..64e5ad2 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_state_6.py @@ -0,0 +1,129 @@ +"""D3 Q6: the item ACTION vocabulary (TO_TRADE_PILE / DISCARD ...) and every consumer +of the tradeable flag; plus a precise search for code that compares item+0x5c against +an itemState value. + +WHAT LED HERE. 'TO_TRADE_PILE' (0x1801f4d48) and 'DISCARD' (0x1801f4d28) are adjacent +in .rdata and BOTH are referenced by the single function FUN_18003e370, which also +manipulates a byte at +0x49 -- the tradeable flag's offset. That is the action-menu +builder, i.e. the thing that greys entries out. + +CONTROL for the string window: the window must also contain other action names we can +recognise as menu entries (not random data); if the neighbourhood is unreadable garbage +the window is wrong and the identification is not made. + +CONTROL for the itemState-value scan: the same look-ahead machinery, pointed at +displacement 0x3c with the constant 0, must rediscover the KNOWN discard guard +`if ((int)local_150 == 0)`-style tests. If the look-ahead finds nothing anywhere the +scan is broken and its silence about +0x5c means nothing. +""" +import traceback, os +from collections import defaultdict + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/" +os.makedirs(OUT, exist_ok=True) +STATES = {1, 2, 5, 6, 0x64, 0x65, 0x66, 0x67, 0x68, 0xff, 0xffffffff} + + +def dump(tag, va, echo=True): + f = func(va) + if f is None: + print("%s %#x -> NO FUNCTION" % (tag, va)) + return "" + src = dec(va) + print("=" * 78) + print("%s %#x %s len(src)=%d (FULL)" % (tag, va, f.getName(), len(src))) + print("=" * 78) + if echo: + print(src) + with open(OUT + "q6_%s_%x.c" % (tag, va), "w") as fh: + fh.write(src) + return src + + +try: + print("###### A. the action-name neighbourhood in .rdata") + a = 0x1801f4a00 + while a < 0x1801f5200: + s = rd_str(a, 120) + if s and all(32 <= ord(c) < 127 for c in s): + xs = xrefs_to(a) + fns = sorted({(fn, e) for _, _, fn, e in xs if e}) + print(" %#x %-46r xrefs=%d %s" % (a, s, len(xs), + ["%s(%#x)" % (n, e) for n, e in fns][:5])) + a += len(s) + 1 + else: + a += 1 + + print() + print("###### B. the action-menu builder and the tradeable-flag consumers") + for tag, va in (("actionmenu_18003e370", 0x18003e370), + ("call_get49_1800bc580", 0x1800bc580), + ("call_gate_1800e2a40", 0x1800e2a40), + ("call_get48_1800d0600", 0x1800d0600), + ("call_get48_1800e4fd0", 0x1800e4fd0)): + dump(tag, va) + + print() + print("###### C. precise scan: [reg+DISP] loaded then compared to a constant") + print(" targets DISP=0x5c (itemState) and DISP=0x49 (tradeable)") + print(" CONTROL DISP=0x3c and DISP=0x4c, which we know are compared") + res = defaultdict(list) + for DISP in (0x5c, 0x49, 0x3c, 0x4c): + it = listing.getInstructions(True) + window = [] + while it.hasNext(): + ins = it.next() + window.append(ins) + if len(window) > 12: + window.pop(0) + s0 = str(window[0]) + if ("+ %#x]" % DISP) not in s0: + continue + if "RSP" in s0 or "RBP" in s0: + continue + mn = window[0].getMnemonicString().upper() + if mn == "CMP": + # direct compare with immediate + try: + v = int(window[0].getOpObjects(1)[0].getValue()) + except Exception: + v = None + f = fm.getFunctionContaining(window[0].getAddress()) + res[DISP].append((int(window[0].getAddress().getOffset()), + f.getName() if f else "?", "DIRECT", v, s0)) + continue + if mn not in ("MOV", "MOVZX", "MOVSX", "MOVSXD"): + continue + try: + dst = str(window[0].getOpObjects(0)[0]) + except Exception: + continue + for nxt in window[1:]: + sn = str(nxt) + mn2 = nxt.getMnemonicString().upper() + if mn2 in ("CMP", "SUB", "TEST") and dst.replace("R", "E") in sn.replace("R", "E"): + try: + v = int(nxt.getOpObjects(1)[0].getValue()) + except Exception: + v = None + f = fm.getFunctionContaining(window[0].getAddress()) + res[DISP].append((int(window[0].getAddress().getOffset()), + f.getName() if f else "?", mn2, v, s0 + " ; " + sn)) + break + print(" DISP %#04x -> %d load/compare pairs" % (DISP, len(res[DISP]))) + for DISP in (0x3c, 0x4c, 0x49, 0x5c): + tag = "CONTROL" if DISP in (0x3c, 0x4c) else "TARGET" + print(" --- %s DISP %#04x, constants seen: %s" + % (tag, DISP, sorted({v for _, _, _, v, _ in res[DISP] if v is not None})[:40])) + print() + print(" --- every DISP 0x5c compare whose constant is an itemState value") + for a, fn, kind, v, txt in res[0x5c]: + if v in STATES: + print(" %#x %-20s %s %s | %s" % (a, fn, kind, hex(v), txt)) + print() + print(" --- every DISP 0x49 compare") + for a, fn, kind, v, txt in res[0x49]: + print(" %#x %-20s %s %s | %s" % (a, fn, kind, v if v is None else hex(v), txt)) + +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_state_7.py b/fifa17-recon/tools/ghidra_queries/q_cd_state_7.py new file mode 100644 index 0000000..1e4197e --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_state_7.py @@ -0,0 +1,97 @@ +"""D3 Q7: the eight card-action flags, and the code that switches on itemState. + +ESTABLISHED. FUN_18003e370 publishes eight per-card booleans to Flash under the names +DISCARD, MODIFY, TO_ACTIVE_SQUAD, TO_TRADE_PILE, TO_STICKER_BOOK, MAY_BE_REMOVED, +QUICK_SEARCH, DREAM_REPLACE, filled by FUN_1800e2a40 in that byte order: + [0] FUN_1801a71c0 [1] FUN_1801a7210 [2] inline (isPlayer && squad room && not in + squad) [3] FUN_1801a7260 [4] FUN_1801a7180 [5] constant 1 [6] FUN_1801a7320 + [7] FUN_1801a71e0 +and a precise, controlled scan (control displacements 0x3c and 0x4c returned 25 and 37 +load/compare pairs) showed that item+0x49 is compared in EXACTLY TWO places in the +whole DLL -- the getter FUN_1801a8940 and the TO_TRADE_PILE predicate FUN_1801a7260 -- +while item+0x5c is compared against 0x64..0x68 in six functions. + +POLARITY IS THE OPEN QUESTION. Byte [2] is computed inline as + isPlayer && squadHasRoom && !alreadyInSquad -> 1 +which can only be an ENABLE flag, so 1 = action offered. Under that reading +FUN_1801a7260 returns 1 (offered) whenever item+0x49 is 0, which is the opposite of +what "tradeable" should do. Either the flag array is a DISABLE mask, or the service +call at vtable+0x270 inverts the sense. This query decompiles all eight predicates and +the service so the polarity is READ, not assumed. + +CONTROL: FUN_1801a7250 is already known to be the +0x4c (card family) accessor and byte +[2] uses it as "is a player". If the decompile of FUN_1801a7250 is not a +0x4c read the +whole byte-order attribution is wrong and nothing here counts. +""" +import traceback, os + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/" +os.makedirs(OUT, exist_ok=True) + + +def dump(tag, va, full=6000): + f = func(va) + if f is None: + print("%s %#x -> NO FUNCTION" % (tag, va)) + return "" + src = dec(va) + print("=" * 78) + print("%s %#x %s len(src)=%d" % (tag, va, f.getName(), len(src))) + print("=" * 78) + with open(OUT + "q7_%s_%x.c" % (tag, va), "w") as fh: + fh.write(src) + if len(src) <= full: + print(src, " [PRINTED IN FULL]") + else: + print(" [TOO LONG TO PRINT IN FULL -- written to q7_%s_%x.c; printing every" + " line mentioning 0x5c plus 6 lines of context]" % (tag, va)) + lines = src.split("\n") + keep = set() + for i, l in enumerate(lines): + if "0x5c" in l or "0x49" in l or "0x48" in l: + for j in range(max(0, i - 6), min(len(lines), i + 7)): + keep.add(j) + prev = -2 + for i in sorted(keep): + if i != prev + 1: + print(" ...") + print(" %4d %s" % (i, lines[i])) + prev = i + return src + + +try: + print("###### A. the eight card-action predicates, in Flash byte order") + for tag, va in (("a0_DISCARD_1801a71c0", 0x1801a71c0), + ("a1_MODIFY_1801a7210", 0x1801a7210), + ("a2_family_acc_1801a7250", 0x1801a7250), + ("a3_TO_TRADE_PILE_1801a7260", 0x1801a7260), + ("a4_TO_STICKER_BOOK_1801a7180", 0x1801a7180), + ("a6_QUICK_SEARCH_1801a7320", 0x1801a7320), + ("a7_DREAM_REPLACE_1801a71e0", 0x1801a71e0), + ("view_init_1801a78f0", 0x1801a78f0), + ("view_isnull_1801a8850", 0x1801a8850), + ("view_4c_1801a8110", 0x1801a8110), + ("helper_1801aa190", 0x1801aa190)): + dump(tag, va) + + print() + print("###### B. the itemState (0x5c) switches") + for tag, va in (("s_180084720", 0x180084720), ("s_180094220", 0x180094220), + ("s_180043880", 0x180043880), ("s_180094ae0", 0x180094ae0), + ("s_180113870", 0x180113870), ("s_1801c3480", 0x1801c3480), + ("s_1801b3640", 0x1801b3640), ("s_18011dc50", 0x18011dc50), + ("s_180051cd0", 0x180051cd0)): + dump(tag, va) + + print() + print("###### C. the CardInventoryAdapter registration (what else it publishes)") + dump("adapter_18003ec30", 0x18003ec30) + dump("listpanel_18003e550", 0x18003e550) + + print() + print("###### D. the service behind FUN_180009c80 -- what is vtable slot 0x270?") + dump("svc_ctor_180009c80", 0x180009c80) + dump("svc_ctor_180009b60", 0x180009b60) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_state_8.py b/fifa17-recon/tools/ghidra_queries/q_cd_state_8.py new file mode 100644 index 0000000..581f85f --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_state_8.py @@ -0,0 +1,123 @@ +"""D3 Q8: close out the lifecycle table -- is forSale(5)/offered(6) ever tested, and +what is the item-record pile (+0x60) vocabulary? + +ESTABLISHED SO FAR. itemState lands at item+0x5c; 0x64..0x68 publish the Flash boolean +IS_ACTIVE (three independent publishers agree, one of them as the range test +`state - 100 < 5`); the equip path FUN_180113870 writes 1 back into +0x5c when it +unequips and 0x67 when it equips; the squad code FUN_1801b3640 accepts state 1 and +state 2. The earlier precise scan reported the constants compared against [reg+0x5c] +anywhere in the DLL as {-1,0,1,2,3,100,101,102,103,104} -- 5 and 6 absent -- but that +population mixes several unrelated structs, so this query re-runs it printing EVERY hit +with its function, and adds the `SUB/DEC ladder` and `switch jump table` forms that a +plain compare scan cannot see. + +CONTROL: the same three forms, pointed at [reg+0x60], must rediscover the values we +have already measured live in the item record (1 for club items, 6 for purchased) and +the value 4 we just read in FUN_1801c3480. If 1/4/6 do not come out of the scan it is +not seeing item-record pile tests and its silence proves nothing. +""" +import traceback, os +from collections import defaultdict + +OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/" +os.makedirs(OUT, exist_ok=True) + + +def dump(tag, va, full=7000): + f = func(va) + if f is None: + print("%s %#x -> NO FUNCTION" % (tag, va)) + return "" + src = dec(va) + print("=" * 78) + print("%s %#x %s len(src)=%d" % (tag, va, f.getName(), len(src))) + print("=" * 78) + with open(OUT + "q8_%s_%x.c" % (tag, va), "w") as fh: + fh.write(src) + print(src if len(src) <= full else " [long; written to file]") + return src + + +try: + for DISP in (0x5c, 0x60): + print() + print("###### displacement %#x : every compare / ladder / jump-table dispatch" % DISP) + it = listing.getInstructions(True) + window = [] + seen = [] + while it.hasNext(): + ins = it.next() + window.append(ins) + if len(window) > 16: + window.pop(0) + s0 = str(window[0]) + if ("+ %#x]" % DISP) not in s0 or "RSP" in s0 or "RBP" in s0: + continue + mn = window[0].getMnemonicString().upper() + f = fm.getFunctionContaining(window[0].getAddress()) + fn = f.getName() if f else "?" + a0 = int(window[0].getAddress().getOffset()) + if mn == "CMP": + try: + v = int(window[0].getOpObjects(1)[0].getValue()) + except Exception: + v = None + seen.append((a0, fn, "CMP", v, s0)) + continue + if mn not in ("MOV", "MOVZX", "MOVSX", "MOVSXD"): + continue + try: + dst = str(window[0].getOpObjects(0)[0]) + except Exception: + continue + key = dst.replace("R", "E") + run = 0 + for nxt in window[1:]: + sn = str(nxt) + m2 = nxt.getMnemonicString().upper() + if key not in sn.replace("R", "E"): + continue + if m2 in ("CMP", "TEST"): + try: + v = int(nxt.getOpObjects(1)[0].getValue()) + except Exception: + v = None + seen.append((a0, fn, m2, v, s0 + " ; " + sn)) + break + if m2 in ("SUB", "DEC", "ADD", "INC"): + try: + d = 1 if m2 == "DEC" else (-1 if m2 == "INC" + else int(nxt.getOpObjects(1)[0].getValue())) + except Exception: + break + run += d if m2 in ("SUB", "DEC") else -d + seen.append((a0, fn, "LADDER@%d" % run, run, s0 + " ; " + sn)) + continue + if m2 == "JMP": + seen.append((a0, fn, "JUMPTABLE", None, s0 + " ; " + sn)) + break + print(" %d hits" % len(seen)) + by_fn = defaultdict(list) + for a0, fn, kind, v, txt in seen: + by_fn[fn].append((a0, kind, v, txt)) + for fn in sorted(by_fn): + vals = sorted({v for _, _, v, _ in by_fn[fn] if v is not None}) + print(" %-22s n=%-3d constants=%s" % (fn, len(by_fn[fn]), vals)) + allv = sorted({v for _, _, _, v, _ in seen if v is not None}) + print(" ALL CONSTANTS for disp %#x: %s" % (DISP, allv)) + if DISP == 0x60: + print(" CONTROL -- are the live-measured pile values 1, 4 and 6 present? %s" + % {k: (k in allv) for k in (1, 4, 6)}) + if DISP == 0x5c: + print(" are forSale(5) and offered(6) present? %s" + % {k: (k in allv) for k in (5, 6)}) + for a0, fn, kind, v, txt in seen: + if v in (5, 6, 3, 0xff, -1): + print(" %#x %-22s %s %s | %s" % (a0, fn, kind, v, txt)) + + print() + print("###### the remaining itemState readers") + for tag, va in (("s_18011dc50", 0x18011dc50), ("s_180094ae0", 0x180094ae0)): + dump(tag, va) +except Exception: + traceback.print_exc() diff --git a/fifa17-recon/tools/ghidra_queries/q_cd_state_9.py b/fifa17-recon/tools/ghidra_queries/q_cd_state_9.py new file mode 100644 index 0000000..6fb03fd --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_cd_state_9.py @@ -0,0 +1,18 @@ +"""D3 Q9 (tail): is the itemState string match case-sensitive, and what is the +FUN_18011dc50 `state == 1` test? CONTROL: FUN_180008190 is also used for the +literal "dds" and "highest"/"outbid"/"buyNow", so whatever it is, it is a plain +string compare; the only open point is case folding.""" +import traceback +try: + for tag, va in (("strcmp_180008190", 0x180008190), ("s_18011dc50", 0x18011dc50)): + f = func(va) + src = dec(va) + print("=" * 78); print("%s %#x %s len=%d" % (tag, va, f.getName() if f else "?", len(src))) + print("=" * 78); print(src) + print("### raw disasm of FUN_180008190") + f = func(0x180008190) + it = listing.getInstructions(f.getBody(), True) + while it.hasNext(): + i = it.next(); print(" %#x %s" % (int(i.getAddress().getOffset()), i)) +except Exception: + traceback.print_exc()