From 6baa67325284d774d78c5fbf48358ee57087e916 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sun, 23 Aug 2026 20:08:08 +0000 Subject: [PATCH] kits: recover the selector data path from CardsDLL; residency tracks the ROUTE, not itemType RETRACTION FIRST. The previous commit added itemType to club items on the theory that it gated ingestion, because player/staff sent it and were resident while kit/badge/stadium omitted it and were not. Relaunched the client with itemType on all three: ?type=kit answered total=2 emitted=2, and still no cardtype-7 record. The correlation was an artefact of the control. Measured read-only over /proc/PID/mem with full coverage (3605 MiB, nothing skipped): the "resident" players and staff were all SQUAD members, which arrive via userMassInfo. Players that appear in /club?type=player but NOT in userMassInfo are not resident either - 0 records for 6 of 6 sampled, 5 with no byte match at all, out of 1966 served. Residency tracks the ROUTE. /club?type= responses never enter the persistent card collection, and no value of itemType changes that. itemType is kept as wire fidelity (every real EA item carries it) and relabelled; its doc no longer claims to fix anything. The diagnostic KIT_PROBE is removed - it could only have tested shape hypotheses that this result makes moot. RECOVERED from the unpacked CardsDLL, no archive extraction, no instrumentation: Packed kit id, both directions present and agreeing: id = (teamid << 14) | (year ? (year-1800) << 5 : 0) | kittype so a kit is addressed by the triple (teamid, year, kittype). FUN_180033770 answers ONLY for team 130000 - 0x1800d8ab0 is literally `mov $0x1fbd0,%eax ; ret`. Every other team id falls through to the engine's catalogue kits, which are the lockable ones. sub_180033430 writes the tile: NAME = "HOME_SIDE"/"AWAY_SIDE", TYPE = the localised Kit_type_0 / Kit_type_1 / Kit_type_historical, and LOCKED (always value 0, never 1). If the queried triple matches NEITHER active triple it writes NOTHING - which is exactly why one tile rendered "undefined". A missing write, not a bad string. There is no Kit_type_2. FUN_1800d73d0 selector 2/3 does `setne dil ; add $0x65,%edi` then compares itemState: active home = 101, active away = 102, derived arithmetically and independent of the enum table. year at +0xba is movzbl - a byte INDEX. Above all of it: FUT_GET_MATCH_KITS_DP (0x7565) handler FUN_1800be6a0 gates on `cmpb $0x1,0x152(%r14)` and returns early otherwise. KITS_AVAILABLE IS ctx+0x152. Constructor zeroes it; the only setter is case index 6 (message 0x757a) of the jump table at 0x1800c00d4. Live value is 0, so no kit list is ever built. 0x757a has no name in CardsDLL and that is bounded, not sloppy: the registration run ends at 0x7575 with the epilogue immediately after, and 70 other ids resolve from the same table as the positive control. Tables (audit_fifa17_kits.py, full-table counts): category 2/3/5 -> engine kit type 0/1/2 with 0 counterexamples against 54/166/145 discriminating keys; the id band is NOT home/away (band 63 holds 740 home AND 88 third). Vault: "Kit Selector Data Path.md". cargo test 429 passed 0 failed across the two crates; clippy -D warnings clean; fmt clean. --- fifa17-recon/tools/cardsdll_kit_strings.py | 77 +++++++++++++++++++++ openfut-adapter-fifa17/src/fut/item.rs | 58 +++++++--------- openfut-utas-host/src/lib.rs | 79 +--------------------- 3 files changed, 104 insertions(+), 110 deletions(-) create mode 100755 fifa17-recon/tools/cardsdll_kit_strings.py diff --git a/fifa17-recon/tools/cardsdll_kit_strings.py b/fifa17-recon/tools/cardsdll_kit_strings.py new file mode 100755 index 0000000..b978f87 --- /dev/null +++ b/fifa17-recon/tools/cardsdll_kit_strings.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Recover the kit caption/localisation vocabulary from the UNPACKED CardsDLL. + +Why CardsDLL and not FIFA17.exe: CardsDLL is not packed, so a MISS here is +meaningful. FIFA17.exe is Denuvo-packed and only partially readable -- a hit +there is useful, a miss proves nothing. Every run therefore prints a positive +control first; if the control fails, the run is void and no negative may be +quoted from it. + +Usage: python3 cardsdll_kit_strings.py [path-to-CardsDLL] +""" +from __future__ import annotations + +import os +import re +import sys + +DEFAULT = os.path.expanduser( + "~/.cache/openfut-investigation/bin/CardsDLL_Win64_retail.dll" +) + +# Strings that MUST be present. If any is missing the search is broken. +CONTROLS = [b"activeHomeKit", b"cardsubtypeid", b"resourceId", b"activeAwayKit"] + +# The kit caption vocabulary this project has referred to, plus neighbours worth +# knowing about either way. +PROBES = [ + b"FUT_UC_KITS", b"TeamName_Abbr15_", b"TeamName_Abbr15", b"TeamName_", + b"FUT_UC_", b"StadiumName_", b"Badge", b"Stadium", + b"activeBadge", b"activeBall", b"activeStadium", + b"kit", b"Kit", b"KIT", + b"home", b"Home", b"HOME", b"away", b"Away", b"AWAY", + b"locked", b"Locked", b"LOCKED", b"unlock", + b"category", b"year", b"teamid", b"teamId", + b"DataProvider", b"itemData", b"itemType", b"itemState", +] + + +def ascii_strings(data, minlen=4): + for m in re.finditer(rb"[ -~]{%d,}" % minlen, data): + yield m.start(), m.group() + + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT + data = open(path, "rb").read() + print(f"{os.path.basename(path)} {len(data)} bytes") + + print("\n-- positive control (a miss voids every negative below) --") + ok = True + for c in CONTROLS: + n = data.count(c) + print(f" {c.decode():16s} {n}") + if n == 0: + ok = False + if not ok: + print(" CONTROL FAILED — do not quote negatives from this run.") + return 1 + + print("\n-- probe counts --") + for p in PROBES: + print(f" {p.decode():18s} {data.count(p)}") + + # Whole-string table: every standalone string containing kit-ish substrings. + print("\n-- standalone strings matching kit/team/caption vocabulary --") + pat = re.compile(rb"(?i)(kit|teamname|abbr|stadiumname|fut_uc|locked|unlock)") + seen = set() + for off, s in ascii_strings(data, 5): + if pat.search(s) and s not in seen: + seen.add(s) + print(f" @{off:#08x} {s.decode('latin1')[:110]}") + print(f" ({len(seen)} distinct)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/openfut-adapter-fifa17/src/fut/item.rs b/openfut-adapter-fifa17/src/fut/item.rs index 37f2a1d..7052ae3 100644 --- a/openfut-adapter-fifa17/src/fut/item.rs +++ b/openfut-adapter-fifa17/src/fut/item.rs @@ -371,38 +371,35 @@ pub fn shape_item( /// Wire `itemType` (atom 0x173) for a cardtype-7 club item. /// -/// MEASURED 2026-08-23, live client pid 8793 parked on the pre-match kit -/// selector, read-only `/proc/PID/mem`. Whether a served club item becomes a -/// resident item record correlates perfectly with whether we send `itemType`: +/// Sent for WIRE FIDELITY only. Every real EA item in the capture corpus carries +/// `itemType`, and the two families OpenFUT already shaped (`player`, `staff`) +/// carry it, so omitting it on the club families was an inconsistency. Tokens +/// come from the `?type=` vocabulary decoded from the `FUN_18012ec50` jump table +/// (`kit` 12, `stadium` 13, `badge` 11). /// -/// ```text -/// family itemType sent resident record? -/// player "player" yes -/// staff "staff" yes -/// kit (absent) NO -/// badge (absent) NO -/// stadium (absent) NO -/// ``` +/// It does NOT fix the pre-match kit selector, and the reasoning that first +/// introduced it was WRONG. That reasoning was: kit/badge/stadium omitted +/// `itemType` and were not resident as item records, while player and staff sent +/// it and were, so `itemType` must gate ingestion. Adding it changed nothing — +/// the client was relaunched, `?type=kit` answered `total=2 emitted=2` with +/// `itemType` present, and still no cardtype-7 record was resident. /// -/// Two of two families that carry it are ingested; none of the three that omit -/// it is. With no cardtype-7 record resident, the club scan `FUN_1800d73d0` -/// (`+0x4c == 7 && +0x50 == 9 && +0x5c in {101,102}`) matches nothing, the FUT -/// match-kit DataProvider is built empty (traced: `KITS_AVAILABLE = 0`), and the -/// selector falls back to catalogue-gated engine kits — which is the observed -/// "This kit is currently locked" dialog. +/// The correlation was an artefact of the CONTROL, not the field. Measured +/// 2026-08-23 read-only over `/proc/PID/mem`: the "resident" players and staff +/// were all SQUAD members, which arrive via `userMassInfo`. Testing players that +/// appear in `/club?type=player` but NOT in `userMassInfo` shows they are not +/// resident either — 0 records for 6 of 6 sampled, 5 with no byte match at all, +/// out of 1966 served. So residency tracks the ROUTE, not this field: +/// `/club?type=` responses do not enter the persistent card collection, and no +/// value of `itemType` changes that. /// -/// `CARD_SYSTEM.md` records that `itemType` "is parsed into a heap string and -/// never stored". That remains true of the RECORD; it does not follow that the -/// string is unused, and the correlation above is evidence that it is consulted -/// before the record is retained. +/// `CARD_SYSTEM.md`'s "parsed into a heap string and never stored" therefore +/// stands unchallenged; the earlier note here that it was "evidence the string is +/// consulted" is withdrawn. /// -/// The tokens are the `?type=` vocabulary decoded from the `FUN_18012ec50` jump -/// table (`kit` 12, `stadium` 13, `badge` 11), which is the same vocabulary the -/// two working families already use (`player` 1, `staff` 10). -/// -/// INFERRED, not proven: no capture of a real EA club item exists anywhere in -/// the corpus, so the exact token for these three families is taken from the -/// atom vocabulary rather than observed on the wire. +/// INFERRED, not proven: no capture of a real EA club item exists anywhere in the +/// corpus, so the exact token for these three families is taken from the atom +/// vocabulary rather than observed on the wire. fn club_item_type(subtype: i64) -> &'static str { match subtype { KIT_SUBTYPE => "kit", @@ -1099,10 +1096,7 @@ mod tests { year: 0, }; let type_of = |subtype| { - shape_club_item( - Fifa17KitIdentity { subtype, ..base }, - item_state::FREE, - )["itemType"] + shape_club_item(Fifa17KitIdentity { subtype, ..base }, item_state::FREE)["itemType"] .as_str() .expect("itemType is always emitted") .to_string() diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 6e594e1..c72278b 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -2382,73 +2382,6 @@ fn club_type_filter(token: Option<&str>) -> Option { Some(filter) } -/// DIAGNOSTIC, `OPENFUT_FIFA17_KIT_PROBE=1`, staging only, default OFF. -/// -/// Appends two synthetic kits to `?type=kit` so one client restart can settle -/// which of the two remaining kit-ingest hypotheses is right. It answers a -/// question no static reading has: the client is served two kits, returns -/// `total=2 emitted=2`, and yet NO cardtype-7 record is ever resident (measured -/// read-only over 3.6 GB of process memory, while player and staff records from -/// the same response family ARE resident). -/// -/// Two shapes, because two things could be rejecting the item: -/// -/// * `6300007` MINIMAL — exactly the field set a STAFF item carries, which is -/// known to ingest, plus `cardsubtypeid` 9. If only this one appears, one of -/// the kit-only extras (`assetId`, `cardassetid`, `teamid`, `category`, -/// `year`) is what makes the client discard the item. -/// * `6300008` NAMED — the full kit shape plus `name`/`localizedName`/ -/// `description`. The cardtype-7 parse arm is documented to copy exactly those -/// three, and OpenFUT sends none of them. If only this one appears, they are -/// required rather than optional. -/// -/// If NEITHER appears, `?type=kit` is not the route that populates the -/// collection `FUN_1800d73d0` scans, and the search moves to which route does. -/// -/// Both ids are real `fcc_kitcards` carddbids for team 21 (`6300007` year 1972 -/// category 2, `6300008` year 0 category 5), so nothing here invents an id the -/// client cannot resolve. They are served `free`, never active, so they cannot -/// disturb the real active-kit assignment. Instance ids are outside Core's -/// range so they can never collide with an owned row. -/// -/// Deliberately small: the one response that has ever crashed this client was 30 -/// items across five families (2026-08-05). This is two items in one family. -fn append_kit_shape_probe(label: &str, items: &mut Vec) { - if label != "kit" || !kit_probe_enabled() { - return; - } - items.push(json!({ - "id": 100009007, - "resourceId": 6300007, - "cardsubtypeid": 9, - "itemType": "kit", - "itemState": "free", - "owners": 1, - "untradeable": false, - })); - items.push(json!({ - "id": 100009008, - "resourceId": 6300008, - "assetId": 6300008, - "cardassetid": 35, - "cardsubtypeid": 9, - "itemType": "kit", - "itemState": "free", - "owners": 1, - "untradeable": false, - "teamid": 21, - "category": 5, - "year": 0, - "name": "OpenFUT Probe Kit", - "localizedName": "OpenFUT Probe Kit", - "description": "kit ingest probe", - })); - eprintln!( - "utas-host owner=RUST route=club KIT_PROBE armed: appended 6300007 (minimal) \ - and 6300008 (named) — diagnostic only" - ); -} - /// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate /// the filtered set locally. Returns `(page, total_specials)`. Pure — the whole /// point is that "special" pagination is over the filtered set, never Core's @@ -2615,12 +2548,11 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) }; let (body, stats) = shape_club_response_with_kits(&visible, deps.entities, deps.assets, active); - let mut all = body + let all = body .get("itemData") .and_then(Value::as_array) .cloned() .unwrap_or_default(); - append_kit_shape_probe(filter_arm.label, &mut all); let (paged, total) = if core_q.special { special_filter_page(&all, offset, limit) } else { @@ -5669,15 +5601,6 @@ fn equippables_enabled() -> bool { *ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_EQUIPPABLES").as_deref() == Ok("1")) } -/// Append the two diagnostic kits described on [`append_kit_shape_probe`]. -/// -/// Default OFF, staging only. Flip the env var off to revert with a restart and -/// no rebuild. -fn kit_probe_enabled() -> bool { - static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); - *ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_KIT_PROBE").as_deref() == Ok("1")) -} - /// A JSON response with an explicit status. fn json_status(status: u16, v: &Value) -> WireResponse { let body = serde_json::to_vec(v).unwrap_or_default();