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
+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"
);
}
}