beb505b0fa
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.
112 lines
4.6 KiB
Rust
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");
|
|
}
|
|
}
|
|
}
|