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