//! 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, }; 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], } /// 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, } /// 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, /// `contract` (atom 0xb8) → `rec+0x8c`. `Some` for the two contract /// families only; they ignore `amount` entirely. pub contract: Option, /// `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; /// 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 { 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 { 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 { None } /// 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, } /// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table; /// non-fatal display field). fn 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. pub fn shape_item( item: &CoreOwnedItem, id: Fifa17Identity, ent: &impl ReverseEntityResolver, ) -> 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 = 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": 7, "fitness": 99, "discardValue": discard_value(item.rating), }) } /// 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_` — needs `teamid` /// * badge `Badge` + `TeamName_Abbr15_` — needs `teamid` /// * stadium `Stadium` + `StadiumName_` — 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, "itemState": item_state, "owners": 1, "untradeable": false, }); if matches!(id.subtype, KIT_SUBTYPE | BADGE_SUBTYPE) { item["teamid"] = json!(id.team_id); } item } /// Contracts remaining on an owned staff card. /// /// Staff consume contracts exactly as players do (`rec+0x8c`), and the client /// refuses to start a match when the manager's has run out. Core does not model /// staff contracts, so this mirrors the constant [`shape_item`] already emits /// for players rather than inventing a second, different default. 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::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], } } #[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, ); 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, ); let b = shape_item( &item("oc-b", "fifa17_101490", 84, "ST"), Fifa17Identity { item_id: 100000031, asset_id: 101490, resource_id: 101490, rareflag: 1, }, &ent, ); 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, ); 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" ); } /// 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 = 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, ); 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, }; 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_`, while a stadium resolves /// `StadiumName_` 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, }; 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}"); } } }