//! FIFA 17 **non-player content taxonomy** — the evidence-based map from a card //! `cardsubtypeid` to its functional family (consumables) or role (staff). //! //! This is the ONLY place the FIFA-specific `cardsubtypeid` vocabulary lives; it //! keeps that game concept out of generic Core, exactly as the player-side //! catalog keeps `resourceId`/`rareflag` out of Core. Nothing here is guessed: //! //! * Consumable families and their contiguous `cardsubtypeid` ranges are taken //! verbatim from `fifa17-recon/tools/fut_consumables.py` //! (`BY_SUBTYPE`/`CORE_KINDS`, Ghidra-derived from `FUN_18013f4d0` / //! `FUN_1801bfac0`) and `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md` //! (verified against the `.105` `fcc_*.json` tables). //! * Staff roles are the `FUN_1800d8330` family selector: 4=manager, 5=headcoach, //! 6=gkcoach, 7=physio, 8=fitnesscoach. //! //! Display **labels are functional, never marketing** (e.g. "Player Chemistry //! Style", not a promo name). A `cardsubtypeid` outside every documented range //! resolves to `None` — the caller DEFERS it (mirroring the player NoName gate), //! never fabricating a family. /// The disjoint content classes a FIFA 17 owned item can belong to. Player is /// the default so a catalog authored before this taxonomy existed (no `kind` /// field) still classifies every entry as a player, unchanged. /// /// The token set is OpenFUT Core's game-independent content vocabulary /// (`player | manager | staff | consumable | kit | badge | ball | stadium | /// misc`), so a Core owned row and a FIFA 17 catalog entry name the same class /// with the same string and the FIFA numerics (`cardsubtypeid`, resource ranges) /// never leak out of this crate. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ContentKind { #[default] Player, /// A MANAGER — its own Core kind, but on the FIFA 17 side it is a member of /// the STAFF family, never a class of its own: see /// [`ContentKind::is_staff_family`]. The wire discriminator is /// [`MANAGER_SUBTYPE`], not this token, so a catalog may classify a manager /// as either `manager` or `staff` + subtype 4 and every consumer here /// treats the two encodings identically. Manager, Staff, Consumable, Kit, Badge, Ball, Stadium, Misc, } impl ContentKind { /// The stable wire/catalog token for this kind. pub fn as_str(&self) -> &'static str { match self { ContentKind::Player => "player", ContentKind::Manager => "manager", ContentKind::Staff => "staff", ContentKind::Consumable => "consumable", ContentKind::Kit => "kit", ContentKind::Badge => "badge", ContentKind::Ball => "ball", ContentKind::Stadium => "stadium", ContentKind::Misc => "misc", } } /// Parse a catalog `kind` token. Unknown or "player" (or an absent field that /// deserializes to the default) is `Player` — backward compatible. // Intentionally infallible (every input maps to a kind, unknown → Player), so // it is NOT `std::str::FromStr` (which is fallible); the name mirrors the // catalog token vocabulary. #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> ContentKind { match s { "manager" => ContentKind::Manager, "staff" => ContentKind::Staff, "consumable" => ContentKind::Consumable, "kit" => ContentKind::Kit, "badge" => ContentKind::Badge, "ball" => ContentKind::Ball, "stadium" => ContentKind::Stadium, "misc" => ContentKind::Misc, _ => ContentKind::Player, } } /// True for the two kinds that make up the FIFA 17 STAFF family. /// /// A manager IS a staff card: the client's own club-stats model counts it /// inside the `staff` total with `staffManager` as a bucket within it, its /// STAFF tab asks for the whole family with `type=manager`, and one record /// shape ([`crate::fut::item::shape_staff_item`]) serves all five families. /// Every staff consumer MUST use this predicate rather than matching /// `Staff` alone, or a `manager`-classified row silently leaves the staff /// bucket and the STAFF tab. pub fn is_staff_family(&self) -> bool { matches!(self, ContentKind::Manager | ContentKind::Staff) } /// True for the three club-customisation kinds that share the **cardtype-7** /// record: kit (9), stadium (10) and badge (11). /// /// `FUN_1800d8330` maps all three subtypes to cardtype 7, and one client-side /// resolver (`FUN_180119bd0`, dispatched on `item+0x4c == 7`) captions all /// three. They therefore share ONE wire record /// ([`crate::fut::item::shape_club_item`]) and one identity resolver. /// /// Ball (30) and league logo (31) are cardtype 9 and are deliberately NOT in /// this family: they have no database name resolver, so their name can only /// come from `localizedName` on the wire, which is not established as safe /// to send. pub fn is_cardtype7_club_item(&self) -> bool { matches!( self, ContentKind::Kit | ContentKind::Stadium | ContentKind::Badge ) } } /// The functional family + honest display label for a consumable `cardsubtypeid`, /// or `None` if the subtype is outside every documented range (→ DEFER). /// /// Returns `(family, label)`, both `'static`. `family` is the neutral machine /// name stored as the CardDefinition family; `label` is the functional /// human-readable name. pub fn consumable_family(subtype: i64) -> Option<(&'static str, &'static str)> { let pair = match subtype { 51..=57 => ("gk_training", "GK Training"), 61..=67 => ("player_training", "Player Training"), 71..=86 => ("manager_formation_mod", "Manager Formation"), 91..=110 => ("position_mod", "Position Modifier"), 121..=136 => ("formation_mod", "Formation Modifier"), 201 => ("player_contract", "Player Contract"), 202 => ("manager_contract", "Manager Contract"), 211..=218 => ("healing", "Healing"), 219 => ("player_fitness", "Player Fitness"), 220 => ("squad_fitness", "Squad Fitness"), 250..=268 => ("player_playstyle", "Player Chemistry Style"), 269..=273 => ("gk_playstyle", "GK Chemistry Style"), 300..=341 => ("manager_league", "Manager League Modifier"), _ => return None, }; Some(pair) } /// `cardsubtypeid` of a MANAGER staff card. This value alone selects the /// `managercards` merge in the client (`FUN_1800d8330` → cardtype 2 → /// `FUN_1801356c0`), and it is what distinguishes a manager from the four coach /// families inside [`ContentKind::Staff`]. pub const MANAGER_SUBTYPE: i64 = 4; /// The staff role + honest display label for a staff `cardsubtypeid` (4..=8), or /// `None` for any other subtype (→ DEFER). Grounded in the `FUN_1800d8330` /// family selector. pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> { let pair = match subtype { 4 => ("manager", "Manager"), 5 => ("headcoach", "Head Coach"), 6 => ("gkcoach", "GK Coach"), 7 => ("physio", "Physio"), 8 => ("fitnesscoach", "Fitness Coach"), _ => return None, }; Some(pair) } /// The ONE extra wire key a consumable family needs, or [`ConsumableNeeds::None`]. /// /// Taken verbatim from `fifa17-recon/data/consumables.json`'s per-subtype `needs` /// (generated by `build_consumables.py` from `FUN_18013f4d0`), and independently /// confirmed by the real profile import, where `amount` is present on exactly the /// training/healing/fitness/play-style/league families and `contract` on exactly /// the two contract families. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConsumableNeeds { /// `amount` (atom 0x1b → `rec+0xbf`, or `+0xbe` for a play style) is /// MANDATORY: the parser initialises its temp to -1 and both accessors read /// it SIGNED, so omitting the key draws "-1" on the card, not "0". Amount, /// `contract` (atom 0xb8 → `rec+0x8c`) carries the number the card grants; /// the two contract families IGNORE `amount` entirely. Contract, /// Nothing beyond the common key set — the card's whole meaning comes from /// `cardsubtypeid` (formation and position modifiers). None, } /// Which extra key a consumable family requires. An unknown family name is /// [`ConsumableNeeds::None`]; callers get families from [`consumable_family`], /// so an unknown one cannot arrive from the wire. pub fn consumable_needs(family: &str) -> ConsumableNeeds { match family { "gk_training" | "player_training" | "healing" | "player_fitness" | "squad_fitness" | "player_playstyle" | "gk_playstyle" | "manager_league" => ConsumableNeeds::Amount, "player_contract" | "manager_contract" => ConsumableNeeds::Contract, _ => ConsumableNeeds::None, } } /// The consumable families one `GET club/consumables/` segment asks /// for, or `None` for a segment outside the client's own group table. /// /// **This route, not `club?type=`.** Consumables are NOT a `?type=` family: a /// previous round shipped four `?type=` arms for them and the screen stayed /// empty, because the client asks here (and only once /// `club/stats/consumables` reports a non-zero count — the counter is the gate /// and this route is the door). /// /// The segment names are the consumable UI group table at `0x180203260` (seven /// codes: `training`, `contracts`, `fitness`, `healing`, `playStyle`, /// `managerLeagueModifier`, `position`); `training` and `contracts` are CONFIRMED /// on the wire and the singular `contract` is accepted because the client has /// used both spellings. Segments are matched lower-cased. /// /// The family sets are the `FUN_18013f4d0` categories those codes name, and the /// correspondence is checkable against the panel: training→42, contracts→13, /// healing→21, fitness→6, position→20, chemistry style→24 items in the oracle's /// own shelf. NOTE the two formation-modifier families (categories 6 and 7) have /// NO group code, so no segment can reach them — that is the client's own gap, /// not an omission here. pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'static str]> { Some(match segment { "training" => &["gk_training", "player_training"], "contracts" | "contract" => &["player_contract", "manager_contract"], "fitness" => &["player_fitness", "squad_fitness"], "healing" => &["healing"], "position" => &["position_mod"], "playstyle" => &["player_playstyle", "gk_playstyle"], "managerleaguemodifier" => &["manager_league"], _ => return None, }) } /// The club-customisation `cardsubtypeid`s, SETTLED (supersedes /// `CARD_SYSTEM.md`'s "STILL UNKNOWN, AND NOT GUESSED" section, which is stale). /// /// Kit 9, stadium 10 and badge 11 are cardtype **7** and resolve through /// `FUN_180119bd0` (the manager vtable slot `+0x498`, verified from disk and live /// memory); ball 30 (`0x1e`) and league logo 31 (`0x1f`) are cardtype 9, the /// latter by elimination over `FUN_1800d8330`'s cardtype-9 set. Four independent /// lines agree on kit = 9, including the deserializer's own `cardassetid` default /// of `0x23` = 35 for cardtype 7 / subtype 9 — exactly the `cardassetid` carried /// by all 1482 rows of `fcc_kitcards`. /// /// `0x91..=0x96` are TROPHIES (tournament/season), not club items. The enum table /// at `0x180229ab0` (`badge=0xa kit=0xb leagueLogo=0xc … stadium=0x15 ball=0x16`) /// is the transfermarket `&cat=%s` vocabulary and NOT a subtype map: reading it as /// one swaps badge and kit and loses stadium. pub const KIT_SUBTYPE: i64 = 9; pub const STADIUM_SUBTYPE: i64 = 10; pub const BADGE_SUBTYPE: i64 = 11; pub const BALL_SUBTYPE: i64 = 30; pub const LEAGUE_LOGO_SUBTYPE: i64 = 31; /// The club-customisation [`ContentKind`] for a `cardsubtypeid`, or `None` for a /// subtype outside the settled set above. A league logo has no Core kind of its /// own (it is not ownable club content in Core's vocabulary), so subtype 31 /// deliberately maps to `None` rather than being folded into `Misc`. pub fn club_item_kind(subtype: i64) -> Option { let kind = match subtype { KIT_SUBTYPE => ContentKind::Kit, STADIUM_SUBTYPE => ContentKind::Stadium, BADGE_SUBTYPE => ContentKind::Badge, BALL_SUBTYPE => ContentKind::Ball, _ => return None, }; Some(kind) } /// The three MY CLUB position tabs (`type=playerdefender|playermidfielder| /// playerforward`). `FUN_18012ddf0` remaps request field `*(req+0x14)` values /// `0x1c/0x1d/0x1e` onto type codes `0x1b/0x1c/0x1d` and SUPPRESSES `position=`, /// so a position tab arrives as one of those three tokens with no other filter. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PositionGroup { Defender, Midfielder, Forward, } /// The FIFA 17 position ID for a FUT position label, from the client's OWN `pos` /// vocabulary — the NUL-terminated `{const char*, int}` table at `0x1802295c0` /// that it emits as the transfer-market `&pos=%s` parameter: /// `GK=0 RWB=2 RB=3 CB=5 LB=7 LWB=8 CDM=10 RM=12 CM=14 LM=16 CAM=18 RF=20 CF=21 /// LF=22 RW=23 ST=25 LW=27`. /// /// `None` = a label outside that table (never guessed): the item then belongs to /// no position tab rather than to an invented one. pub fn position_id(pos: &str) -> Option { let id = match pos { "GK" => 0, "RWB" => 2, "RB" => 3, "CB" => 5, "LB" => 7, "LWB" => 8, "CDM" => 10, "RM" => 12, "CM" => 14, "LM" => 16, "CAM" => 18, "RF" => 20, "CF" => 21, "LF" => 22, "RW" => 23, "ST" => 25, "LW" => 27, _ => return None, }; Some(id) } /// Which position tab a FUT position label belongs to, or `None` for a label /// outside the client's own `pos` table. /// /// The ladder is the client's, not ours: `FUN_180135890` recomputes `rec+0x14c` /// from the position at `rec+0x146` as `0 → GK`, `1..=8 → DEF`, `9..=19 → MID`, /// `20..=27 → ATT`. /// /// THE ONE GUESS, named: GK is folded into `Defender`, because the client has /// exactly three position tabs and no fourth, so a keeper must land in one of /// them or vanish from every drill-down. Falsifier: if the DEF tab renders /// without goalkeepers, move GK out (the group boundary becomes `1..=8`). pub fn position_group(pos: &str) -> Option { match position_id(pos)? { 0..=8 => Some(PositionGroup::Defender), 9..=19 => Some(PositionGroup::Midfielder), 20..=27 => Some(PositionGroup::Forward), _ => None, } } #[cfg(test)] mod tests { use super::*; #[test] fn content_kind_round_trips_and_defaults_to_player() { assert_eq!(ContentKind::default(), ContentKind::Player); // The FULL Core content vocabulary, every token round-tripping. let all = [ ContentKind::Player, ContentKind::Manager, ContentKind::Staff, ContentKind::Consumable, ContentKind::Kit, ContentKind::Badge, ContentKind::Ball, ContentKind::Stadium, ContentKind::Misc, ]; for k in all { assert_eq!(ContentKind::from_str(k.as_str()), k); } let tokens: Vec<&str> = all.iter().map(|k| k.as_str()).collect(); assert_eq!( tokens, vec![ "player", "manager", "staff", "consumable", "kit", "badge", "ball", "stadium", "misc" ], "these exact strings are the cross-crate contract with Core" ); // Unknown / absent tokens fall back to Player (backward compatible). assert_eq!(ContentKind::from_str(""), ContentKind::Player); assert_eq!(ContentKind::from_str("nonsense"), ContentKind::Player); assert_eq!(ContentKind::from_str("player"), ContentKind::Player); } #[test] fn only_manager_and_staff_are_the_staff_family() { for k in [ContentKind::Manager, ContentKind::Staff] { assert!(k.is_staff_family(), "{} is a staff card", k.as_str()); } for k in [ ContentKind::Player, ContentKind::Consumable, ContentKind::Kit, ContentKind::Badge, ContentKind::Ball, ContentKind::Stadium, ContentKind::Misc, ] { assert!(!k.is_staff_family(), "{} is not staff", k.as_str()); } } #[test] fn every_consumable_family_needs_exactly_what_the_client_reads() { // Grouped from data/consumables.json's per-subtype `needs`, and observed // key-for-key in the real profile import. for f in [ "gk_training", "player_training", "healing", "player_fitness", "squad_fitness", "player_playstyle", "gk_playstyle", "manager_league", ] { assert_eq!(consumable_needs(f), ConsumableNeeds::Amount, "{f}"); } for f in ["player_contract", "manager_contract"] { assert_eq!(consumable_needs(f), ConsumableNeeds::Contract, "{f}"); } for f in ["manager_formation_mod", "formation_mod", "position_mod"] { assert_eq!(consumable_needs(f), ConsumableNeeds::None, "{f}"); } } #[test] fn consumable_route_categories_partition_the_reachable_families() { // The seven group codes, plus the singular `contract` spelling. let segments = [ "training", "contracts", "fitness", "healing", "position", "playstyle", "managerleaguemodifier", ]; let mut seen: Vec<&str> = Vec::new(); for seg in segments { for f in consumable_families_for_category(seg).unwrap() { assert!(!seen.contains(f), "{f} claimed by two categories"); seen.push(f); } } assert_eq!( consumable_families_for_category("contract"), consumable_families_for_category("contracts"), "both spellings the client has used mean the same set" ); // Eleven of the thirteen families are reachable; the two formation // modifiers have no group code in the client's own table. assert_eq!(seen.len(), 11, "no duplicates: {seen:?}"); for subtype in [51, 61, 91, 201, 202, 211, 219, 220, 250, 269, 300] { let (family, _) = consumable_family(subtype).unwrap(); assert!(seen.contains(&family), "no category serves {family}"); } for unreachable in [71, 121] { let (family, _) = consumable_family(unreachable).unwrap(); assert!( !seen.contains(&family), "{family} has no group code; claiming it would invent a segment" ); } // Not a consumables segment (and NOT a `?type=` token either). for s in ["", "player", "kit", "Training", "development"] { assert!( consumable_families_for_category(s).is_none(), "{s:?} is not a consumable category" ); } } #[test] fn club_item_subtypes_are_the_settled_five() { assert_eq!(club_item_kind(KIT_SUBTYPE), Some(ContentKind::Kit)); assert_eq!(club_item_kind(STADIUM_SUBTYPE), Some(ContentKind::Stadium)); assert_eq!(club_item_kind(BADGE_SUBTYPE), Some(ContentKind::Badge)); assert_eq!(club_item_kind(BALL_SUBTYPE), Some(ContentKind::Ball)); assert_eq!((KIT_SUBTYPE, STADIUM_SUBTYPE, BADGE_SUBTYPE), (9, 10, 11)); assert_eq!((BALL_SUBTYPE, LEAGUE_LOGO_SUBTYPE), (30, 31)); // A league logo is not ownable Core content, so it maps to no kind. assert_eq!(club_item_kind(LEAGUE_LOGO_SUBTYPE), None); // Trophies (0x91..0x96) are NOT club items, and staff/consumable // subtypes must never be mistaken for one. for s in [0, 4, 8, 0x91, 0x96, 201, 231] { assert_eq!(club_item_kind(s), None, "subtype {s} is not a club item"); } } #[test] fn position_groups_follow_the_clients_own_ladder() { // Ids are the client's `pos` table; groups are its 0/1..8/9..19/20..27 // recompute. GK folded into DEF is the one named guess. for p in ["GK", "CB", "LB", "RB", "LWB", "RWB"] { assert_eq!(position_group(p), Some(PositionGroup::Defender), "{p}"); } for p in ["CDM", "CM", "CAM", "LM", "RM"] { assert_eq!(position_group(p), Some(PositionGroup::Midfielder), "{p}"); } for p in ["RF", "CF", "LF", "RW", "ST", "LW"] { assert_eq!(position_group(p), Some(PositionGroup::Forward), "{p}"); } assert_eq!(position_id("ST"), Some(25)); assert_eq!(position_id("CDM"), Some(10)); // Not in the client's table → no tab, never an invented one. for p in ["", "SW", "st", "MID", "SUB"] { assert_eq!(position_group(p), None, "{p:?}"); assert_eq!(position_id(p), None, "{p:?}"); } } #[test] fn consumable_family_range_boundaries() { // Each contiguous range: lower boundary, upper boundary, family + label. let cases: &[(i64, i64, &str, &str)] = &[ (51, 57, "gk_training", "GK Training"), (61, 67, "player_training", "Player Training"), (71, 86, "manager_formation_mod", "Manager Formation"), (91, 110, "position_mod", "Position Modifier"), (121, 136, "formation_mod", "Formation Modifier"), (211, 218, "healing", "Healing"), (250, 268, "player_playstyle", "Player Chemistry Style"), (269, 273, "gk_playstyle", "GK Chemistry Style"), (300, 341, "manager_league", "Manager League Modifier"), ]; for &(lo, hi, family, label) in cases { assert_eq!(consumable_family(lo), Some((family, label)), "lo {lo}"); assert_eq!(consumable_family(hi), Some((family, label)), "hi {hi}"); } // Singleton subtypes. assert_eq!( consumable_family(201), Some(("player_contract", "Player Contract")) ); assert_eq!( consumable_family(202), Some(("manager_contract", "Manager Contract")) ); assert_eq!( consumable_family(219), Some(("player_fitness", "Player Fitness")) ); assert_eq!( consumable_family(220), Some(("squad_fitness", "Squad Fitness")) ); } #[test] fn consumable_family_gaps_and_out_of_range_are_none() { // Just outside range edges, and in documented gaps between ranges. for s in [ 0, 50, 58, 60, 68, 70, 87, 90, 111, 120, 137, 200, 203, 210, 221, 249, 274, 299, 342, 999, ] { assert_eq!(consumable_family(s), None, "subtype {s} must be unknown"); } } #[test] fn staff_role_each_role_and_unknown_is_none() { assert_eq!(staff_role(4), Some(("manager", "Manager"))); assert_eq!(staff_role(5), Some(("headcoach", "Head Coach"))); assert_eq!(staff_role(6), Some(("gkcoach", "GK Coach"))); assert_eq!(staff_role(7), Some(("physio", "Physio"))); assert_eq!(staff_role(8), Some(("fitnesscoach", "Fitness Coach"))); for s in [0, 1, 2, 3, 9, 10, 201, 300] { assert_eq!(staff_role(s), None, "staff subtype {s} must be unknown"); } } }