Files
OpenFUT/openfut-adapter-fifa17/src/fut/item_state.rs
T
funman300 beb505b0fa tools(fifa17): resolve the itemState comparator live — it is CASE-SENSITIVE
The plan recorded this as "almost certainly unresolvable statically", because
`FUN_180008190` is only a forwarding stub through a slot the host fills at
runtime: `mov rax,[DAT_1802ddfd8]; mov r9,[rax+0x248]; jmp r9`.

It IS resolvable — just not from disk. Read read-only out of the running client
(pid 6580): the slot forwards through two FIFA17.exe thunks into
msvcr120.dll+0x3c330, whose body is strncmp (`test r8,r8` count, `test al,al`
NUL stop, `cmp al,[rcx+rdx]`, then MSVC's 0x8080../0xfefe.. NUL-detect fast
path). No `or ..,0x20`, no folding table: the compare is raw bytes.

So the casing in the table at 0x180229cc0 is a CONTRACT. A mis-cased token does
not degrade gracefully — FUN_180166660 returns 0xffffffff, the record keeps 0 =
invalid, and the item fails the squad builder. This confirms what
fut::item_state already emits; it was previously true by convention and is now
true by measurement.

The probe follows the chain and attributes each hop to its module, which needs
care under Wine: PE sections are mapped anonymously, so a module is identified
by the nearest preceding named mapping rather than the containing one.
2026-08-21 20:54:29 +00:00

112 lines
4.6 KiB
Rust

//! 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.
//!
//! **The casing is a CONTRACT, not a convention** — measured, not assumed. The
//! table lookup compares through a slot the host fills at runtime
//! (`FUN_180008190` is just `mov rax,[DAT_1802ddfd8]; mov r9,[rax+0x248]; jmp r9`),
//! so this was long recorded as unresolvable without a live process. Resolved
//! read-only against the running client on 2026-08-21
//! (`fifa17-recon/tools/service_ptr_probe.py`): the slot forwards through two
//! FIFA17.exe thunks into `msvcr120.dll+0x3c330`, whose body is `strncmp` — a
//! plain byte compare (`cmp al,[rcx+rdx]`) with NO case folding anywhere. So a
//! mis-cased token does not "mostly work": it matches nothing, decodes to
//! [`INVALID`], and the item fails the squad builder. Emit these strings
//! verbatim.
//!
//! (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");
}
}
}