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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user