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