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()