9c2edc4eee
FIFA refuses to kick off with "your player or managers contracts have expired".
The club had no manager, and could not have had one: `/club?type=manager` (the
token the STAFF tab actually sends) was rejected by the host, and staff items
were counted and dropped by the adapter instead of being shaped.
The squad's manager reference is a red herring worth recording. It points at
wire id 100000427, which resolves to resourceId 3000083 = a FITNESS COACH
(cardsubtypeid 8), not a manager. The client's own club/stats agrees:
staff:3, staffManager:0, staffGKCoach:1, staffFitnessCoach:2. This club has
never owned a manager, so one is MINTED rather than restored.
Wire shape is not guessed. `fifa17-recon/tools/fut_staff.py` is an
instruction-level reversal of the item parser and the managercards merge that
justifies every key by its record offset, and CARD_SYSTEM.md records it
confirmed live on 2026-08-05 (ten managers rendered with correct flags, league
names and "CONTRACT 7" on the card front). `shape_staff_item` emits exactly that
key set and nothing else:
* `nation` (rec+0xde) and `leagueId` (rec+0xe0) are MANAGER-ONLY slots the
client's merge never writes, so the server is their only source — they are the
flag, the league badge and both halves of manager chemistry. Coaches get
neither, because the four coach tables have no nation/league/team column and
emitting zeroes there would be invention.
* `resourceId` is the RAW merge key: staff are read as a u32 with NO &0xffffff
mask (players are the only masked family), so `version` must stay 0 or the
lookup misses — silently, since the manager branch has no else-arm.
* `preferredPosition`/`attributeList` are omitted because they SURVIVE the merge
and are then read by the card view-model; `assetId`/`rating`/`rareflag` are
omitted because the merge overwrites them from the client's own tables. A
staff card is therefore never routed through `shape_item`.
Managers stay inside `ContentKind::Staff`, discriminated by `cardsubtypeid == 4`
— the client's own discriminator, and its own stats model counts a manager
INSIDE the staff total with staffManager as a bucket within it. A parallel
`ContentKind::Manager` would have been a second source of truth for a fact the
subtype already carries, and would have silently under-counted club/stats.
`squad.manager[]` stays `[{id, dream}]`. The only populated form anywhere is the
oracle's DRAFT squad; no capture has ever shown itemData in a regular squad, and
feeding that deserializer the wrong container type freezes the SAX reader. The
contract reaches the client through the CardsDb record registered from the
/club envelope, which is a find-or-insert and therefore accumulates.
TWO SILENT BUGS FOUND ON THE WAY, both of which made a correct assignment look
like no assignment at all:
1. `get_squad_manager` read `manager.owned_card_id`, but Core returns the
assigned OWNED CARD, whose field is `id`. It therefore ALWAYS returned None —
indistinguishable from "no manager". Now reads `id`, and a present-but-
unreadable manager is an error rather than a silent absence. The projection
also now warns when an assignment cannot be resolved to an owned instance,
which is the documented "Core drops an owned card with no CardDefinition from
/collection without erroring" trap.
2. `Route::WatchList` was produced by NO classifier arm, so its handler was
unreachable and every `watchList` request fell through to Passthrough — the
same defect class as `season/list`. Against a stack whose Python upstream is
deliberately dead this 502'd. This was failing
`sbc_survives_complete_core_and_host_restart` at HEAD before this change.
The manager itself is seeded from the client's own tables, never invented:
managercards 1000509 (assetid == carddbid), nation 45, manager[509] "Luis
Enrique" teamid 241, leagueteamlinks 241 -> league 53. League 53 is also the
dominant league in the restored squad (12 of 23), so the chemistry pairing is
the correct one rather than an arbitrary pick.
Verified live against the restored club: /club?type=manager and ?type=staff both
return 4 items (the minted manager plus the 3 coaches the profile already owned
and could never see), the manager carries contract 7 with nation/league/team,
coaches correctly carry none of the three, squad.manager resolves to the same
wire id, and no staff leaks into ?type=player. Adapter 219 tests, host 114 lib +
36 host_test + all economy suites green.
188 lines
7.6 KiB
Rust
188 lines
7.6 KiB
Rust
//! 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 `docs/CARD_TAXONOMY.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.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum ContentKind {
|
|
#[default]
|
|
Player,
|
|
Consumable,
|
|
Staff,
|
|
Kit,
|
|
}
|
|
|
|
impl ContentKind {
|
|
/// The stable wire/catalog token for this kind.
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
ContentKind::Player => "player",
|
|
ContentKind::Consumable => "consumable",
|
|
ContentKind::Staff => "staff",
|
|
ContentKind::Kit => "kit",
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
"consumable" => ContentKind::Consumable,
|
|
"staff" => ContentKind::Staff,
|
|
"kit" => ContentKind::Kit,
|
|
_ => ContentKind::Player,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn content_kind_round_trips_and_defaults_to_player() {
|
|
assert_eq!(ContentKind::default(), ContentKind::Player);
|
|
for k in [
|
|
ContentKind::Player,
|
|
ContentKind::Consumable,
|
|
ContentKind::Staff,
|
|
ContentKind::Kit,
|
|
] {
|
|
assert_eq!(ContentKind::from_str(k.as_str()), k);
|
|
}
|
|
// 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 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");
|
|
}
|
|
}
|
|
}
|