feat(fifa17): project every owned content kind, from one recovered vocabulary

Extends the FIFA17 adapter past players so the wire can carry the rest of a
real club's inventory.

itemState: the recovered 12-row table at 0x180229cc0 becomes the single source
(`fut::item_state`), replacing scattered literals. Every shaper draws from it
and the tests assert no shaper can emit a state the client does not know.
CARD_SYSTEM.md's 0x180229d20 is the middle of that table, not its start.

ContentKind covers all nine tokens. Managers stay inside the staff family for
counting, because the client's own club-stats model puts a manager INSIDE the
staff total with staffManager as a sub-bucket — a parallel Manager kind would
silently under-count.

Consumables get their own route (`club/consumables/<category>`) and a
stack-wrapper envelope, classified BEFORE the other club/ arms; they are not a
`?type=` family. This path previously fell through to Python, so owned
inventory was being served by the oracle.

The shaper refuses to emit a card it cannot render: no known art id, or a
missing `amount`/`contract` for the families that read them, or the subtype-219
rareflag trap that silently turns Player Fitness into Squad Fitness. A dropped
card is counted and logged, never faked.
This commit is contained in:
funman300
2026-08-21 19:49:52 +00:00
parent dcd470cddc
commit 6c7d0856b6
8 changed files with 1215 additions and 30 deletions
+74
View File
@@ -54,6 +54,22 @@ pub struct Fifa17CardIdentity {
/// `manager.teamid` → `leagueteamlinks.leagueid`, because `managercards` has
/// no league column. Lands in the equally untouched slot `rec+0xe0`.
pub league_id: i64,
/// EA's authored `rating` for a NON-PLAYER definition (`fcc_*.rating`), which
/// Core does not model: an imported consumable's Core `overall` is 0, while
/// the client's own copies carry 55..95 and the value drives the card level
/// (`rec+0x54`) and therefore its quick-sell price. `None` → the caller falls
/// back to Core's rating, which stays authoritative for players.
pub rating: Option<u8>,
/// `amount` (atom 0x1b) for a consumable definition — the bonus magnitude EA
/// authored in the `fcc_*` row (+5 / +10 / +15 …). MANDATORY for the
/// training, healing, fitness, play-style and manager-league families:
/// omitting the key draws "-1" on the card, not "0".
pub amount: Option<i64>,
/// `contract` (atom 0xb8) for a contract-card definition (`cardsubtypeid`
/// 201/202) — the number of matches the card grants. `fcc_contractcards` has
/// no amount column, so this value comes from observed data; it is never
/// defaulted here.
pub contract: Option<i64>,
}
/// The FIFA 17 numeric namespace policy for owned-item wire ids.
@@ -182,6 +198,15 @@ struct RawCard {
/// Manager chemistry league; absent → `0`.
#[serde(default)]
league_id: Option<i64>,
/// EA-authored rating for a non-player definition; absent → Core's rating.
#[serde(default)]
rating: Option<u8>,
/// Consumable bonus magnitude (atom 0x1b); absent → key omitted.
#[serde(default)]
amount: Option<i64>,
/// Contract-card grant (atom 0xb8); absent → key omitted.
#[serde(default)]
contract: Option<i64>,
}
fn default_rareflag() -> i64 {
@@ -242,6 +267,9 @@ impl Fifa17CardCatalog {
team_id: rc.team_id.unwrap_or(0),
nation: rc.nation.unwrap_or(0),
league_id: rc.league_id.unwrap_or(0),
rating: rc.rating,
amount: rc.amount,
contract: rc.contract,
},
);
}
@@ -327,6 +355,52 @@ mod tests {
assert_eq!(cat.lookup("card_missing"), None);
}
/// The non-player definition fields a consumable needs, and the ABSENCE that
/// must stay an absence: a defaulted `amount` would draw "-1" on the card and
/// a defaulted `contract` would invent the number of matches a card grants.
#[test]
fn consumable_definition_fields_are_carried_and_never_defaulted() {
let cat = Fifa17CardCatalog::from_json_str(
r#"{"schema_version":1,"game":"fifa17","cards":{
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,
"card_asset_id":3,"rareflag":0,"rating":85,"amount":15},
"fifa17_5001004":{"asset_id":5001004,"kind":"consumable","subtype":201,
"card_asset_id":7,"rareflag":0,"rating":60,"contract":7},
"fifa17_5003059":{"asset_id":5003059,"kind":"consumable","subtype":91,
"card_asset_id":34,"rareflag":0,"rating":95},
"fifa17_20801":{"asset_id":20801}
}}"#,
)
.unwrap();
// A training card: art id 3 (NOT the carddbid), EA's rating, amount 15.
let training = cat.lookup("fifa17_5003012").unwrap();
assert_eq!(training.kind, ContentKind::Consumable);
assert_eq!(training.subtype, 54);
assert_eq!(training.card_asset_id, 3);
assert_eq!(training.rating, Some(85));
assert_eq!(training.amount, Some(15));
assert_eq!(training.contract, None);
// A contract card takes its number from `contract`, not `amount`.
let contract = cat.lookup("fifa17_5001004").unwrap();
assert_eq!(contract.contract, Some(7));
assert_eq!(contract.amount, None);
// A position modifier needs neither.
let position = cat.lookup("fifa17_5003059").unwrap();
assert_eq!(position.amount, None);
assert_eq!(position.contract, None);
assert_eq!(position.card_asset_id, 34);
// A player carries none of them and keeps Core's authoritative rating.
let player = cat.lookup("fifa17_20801").unwrap();
assert_eq!(player.kind, ContentKind::Player);
assert_eq!(player.rating, None);
assert_eq!(player.amount, None);
assert_eq!(player.contract, None);
assert_eq!(
player.card_asset_id, player.asset_id,
"a player's card art IS its asset id"
);
}
#[test]
fn two_cards_same_resource_is_a_conflict() {
let err = Fifa17CardCatalog::from_json_str(
@@ -12,11 +12,12 @@ use serde_json::{json, Value};
use crate::fut::content_taxonomy::ContentKind;
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::{shape_item, shape_kit_item, shape_staff_item, STAFF_CONTRACT};
use crate::fut::item_state;
// Re-exported so existing `club_response::{…}` callers keep working; the types
// are now defined once in `fut::item`.
pub use crate::fut::item::{
CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
ShapeStats,
CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity,
Fifa17StaffIdentity, ItemIdentityResolver, ShapeStats,
};
/// Active club-level kit roles, keyed by Core owned-instance id.
@@ -36,7 +37,11 @@ pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
}
/// Shape `/club` items, including ownership-backed active kit designations.
/// Consumables/staff remain excluded because they use separate wire envelopes.
///
/// This envelope carries the two families whose record shape it can carry:
/// players and kits, plus the staff family (manager + the four coach families).
/// Consumables have their own route and their own STACK envelope, and the
/// club-customisation families are counted and withheld — see each arm.
pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
items: &[CoreOwnedItem],
ent: &impl ReverseEntityResolver,
@@ -56,28 +61,50 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
},
ContentKind::Kit => match ident.resolve_kit(item) {
Some(id) => {
let item_state = if active_kits.home == Some(item.owned_card_id.as_str()) {
"activeHomeKit"
let state = if active_kits.home == Some(item.owned_card_id.as_str()) {
item_state::ACTIVE_HOME_KIT
} else if active_kits.away == Some(item.owned_card_id.as_str()) {
"activeAwayKit"
item_state::ACTIVE_AWAY_KIT
} else {
"free"
item_state::FREE
};
out.push(shape_kit_item(id, item_state));
out.push(shape_kit_item(id, state));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
},
ContentKind::Staff => match ident.resolve_staff(item) {
// A manager is a staff card: both Core kinds resolve through the one
// staff record shape, discriminated on the wire by `cardsubtypeid`
// (the same set as `ContentKind::is_staff_family`, spelled out here
// because a guard arm would not prove exhaustiveness).
ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) {
Some(id) => {
out.push(shape_staff_item(id, STAFF_CONTRACT));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
},
// Consumables have their OWN route and their own envelope:
// `GET club/consumables/<category>`, whose element is a stack
// wrapper, not an item (see [`crate::fut::consumables`]). A bare
// consumable item in THIS envelope is accepted by the client and
// silently discarded, so emitting one here would be a 200 that does
// nothing — the worst failure shape in this project. Counted.
ContentKind::Consumable => {
stats.excluded_non_player += 1;
}
// Club customisation. The SUBTYPES are settled (kit 9, stadium 10,
// badge 11, ball 30, league logo 31), but the RECORD is not: a
// badge/stadium still needs the narrow `teamid`/`assetId` test that
// the 2026-08-05 crash denied us, and a ball has no display name at
// all except `localizedName`, which this project's own rule scores as
// "the parser reads it" and NOT "sending it is safe". Counted and
// withheld rather than guessed — ownership stays authoritative in
// Core either way, and club/stats still counts these families so the
// screen's own numbers are right.
ContentKind::Badge | ContentKind::Ball | ContentKind::Stadium | ContentKind::Misc => {
stats.excluded_non_player += 1;
}
}
}
(json!({ "itemData": out }), stats)
+74 -9
View File
@@ -65,6 +65,8 @@ const S_KITS: i64 = 0x28;
const S_KITS_HOME: i64 = 0x29;
const S_KITS_AWAY: i64 = 0x2A;
const S_BADGES: i64 = 0x2D;
const S_STADIA: i64 = 0x14;
const S_BALLS: i64 = 0x1E;
/// First `carddbid` of the AWAY kit family. `fcc_kitcards` is split into a
/// `63xxxxx` home family and a `64xxxxx` away family, and the table's own
@@ -206,10 +208,10 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
g.insert(sid, 0);
}
let mut staff_total = 0i64;
for it in items
.iter()
.filter(|i| matches!(i.kind, ContentKind::Staff))
{
// A manager counts INSIDE the staff total (`staffManager` is a bucket within
// it), so this selects the whole staff FAMILY, not `ContentKind::Staff`
// alone — a `manager`-classified row would otherwise vanish from the panel.
for it in items.iter().filter(|i| i.kind.is_staff_family()) {
if let Some(sid) = staff_stat(it.subtype) {
*g.get_mut(&sid).unwrap() += 1;
staff_total += 1;
@@ -237,13 +239,23 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
}
g.insert(S_CONSUMABLES, cons_total);
// Club items: kits are Core-owned and counted (total plus the home/away
// family split); unimplemented families stay honest zeros.
for sid in [
0x14, 0x1E, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
] {
// Club items. THESE COUNTS ARE THE GATE: the client does not ask for a
// family's items until club/stats reports a non-zero count for it (proven by
// the consumables round, where two rounds of item work sat unrequested
// because this panel answered zero). They are plain ints read by the same
// getter/publisher shape as the live-proven PLAYERS_EMPLOYED rows, so every
// family Core can own is counted here — including the ones whose ITEM record
// shape is still withheld, because a count cannot desync a parser and a zero
// guarantees the family is never even asked about. Unowned families stay
// honest zeros.
for sid in [0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38] {
g.entry(sid).or_insert(0);
}
let count_kind =
|want: ContentKind| items.iter().filter(|item| item.kind == want).count() as i64;
g.insert(S_STADIA, count_kind(ContentKind::Stadium));
g.insert(S_BALLS, count_kind(ContentKind::Ball));
g.insert(S_BADGES, count_kind(ContentKind::Badge));
let kits: Vec<&ClubStatInput> = items
.iter()
.filter(|item| matches!(item.kind, ContentKind::Kit))
@@ -414,6 +426,59 @@ mod tests {
assert_eq!(g["staffManager"], 0);
}
/// A row Core classifies as `manager` must still land in the STAFF bucket and
/// in `staffManager`: the client's own model counts a manager inside its staff
/// total, and the two encodings (`manager`, or `staff` + subtype 4) are the
/// same card.
#[test]
fn a_manager_counts_inside_staff_under_either_kind_token() {
for kind in [ContentKind::Manager, ContentKind::Staff] {
let mut manager = staff(4);
manager.kind = kind;
let g = global(&club_stats_body(&[manager, staff(8)], ContextField::Nation));
assert_eq!(g["staffManager"], 1, "kind={}", kind.as_str());
assert_eq!(
g["staff"],
2,
"the manager is INSIDE the staff total (kind={})",
kind.as_str()
);
assert_eq!(g["staffFitnessCoach"], 1);
assert_eq!(g["players"], 0, "a manager is not a player");
}
}
/// The count is the GATE: the client will not ask for a family's items until
/// this panel reports a non-zero count for it, so an owned badge/ball/stadium
/// must be counted even while its item record is withheld.
#[test]
fn owned_club_items_are_counted_per_family() {
let club_item = |kind: ContentKind, subtype: i64| ClubStatInput {
kind,
subtype,
rating: 0,
rare: false,
asset_id: 0,
nation_id: None,
league_id: None,
team_id: None,
};
let items = vec![
club_item(ContentKind::Badge, 11),
club_item(ContentKind::Badge, 11),
club_item(ContentKind::Ball, 30),
club_item(ContentKind::Stadium, 10),
kit(),
];
let g = global(&club_stats_body(&items, ContextField::Nation));
assert_eq!(g["badges"], 2);
assert_eq!(g["balls"], 1);
assert_eq!(g["stadia"], 1);
assert_eq!(g["kits"], 1);
assert_eq!(g["players"], 0, "no club item is ever a player");
assert_eq!(g["leagueLogos"], 0, "not an ownable Core kind: honest zero");
}
#[test]
fn consumables_by_family() {
// 54 gk_training, 201 player_contract, 217 healing, 258 player_playstyle
@@ -0,0 +1,230 @@
//! The FIFA 17 **consumables screen** response — `GET …/club/consumables/<category>`.
//!
//! Consumables are NOT a `club?type=` family. A previous round shipped four
//! `?type=` arms for them and the screen stayed empty, because the client asks
//! HERE — and it asks only once `club/stats/consumables` reports a non-zero count
//! for the family, so the counter in [`crate::fut::club_stats`] is the gate and
//! this route is the door. Before that was known, the path fell through the
//! generic `/club` PREFIX and the consumables screen was answered with the club's
//! player list.
//!
//! ## The element is a STACK WRAPPER, not an item
//!
//! Learned the hard way (live, 2026-08-05): bare items here were ACCEPTED and did
//! nothing — the client's card map afterwards held only the squad, and the screen
//! stayed empty with no error anywhere. `FutConsumablesSearchServerResponse`
//! (RS4 literal `0x1802222f8`, factory `0x180130a10`, vtable `0x180222200`,
//! deserializer `+0x08` = `0x180130d10`) reads `itemData` (atom 0x16b) at the root
//! like the club list, but its ELEMENT is a five-atom wrapper of which exactly one
//! atom carries the item:
//!
//! | atom | key | |
//! |---|---|---|
//! | 0xbc | `count` | copies in the stack |
//! | 0xd7 | `discardValue` | |
//! | 0x16a | `item` | → `FUN_18013fe00`, the item parser itself |
//! | 0x287 | `resourceId` | the stack's identity |
//! | 0x362 | `untradeableCount` | drives a UI flag as `untradeableCount < count` |
//!
//! Everything else falls to the value-SKIP handler, which is exactly why a bare
//! item was silently discarded. It is also why FUT draws consumables as one card
//! with a quantity badge rather than N cards: identical copies COLLAPSE by
//! `resourceId` here.
use serde_json::{json, Value};
use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats};
/// Build the consumables-screen body from the club's owned consumable copies.
///
/// Copies are collapsed by `resourceId` into one stack each, in first-seen order
/// (deterministic: Core's own owned order), with `count` and `untradeableCount`
/// counted over the copies. A copy whose definition is incomplete is DROPPED and
/// counted — see [`Fifa17ConsumableIdentity::is_renderable`]; drawing "-1" or a
/// different item than the club owns is worse than omitting the stack.
///
/// `discardValue` is `0`: the client computes a card's own quick-sell price from
/// `fcc_discardcoins` on `(cardtype 6, level, rare)`, and `0` is the value the
/// live-proven oracle sends on this route. Inventing a price from the player
/// quick-sell table would be a fabricated number the client does not need.
///
/// The stack's `item` is the FIRST copy, so its `id` is a real owned wire id — a
/// later item operation on the stack therefore addresses a card the club really
/// owns. (Which copy a quick-sell of a whole stack should consume is a lifecycle
/// question, not a projection one, and is not decided here.)
pub fn consumables_response(items: &[Fifa17ConsumableIdentity]) -> (Value, ShapeStats) {
let mut stats = ShapeStats::default();
// (resource_id, index into `stacks`) — a Vec keeps first-seen order without a
// second sort, and a club holds tens of stacks, not thousands.
let mut order: Vec<u32> = Vec::new();
let mut stacks: Vec<Value> = Vec::new();
for id in items {
if !id.is_renderable() {
stats.dropped_incomplete += 1;
continue;
}
stats.emitted += 1;
match order.iter().position(|r| *r == id.resource_id) {
Some(i) => {
let stack = stacks[i].as_object_mut().expect("stack is an object");
let count = stack["count"].as_i64().unwrap_or(0) + 1;
stack["count"] = json!(count);
if id.untradeable {
let untradeable = stack["untradeableCount"].as_i64().unwrap_or(0) + 1;
stack["untradeableCount"] = json!(untradeable);
}
}
None => {
order.push(id.resource_id);
stacks.push(json!({
"count": 1,
"discardValue": 0,
"item": shape_consumable_item(*id),
"resourceId": id.resource_id,
"untradeableCount": i64::from(id.untradeable),
}));
}
}
}
(json!({ "itemData": stacks }), stats)
}
#[cfg(test)]
mod tests {
use super::*;
/// A play-style card (category 9): `amount` mandatory, art id 50.
fn playstyle(item_id: u32, resource_id: u32) -> Fifa17ConsumableIdentity {
Fifa17ConsumableIdentity {
item_id,
resource_id,
asset_id: resource_id,
card_asset_id: 50,
subtype: 258,
rareflag: 0,
rating: 95,
amount: Some(2),
contract: None,
untradeable: true,
}
}
#[test]
fn identical_copies_collapse_into_one_counted_stack() {
// Two copies of 5003103 plus one of 5003112 → two stacks, counts 2 and 1.
let items = vec![
playstyle(100000293, 5_003_103),
playstyle(100000326, 5_003_112),
playstyle(100000294, 5_003_103),
];
let (body, stats) = consumables_response(&items);
assert_eq!(stats.emitted, 3, "every copy is accounted for");
let stacks = body["itemData"].as_array().unwrap();
assert_eq!(stacks.len(), 2, "collapsed by resourceId");
assert_eq!(stacks[0]["resourceId"], 5_003_103);
assert_eq!(stacks[0]["count"], 2);
assert_eq!(stacks[0]["untradeableCount"], 2);
assert_eq!(stacks[1]["resourceId"], 5_003_112);
assert_eq!(stacks[1]["count"], 1);
// The five wrapper atoms and nothing else: anything extra falls to the
// value-SKIP handler and only misleads the next reader.
let mut keys: Vec<&str> = stacks[0]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
vec![
"count",
"discardValue",
"item",
"resourceId",
"untradeableCount"
]
);
// The item rides inside the wrapper, not beside it.
assert_eq!(stacks[0]["item"]["id"], 100000293);
assert_eq!(stacks[0]["item"]["cardsubtypeid"], 258);
assert_eq!(stacks[0]["item"]["cardassetid"], 50);
}
#[test]
fn a_tradeable_copy_lowers_untradeable_count_below_the_stack_count() {
// The client's UI flag is `untradeableCount < count`, so the two numbers
// must be counted over the same copies.
let mut tradeable = playstyle(100000295, 5_003_103);
tradeable.untradeable = false;
let items = vec![playstyle(100000293, 5_003_103), tradeable];
let (body, _) = consumables_response(&items);
let stack = &body["itemData"][0];
assert_eq!(stack["count"], 2);
assert_eq!(stack["untradeableCount"], 1);
}
#[test]
fn incomplete_definitions_are_dropped_and_counted_never_drawn_wrong() {
// (a) a play style with no `amount` would draw "-1" on the card;
let mut no_amount = playstyle(100000293, 5_003_103);
no_amount.amount = None;
// (b) rareflag on 219 turns Player Fitness into SQUAD Fitness;
let trap = Fifa17ConsumableIdentity {
item_id: 100000300,
resource_id: 5_002_030,
asset_id: 5_002_030,
card_asset_id: 9,
subtype: 219,
rareflag: 1,
rating: 70,
amount: Some(10),
contract: None,
untradeable: true,
};
// (c) a subtype in no documented range renders as a plausible Squad
// Training (Pace) card with amount 0.
let mut dead_zone = playstyle(100000301, 5_003_999);
dead_zone.subtype = 137;
// (d) no `card_asset_id` in the catalog → the resolver defaulted it to
// the asset id and the client would draw the notfound.swf green box.
let mut no_art = playstyle(100000302, 5_003_104);
no_art.card_asset_id = no_art.asset_id;
let (body, stats) = consumables_response(&[no_amount, trap, dead_zone, no_art]);
assert_eq!(stats.emitted, 0);
assert_eq!(stats.dropped_incomplete, 4);
assert_eq!(body["itemData"].as_array().unwrap().len(), 0);
}
#[test]
fn a_contract_card_carries_contract_and_no_amount() {
let contract = Fifa17ConsumableIdentity {
item_id: 100000294,
resource_id: 5_001_004,
asset_id: 5_001_004,
card_asset_id: 7,
subtype: 201,
rareflag: 0,
rating: 60,
amount: None,
contract: Some(7),
untradeable: true,
};
let (body, stats) = consumables_response(&[contract]);
assert_eq!(stats.emitted, 1);
let item = &body["itemData"][0]["item"];
assert_eq!(item["contract"], 7);
assert!(
item.get("amount").is_none(),
"categories 2 and 3 ignore `amount` entirely"
);
}
#[test]
fn an_empty_club_is_an_empty_itemdata_not_a_missing_key() {
let (body, stats) = consumables_response(&[]);
assert_eq!(stats.emitted, 0);
assert!(body["itemData"].as_array().unwrap().is_empty());
assert_eq!(body.as_object().unwrap().len(), 1, "only itemData at root");
}
}
@@ -21,13 +21,30 @@
/// 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,
Consumable,
/// 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 {
@@ -35,9 +52,14 @@ impl ContentKind {
pub fn as_str(&self) -> &'static str {
match self {
ContentKind::Player => "player",
ContentKind::Consumable => "consumable",
ContentKind::Manager => "manager",
ContentKind::Staff => "staff",
ContentKind::Consumable => "consumable",
ContentKind::Kit => "kit",
ContentKind::Badge => "badge",
ContentKind::Ball => "ball",
ContentKind::Stadium => "stadium",
ContentKind::Misc => "misc",
}
}
@@ -49,12 +71,30 @@ impl ContentKind {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> ContentKind {
match s {
"consumable" => ContentKind::Consumable,
"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)
}
}
/// The functional family + honest display label for a consumable `cardsubtypeid`,
@@ -104,6 +144,172 @@ pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> {
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/<category>` 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<ContentKind> {
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<i64> {
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<PositionGroup> {
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::*;
@@ -111,20 +317,171 @@ mod tests {
#[test]
fn content_kind_round_trips_and_defaults_to_player() {
assert_eq!(ContentKind::default(), ContentKind::Player);
for k in [
// The FULL Core content vocabulary, every token round-tripping.
let all = [
ContentKind::Player,
ContentKind::Consumable,
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.
+337 -6
View File
@@ -24,8 +24,11 @@
use serde_json::{json, Value};
use crate::fut::content_taxonomy::{ContentKind, MANAGER_SUBTYPE};
use crate::fut::content_taxonomy::{
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, 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)]
@@ -101,6 +104,85 @@ pub struct Fifa17StaffIdentity {
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 {
@@ -119,7 +201,21 @@ pub trait ItemIdentityResolver {
None
}
/// Classify a Core item's definition as player/consumable/staff. Defaults to
/// 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 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
@@ -133,9 +229,18 @@ pub trait ItemIdentityResolver {
#[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,
/// Consumable/staff items excluded from a player projection (they must never
/// render as a 0-rated player). Counted, never emitted.
/// 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,
}
@@ -192,7 +297,7 @@ pub fn shape_item(
"leagueId": league_id,
"playStyle": 250,
"attributeList": attribute_list,
"itemState": "free",
"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
@@ -267,7 +372,7 @@ pub fn shape_staff_item(id: Fifa17StaffIdentity, contract: i64) -> Value {
// readers use it to tell a staff card from a footballer at a glance.
"itemType": "staff",
"contract": contract,
"itemState": "free",
"itemState": item_state::FREE,
"owners": 1,
"untradeable": false,
});
@@ -280,6 +385,92 @@ pub fn shape_staff_item(id: Fifa17StaffIdentity, contract: i64) -> Value {
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::*;
@@ -403,4 +594,144 @@ mod tests {
"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<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,
);
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_kit_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"
);
}
}
@@ -0,0 +1,99 @@
//! The FIFA 17 **`itemState` vocabulary** — the complete recovered set, and the
//! only place these strings are written down.
//!
//! Twelve entries in one NUL-terminated `{const char* name, u32 value}` table at
//! `0x180229cc0` (stride 0x10), walked in full from both disk and live memory.
//! `FUN_180166660` is a linear walk over that table and returns `0xffffffff` for
//! anything not in it, so an invented token is not a cosmetic slip: it decodes to
//! "unrecognised state" and the client acts on garbage. Every shaper in this
//! crate therefore takes its `itemState` from a constant here, and
//! [`is_recovered`] is asserted over every emitted value by the tests.
//!
//! **Omitting `itemState` is NOT the same as sending [`FREE`].** The record
//! constructor zero-initialises `+0x50..+0x5f` from `_DAT_1801f66a0`, so an
//! absent key leaves `0` = [`INVALID`], and an item left at `0` fails the squad
//! builder's `state == 1 || state == 2` acceptance test. Always send it.
//!
//! (Source: `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md` §4, which also
//! corrects `CARD_SYSTEM.md`'s earlier ten-row reading — that one started at
//! `0x180229d20`, the MIDDLE of the table, and so missed `invalid`, `free`,
//! `WAITING_FOR_GAME`, `inGame`, `forSale` and `offered`.)
/// `0` — what an item gets when `itemState` is OMITTED. No consumer found; it
/// fails the squad builder. Never emit it deliberately.
pub const INVALID: &str = "invalid";
/// `1` — the normal owned state: accepted by the squad builder, and what the
/// unequip path writes back.
pub const FREE: &str = "free";
/// `2` — alias of [`IN_GAME`] (both decode to 2).
pub const WAITING_FOR_GAME: &str = "WAITING_FOR_GAME";
/// `2` — accepted by the squad builder.
pub const IN_GAME: &str = "inGame";
/// `5` — an item offered for sale. Never TESTED anywhere in CardsDLL, but it is
/// in the table, so it decodes; the transfer market emits it.
pub const FOR_SALE: &str = "forSale";
/// `6` — never tested anywhere in CardsDLL.
pub const OFFERED: &str = "offered";
/// `100` — equipped badge; drives the `IS_ACTIVE` tick.
pub const ACTIVE_BADGE: &str = "activeBadge";
/// `101` — equipped home kit.
pub const ACTIVE_HOME_KIT: &str = "activeHomeKit";
/// `102` — equipped away kit.
pub const ACTIVE_AWAY_KIT: &str = "activeAwayKit";
/// `103` — equipped ball; the unequip path writes [`FREE`] back over it.
pub const ACTIVE_BALL: &str = "activeBall";
/// `104` — equipped stadium.
pub const ACTIVE_STADIUM: &str = "activeStadium";
/// `255` — no consumer found.
pub const ACTIVE: &str = "active";
/// The complete recovered vocabulary, in table order.
pub const ALL: [&str; 12] = [
INVALID,
FREE,
WAITING_FOR_GAME,
IN_GAME,
FOR_SALE,
OFFERED,
ACTIVE_BADGE,
ACTIVE_HOME_KIT,
ACTIVE_AWAY_KIT,
ACTIVE_BALL,
ACTIVE_STADIUM,
ACTIVE,
];
/// Whether `state` is one of the twelve recovered tokens. Case-sensitive, as the
/// client's own lookup is a `strcmp` walk.
pub fn is_recovered(state: &str) -> bool {
ALL.contains(&state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_table_is_the_twelve_recovered_rows_and_nothing_else() {
assert_eq!(ALL.len(), 12);
for s in ALL {
assert!(is_recovered(s), "{s} must be in its own table");
}
// Tokens this project has actually seen invented or mis-cased. `listFS`
// in particular is the Python oracle's own token and appears NOWHERE in
// the client (zero occurrences in the DLL and in 4.26 GiB of live
// process memory), so it decodes to -1.
for s in [
"listFS",
"free ",
"Free",
"activehomekit",
"sold",
"won",
"equipped",
"",
] {
assert!(!is_recovered(s), "{s:?} is not a FIFA 17 itemState");
}
}
}
+2
View File
@@ -7,11 +7,13 @@
pub mod catalog;
pub mod club_response;
pub mod club_stats;
pub mod consumables;
pub mod content_taxonomy;
pub mod economy;
pub mod economy_policy;
pub mod entities;
pub mod item;
pub mod item_state;
pub mod match_wire;
pub mod non_economy;
pub mod owned_query;