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.
This commit is contained in:
funman300
2026-08-23 20:08:08 +00:00
parent eefa98c961
commit 6baa673252
3 changed files with 104 additions and 110 deletions
+77
View File
@@ -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())
+26 -32
View File
@@ -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()
+1 -78
View File
@@ -2382,73 +2382,6 @@ fn club_type_filter(token: Option<&str>) -> Option<ClubTypeFilter> {
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<Value>) {
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<bool> = 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();