fifa17-recon: card-family enumeration round -- managers cracked, consumables need no ids

11-agent round, every investigation adversarially reviewed. The headline is that this
was never five id hunts: the client's database is resident in ordinary heap as a
self-describing catalog of bit-packed fixed-stride row arrays, walkable READ-ONLY, so
the id sets fall out at zero cost in human club visits.

MEASURED:
  managercards carddbid 1000001..1001455, assetid == carddbid; two agents using two
    different block locators agreed 417/417 (docs/managercards_ids.txt). This is why
    the earlier sweeps of 1..5000 and 6000..8000 were silent.
  staff bands: headcoach 2000004+, fitnesscoach 3000019+, physio 4000002+,
    gkcoach 9000001+, corroborated by the four miss-fallback assetids hard-coded in
    FUN_180141660 each landing inside its own table's decoded range.
  all four coach branches write a LOUD miss-fill: firstname/lastname "DB Error",
    rating 0x32, rare 1, attrs[0] 0xf, plus a table-unique assetid. Managers write
    none, so a wrong manager id is silent and a wrong coach id labels itself.
  consumables have NO table and NO id space: cardtype 6 has no arm in the merge,
    FUN_18013f4d0's only callees are a range clamp and an enum map, and every string
    is a hardcoded FUT_CONSUMABLE_* literal. A contract is three JSON keys.
  the ?type= taxonomy is 29 explicit arms plus a default: badge 11, kit 12, stadium
    13, ball 14, equippables 15, leaguelogos 16, misc 26. club/stats kits and
    badgeDBid are PLAIN COUNTS, not ids.
  fancards is a boolean column of the fixtures table; newcards is FUT atom 0x1d7.
    NEITHER is a card family, so two of the five hunts never existed.

REFUTED, and worth keeping: the live table-directory walk was off by one entry
(descriptor for table T is at entry-0x20, not entry+0x08), which had mislabelled
managercards as factory_teams and shifted every column count. managercards names are
32-bit string-pool offsets and the pool was never located -- we get ids, not names,
and we do not need names because the client supplies them.

TRAPS RECORDED: rareflag=1 silently converts a Player Fitness card (219) into Squad
Fitness, and fut_store._item() hardcodes rareflag 1 on every item.

Four proposed club-item sweep windows were killed by review as invariant by
construction: with no merge arm there is no miss-fill, so every id yields a
byte-identical record and the probe cannot discriminate. That is a wasted human action
correctly caught before it cost one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
funman300
2026-08-04 22:40:02 -07:00
parent c76cf706ef
commit 49733f79d1
4 changed files with 1044 additions and 0 deletions
+417
View File
@@ -0,0 +1,417 @@
1000001
1000004
1000005
1000012
1000017
1000018
1000019
1000020
1000022
1000023
1000026
1000028
1000031
1000032
1000033
1000034
1000035
1000038
1000041
1000052
1000054
1000057
1000058
1000063
1000065
1000066
1000067
1000068
1000072
1000073
1000074
1000075
1000081
1000083
1000087
1000089
1000090
1000096
1000097
1000102
1000104
1000107
1000109
1000110
1000114
1000115
1000118
1000119
1000121
1000124
1000127
1000128
1000129
1000133
1000135
1000138
1000146
1000148
1000156
1000158
1000161
1000165
1000167
1000172
1000174
1000175
1000178
1000181
1000183
1000186
1000196
1000198
1000199
1000202
1000203
1000206
1000208
1000213
1000214
1000218
1000219
1000221
1000222
1000227
1000230
1000243
1000247
1000252
1000256
1000257
1000259
1000260
1000263
1000264
1000266
1000270
1000272
1000273
1000275
1000276
1000281
1000292
1000302
1000309
1000314
1000316
1000321
1000322
1000324
1000340
1000343
1000345
1000353
1000361
1000380
1000414
1000417
1000433
1000445
1000451
1000452
1000462
1000470
1000495
1000496
1000497
1000500
1000503
1000505
1000506
1000507
1000509
1000512
1000517
1000519
1000522
1000523
1000527
1000529
1000530
1000532
1000534
1000535
1000543
1000544
1000546
1000548
1000550
1000551
1000564
1000599
1000600
1000603
1000608
1000609
1000611
1000612
1000616
1000622
1000623
1000624
1000628
1000630
1000656
1000664
1000666
1000680
1000694
1000696
1000698
1000702
1000705
1000712
1000720
1000726
1000731
1000732
1000733
1000743
1000758
1000762
1000763
1000789
1000794
1000800
1000802
1000811
1000815
1000817
1000819
1000825
1000830
1000832
1000833
1000836
1000842
1000846
1000857
1000858
1000861
1000871
1000873
1000874
1000878
1000879
1000895
1000899
1000902
1000903
1000907
1000913
1000914
1000915
1000916
1000920
1000924
1000925
1000927
1000929
1000940
1000948
1000954
1000957
1000969
1000972
1000982
1001008
1001010
1001021
1001032
1001037
1001038
1001039
1001045
1001046
1001047
1001052
1001053
1001072
1001081
1001099
1001104
1001107
1001113
1001119
1001121
1001122
1001129
1001133
1001135
1001137
1001156
1001167
1001178
1001181
1001182
1001184
1001228
1001229
1001247
1001257
1001269
1001292
1001299
1001303
1001309
1001311
1001320
1001326
1001331
1001333
1001334
1001335
1001338
1001339
1001340
1001341
1001342
1001345
1001346
1001347
1001348
1001354
1001360
1001362
1001369
1001370
1001371
1001373
1001374
1001376
1001390
1001393
1001396
1001408
1001409
1001421
1001432
1001433
1001434
1001435
1001436
1001437
1001438
1001439
1001440
1001441
1001442
1001443
1001444
1001445
1001446
1001447
1001448
1001449
1001450
1001451
1001452
1001453
1001454
1001455
1001456
1001457
1001458
1001459
1001460
1001461
1001462
1001463
1001464
1001465
1001466
1001467
1001468
1001469
1001470
1001471
1001472
1001473
1001474
1001475
1001476
1001477
1001478
1001479
1001480
1001481
1001482
1001483
1001484
1001485
1001486
1001487
1001488
1001489
1001490
1001491
1001492
1001493
1001494
1001495
1001496
1001497
1001498
1001499
1001500
1001501
1001502
1001504
1001505
1001506
1001507
1001508
1001509
1001510
1001511
1001512
1001513
1001514
1001515
1001516
1001517
1001518
1001519
1001520
1001521
1001522
1001523
1001524
1001525
1001526
1001527
1001528
1001529
1001530
1001531
1001532
1001533
1001534
1001535
1001536
1001537
1001538
1001539
1001540
1001541
1001542
1001543
1001544
1001545
1001546
1001547
1001548
1001549
1001550
1001551
1001552
@@ -0,0 +1,225 @@
# Card families: the 2026-08-04 enumeration round
Workflow wf_3afc63b3-994, 11 agents, every investigation paired with an adversarial
reviewer. Verbatim synthesis. Where a reviewer refuted an investigator the refutation
is recorded rather than smoothed over, because several of those corrections are the
reason the plan reads the way it does.
# The next round: one enumeration, not five id hunts
Written 2026-08-04. Facts below are attributed to the investigation that produced them and to the reviewer that confirmed or refuted them. Anything not settled is marked TODO/CONFIRM.
Live state at the time of writing, checked directly: `FIFA17.exe` is running as **pid 52703**, `utas_server.py` is running as **pid 37291**, and `/tmp/fut_id_sweep` is **empty (0 bytes)**, so no sweep is armed and the real club is being served. That matters for the ordering below: the single most valuable measurement costs zero human actions and the window for it is open right now.
---
## 1. What was established
**Managers.** The investigator re-derived `FUN_1801356c0` (len 1587) verbatim and both the reviewer and the taxonomy pair confirmed it independently: `SELECT firstname,lastname,assetid,value,talkrating,negotiation,rare FROM managercards WHERE carddbid == *(u32*)(record+0x18)`, key read **raw**, no `&0xffffff`, no else-branch, therefore no miss-fill. Writes: firstname to +0xb8 (0x10 bytes), lastname to +0xc8 (0x15), assetid to +0x20, `value` to +0xb4 (this is the rating byte), talkrating +0xe2, negotiation +0xe3, `rare==1` to +0x58. **Where the reviewer refuted the investigator:** the investigator's live table-directory walk was off by exactly one entry (descriptor for table T is at `entry-0x20`, not `entry+0x08`), so its headline "managercards has 62 columns in a 128-byte row" was actually `factory_teams`; the real row is 20 bytes, 10 columns. Its per-table column counts (18/107/9/11) were all shifted. Its 1M band was a guess that happened to land right. The reviewer then measured the ids.
**The four coach tables.** Verified and reproduced by its reviewer: all four inline branches of `FUN_180141660` (len 7937) write a loud miss-fill on `rowcount < 1`: firstname and lastname both `"DB Error"`, rating +0xb4 = 0x32, rare +0x58 = 1, attrs[0] +0x98 = 0xf, and a table-unique assetid at +0x20 (headcoach 2000148, fitnesscoach 3000259, physio 4000146, gkcoach 9000258, each appearing exactly once in the whole binary, at its own branch). This is the single most useful fact of the round: **a wrong coach id is self-labelling, a wrong manager id is silent.** The jump table (1, 2, 3, 4, 5, 10 live; 6/7/8/9 to the shared tail) was read dword by dword by both. **Where the reviewer refuted the investigator:** "the tail always runs" is false, `FUN_180141660` returns immediately if `record+8` is null; `FUN_1801362e0` is a partial overwrite, not a sniffer that derives cardtype from the id alone; and the investigator missed that the headcoach and gkcoach branches also write the attribute array, so `card_identity_probe`'s `attrs` column is not trustworthy for staff either (and `attrs[0]==15` is a free second miss fingerprint).
**Consumables.** Confirmed structurally by three independent routes: cardtype 6 has no arm in the merge; `FUN_18013f4d0` (len 8354) has exactly two callees, a range clamp and an enum map, and touches no DB handle; and every string the UI shows is a hardcoded `FUT_CONSUMABLE_*` literal referenced from exactly one function, `FUN_1801bfac0`. **Consumables have no table and no id space to crack.** Category comes from `cardsubtypeid` alone into record+0xb8; artwork is a fixed client-side constant per category, so `resourceId` never reaches the screen. **Where the reviewer refuted the investigator:** the dead-zone list was wrong twice (137-200 is cardtype 0, never reaches `FUN_18013f4d0` at all, and 145-150 inside it is cardtype 9; the 87-88 gap is really 87-90); the formation map's 8th entry is 0x06 not 0x00; the play-style artwork field is record+0xbe not +0xc1; the atom-name table index is `0x1802d2760 + 8*atom` with no `+6`; and there are 24 `FUT_CONSUMABLE_*` strings, not 26. None of that touches the headline. Two real traps survive: `rareflag=1` silently converts a Player Fitness card (219) into a Squad Fitness card, and `fut_store._item()` hardcodes `"rareflag": 1` on every item (confirmed at line 56).
**Club items.** The `type=` taxonomy is real and complete: 29 explicit arms in `FUN_18012ec50` plus a default that returns `any`, including badge(11), kit(12), stadium(13), ball(14), equippables(15), leaguelogos(16), misc(26). The `equippables` value has a second selector at `this+0x30` (10 badge, 11 kit, 21 stadium, 22 ball). `state=` carries activeBadge/activeHomeKit/activeAwayKit/activeBall/activeStadium. `club/stats` `kits` and `badgeDBid` are consumed as **plain counts** by the same getter and publisher shape as the already-live `PLAYERS_EMPLOYED` rows, so that family needs no id at all. The registry correction is solid and the reviewer re-did it properly: `GET ut/%s/club` is **ClubSearch**, not `GetClubInfo`; `GetClubInfo` is on `ut/%s/user/list`. **Where the reviewer refuted the investigator:** the registry census was 47 of 125 records (the conclusion survived a full re-walk); "`misc` belongs to the market vocabulary" is exactly backwards; the "no `RS4:FutClubSearchServerResponse` therefore it reuses another class" argument is invalid, because `FutClubStatsServerResponse` also does not exist and club/stats works today; and **all four proposed club-item sweep windows are invariant by construction** and would be wasted human actions.
**Taxonomy, and the finding that reorders the round.** `newcards` is FUT atom 0x1d7, not a table. `fancards` is a boolean column of the `fixtures` table, next to `fanflags`. Neither is a card family. And the client's database is **resident in the ordinary process heap as a self-describing catalog of bit-packed, fixed-stride row arrays, walkable entirely read-only.** Table descriptor 0x28 bytes, column descriptor 0x30 bytes, both anchored on the shared string-class constant `0x07C20760`; layout block header carries the row count, entries are `{bitOffset, colTag, width, type}`, the row pointer sits at `hdr-0x48`, values are `raw + min`. About 150 tables with complete schemas. **Where the reviewer refuted the investigator:** "no caller omits the WHERE" is false (7 functions call FROM and never WHERE; `FUN_180042440` literally enumerates `fcc_myclubscategories` with no predicate, so the client itself does full-table scans); the "one staff id space at 4,000,000 with stride 4, tiled by residue mod 4" is wrong (four disjoint bases); `fitnesscoachcards` does have a layout block; and the header-signature locator found 90 blocks for 151 tables, while a tag-set-run locator finds 183 blocks covering 145 tables. The investigator also overclaimed "verified end to end by decoding manager names": the names it decoded were the `manager` (career staff) table's inline names. **managercards names are stored as 32-bit string-pool offsets and the pool was never located.** We get ids, not names, and we do not need names because the client supplies them.
---
## 2. The route
**This is one enumeration problem. Commit to it.**
The alternative, five per-table id hunts driven from the club sweep, was costed honestly by the investigations themselves: the coach investigation priced an exhaustive single-family walk at roughly 480 club visits, and the manager investigation priced an exhaustive 1M-band walk at roughly 29. Even the cheap banded windows come to a dozen or more human tab re-entries across the five families, and for managers alone two of those visits already produced 2000+ records and no interpretable signal.
The enumeration route has already produced, at zero human tab cost, the exact id sets for four of the five card tables plus the consumable definition tables plus the full 107-column `players` table, and it has done so twice, by two agents using two different block locators, agreeing 417/417 on managercards against `fifa17-recon/docs/managercards_ids.txt`. It also answered the two questions the sweeps could never have answered: `fancards` and `newcards` are not families at all, so two of the five hunts do not exist.
**Say it plainly: enumeration is reachable read-only, and it makes the per-table id hunts moot.** Live sweeps are demoted from "how we find ids" to "how we confirm the decode, one visit, once."
I would switch back to sweeps only on one of three triggers:
1. The `width == ceil(log2(max-min+1))` assertion (see Q0) fails on a table we care about. That would mean the layout-entry field order is wrong, which is genuinely in dispute: the taxonomy pair read `{bitOffset, colTag, width, type}` and the manager reviewer read `{type, bit_offset, hash4, bit_width}`. The assertion is the discriminator.
2. A family we need has no resident block and no second route to it. Nothing currently in scope is in that state, but the block census is a coverage claim, not a proof of absence.
3. The one live confirmation in Q3 comes back disagreeing with the decoded ids in a way that is not a simple row-count over-read. Then the decode is a plausible byte run after all and everything below it is a guess.
---
## 3. The queue
Ordered by the cost of the measurement that settles the item, cheapest first. "Live test" means a human leaves a menu, re-enters a tab, and reports what is on screen.
### Q0. Dump the whole resident store to disk. Live tests: 0.
Do this first and do it now, while pid 52703 is alive.
**What it changes:** turns every remaining per-table id question in this round into arithmetic on a file.
**Code change:** new `fifa17-recon/tools/dbstore_dump.py`, built from the existing `fifa17-recon/tools/db_catalog_walk.py` (160 lines, already walks the catalog and writes `layouts.json`, which is not currently on disk). Three changes to it:
- replace the header-signature block locator with the tag-set-run locator (scan for 4-aligned dwords equal to known column tags, group 16-byte-stride runs, match run tag-sets against the catalog). The signature version finds 90 blocks for 151 tables and missed `fitnesscoachcards`; the run version finds 183 for 145.
- add a row decoder that emits values (`raw + min`) and writes one TSV per table under `fifa17-recon/data/dbstore/`, plus a manifest with `{table, rows, stride, rowptr, hdr, columns[]}`.
- add three assertions, and fail loudly rather than writing a file: (a) for every int column, `width == ceil(log2(max-min+1))`; (b) the directory route, with the manager reviewer's `entry-0x20` correction, resolves the same descriptor as the string-pointer route; (c) each staff family's hardcoded DB-Error fallback assetid (2000148, 3000259, 4000146, 9000258) lands inside that family's own decoded id range.
No sweep, no `/tmp/fut_id_sweep` write, no server restart. `/proc/PID/mem` opened `'rb'`, seek and read only.
**POSITIVE looks like:** all three assertions pass; `managercards` decodes 417 ids matching `docs/managercards_ids.txt` exactly; `players` decodes 17563 ids that are a superset of the 17547 in `fifa17-recon/data/roster.json`; every `fcc_*` table lands on disk.
**A NEGATIVE proves:** assertion (a) failing proves the layout-entry field order is wrong, which invalidates every id in this plan and sends us back to the binary before any live test is spent. That is the whole reason to run it before Q3. Assertion (b) failing proves the two routes disagree and one of them is mis-anchored, which is precisely the error that produced the `factory_teams` mix-up. Assertion (c) failing proves the block-to-table attribution is coincidence, and the staff bands in Q3 should not be fired.
### Q1. Settle 417 versus 321 offline. Live tests: 0.
**What it changes:** the exact top of the manager id range, and more importantly which row-count field is authoritative for every table.
**The conflict:** the manager reviewer read `rows = 321`, `carddbid` topping out at **1,001,455**, from the count `u32` at `descriptor-0x10`. The taxonomy investigator and its reviewer both read `rows = 417`, topping out at **1,001,552**, from the layout header at `hdr+0x04`, and the reviewer reproduced all 417 against the committed file. Two different fields, two different pids (52703 and 11864), one static database. TODO/CONFIRM.
**Code change:** none beyond Q0. In the dump tool, print both counters for `managercards` side by side, then decode rows 321 through 416 and check that `carddbid` stays strictly increasing, that `assetid == carddbid` still holds, that `value` stays in a plausible rating range and `rare` stays 0/1. Junk in that tail means 321 is right and the layout header over-reads.
**POSITIVE:** the tail rows are well-formed, 417 stands, and `hdr+0x04` is the authoritative counter.
**A NEGATIVE proves:** if the tail decodes as noise, `descriptor-0x10` is authoritative and **every** row count in the Q0 dump needs re-reading from that field, including the staff families. That is a broad correction and it is much cheaper to catch here than in a live session.
### Q2. Batch every tool change into one server restart. Live tests: 0.
Restarting `utas_server.py` mid-session is the action that once produced "error connecting to FIFA 17 Ultimate Team", per the comment in `sweep_window()`. So all of this ships together, before the human launches.
**(a) Window lists in the sweep control file.** `_parse_window()` currently takes one window. Extend `sweep_window()`/`sweep_items()` to accept a `;`-separated or newline-separated list and advance one window per club fetch, exactly as `auto:` advances one chunk per fetch. **This is the highest-leverage change in the round**, because a club visit issues roughly 7 club fetches (recorded in the `_SWEEP_POS` comment), so it converts Q3 from five re-aims and five tab re-entries into one visit. Requirement: give each window its own item-id base. `sweep_items()` currently derives ids from `SWEEP_ID_BASE + (pid - lo) * len(subs) + si`, and two windows with overlapping offsets would collide in the CardsDb map and let one family overwrite another's readback.
**(b) `card_identity_probe.py`: print the consumable descriptor.** It already reads `cardtype` (+0x4c) and `subtype` (+0x50), which is what makes the consumable windows interpretable at all. Add `category = u32(+0xb8)`, `i16(+0xbc)`, `u8(+0xbe)`, `u8(+0xbf)`, `u8(+0xc0)`, `u8(+0xc1)`. Note that +0xb8 is firstname-as-string for players and staff and category-as-int32 for consumables; print both and let `cardtype` select which one is meaningful. Also surface `attrs[0]` prominently, since `attrs[0]==15` is the second staff-miss fingerprint.
**(c) `fut_store._item()`: sweep-only overrides.** Add optional `rareflag`, `amount` (atom 0x1b) and `contract` (atom 0xb8) overrides, applied **only** when `sweep_items()` passes them. The default item dict must not change by one byte: it is the live-proven player path. `amount` in particular has never been sent by us in any item ever, and it defaults to -1 in the parser, which stamps 0xFF into +0xbf for training/healing/fitness and +0xbe for play-style.
**POSITIVE:** the server restarts, the existing player club renders exactly as before with an empty control file, and a one-window control file still behaves identically to today.
**A NEGATIVE proves:** if the player club changes at all with an empty control file, one of these three edits leaked into the default path, and it must be reverted before any session.
### Q3. THE live session: managers plus the four staff bands. Live tests: 1 visit (5 fetches) if Q2(a) ships, otherwise 5.
**What it changes:** confirms or kills the entire enumeration route, and if it confirms, managers and all four coach families become servable in the same round.
**Control file** (`/tmp/fut_id_sweep`), in this order:
```
t4@1000001-1001552
t*@2000001-2000330
t*@3000001-3000330
t*@4000001-4000260
t*@9000001-9000330
```
Item counts: 1552, 1650, 1650, 1300, 1650. All under the 5000 ingest ceiling. `t*@` fans subtypes 4, 5, 6, 7, 8 per `_STAFF_SUBTYPES`.
**Predicted family mapping**, from the reviewer's attribution (fallback assetid in range, plus `fitnesscoachcards` pinned by its unique 9-column schema as the control, plus `physiocards` pinned independently by its 6-bit `amount` column):
| band | ids | rows | family | cardsubtypeid |
|---|---|---|---|---|
| 1M | 1000001-1001552 (or -1001455, see Q1) | 417 or 321 | managercards | 4 |
| 2M | 2000004-2000328 | 124 | headcoachcards | 5 |
| 3M | 3000019-3000328 | 115 | fitnesscoachcards | 8 |
| 4M | 4000002-4000259 | 51 | physiocards | 7 |
| 9M | 9000001-9000324 | 121 | gkcoachcards | 6 |
**POSITIVE, window 1:** exactly 417 records come back with non-blank first/last and rating in **57..88** (the decoded `value` range), matching `docs/managercards_ids.txt` id for id, with exactly 135 carrying rare. The other 1135 keep sentinel rating 7 and blank names. The rating range is what makes the manager window interpretable despite the missing miss-fill: sentinel 7 is nowhere near 57.
**POSITIVE, windows 2-5:** in each band, exactly **one** subtype returns real names and the other three return `firstname == "DB Error"`, `assetId ==` that family's fallback constant, rating 0x32, `attrs[0] == 15`. Subtype 4 stays untouched throughout, which is a free control.
**A NEGATIVE proves, and this is why these particular windows are worth a visit:**
- Window 1 silent but windows 2-5 showing "DB Error": the merge ran, the plumbing works, and the failure is specific to how `resourceId` reaches `carddbid` on the manager branch. Actionable.
- Window 1 hitting exactly 321 with 1001456-1001552 silent: Q1's conflict resolves to 321 and every row count in the dump needs re-reading. Actionable.
- Windows 2-5 all "DB Error" with no real names anywhere: the branches execute, the subtype map is right, the plumbing is right, and only the decoded id sets are wrong. That is a clean, fully interpretable negative and it isolates the failure to Q0's decoder.
- Nothing changes anywhere, in any window, including no "DB Error": the sweep never ingested. Check first that *any* record changed before concluding anything; if none did, both of the earlier manager sweeps were vacuous rather than negative, and the bug is upstream of every id in this document.
**Do not fire** `t*@6000000-...` or `t9@6000000-...`. The 6,000,000 to 6,499,999 band is set by `FUN_1801362e0`, which is not on the club path (`FUN_18013fe00` does not call it), so serving ids in that band through the club changes nothing at all and the negative proves nothing.
### Q4. Observe the club-item tabs, same session, zero extra launches.
**What it changes:** gives us the request half of a club-item wire shape, which is the only half we can obtain without inventing anything.
**No code change, no sweep.** While the human is already in the club for Q3 (with the control file **emptied**, so the real club is showing), have them open the My Club categories and club-items screens. We read `/tmp/utas_server.log` afterwards for any `type=` value never seen before (badge, kit, ball, stadium, equippables, leaguelogos, misc, unlocks, development, trophy variants) and for any `state=activeBadge|activeHomeKit|activeAwayKit|activeBall|activeStadium`. Today the log contains only `type=player`, `type=manager`, `type=custom&league=N`, and no-type with `team=N`, all under `User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)`. (Be careful reading that log: it also contains `Python-urllib/3.14` requests from our own probes, and one earlier investigation nearly refuted the URL builder on the strength of those.)
**POSITIVE:** a new `type=` appears in the log. Then club items have a real request to answer and the family moves up the queue.
**A NEGATIVE proves:** that screen does not fetch from us, which means club items are further away than their id spaces suggest and the ranking in Q7 is right.
### Q5. Consumables: one fetch, ten categories. Live tests: 1 fetch (can share the Q3 visit if Q2(a) ships).
**What it changes:** confirms the "pure enum card" model and tells us whether a contract card is three JSON keys.
**Control file**, appended to the Q3 list or fired on its own:
```
t201@1-1;t202@1-1;t211@1-1;t219@1-1;t91@1-1;t251@1-1;t300@1-1;t51@1-1;t61@1-1;t71@1-1
```
with Q2(c) emitting `amount` on cardtype-6 subtypes and `contract` on 201/202.
**POSITIVE:** `cardtype` (+0x4c) reads **6** on all ten, and `category` (+0xb8) reads 2, 3, 4, 5, 8, 9, 10, 0, 0, and 6-or-7 respectively. On t91, `+0xbf == 7` and `+0xbc == 8`, both derived from the subtype alone with nothing sent, which is the strongest available test of the no-data-needed claim. On t300, `+0xbc` carries the league id from `amount`.
**A NEGATIVE proves:** `+0x4c` reading 0 proves the `FUN_1800d8330` range read is wrong; `+0x4c` reading 6 with category 0 everywhere proves `FUN_18013f4d0` is not reached on this path. Both are sharp, and they are sharp only because the probe prints `+0x4c`. Reading `+0xb8` alone cannot distinguish "cardtype 6, dead zone, category 0" from "cardtype 0, resolver never called", since the parser memsets +0xb8..+0x157 to zero.
**One exception, state it to the human:** t219 tests the `rareflag` trap, and the trap is a loc-key choice made at render time inside `FUN_1801bfac0` that leaves **no trace in the record**. The probe cannot answer it. It needs a human reading the card on screen: "Squad Fitness" confirms the trap, "Player Fitness" refutes the read of case 5.
### Q6. cardtype 7 (cardsubtypeid 9, 10, 11). Live tests: 1, low priority.
No DB merge, no table, no id space. The only observable is the parser's cardtype-7 arm copying `name`/`localizedName` and `description` (0x1f bytes each) straight off the wire, plus a `cardassetid` default of 0x23 for subtype 9. Those are scalar string atoms and cannot desync the tokenizer, so the risk is low, but the value is also low: this family currently has no known use. Fire it only if a session has a spare fetch.
### Q7. Club items: badges, balls, kits, stadium. Ranked LAST, and here is the reason.
The id spaces come free from Q0 (`fcc_badgecards` 11 columns, `fcc_kitcards` 12, `fcc_balls` 9, `fcc_stadium` 13, `fcc_leaguelogos` 8, `fcc_misccards` 10 are all in the catalog), so nothing is being deferred out of ignorance. What is missing is everything else: cardtype 9 has no merge arm, so no name, no miss-fill, no readback signal of any kind; no badge/kit/ball/stadium request has ever been observed from this client; no response shape has ever been observed; and the artwork path (probably keyed on `teamid` 0x306 or `stadiumid` 0x2db) was never read, which is the one place a hidden id space could still be lurking.
Every sweep window anyone has proposed for this family is **invariant by construction**: with no merge arm and no miss-fill, every id in the window produces a byte-identical record, so the probe's readback cannot discriminate. The only signal on offer is a human eyeballing the UI, and that is confounded twice: `sweep_items()` deliberately keeps `itemType: "player"` while `itemType` is one of the two wire discriminators, and `club_route` returns the sweep payload **before** the `?type=` filter, so the items arrive in answer to a `type=player` fetch.
Do not spend a live test here. Revisit only if Q4 produces a real `type=badge` (or kit/ball/stadium/equippables) request in the log. The honest cheap win in this family is not items at all: it is adding the `kits`, `kitsHome`, `kitsAway`, `badges`, `badgeDBid`, `balls`, `stadia`, `leagueLogos` and trophy rows to the existing, live-proven `club/stats` payload, where the values are plain counts and the row shape is already exercised. That is a data change with no new wire shape and freeze risk none.
### Q8. Documentation corrections, zero cost, commit alongside Q0.
`docs/ENDPOINT_MAP.md`: `GET ut/%s/club` is **ClubSearch** (registry index 3, confirmed by a full 125-record walk), not `FutGetClubInfo`; `GetClubInfo` is on `ut/%s/user/list`, `GetClubUsers` on `ut/%s/clubUser`. Row 12: `FutConsumablesSearch` (deser 0x180130d10) has no `displayGroupUseDefaultImage`; its only top-level atom is `itemData` (0x16b), whose elements are `{count 0xbc, discardValue 0xd7, item 0x16a, resourceId 0x287, untradeableCount 0x362}`. Also record: `fancards` is a `fixtures` column, `newcards` is atom 0x1d7, and neither is a card family.
---
## 4. The next live session
**One launch. Do Q0, Q1 and Q2 before the human touches anything.**
Preconditions to verify from the terminal, not from memory: `FIFA17.exe` alive; `utas_server.py` alive on its port; `/tmp/fut_id_sweep` exists and is **empty**; `fifa17-recon/data/dbstore/` written by Q0 with all three assertions passing; `utas_server.py` restarted **once** with the Q2 changes and the empty control file still rendering the real club.
Then, in order:
1. **Human launches FIFA 17 and reaches the FUT club UI.** Record: that the club looks normal, and the game's pid. If Q0 has not run yet, run it now against this pid before arming anything.
2. **Arm window 1.** Write `t4@1000001-1001552` (or the full 5-window list, if Q2(a) shipped) into `/tmp/fut_id_sweep`. No restart.
3. **Human leaves the club tab and re-enters it.** Record: does the club fill with cards at all; do any of them show real manager names; roughly how many named versus blank. If Q2(a) shipped, the human should page through the club so the client issues its ~7 fetches and all five windows advance in this one visit.
4. **Run `card_identity_probe`.** Record to a file, not to the eye: the count of records with rating != 7, the count with non-blank first/last, the exact set of `resourceId` values that resolved, the count with rare set, and separately the count with `first == "DB Error"` and their `assetId` values.
5. **Diff the resolved id set against `docs/managercards_ids.txt`.** Record the three numbers: matched, expected-but-missing, resolved-but-unexpected. Any id above 1001455 that resolved settles Q1 in favour of 417.
6. **For each staff band, record which single cardsubtypeid returned real names** and confirm the other three returned "DB Error" with the right fallback assetid. That is the table attribution, confirmed from outside, and it is the one thing the memory walk could not do on its own for the three schema-identical families.
7. **Optionally append the Q5 consumables list** and have the human re-enter the tab once more. Record `cardtype` and `category` for all ten, plus what the fitness card (t219) literally says on screen, in words.
8. **Empty `/tmp/fut_id_sweep`.** Human re-enters the club tab. **Record that the real club is back and unchanged.** Do not end the session without this step.
9. **Q4, last, with the control file empty:** human opens the My Club categories and club-items screens. We read the log for new `type=` values. Nothing is served, nothing is judged on screen.
Everything the human is asked to report is a count or a literal string, never a judgement about whether something "looks right".
---
## 5. What could make this plan wrong
**The decode could be a plausible byte run rather than a mapped structure.** Three independent signals say otherwise: the `width == ceil(log2(max-min+1))` prediction held 8 of 8 on managercards' integer columns, the four hardcoded DB-Error fallback assetids each land inside their own family's decoded range, and two agents using two different block locators agreed 417 of 417 on managercards. If any of those three stops holding on a table, treat that table's ids as a guess again. This exact failure mode has already burned two sessions: pointer fragments read as sequential ids, and a strictly-increasing 18-bit field in the `manager` table that turned out to be `teamid`.
**The two row-count fields disagree and we do not know which is authoritative.** 417 versus 321 is unresolved (TODO/CONFIRM). If `descriptor-0x10` wins, every row count in the Q0 dump is wrong, including all four staff bands, and Q3's windows are mis-sized.
**The layout-entry field order is in dispute.** `{bitOffset, colTag, width, type}` versus `{type, bit_offset, hash4, bit_width}`. Same 16 bytes, different reading. The `ceil(log2)` assertion is the only thing standing between us and silently decoding the wrong bits.
**Q0 can kill the oracle.** `/proc/PID/mem` is the whole route, and the game is a live oracle that costs a launch to recreate. A stray `'r+b'`, a mistaken write path, or an out-of-range seek in a hardened version of the tool takes FIFA17.exe down. Loading gigabytes in 16 MB chunks can also push the machine into swap and make the game hitch. Open `'rb'` only, read while the game sits at a menu, never mid-match.
**Q0 can break the one thing that is fully working: the roster.** `fifa17-recon/data/roster.json` holds 17,547 players and was cross-validated 573 of 573 against an independent oracle. The decoded `players` table has **17,563** rows, a 16-id delta with no explanation (TODO/CONFIRM). It will be tempting to regenerate the roster from the richer 107-column table. Do not. Diff the id sets first, only ever add columns, and keep the validated file until a fresh cross-validation passes on the new one.
**Q2(c) can break every player card in the club.** `fut_store._item()` hardcodes `"rareflag": 1`, the merge reads +0x58 for the rare tier, and the parser's `discardValue` fallback query keys on `rare` as well as `cardtype` and `rating`. Any change to that default re-tiers and re-prices every card in an existing save. Keep the overrides sweep-only and default-off, and verify with an empty control file before the human launches.
**Q2(a) can silently corrupt its own readback.** `sweep_items()` derives item ids from the candidate's offset within the range so chunks accumulate in the map without colliding. A window **list** breaks that invariant unless each window gets its own id base: two windows would write different families to the same map keys and one would overwrite the other, producing a clean-looking result that is simply the last window five times.
**Q3 transiently breaks the club tab fix that just shipped.** `club_route` returns the sweep payload before the `?type=` filter, so while any window is armed, the player tab, the staff tab and the league and team drill-downs all show sweep items. That is precisely the regression class the `type=` filter was added to fix (footballers displayed as coaching staff). It reverses on the next fetch once the file is emptied, but the human must never be asked to judge anything about their real club while a window is armed, and step 8 of the session is not optional.
**Q3's item counts are under a ceiling that was proven for a different payload.** 5000 items is live-proven for a single-subtype player payload; 20000 was served and silently not ingested. A `t*@` payload at 1650 has never been ingested at any size (4995 was proposed, never fired). Before drawing any conclusion from silence, confirm that at least one record in the window changed. TODO/CONFIRM.
**Q5 can freeze the club screen.** Consumables are the first cardtype-6 items this client will ever have been served, and the highest `cardsubtypeid` it has seen to date is 8. If a category the UI cannot lay out arrives, the club tab is where it fails. Serve one item per subtype, never thousands, and never a subtype outside the ranges read out of `FUN_18013f4d0`.
**Q7, if anyone rushes it, breaks the staff tab.** Adding `type=badge` handling means touching the narrow filter in `club_route`, whose default for unrecognised types is deliberately "filter, not unfiltered". Flipping that default is exactly how footballers reappear in the coaching staff list.
**And the honest limit of the whole round:** an id space buys names and ratings, not pictures. The merge writes manager firstname, lastname, assetid, value, talkrating, negotiation and rare, and nothing else. Nothing in this plan establishes that a manager card will render a portrait, or that a badge will render a crest. Manager names are stored in the client's own tables as 32-bit string-pool offsets whose pool was never located, so we cannot even preview offline what the client will show. Artwork is the next problem, and it should not be promised as part of this one.
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""READ-ONLY: enumerate every resident FIFA 17 DB table and dump its rows.
See dbwalk.py header for the on-heap format. This version locates a table's
layout block by its header signature (ncols<<16 | 0xffff) and matches on the
column tag set. Opens /proc/PID/mem 'rb'; only seek()/read().
"""
import glob, struct, sys, json
CONST = 0x07C20760
def find_pid():
for d in glob.glob("/proc/[0-9]*"):
try:
if open(d + "/comm").read().strip() == "FIFA17.exe":
return int(d.rsplit("/", 1)[-1])
except Exception:
pass
pid = find_pid()
f = open("/proc/%d/mem" % pid, "rb")
def rd(va, n):
try:
f.seek(va); b = f.read(n)
return b if b and len(b) == n else None
except Exception:
return None
def q(va):
b = rd(va, 8)
return struct.unpack("<Q", b)[0] if b else None
def cstr(va, m=64):
b = rd(va, m)
if not b:
return None
z = b.find(b"\x00")
if z <= 0:
return None
try:
return b[:z].decode("ascii")
except UnicodeDecodeError:
return None
def regions(lo_lim=0, hi_lim=1 << 62):
out = []
for line in open("/proc/%d/maps" % pid):
p = line.split()
a, b = p[0].split("-")
if "r" not in p[1] or "w" not in p[1]:
continue
lo, hi = int(a, 16), int(b, 16)
if hi - lo > 512 << 20 or hi < lo_lim or lo > hi_lim:
continue
out.append((max(lo, lo_lim), min(hi, hi_lim)))
return out
# ------- load the DB heap once -------
CHUNKS = []
for lo, hi in regions(0x06000000, 0x48000000):
off = lo
while off < hi:
n = min(16 << 20, hi - off)
d = rd(off, n)
if d:
CHUNKS.append((off, d))
off += n
print("loaded %d chunks, %.0f MB" % (len(CHUNKS), sum(len(c[1]) for c in CHUNKS) / 1e6))
def scan(pat):
out = []
for base, d in CHUNKS:
i = d.find(pat)
while i != -1:
out.append(base + i)
i = d.find(pat, i + 1)
return out
# ------- catalog -------
tables = {}
for c in scan(struct.pack("<Q", CONST)):
if c % 8:
continue
nm = cstr(q(c + 8) or 0)
if not nm:
continue
h = rd(c - 0x10, 0x10)
if not h:
continue
tag, cnt, colarr = struct.unpack("<IIQ", h)
if not (1 <= cnt <= 200) or colarr < 0x1000 or q(colarr + 0x20) != CONST:
continue
cols = []
for i in range(cnt):
d = rd(colarr + i * 0x30, 0x30)
if not d:
break
ty, ctag, mn, mx, ln = struct.unpack_from("<IIiII", d, 0)
cols.append(dict(name=cstr(struct.unpack_from("<Q", d, 0x28)[0]),
type=ty, tag=ctag, min=mn, max=mx, len=ln))
tables.setdefault(nm, []).append(dict(desc=c - 0x10, ncols=cnt, cols=cols))
print("tables: %d" % len(tables))
# ------- layout blocks by header signature -------
ncounts = sorted({t["ncols"] for v in tables.values() for t in v})
blocks = []
for n in ncounts:
sig = struct.pack("<I", (n << 16) | 0xFFFF)
for a in scan(sig):
if a % 4:
continue
hdr = a - 8 # hdr = {cap, rowcount, sig, ?}
ents = []
ok = True
for k in range(n):
e = rd(hdr + 0x10 + k * 16, 16)
if not e:
ok = False; break
off, tag, w, ty = struct.unpack("<4I", e)
tb = struct.pack("<I", tag)
if not (all(0x30 <= x < 0x7B for x in tb) and 0 < w <= 512 and off < 16384):
ok = False; break
ents.append((off, tag, w, ty))
if ok:
blocks.append((hdr, struct.unpack("<I", rd(hdr + 4, 4))[0], ents))
print("layout blocks: %d" % len(blocks))
byset = {}
for hdr, rc, ents in blocks:
byset.setdefault(frozenset(e[1] for e in ents), []).append((hdr, rc, ents))
def align4(x):
return (x + 3) & ~3
out = {}
for name in sorted(tables):
for t in tables[name]:
tset = frozenset(c["tag"] for c in t["cols"])
for hdr, rc, ents in byset.get(tset, []):
lay = {e[1]: (e[0], e[2], e[3]) for e in ents}
maxbit = max(o + w for o, w, ty in
[(lay[c["tag"]][0], 32 if c["type"] == 1 else lay[c["tag"]][1],
0) for c in t["cols"]])
stride = align4((maxbit + 7) // 8)
rowptr = q(hdr - 0x48)
out.setdefault(name, []).append(
dict(hdr=hdr, rows=rc, rowptr=rowptr, stride=stride,
cols=[(c["name"], c["type"], lay[c["tag"]][0],
lay[c["tag"]][1], c["min"], c["max"]) for c in t["cols"]]))
for name in sorted(out):
for b in out[name]:
print("\n== %-24s rows=%-7d stride=%-3d rowptr=%#x hdr=%#x"
% (name, b["rows"], b["stride"], b["rowptr"] or 0, b["hdr"]))
for cn, ty, o, w, mn, mx in sorted(b["cols"], key=lambda x: x[2]):
print(" bit %-5d w=%-4d %-26s type=%d [%d..%d]" % (o, w, cn, ty, mn, mx))
json.dump({k: [{kk: vv for kk, vv in b.items()} for b in v] for k, v in out.items()},
open("layouts.json", "w"), indent=1)
print("\nwrote layouts.json for %d tables" % len(out))
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Read FIFA 17's LOADED database schema (and any table's rows) out of a live
FIFA17.exe. READ-ONLY: /proc/PID/mem is opened 'rb' and only ever seek()/read().
There is no write path in this file.
WHY THIS EXISTS
---------------
`managercards` (and headcoachcards / gkcoachcards / physiocards /
fitnesscoachcards) are queried by CardsDLL through a column called `carddbid`,
and the set of legal carddbid values was unknown -- two blind sweeps (1..5000 and
6000..8000) resolved nothing, and the manager branch writes no miss-fill so a
wrong id is completely silent. dbdata.dll on disk is packed (see
dbdata_extract.py's docstring: uniform ~7.5 bits/byte entropy, one export that
returns a ~759-byte attestation blob), so the tables cannot be read off disk.
They ARE fully resident in the running game, and unlike the player roster they
are reachable through the DB's own SCHEMA rather than by content-anchoring.
THE STRUCTURES (resolved live, 2026-08-04, pid 11864, sitting in the FUT club UI)
--------------------------------------------------------------------------------
Three cooperating structures, all in the game's rw-p heap:
* IDENTIFIER INTERN POOL, seen at 0x0770_0000..0x07d0_0000. Every table name
and every column name in the whole database, interned once. Entry:
+0x00 char* next-in-bucket
+0x08 u64 strlen+1
+0x10 u64 hash
+0x18 char[] the NUL-terminated identifier, INLINE
e.g. "managercards" at 0x07be63a0, "carddbid" at 0x078eefc8,
"talkrating" at 0x078ec188, "headcoachcards" at 0x07be5b80.
* TABLE DIRECTORY, seen at 0x4254_7ff0.., 573 entries of 40 bytes. The
reliable signature is the CONSTANT QWORD 0x07c20760 that every entry carries
at +0x20 -- anchor on that, not on any fixed address:
+0x00 char* table name (into the intern pool)
+0x08 void* table descriptor ("p1")
+0x10 u32 name hash4 (4 bytes, also repeated inside the descriptor)
+0x14 u32 column count
+0x18 void* column-descriptor bucket ("p2")
+0x20 void* 0x07c20760 (the anchor)
Live values read this session:
managercards p1=0x42482f08 hash='xIfB' ncol=62
headcoachcards p1=0x427429d8 hash='WTdJ' ncol=18
gkcoachcards p1=0x42e46648 hash='CZUM' ncol=107
physiocards p1=0x42482478 hash='AThf' ncol=9
fitnesscoachcards p1=0x42d668e8 hash='MmoU' ncol=11
* TABLE DESCRIPTOR (at p1). Self-identifies by repeating the table's own
hash4; immediately after that hash come
u32 row_size_bytes, u32 max_bit_index
(managercards: 0x80 and 0x3ff -- 128 bytes == 1024 bits, consistent), and
then a column array of 16-byte records:
u32 type (3 for the scalar columns seen), u32 bit_offset,
u32 name hash4, u32 bit_width
First managercards columns read live (bit_offset, width):
(0x260,7) (0x20,8) (0x267,15) (0x28,8) (0x276,14) (0x284,5)
(0x30,8) (0x289,7) (0x290,2) (0x38,8) (0x292,18) (0x2a4,...)
Note the bit offsets run past 416, so a row is NOT the 0x34-byte
firstname/lastname record seen near 0x42530928 -- that is a different table.
* COLUMN-NAME entry (0x30 bytes, in the p2 buckets, also anchored by
0x07c20760 at +0x20): +0x04 hash4, +0x28 char* name. This is what turns a
column's hash4 back into "carddbid".
WHAT THIS TOOL DOES NOT YET DO
------------------------------
It stops at the schema. It does not locate the ROW STORAGE for a table -- that
pointer was not identified before the game exited. Candidate leads recorded at
the time: managercards' descriptor holds 0x426cb278 at p1+0x08 and 0x07822918 at
p1+0x30, and the 16 bytes before p1 read `... 81 04 00 00` (0x481 = 1153, a
plausible row count). Once row storage is found, carddbid for every row is a
mechanical bit-extract with (bit_offset, bit_width) from --table.
USAGE
-----
./dbschema_probe.py # list the FUT card tables
./dbschema_probe.py --table managercards # full column list for one table
./dbschema_probe.py --list # every table in the directory
Requires ptrace access to the FIFA process (ptrace_scope=1 + same uid suffices)
and FIFA17.exe actually running.
"""
import argparse
import os
import re
import struct
import sys
SHARED_ANCHOR = 0x07C20760 # the qword every directory/column entry carries at +0x20
POOL_LO, POOL_HI = 0x07700000, 0x07D00000
HEAP_LO, HEAP_HI = 0x42000000, 0x43000000
IDENT = re.compile(r'[A-Za-z][A-Za-z0-9_]*\Z')
FUT_TABLES = ("managercards", "headcoachcards", "gkcoachcards", "physiocards",
"fitnesscoachcards", "players", "teams", "leagues", "nations")
def find_pid():
for d in os.listdir('/proc'):
if not d.isdigit():
continue
try:
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
return int(d)
except OSError:
pass
raise SystemExit("FIFA17.exe is not running (this tool needs the live game)")
class Mem(object):
"""Read-only window onto /proc/PID/mem, with the two regions pre-slurped."""
def __init__(self, pid):
self.fh = open('/proc/%d/mem' % pid, 'rb')
self.pool = self.read(POOL_LO, POOL_HI - POOL_LO)
self.heap = self.read(HEAP_LO, HEAP_HI - HEAP_LO)
if not self.pool or not self.heap:
raise SystemExit("could not read the DB regions -- is the game past the "
"main menu? (the DB is loaded lazily)")
def read(self, addr, n):
if addr < 0 or addr > (1 << 47):
return b''
try:
self.fh.seek(addr)
return self.fh.read(n)
except (OSError, ValueError):
return b''
def cstr(self, addr, maxlen=64):
for base, buf in ((POOL_LO, self.pool), (HEAP_LO, self.heap)):
if base <= addr < base + len(buf):
i = addr - base
j = buf.find(b'\x00', i, i + maxlen)
if j <= i:
return None
s = buf[i:j]
return s.decode('latin1') if re.fullmatch(rb'[ -~]+', s) else None
return None
def hash4_to_names(mem):
"""Every column-name entry in the DB, keyed by its 4-byte name hash."""
out = {}
for base, buf in ((HEAP_LO, mem.heap), (POOL_LO, mem.pool)):
for off in range(0, len(buf) - 0x30, 8):
if struct.unpack_from('<Q', buf, off + 0x20)[0] != SHARED_ANCHOR:
continue
nm = mem.cstr(struct.unpack_from('<Q', buf, off + 0x28)[0], 48)
if not nm or not IDENT.match(nm):
continue
out.setdefault(struct.unpack_from('<I', buf, off + 4)[0], set()).add(nm)
return out
def table_directory(mem):
"""name -> (entry_addr, descriptor, hash4, ncol, colbucket)."""
out = {}
buf = mem.heap
for off in range(0, len(buf) - 0x28, 8):
if struct.unpack_from('<Q', buf, off + 0x20)[0] != SHARED_ANCHOR:
continue
nm = mem.cstr(struct.unpack_from('<Q', buf, off)[0], 48)
if not nm or not IDENT.match(nm):
continue
out[nm] = (HEAP_LO + off,
struct.unpack_from('<Q', buf, off + 8)[0],
struct.unpack_from('<I', buf, off + 0x10)[0],
struct.unpack_from('<I', buf, off + 0x14)[0],
struct.unpack_from('<Q', buf, off + 0x18)[0])
return out
def h4str(v):
return struct.pack('<I', v).decode('latin1')
def describe(mem, tables, h2n, name):
if name not in tables:
print("### %-20s NOT IN DIRECTORY" % name)
return
ent, p1, h4, ncol, p2 = tables[name]
print("\n### %s dir@%#x descriptor=%#x hash=%r columns=%d"
% (name, ent, p1, h4str(h4), ncol))
blk = mem.read(p1, 0x80 + ncol * 16 + 0x80)
if not blk:
print(" descriptor unreadable")
return
pos = blk.find(struct.pack('<I', h4))
if pos < 0:
print(" self-hash not found in the descriptor -- layout changed")
return
rowsz, maxbit = struct.unpack_from('<II', blk, pos + 4)
print(" self-hash at +%#x row_size=%#x bytes max_bit=%#x" % (pos, rowsz, maxbit))
# the column array begins at the first 16-byte record whose hash4 is a known
# column name and whose width is sane
st = pos + 12
while st < len(blk) - 16:
t, boff, ch, w = struct.unpack_from('<IIII', blk, st)
if t == 3 and ch in h2n and 0 < w <= 64:
break
st += 4
print(" column array at +%#x" % st)
cols = []
for i in range(ncol):
o = st + i * 16
if o + 16 > len(blk):
break
t, boff, ch, w = struct.unpack_from('<IIII', blk, o)
cols.append((boff, w, t, ch,
"|".join(sorted(h2n.get(ch, {"?" + h4str(ch)})))))
for boff, w, t, ch, nm in sorted(cols):
print(" bit %5d width %-3d type %-2d %s" % (boff, w, t, nm))
return cols
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--pid', type=int)
ap.add_argument('--table', action='append')
ap.add_argument('--list', action='store_true')
a = ap.parse_args()
pid = a.pid or find_pid()
print("FIFA17.exe pid %d" % pid)
mem = Mem(pid)
h2n = hash4_to_names(mem)
tables = table_directory(mem)
print("column-name hashes: %d tables in directory: %d" % (len(h2n), len(tables)))
if a.list:
for nm in sorted(tables):
ent, p1, h4, ncol, p2 = tables[nm]
print(" %-34s desc=%#012x hash=%r cols=%d" % (nm, p1, h4str(h4), ncol))
return
for nm in (a.table or FUT_TABLES):
describe(mem, tables, h2n, nm)
if __name__ == '__main__':
main()