Files
OpenFUT/openfut-adapter-fifa17/src/fut/item.rs
T
funman300 6baa673252 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.
2026-08-23 20:08:08 +00:00

1109 lines
48 KiB
Rust

//! The shared FIFA 17 FUT **item-shaping primitive**.
//!
//! One function shapes one owned FUT item into the numeric card object the FIFA
//! 17 client renders, and **every** route that emits a player item goes through
//! it — `/club` (via [`crate::fut::club_response`]) and squad projection (via
//! [`crate::fut::squad_projection`]) alike. There is deliberately no second copy
//! of the field set: an item is shaped in exactly one place so the two routes
//! can never drift.
//!
//! ## The asset-id boundary (load-bearing, evidence-grounded)
//!
//! FIFA renders a card by resolving `resourceId & 0xffffff` against the client's
//! OWN local players table (proven live): a real id renders a real footballer,
//! an **invented id renders a blank generic card**. OpenFUT Core's catalogue is
//! synthetic string ids (`card_pl_001`) with no FIFA asset id. So an
//! [`ItemIdentityResolver`] is injected; when it cannot supply a **real** FIFA
//! asset id for an item, the item carries no fabricated identity — the caller
//! decides what that means (`/club` drops and counts it; a squad refuses to
//! project a starter it cannot render, never faking one).
//!
//! Entity ids (`leagueId`/`teamid`/`nation`) come from a reverse resolver; an
//! unresolved name yields a neutral `0` (a valid int — non-fatal; it only means
//! "no badge/flag"), because those are not the identity the renderer keys on.
use serde_json::{json, Value};
use crate::fut::content_taxonomy::{
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, BADGE_SUBTYPE, KIT_SUBTYPE,
MANAGER_SUBTYPE, STADIUM_SUBTYPE,
};
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item_state;
/// One owned item in game-independent terms, as read from Core's inventory.
#[derive(Debug, Clone)]
pub struct CoreOwnedItem {
/// Core owned-instance id (string). The stable per-copy identity — two
/// copies of the same card definition have distinct `owned_card_id`s.
pub owned_card_id: String,
/// Core card-definition id (string), used for asset resolution.
pub card_id: String,
/// Effective overall rating.
pub rating: u8,
/// Effective position, e.g. "ST".
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
/// [pace, shooting, passing, dribbling, defending, physical].
pub attributes: [u8; 6],
/// Match-contracts remaining on this instance, as persisted by Core.
/// `None` = Core tracks none, so the caller substitutes the pack-fresh
/// default ([`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] for a
/// player, [`STAFF_CONTRACT`] for staff). Core deliberately stores NULL for
/// "untracked" rather than seeding a number, so the game-specific default
/// stays on this side of the boundary.
pub contract_matches: Option<i64>,
/// EA's authored definition rating for a non-player, from Core. `None` = Core
/// tracks none; callers MUST fail closed rather than substitute a tier.
pub source_rating: Option<u8>,
/// Core's own `content_kind` token for this instance, verbatim. Distinct from
/// the adapter catalog's kind: Core calls the squad manager `manager` while the
/// catalog classifies it `staff` + subtype 4.
pub core_content_kind: Option<String>,
}
/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real
/// FIFA player asset (low 24 bits the client resolves); `item_id` is the wire
/// instance id used for later item operations. Two owned copies of the same
/// definition share an `asset_id` but MUST have distinct `item_id`s.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17Identity {
pub item_id: u32,
/// Base FIFA player asset (low 24 bits the client resolves art/name from).
pub asset_id: u32,
/// Full versioned resource id = `(version << 24) | asset_id`. Equals
/// `asset_id` for a version-0 base card. This is the wire
/// `resourceId`/`definitionId`, kept DISTINCT from `asset_id` so a versioned
/// (special) card never collapses onto its base on the wire.
pub resource_id: u32,
/// FIFA wire `rareflag` — the card's rare/special TYPE (e.g. 3=inform,
/// 21..=24 = special programmes). Drives the client's special-card art;
/// carried from the catalog, never hardcoded, so specials render as specials.
pub rareflag: i64,
}
/// FIFA-side identity fields needed to render an owned club kit. Unlike player
/// items, kit art and source-team metadata come from `fcc_kitcards`, not Core.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17KitIdentity {
pub item_id: u32,
pub asset_id: u32,
pub resource_id: u32,
pub card_asset_id: u32,
pub subtype: i64,
pub team_id: i64,
/// Kit slot family: `2` home, `3` away, `5` third. Read at record `+0xb8`
/// and mapped to the engine kit SLOT (2→0, 3→1, 5→3).
pub category: i64,
/// Kit season; `0` = current season. Record `+0xba`.
pub year: i64,
}
/// FIFA-side identity fields needed to render an owned staff card (manager or
/// coach). Unlike a player, a staff record carries NO attributes, rating,
/// position or rareflag: the client merges all of those from its own
/// `managercards`/`*coachcards` tables keyed on `resource_id`.
///
/// `nation`/`league_id`/`team_id` are meaningful for a MANAGER only
/// (`subtype == MANAGER_SUBTYPE`) and are zero for the four coach families,
/// whose tables carry no nation/league/team column.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17StaffIdentity {
pub item_id: u32,
/// THE merge key, read RAW as a u32 by `FUN_1801356c0` with NO `& 0xffffff`
/// mask (players are the only family that is masked). It must equal the
/// table `carddbid` exactly — a non-zero version byte silently breaks the
/// lookup, and the manager branch has no else-arm to report the miss.
pub resource_id: u32,
/// `cardsubtypeid`. This ALONE selects which staff table the client merges.
pub subtype: i64,
pub nation: i64,
pub league_id: i64,
pub team_id: i64,
}
/// FIFA-side identity + definition facts needed to render an owned consumable.
///
/// A consumable carries NO id space to discover: `FUN_18013f4d0` never touches a
/// DB handle, and category, artwork, name and both stat bytes all derive from
/// `cardsubtypeid` alone. What it does need is the fcc_* row's ART id and the one
/// extra key its family reads — see [`Fifa17ConsumableIdentity::is_renderable`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17ConsumableIdentity {
pub item_id: u32,
/// `rec+0x18`. Bookkeeping only for a consumable (artwork is a client-side
/// constant, so this never reaches the screen), but kept as EA's own
/// `carddbid` so nothing drifts out of their space.
pub resource_id: u32,
pub asset_id: u32,
/// The fcc_* `cardassetid` — the ART id, NOT a copy of `resource_id`.
/// Observed values in the real profile: 3 (training), 7/8 (contracts),
/// 9 (healing), 34 (position), 50/51 (play style). Copying `resource_id`
/// here is right for players and wrong for every other family: the client
/// looks up art `5003001`, finds none, and draws the `notfound.swf` green
/// "NOT FOUND" box.
pub card_asset_id: u32,
/// `rec+0x50`. THE ONLY selector: category, artwork, name and both stat
/// bytes derive from it.
pub subtype: i64,
/// `rec+0x58`. Observed 0 on every owned consumable in the real profile.
pub rareflag: i64,
/// `rec+0xb4`. Drives the card level (`rec+0x54`) and therefore the
/// `fcc_discardcoins` price. Definition-level EA data (55..95 observed).
pub rating: u8,
/// `amount` (atom 0x1b) → `rec+0xbf`, or `+0xbe` for a play style.
/// `Some` exactly for the families [`ConsumableNeeds::Amount`] names.
pub amount: Option<i64>,
/// `contract` (atom 0xb8) → `rec+0x8c`. `Some` for the two contract
/// families only; they ignore `amount` entirely.
pub contract: Option<i64>,
/// `rec+0x49`. Per-INSTANCE in FIFA, unmodelled by Core, so the host passes
/// the observed constant [`CONSUMABLE_UNTRADEABLE`]. Carried per copy rather
/// than baked into the shaper because the consumables route's stack wrapper
/// reports `untradeableCount` over the copies in the stack.
pub untradeable: bool,
}
impl Fifa17ConsumableIdentity {
/// Whether this definition can be drawn HONESTLY. Three refusals, every one a
/// silent-failure guard rather than taste:
///
/// * the family's mandatory extra key is missing — the parser initialises
/// its `amount` temp to `-1` and both accessors read the byte SIGNED, so
/// an omission draws "-1" on the card, not "0" (and a contract card with
/// no `contract` grants nothing);
/// * `rareflag != 0` on subtype 219 — `FUN_1801bfac0` case 5 renders a RARE
/// Player Fitness card as a SQUAD Fitness card, i.e. a different item
/// entirely, with no error anywhere;
/// * `card_asset_id == asset_id` — a consumable's art id is a SMALL `fcc_`
/// art id (3, 7, 8, 9, 34, 50, 51 observed) and never its own `carddbid`,
/// so this means the catalog carried no `card_asset_id` and the client
/// would draw `notfound.swf`, the green "NOT FOUND" box.
///
/// A subtype outside every documented range is also refused: it falls to
/// `FUN_18013f4d0`'s bottom default and renders as a perfectly ordinary
/// Squad Training (Pace) card with amount 0 — plausible and wrong.
pub fn is_renderable(&self) -> bool {
if self.subtype == SQUAD_FITNESS_TRAP_SUBTYPE && self.rareflag != 0 {
return false;
}
if self.card_asset_id == self.asset_id {
return false;
}
match consumable_family(self.subtype) {
None => false,
Some((family, _)) => match consumable_needs(family) {
ConsumableNeeds::Amount => self.amount.is_some(),
ConsumableNeeds::Contract => self.contract.is_some(),
ConsumableNeeds::None => true,
},
}
}
}
/// Supplies the FIFA numeric identity for a Core item. Returning `None` means
/// "no real FIFA asset id known" → the caller must not fabricate one.
pub trait ItemIdentityResolver {
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
/// Resolve one owned kit definition. Default `None` preserves existing
/// player-only resolvers; the catalog-backed FIFA17 resolver overrides it.
fn resolve_kit(&self, _item: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
None
}
/// Resolve one owned staff definition (manager or coach). Default `None`
/// preserves existing resolvers; the catalog-backed FIFA17 resolver
/// overrides it.
fn resolve_staff(&self, _item: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
None
}
/// Resolve one owned consumable definition. Default `None` preserves
/// existing resolvers; the catalog-backed FIFA17 resolver overrides it.
fn resolve_consumable(&self, _item: &CoreOwnedItem) -> Option<Fifa17ConsumableIdentity> {
None
}
/// The card's discard (quick-sell) value in coins — BOTH the number the
/// client displays on the card and the number the server MUST credit when
/// it is sold. One method serves both so the wire and the wallet cannot
/// disagree: a non-zero `discardValue` suppresses the client's own local
/// computation (`0x180141025`), so whatever is sent here is what the player
/// is promised.
///
/// NON-MINTING by contract, like [`Self::subtype_of`] — it takes the Core
/// item, never a resolved [`Fifa17Identity`], so pricing a card on a read
/// path cannot allocate a wire id.
///
/// The default is the [`legacy_discard_value`] placeholder ladder, which
/// preserves the behaviour of every resolver without a catalog behind it.
/// The catalog-backed FIFA17 resolver overrides it with the client's own
/// `fcc_discardcoins` table (see [`super::discard`]).
fn discard_value(&self, item: &CoreOwnedItem) -> i64 {
legacy_discard_value(item.rating)
}
/// The FIFA `cardsubtypeid` of a Core item's definition, or `0` when unknown
/// or a player. NON-MINTING by contract: `/club`'s per-family filters call it
/// for every owned row, so allocating a wire id here would pollute the
/// identity store on a read.
fn subtype_of(&self, _item: &CoreOwnedItem) -> i64 {
0
}
/// Classify a Core item's definition into the content vocabulary. Defaults to
/// [`ContentKind::Player`] so existing resolvers keep their behaviour; a
/// catalog-backed resolver overrides this to consult its `kind_of`, letting
/// `/club` exclude non-player content (which must never render as a
/// 0-rated player).
fn kind_of(&self, _item: &CoreOwnedItem) -> ContentKind {
ContentKind::Player
}
}
/// Diagnostics from shaping (safe to log — counts only).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ShapeStats {
pub emitted: usize,
/// No real FIFA asset id for this definition — dropped, never faked.
pub dropped_no_asset: usize,
/// The definition resolved but is INCOMPLETE or self-contradictory, so
/// drawing it would be a lie the client cannot detect (a consumable missing
/// the mandatory `amount`/`contract`, or the subtype-219 rareflag trap).
/// Dropped and counted separately, because the fix is a catalog re-emit, not
/// an identity mapping.
pub dropped_incomplete: usize,
/// Owned content this envelope deliberately does not carry: a CONSUMABLE
/// (its own route serves it as a stack), or a club-customisation family
/// whose record shape is not yet verified (badge, ball, stadium, misc).
/// Core owns the row; the projection is withheld, never guessed.
pub excluded_non_player: usize,
}
/// The ORIGINAL rating-only quick-sell ladder. **A placeholder, not
/// EA-authentic**: it is blind to both card type and `rareflag`, so it prices a
/// 94-rated TOTW special and a 94-rated gold common identically, and it pays a
/// flat floor for every non-player (whose Core rating is 0).
///
/// The client's real value is `round_half_up(rating * fcc_discardcoins.price /
/// 100)` — see [`super::discard`], which reproduces it exactly. This ladder is
/// retained as the [`ItemIdentityResolver::discard_value`] default so a resolver
/// with no catalog behind it keeps its existing behaviour, and so the deployed
/// economy only changes when an operator opts in.
pub fn legacy_discard_value(rating: u8) -> i64 {
match rating {
r if r >= 85 => 1500,
r if r >= 80 => 900,
r if r >= 75 => 600,
r if r >= 65 => 300,
_ => 150,
}
}
/// Build one FIFA `_item` object. `resourceId`/`definitionId` carry the full
/// versioned resource id; `assetId`/`cardassetid` carry the base asset. For a
/// version-0 base card these coincide; for a special they differ and MUST NOT
/// be collapsed.
///
/// This is the single source of truth for a player item's on-wire shape; the
/// `/club` envelope and squad projection both call it, so their items are
/// identical by construction. `id` is the owned instance's resolved FIFA
/// identity — pass the resolver's answer for *this* owned copy so two copies of
/// one definition stay distinct on the wire.
///
/// `discard_value` and `contract` are explicit scalars for the same reason: both
/// are per-instance numbers this shaper must not invent. `contract` used to be a
/// hardcoded `7`, which made every card look pack-fresh no matter how many
/// matches it had played or how many contracts had been applied to it. The
/// caller passes [`CoreOwnedItem::contract_matches`] resolved against
/// [`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] — the substitution for
/// Core's "untracked" NULL belongs to the caller, because the default is
/// FIFA-specific and Core stores no number to speak for it.
pub fn shape_item(
item: &CoreOwnedItem,
id: Fifa17Identity,
ent: &impl ReverseEntityResolver,
discard_value: i64,
contract: i64,
) -> Value {
let asset = id.asset_id;
let league_id = ent.league_id(&item.league).unwrap_or(0);
let team_id = ent.team_id(&item.club).unwrap_or(0);
let nation_id = ent.nation_id(&item.nation).unwrap_or(0);
let attribute_list: Vec<Value> = item
.attributes
.iter()
.enumerate()
.map(|(i, v)| json!({ "index": i, "value": v }))
.collect();
json!({
"id": id.item_id,
"resourceId": id.resource_id,
"assetId": asset,
"cardassetid": asset,
"definitionId": id.resource_id,
"cardsubtypeid": 0,
"itemType": "player",
"rareflag": id.rareflag,
"rating": item.rating,
"preferredPosition": item.position,
"nation": nation_id,
"teamid": team_id,
"leagueId": league_id,
"playStyle": 250,
"attributeList": attribute_list,
"itemState": item_state::FREE,
"owners": 1,
// Owned/pack-pulled cards are TRADEABLE in FIFA 17 (untradeable is the
// exception for SBC/promo rewards, which Core does not model). Emitting
// `true` greyed out "Place on Transfer Market" for every card — the same
// "our own data showing through" bug the Python oracle fixed by forcing
// this off for owned copies (item_def keeps `true`; instances do not).
"untradeable": false,
"contract": contract,
"fitness": 99,
"discardValue": discard_value,
})
}
/// Wire `itemType` (atom 0x173) for a cardtype-7 club item.
///
/// 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).
///
/// 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.
///
/// 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`'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.
///
/// 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",
STADIUM_SUBTYPE => "stadium",
BADGE_SUBTYPE => "badge",
_ => "misc",
}
}
/// Build one FIFA 17 **cardtype-7 club item**: a kit (subtype 9), a badge (11)
/// or a stadium (10). `item_state` is the proven wire enum token — `free`, or
/// one of the `active*` designations the client deserializes to 100..104.
///
/// All three families share one record and one client-side resolver
/// (`FUN_180119bd0`, dispatched when `item+0x4c == 7`), differing only in the
/// field their caption reads:
///
/// * kit `FUT_UC_KITS` + `TeamName_Abbr15_<teamid>` — needs `teamid`
/// * badge `Badge` + `TeamName_Abbr15_<teamid>` — needs `teamid`
/// * stadium `Stadium` + `StadiumName_<assetId>` — needs `assetId`, which
/// `resourceId` already supplies
///
/// `teamid` (atom 0x306, record `+0x94`) is therefore emitted for kits and
/// badges and WITHHELD for stadiums, whose resolver never reads it. It is an
/// established scalar field, not a new shape.
///
/// The cardtype-9 families (ball 30, league logo 31) are deliberately NOT
/// shaped here: they have no DB name resolver, so their display name can only
/// come from `localizedName` on the wire, and while that offset is confirmed
/// it is NOT established that sending it is safe.
pub fn shape_club_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
let mut item = json!({
"id": id.item_id,
"resourceId": id.resource_id,
"assetId": id.asset_id,
"cardassetid": id.card_asset_id,
"cardsubtypeid": id.subtype,
"itemType": club_item_type(id.subtype),
"itemState": item_state,
"owners": 1,
"untradeable": false,
});
if matches!(id.subtype, KIT_SUBTYPE | BADGE_SUBTYPE) {
item["teamid"] = json!(id.team_id);
}
// Kits only. `category` and `year` complete the `(teamid, year, slot)`
// identity the client's active-kit resolver builds at record `+0xb8`/`+0xba`
// (`FUN_1800d73d0`), and which the engine's kit descriptor
// (`sub_180033430`) compares against before it will name — rather than
// lock — a kit. Without them the triple can never match and the pre-match
// selector reports "This kit is currently locked".
//
// Deliberately NOT emitted for badges or stadiums: the slot mapping is
// kit-specific, and those families resolve their caption by other fields.
if id.subtype == KIT_SUBTYPE {
item["category"] = json!(id.category);
item["year"] = json!(id.year);
}
item
}
/// Pack-fresh contracts on a staff card — the FALLBACK for an instance Core
/// tracks no contract for, no longer an unconditional constant.
///
/// Staff consume contracts exactly as players do (`rec+0x8c`), and the client
/// refuses to start a match when the manager's has run out. A caller holding an
/// owned item passes `item.contract_matches.unwrap_or(STAFF_CONTRACT)`, so a
/// tracked staff instance now reports its real remaining matches and only an
/// untracked one falls back here. The value matches
/// [`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] rather than inventing a
/// second, different default for the staff families.
pub const STAFF_CONTRACT: i64 = 7;
/// Build one FIFA 17 staff item (manager or coach).
///
/// The key set is deliberately minimal and is taken field-by-field from the
/// instruction-level reversal in `fifa17-recon/tools/fut_staff.py`, where every
/// key is justified by its CardsDLL record offset:
///
/// * `id` → `rec+0x08`, `resourceId` → `rec+0x18` (the RAW merge key),
/// `cardsubtypeid` → `rec+0x50` (alone selects which staff table is merged),
/// `contract` → `rec+0x8c`, `itemState` → `rec+0x5c`, `owners` → `rec+0x48`,
/// `untradeable` → `rec+0x49`.
/// * `nation` → `rec+0xde` and `leagueId` → `rec+0xe0` are MANAGER-ONLY record
/// slots the client's merge never writes, so the server is their only source;
/// they drive the manager's flag, league badge and both halves of manager
/// chemistry. `teamid` → `rec+0x94` is read by the card view-model.
///
/// Everything else is omitted on purpose, because each is either overwritten by
/// the merge from the client's own table (`assetId`/`cardassetid` at `rec+0x20`,
/// `rating` at `rec+0xb4`, `rareflag` at `rec+0x58`), skipped by the parser
/// (`definitionId`), or — worse — SURVIVES the merge and is then read by the
/// view-model, which would hang a position label or an attribute row on a
/// manager (`preferredPosition` at `rec+0x146`, `attributeList` at `rec+0x98`).
/// A staff card must therefore never be routed through [`shape_item`].
///
/// The four coach families carry no nation/league/team columns in the client's
/// tables, so those three keys are emitted for a manager only rather than being
/// invented as zeroes for a coach.
pub fn shape_staff_item(id: Fifa17StaffIdentity, contract: i64) -> Value {
let mut item = json!({
"id": id.item_id,
"resourceId": id.resource_id,
"cardsubtypeid": id.subtype,
// Inert on the wire (the parser reads atom 0x173 into a stack string and
// frees it), but it is what every staff family reports, and our own
// readers use it to tell a staff card from a footballer at a glance.
"itemType": "staff",
"contract": contract,
"itemState": item_state::FREE,
"owners": 1,
"untradeable": false,
});
if id.subtype == MANAGER_SUBTYPE {
let obj = item.as_object_mut().expect("json! built an object");
obj.insert("nation".to_string(), json!(id.nation));
obj.insert("leagueId".to_string(), json!(id.league_id));
obj.insert("teamid".to_string(), json!(id.team_id));
}
item
}
/// Build one FIFA 17 consumable item.
///
/// The key set is EXACTLY what the real profile import holds for its 17 owned
/// consumables — i.e. what the client itself stored — and every key is a key the
/// live player path already proves, so this introduces NO new wire shape:
///
/// * `id` → `rec+0x08`, `resourceId` → `rec+0x18`, `assetId`, `cardassetid` (the
/// ART id, see [`Fifa17ConsumableIdentity::card_asset_id`]),
/// `cardsubtypeid` → `rec+0x50`, `rareflag` → `rec+0x58`,
/// `rating` → `rec+0xb4`, `itemState` → `rec+0x5c`, `owners` → `rec+0x48`,
/// `untradeable` → `rec+0x49`.
/// * `amount` → `rec+0xbf` / `+0xbe` and `contract` → `rec+0x8c`, each emitted
/// only for the families that read it (the caller has already gated on
/// [`Fifa17ConsumableIdentity::is_renderable`]).
///
/// `itemType` is `"player"`, which is not a mislabel: it is the ONLY value this
/// client has ever been sent, it is what the real profile stores on all 17, and
/// `cardtype` is derived from `cardsubtypeid` alone (`FUN_18013fe00`), so the
/// string cannot affect the render. A consumable is discriminated by its subtype
/// plus the ABSENCE of `attributeList`; inventing `"consumable"` here would be a
/// fabricated token.
///
/// `untradeable` is carried per copy from
/// [`Fifa17ConsumableIdentity::untradeable`] (the host supplies the observed
/// [`CONSUMABLE_UNTRADEABLE`]), because the consumables route reports
/// `untradeableCount` over a stack and the two must agree.
///
/// DELIBERATELY ABSENT, each for a named reason:
/// * `teamid`, `leagueid` and `value` — the three "extras" copied out of an fcc
/// row that CRASHED the client on 2026-08-05. `value` is the established
/// culprit (it is an OBJECT member elsewhere, and a scalar where an object is
/// expected is the type-desync busy loop at `0x1801c7f1a`); none of the three
/// is needed to draw a card.
/// * `preferredPosition`, `nation`, `playStyle`, `attributeList`, `fitness` —
/// player-only, and `attributeList` is the very thing that distinguishes a
/// footballer from a consumable.
/// * `definitionId` — not an atom at all; the parser has always skipped it.
/// * `discardValue` — the client computes it from `fcc_discardcoins` on
/// `(cardtype 6, level, rare)`, and real rows exist for both rare values.
/// * `pile` — Core/host state (the transfer pile), not a wire atom: the
/// live-proven player path does not send it either.
pub fn shape_consumable_item(id: Fifa17ConsumableIdentity) -> Value {
let mut item = json!({
"id": id.item_id,
"resourceId": id.resource_id,
"assetId": id.asset_id,
"cardassetid": id.card_asset_id,
"cardsubtypeid": id.subtype,
"itemType": "player",
"rareflag": id.rareflag,
"rating": id.rating,
"itemState": item_state::FREE,
"owners": 1,
"untradeable": id.untradeable,
});
let obj = item.as_object_mut().expect("json! built an object");
if let Some(amount) = id.amount {
obj.insert("amount".to_string(), json!(amount));
}
if let Some(contract) = id.contract {
obj.insert("contract".to_string(), json!(contract));
}
item
}
/// `cardsubtypeid` of the PLAYER FITNESS card, and the one subtype where
/// `rareflag` is load-bearing rather than cosmetic: `FUN_1801bfac0` case 5 reads
/// it as the squad-fitness selector, so a rare Player Fitness card silently
/// becomes a SQUAD Fitness card — a different item, with no error anywhere.
pub const SQUAD_FITNESS_TRAP_SUBTYPE: i64 = 219;
/// Tradeability of an owned consumable.
///
/// FIFA models this per INSTANCE (`rec+0x49`) and Core does not model it at all,
/// so this is the observed value, not a policy: all 17 owned consumables in the
/// real profile import carry `untradeable: true`, and it is also the oracle's own
/// default for the family. When Core models per-instance tradeability, this
/// constant is what it replaces.
///
/// Note the lever it controls on screen: the consumables deserializer sets a UI
/// flag from `untradeableCount < count`, so an all-untradeable stack draws the
/// untradeable badge. That is correct for genuinely untradeable copies; it was
/// only wrong for the oracle's SYNTHETIC shelf, where the badge was its own data
/// showing through.
pub const CONSUMABLE_UNTRADEABLE: bool = true;
#[cfg(test)]
mod tests {
use super::*;
use crate::fut::content_taxonomy::STADIUM_SUBTYPE;
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
use crate::fut::entities::Fifa17Entities;
use std::collections::HashMap;
fn entities() -> Fifa17Entities {
Fifa17Entities::from_maps(
HashMap::from([(13, "Premier League".to_string())]),
HashMap::from([(52, "Argentina".to_string())]),
HashMap::from([(5, "Chelsea".to_string())]),
)
}
fn item(owned: &str, card: &str, rating: u8, pos: &str) -> CoreOwnedItem {
CoreOwnedItem {
owned_card_id: owned.into(),
card_id: card.into(),
rating,
position: pos.into(),
nation: "Argentina".into(),
league: "Premier League".into(),
club: "Chelsea".into(),
attributes: [90, 88, 70, 85, 40, 78],
// Untracked by default; the contract tests below set it explicitly.
contract_matches: None,
// Players: their rating IS `overall`, so Core carries no separate
// authored definition rating, and these fixtures are player items.
source_rating: None,
core_content_kind: None,
}
}
#[test]
fn shapes_real_identity_and_reverse_entity_ids() {
let ent = entities();
let it = shape_item(
&item("oc1", "card_ch_1", 86, "CDM"),
Fifa17Identity {
item_id: 100000001,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
&ent,
legacy_discard_value(86),
PACK_FRESH_CONTRACT_MATCHES,
);
assert_eq!(it["id"], 100000001, "wire instance id");
assert_eq!(it["resourceId"], 20801);
assert_eq!(it["assetId"], 20801);
assert_eq!(
it["definitionId"], 20801,
"version byte 0 => resourceId==assetId==definitionId"
);
assert_eq!(it["rating"], 86);
assert_eq!(it["preferredPosition"], "CDM");
assert_eq!(it["leagueId"], 13);
assert_eq!(it["teamid"], 5);
assert_eq!(it["nation"], 52);
assert_eq!(it["itemType"], "player");
assert_eq!(it["attributeList"].as_array().unwrap().len(), 6);
assert_eq!(it["attributeList"][0], json!({"index":0,"value":90}));
}
#[test]
fn two_owned_copies_of_one_definition_stay_distinct_on_the_wire() {
// fifa17_101490 has two owned instances: same definition/asset, two
// distinct owned ids and two distinct wire ids. Shaping each from its
// own identity must NEVER collapse them.
let ent = entities();
let a = shape_item(
&item("oc-a", "fifa17_101490", 84, "ST"),
Fifa17Identity {
item_id: 100000030,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
&ent,
legacy_discard_value(84),
PACK_FRESH_CONTRACT_MATCHES,
);
let b = shape_item(
&item("oc-b", "fifa17_101490", 84, "ST"),
Fifa17Identity {
item_id: 100000031,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
&ent,
legacy_discard_value(84),
PACK_FRESH_CONTRACT_MATCHES,
);
assert_eq!(
a["resourceId"], b["resourceId"],
"same definition => same asset"
);
assert_ne!(
a["id"], b["id"],
"distinct owned copies keep distinct wire ids"
);
assert_eq!(a["id"], 100000030);
assert_eq!(b["id"], 100000031);
}
#[test]
fn versioned_special_keeps_resourceid_distinct_from_assetid() {
// A versioned (special) card: resourceId/definitionId carry the full
// versioned id; assetId/cardassetid stay the base asset. They MUST NOT
// collapse. (resource 117617092 = version 7 of asset 176580.)
let ent = entities();
let it = shape_item(
&item("oc-v", "fifa17_117617092", 92, "ST"),
Fifa17Identity {
item_id: 100000384,
asset_id: 176580,
resource_id: 117617092,
rareflag: 3,
},
&ent,
legacy_discard_value(92),
PACK_FRESH_CONTRACT_MATCHES,
);
assert_eq!(
it["resourceId"], 117617092,
"versioned resource id on the wire"
);
assert_eq!(it["definitionId"], 117617092);
assert_eq!(it["assetId"], 176580, "base asset id preserved");
assert_eq!(it["cardassetid"], 176580);
assert_eq!(
it["rareflag"], 3,
"special rareflag carried, not hardcoded 1"
);
}
/// `contract` USED to be a hardcoded `7`, so every card looked pack-fresh no
/// matter what Core had persisted — a contract consumable could be applied,
/// committed and then be invisible on the very screen that spends it. The
/// shaper must emit the number it was PASSED.
#[test]
fn contract_is_the_passed_value_not_a_constant() {
let ent = entities();
let id = Fifa17Identity {
item_id: 100000001,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
};
let base = item("oc1", "card_ch_1", 86, "CDM");
let it = shape_item(&base, id, &ent, legacy_discard_value(86), 22);
assert_eq!(it["contract"], 22, "the passed count, not 7");
// The whole 0..=99 range reaches the wire verbatim, including a spent
// card (0) and a capped one (99) — no clamping, no substitution.
for contract in [0, 1, 7, 22, 99] {
let it = shape_item(&base, id, &ent, legacy_discard_value(86), contract);
assert_eq!(it["contract"], contract);
}
// `fitness` is the same class of hardcode and deliberately out of scope
// here; asserting it keeps this test honest about what it proved.
assert_eq!(it["fitness"], 99);
}
/// Core stores NULL for an instance it tracks no contract for, so the
/// FIFA-specific pack-fresh default is substituted by the CALLER — the same
/// `unwrap_or` both production call sites use.
#[test]
fn an_untracked_instance_falls_back_to_pack_fresh() {
let ent = entities();
let id = Fifa17Identity {
item_id: 100000001,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
};
let mut untracked = item("oc1", "card_ch_1", 86, "CDM");
untracked.contract_matches = None;
let it = shape_item(
&untracked,
id,
&ent,
legacy_discard_value(86),
untracked
.contract_matches
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
);
assert_eq!(it["contract"], PACK_FRESH_CONTRACT_MATCHES);
assert_eq!(it["contract"], 7, "the proven pack-fresh count");
// A tracked instance is NOT overwritten by the fallback.
let mut tracked = item("oc1", "card_ch_1", 86, "CDM");
tracked.contract_matches = Some(31);
let it = shape_item(
&tracked,
id,
&ent,
legacy_discard_value(86),
tracked
.contract_matches
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
);
assert_eq!(it["contract"], 31);
}
/// The GK-training card the real profile owns: `5003012`, art 3, subtype 54,
/// rating 85, amount 15. Its key set is the acceptance criterion.
fn training_consumable() -> Fifa17ConsumableIdentity {
Fifa17ConsumableIdentity {
item_id: 100000239,
resource_id: 5_003_012,
asset_id: 5_003_012,
card_asset_id: 3,
subtype: 54,
rareflag: 0,
rating: 85,
amount: Some(15),
contract: None,
untradeable: CONSUMABLE_UNTRADEABLE,
}
}
#[test]
fn consumable_emits_exactly_the_keys_the_client_itself_stored() {
let it = shape_consumable_item(training_consumable());
// Verbatim from the real profile import (persona 33068179):
// {"id":100000239,"resourceId":5003012,"assetId":5003012,"cardassetid":3,
// "cardsubtypeid":54,"itemType":"player","rareflag":0,"rating":85,
// "itemState":"free","owners":1,"untradeable":true,"amount":15}
assert_eq!(
it,
json!({
"id": 100000239,
"resourceId": 5_003_012,
"assetId": 5_003_012,
"cardassetid": 3,
"cardsubtypeid": 54,
"itemType": "player",
"rareflag": 0,
"rating": 85,
"itemState": "free",
"owners": 1,
"untradeable": true,
"amount": 15,
})
);
// The three "extras" that crashed the client on 2026-08-05, and the
// player-only keys that would make a consumable look like a footballer.
for forbidden in [
"teamid",
"leagueid",
"leagueId",
"value",
"attributeList",
"preferredPosition",
"nation",
"playStyle",
"fitness",
"definitionId",
"discardValue",
"pile",
] {
assert!(
it.get(forbidden).is_none(),
"a consumable must not carry `{forbidden}`"
);
}
}
#[test]
fn consumable_art_id_is_never_the_resource_id() {
// The green "NOT FOUND" box: the client resolves artwork by cardassetid,
// which is a SMALL fcc_ art id, not the carddbid.
let it = shape_consumable_item(training_consumable());
assert_eq!(it["cardassetid"], 3);
assert_ne!(it["cardassetid"], it["resourceId"]);
}
/// EVERY `itemState` this crate can put on the wire must be one of the twelve
/// tokens recovered from the client's own table. An unrecovered token decodes
/// to `0xffffffff` through `FUN_180166660` and the client then acts on an
/// unrecognised state.
#[test]
fn every_emitted_item_state_is_in_the_recovered_table() {
let ent = entities();
let mut emitted: Vec<String> = Vec::new();
let player = shape_item(
&item("oc1", "card_ch_1", 86, "CDM"),
Fifa17Identity {
item_id: 1,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
&ent,
legacy_discard_value(86),
PACK_FRESH_CONTRACT_MATCHES,
);
emitted.push(player["itemState"].as_str().unwrap().to_string());
let staff = shape_staff_item(
Fifa17StaffIdentity {
item_id: 2,
resource_id: 1_000_509,
subtype: MANAGER_SUBTYPE,
nation: 45,
league_id: 53,
team_id: 241,
},
STAFF_CONTRACT,
);
emitted.push(staff["itemState"].as_str().unwrap().to_string());
emitted.push(
shape_consumable_item(training_consumable())["itemState"]
.as_str()
.unwrap()
.to_string(),
);
// Every state `/club` can hand a kit, including both equipped roles.
let kit = Fifa17KitIdentity {
item_id: 3,
asset_id: 6_300_006,
resource_id: 6_300_006,
card_asset_id: 35,
subtype: 9,
team_id: 21,
category: 2,
year: 0,
};
for state in [
item_state::FREE,
item_state::ACTIVE_HOME_KIT,
item_state::ACTIVE_AWAY_KIT,
] {
let it = shape_club_item(kit, state);
emitted.push(it["itemState"].as_str().unwrap().to_string());
}
for state in &emitted {
assert!(
item_state::is_recovered(state),
"{state:?} is not one of the twelve recovered itemState tokens"
);
}
assert!(
!emitted.iter().any(|s| s == item_state::INVALID),
"omitting itemState yields `invalid` (0) and fails the squad builder; \
no shaper may emit it deliberately either"
);
}
/// Kit, badge and stadium share ONE cardtype-7 record, but their captions do
/// not read the same field: kit and badge resolve
/// `TeamName_Abbr15_<teamid>`, while a stadium resolves
/// `StadiumName_<assetId>` and its resolver never reads teamid. Sending a
/// field the resolver does not read is how this project earned a client
/// freeze, so the record carries exactly what each family consumes.
#[test]
fn a_club_item_carries_only_the_field_its_caption_resolves() {
let ident = |subtype| Fifa17KitIdentity {
item_id: 100_000_500,
asset_id: 6_000_005,
resource_id: 6_000_005,
card_asset_id: 39,
subtype,
team_id: 21,
category: 2,
year: 0,
};
for subtype in [KIT_SUBTYPE, BADGE_SUBTYPE] {
let it = shape_club_item(ident(subtype), item_state::FREE);
assert_eq!(
it["teamid"], 21,
"subtype {subtype} resolves TeamName_Abbr15"
);
assert_eq!(it["cardsubtypeid"], subtype);
}
let stadium = shape_club_item(ident(STADIUM_SUBTYPE), item_state::FREE);
assert!(
stadium.get("teamid").is_none(),
"a stadium caption reads assetId, never teamid"
);
// The art id is the FAMILY's, never a copy of the resource id: a card
// whose card_asset_id equals its asset_id draws the notfound box.
assert_eq!(stadium["cardassetid"], 39);
assert_ne!(stadium["cardassetid"], stadium["resourceId"]);
// Every cardtype-7 family keeps the established minimal key set.
for key in [
"id",
"resourceId",
"assetId",
"cardassetid",
"cardsubtypeid",
"itemState",
"owners",
"untradeable",
] {
assert!(stadium.get(key).is_some(), "missing {key}");
}
// Never a player field: these have no rating, contract or attributes.
for key in [
"attributeList",
"contract",
"fitness",
"rating",
"discardValue",
] {
assert!(stadium.get(key).is_none(), "club item must not carry {key}");
}
}
/// The pre-match kit selector needs the WHOLE identity triple, not just
/// `teamid`.
///
/// The client's active-kit resolver `FUN_1800d73d0` reads `category` at
/// record `+0xb8` (mapping 2→slot 0, 3→slot 1, 5→slot 3) and `year` at
/// `+0xba`, and the engine's kit descriptor `sub_180033430` will only NAME a
/// kit whose decoded `(teamid, year, slot)` equals the club's active home or
/// away triple. A descriptor it does not match is left completely unwritten
/// and the engine reports "This kit is currently locked" instead.
///
/// Regression: we shipped kits with `teamid` alone, which cannot match.
#[test]
fn a_kit_carries_the_full_identity_triple_the_selector_matches_on() {
let kit = Fifa17KitIdentity {
item_id: 100_004_874,
asset_id: 6_300_006,
resource_id: 6_300_006,
card_asset_id: 35,
subtype: KIT_SUBTYPE,
team_id: 21,
category: 2,
year: 0,
};
let home = shape_club_item(kit, item_state::ACTIVE_HOME_KIT);
assert_eq!(home["teamid"], 21);
assert_eq!(home["category"], 2, "category is the SLOT source");
assert_eq!(home["year"], 0, "year completes the triple");
assert_eq!(home["itemState"], item_state::ACTIVE_HOME_KIT);
// A historical kit must round-trip its real season, not be flattened.
let historical = shape_club_item(
Fifa17KitIdentity {
year: 2002,
category: 5,
..kit
},
item_state::ACTIVE_HOME_KIT,
);
assert_eq!(historical["year"], 2002);
assert_eq!(historical["category"], 5);
// Badges and stadiums must NOT gain the kit-only fields: the slot map is
// kit-specific and this project has frozen the client before by sending
// a family a field its resolver does not read.
for subtype in [BADGE_SUBTYPE, STADIUM_SUBTYPE] {
let other = shape_club_item(Fifa17KitIdentity { subtype, ..kit }, item_state::FREE);
assert!(other.get("category").is_none(), "subtype {subtype}");
assert!(other.get("year").is_none(), "subtype {subtype}");
}
}
/// Every cardtype-7 family MUST carry a DISTINCT `itemType`.
///
/// Measured live 2026-08-23: the two families that carry `itemType`
/// (player, staff) become resident item records and the three that omitted
/// it (kit, badge, stadium) did not, leaving the pre-match kit selector with
/// an empty DataProvider and a "kit is currently locked" dialog.
///
/// The distinctness half is not pedantry. `STADIUM_SUBTYPE` was initially
/// unimported here, so `match` read it as a fresh binding rather than a
/// constant, silently made the stadium arm irrefutable, and typed badges as
/// `"stadium"`. It compiled with only an unused-variable warning. Asserting
/// three different tokens is what catches that class of mistake.
#[test]
fn every_cardtype7_family_carries_its_own_item_type() {
let base = Fifa17KitIdentity {
item_id: 100004874,
asset_id: 6300006,
resource_id: 6300006,
card_asset_id: 35,
subtype: KIT_SUBTYPE,
team_id: 21,
category: 2,
year: 0,
};
let type_of = |subtype| {
shape_club_item(Fifa17KitIdentity { subtype, ..base }, item_state::FREE)["itemType"]
.as_str()
.expect("itemType is always emitted")
.to_string()
};
assert_eq!(type_of(KIT_SUBTYPE), "kit");
assert_eq!(type_of(STADIUM_SUBTYPE), "stadium");
assert_eq!(type_of(BADGE_SUBTYPE), "badge");
}
}