6c7d0856b6
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.
738 lines
31 KiB
Rust
738 lines
31 KiB
Rust
//! The shared FIFA 17 FUT **item-shaping primitive**.
|
|
//!
|
|
//! One function shapes one owned FUT item into the numeric card object the FIFA
|
|
//! 17 client renders, and **every** route that emits a player item goes through
|
|
//! it — `/club` (via [`crate::fut::club_response`]) and squad projection (via
|
|
//! [`crate::fut::squad_projection`]) alike. There is deliberately no second copy
|
|
//! of the field set: an item is shaped in exactly one place so the two routes
|
|
//! can never drift.
|
|
//!
|
|
//! ## The asset-id boundary (load-bearing, evidence-grounded)
|
|
//!
|
|
//! FIFA renders a card by resolving `resourceId & 0xffffff` against the client's
|
|
//! OWN local players table (proven live): a real id renders a real footballer,
|
|
//! an **invented id renders a blank generic card**. OpenFUT Core's catalogue is
|
|
//! synthetic string ids (`card_pl_001`) with no FIFA asset id. So an
|
|
//! [`ItemIdentityResolver`] is injected; when it cannot supply a **real** FIFA
|
|
//! asset id for an item, the item carries no fabricated identity — the caller
|
|
//! decides what that means (`/club` drops and counts it; a squad refuses to
|
|
//! project a starter it cannot render, never faking one).
|
|
//!
|
|
//! Entity ids (`leagueId`/`teamid`/`nation`) come from a reverse resolver; an
|
|
//! unresolved name yields a neutral `0` (a valid int — non-fatal; it only means
|
|
//! "no badge/flag"), because those are not the identity the renderer keys on.
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
use crate::fut::content_taxonomy::{
|
|
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, 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<i64>,
|
|
/// `contract` (atom 0xb8) → `rec+0x8c`. `Some` for the two contract
|
|
/// families only; they ignore `amount` entirely.
|
|
pub contract: Option<i64>,
|
|
/// `rec+0x49`. Per-INSTANCE in FIFA, unmodelled by Core, so the host passes
|
|
/// the observed constant [`CONSUMABLE_UNTRADEABLE`]. Carried per copy rather
|
|
/// than baked into the shaper because the consumables route's stack wrapper
|
|
/// reports `untradeableCount` over the copies in the stack.
|
|
pub untradeable: bool,
|
|
}
|
|
|
|
impl Fifa17ConsumableIdentity {
|
|
/// Whether this definition can be drawn HONESTLY. Three refusals, every one a
|
|
/// silent-failure guard rather than taste:
|
|
///
|
|
/// * the family's mandatory extra key is missing — the parser initialises
|
|
/// its `amount` temp to `-1` and both accessors read the byte SIGNED, so
|
|
/// an omission draws "-1" on the card, not "0" (and a contract card with
|
|
/// no `contract` grants nothing);
|
|
/// * `rareflag != 0` on subtype 219 — `FUN_1801bfac0` case 5 renders a RARE
|
|
/// Player Fitness card as a SQUAD Fitness card, i.e. a different item
|
|
/// entirely, with no error anywhere;
|
|
/// * `card_asset_id == asset_id` — a consumable's art id is a SMALL `fcc_`
|
|
/// art id (3, 7, 8, 9, 34, 50, 51 observed) and never its own `carddbid`,
|
|
/// so this means the catalog carried no `card_asset_id` and the client
|
|
/// would draw `notfound.swf`, the green "NOT FOUND" box.
|
|
///
|
|
/// A subtype outside every documented range is also refused: it falls to
|
|
/// `FUN_18013f4d0`'s bottom default and renders as a perfectly ordinary
|
|
/// Squad Training (Pace) card with amount 0 — plausible and wrong.
|
|
pub fn is_renderable(&self) -> bool {
|
|
if self.subtype == SQUAD_FITNESS_TRAP_SUBTYPE && self.rareflag != 0 {
|
|
return false;
|
|
}
|
|
if self.card_asset_id == self.asset_id {
|
|
return false;
|
|
}
|
|
match consumable_family(self.subtype) {
|
|
None => false,
|
|
Some((family, _)) => match consumable_needs(family) {
|
|
ConsumableNeeds::Amount => self.amount.is_some(),
|
|
ConsumableNeeds::Contract => self.contract.is_some(),
|
|
ConsumableNeeds::None => true,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Supplies the FIFA numeric identity for a Core item. Returning `None` means
|
|
/// "no real FIFA asset id known" → the caller must not fabricate one.
|
|
pub trait ItemIdentityResolver {
|
|
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
|
|
|
|
/// Resolve one owned kit definition. Default `None` preserves existing
|
|
/// player-only resolvers; the catalog-backed FIFA17 resolver overrides it.
|
|
fn resolve_kit(&self, _item: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
|
None
|
|
}
|
|
|
|
/// Resolve one owned staff definition (manager or coach). Default `None`
|
|
/// preserves existing resolvers; the catalog-backed FIFA17 resolver
|
|
/// overrides it.
|
|
fn resolve_staff(&self, _item: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
|
|
None
|
|
}
|
|
|
|
/// Resolve one owned consumable definition. Default `None` preserves
|
|
/// existing resolvers; the catalog-backed FIFA17 resolver overrides it.
|
|
fn resolve_consumable(&self, _item: &CoreOwnedItem) -> Option<Fifa17ConsumableIdentity> {
|
|
None
|
|
}
|
|
|
|
/// The 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<Value> = item
|
|
.attributes
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, v)| json!({ "index": i, "value": v }))
|
|
.collect();
|
|
json!({
|
|
"id": id.item_id,
|
|
"resourceId": id.resource_id,
|
|
"assetId": asset,
|
|
"cardassetid": asset,
|
|
"definitionId": id.resource_id,
|
|
"cardsubtypeid": 0,
|
|
"itemType": "player",
|
|
"rareflag": id.rareflag,
|
|
"rating": item.rating,
|
|
"preferredPosition": item.position,
|
|
"nation": nation_id,
|
|
"teamid": team_id,
|
|
"leagueId": league_id,
|
|
"playStyle": 250,
|
|
"attributeList": attribute_list,
|
|
"itemState": item_state::FREE,
|
|
"owners": 1,
|
|
// Owned/pack-pulled cards are TRADEABLE in FIFA 17 (untradeable is the
|
|
// exception for SBC/promo rewards, which Core does not model). Emitting
|
|
// `true` greyed out "Place on Transfer Market" for every card — the same
|
|
// "our own data showing through" bug the Python oracle fixed by forcing
|
|
// this off for owned copies (item_def keeps `true`; instances do not).
|
|
"untradeable": false,
|
|
"contract": 7,
|
|
"fitness": 99,
|
|
"discardValue": discard_value(item.rating),
|
|
})
|
|
}
|
|
|
|
/// Build one FIFA 17 club-kit item. `item_state` is the proven wire enum token:
|
|
/// `free`, `activeHomeKit`, or `activeAwayKit`; the client deserializes the
|
|
/// latter two to runtime values 101 and 102.
|
|
pub fn shape_kit_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
|
|
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,
|
|
"teamid": id.team_id,
|
|
})
|
|
}
|
|
|
|
/// 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::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<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"
|
|
);
|
|
}
|
|
}
|