diff --git a/fifa17-recon/docs/plan-2026-08-05-store-subsystem.md b/fifa17-recon/docs/plan-2026-08-05-store-subsystem.md index 4e5c82b..a37f70b 100644 --- a/fifa17-recon/docs/plan-2026-08-05-store-subsystem.md +++ b/fifa17-recon/docs/plan-2026-08-05-store-subsystem.md @@ -630,21 +630,38 @@ placeholder ladder because switching revalues an existing club by **10.5x** liquidated). Players drive it (an r93 special goes 1500 -> 74,400); consumables move the OTHER way (2,400 -> 437, i.e. the ladder was overpaying 5.5x). -NOT yet exact: the five staff families. Measured on staging, a staff wire record -carries NO `rating`, NO `rareflag` and NO `discardValue`, so the client prices it -wholly from its own re-rate of cardtypes 2/3/4/5/10 and the server never sees the -number. The catalog's `rating` is `None` and Core's `overall` is 0, so pricing -DECLINES for staff and falls back to the ladder rather than paying 0 coins. The -server therefore pays 150 while the client displays something else — a divergence -that PRE-DATES this work (the old ladder paid the same 150 and also sent no -`discardValue`). +STAFF: CLOSED, and the `value`-is-the-rating question is now SETTLED against the +running client rather than inferred. A staff wire record carries no `rating`, no +`rareflag` and no `discardValue`, so the displayed price had to be read back out +of memory. `tools/coach_probe.py` grades the four resident staff records HIT, +which requires record `+0xb4` == the table's `value` and `+0x58` == its `rare`; +`tools/discard_probe.py` (new) then reads the two discard slots directly — +`+0x38` is what we sent, `+0x3c` is what the client computed: -It is left open deliberately. The only rating-shaped column in those five tables -is `value` (66 GK coach, 88 manager), but that `value` is the discard rating is an -INFERENCE: §3.6 names the tables and never the column. Importing it would guess an -economy, which is what this change removed. FALSIFIER, one launch: read the -discard value the client shows on a staff card — 36 on the `value`-66 GK coach -confirms it and the fix is to carry `value` through `openfut-import-fifa17`. +``` +resource sub ct rat lvl rar sent+38 calc+3c predicted +1000509 4 2 88 3 1 0 282 282 AGREES (manager) +9000081 6 10 66 2 0 0 36 36 AGREES (gk coach) +3000083 8 4 66 2 0 0 36 36 AGREES (fitness) +``` + +4 of 4 agree, 0 disagree, and 36 on the `value`-66 GK coach was the stated +falsifier. `openfut-import-fifa17::Entities::enrich_staff` now carries `value` -> +rating and `rare` -> rareflag for the five families, so the catalog holds what +the client re-rates to; verified on staging, a GK coach quick-sells for 36 rather +than the 150 floor. The catalog diff is exactly the two coach entries. + +The same probe shows what production is doing to PLAYERS today: all 23 resident +player records carry `sent+38 = 1500`, which suppresses the local computation, so +the client displays 1500 for every one of them — against its own table's 688..752 +for a gold rare, 11,102..11,468 for the 21/23/24 specials, 22,080..23,280 for +rareflag 11, and 72,800 / 74,400 for the two rareflag 5/6 legends. A 50x underpay +at the top and a 2x overpay at the bottom. + +STILL OPEN, and NOT a discard problem: the manager `fifa17_1000509` is owned in +Core but has no catalog entry and no card definition (it reaches the client +through the opaque squad extension), so pricing declines for it and falls back to +the ladder — 150 against the client's 282. That is definition coverage. ### 3.7 `duplicateItemIdList` diff --git a/fifa17-recon/tools/discard_probe.py b/fifa17-recon/tools/discard_probe.py new file mode 100755 index 0000000..1dc60fc --- /dev/null +++ b/fifa17-recon/tools/discard_probe.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Read back the DISCARD (quick-sell) value the live client holds for every +resident card, and check it against the client's own `fcc_discardcoins` table. + +READ-ONLY. Walks the same CardsDb node tree as card_identity_probe / coach_probe +via /proc/PID/mem; there is no write path in this file. + +WHAT THE TWO SLOTS MEAN (FUN_18013fe00 / FUN_180141660) +------------------------------------------------------- + item+0x38 the `discardValue` WE sent (atom 0xd7), stored verbatim. + item+0x3c the value the CLIENT computed for itself. + +At 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` SKIPS the whole local +computation when +0x38 is non-zero. So: + + * +0x38 non-zero -> the client displays OUR number and +0x3c is not filled. + * +0x38 zero -> the client computes, and +0x3c is what the player sees. + +The local computation is + SELECT price FROM fcc_discardcoins WHERE cardtype==? AND level==? AND rare==? + value = round_half_up(rating * price / 100) +with `level` = 3 if rating >= 0x4b, 2 if >= 0x41, else 1 (item+0x54), and +cardtype derived from cardsubtypeid by FUN_1800d8330. + +WHY THIS TOOL EXISTS +-------------------- +For cardtypes 2/3/4/5/10 (the five staff families) the client OVERWRITES the +rating and rare flag we send with values from its own card database before +computing. The server therefore cannot know the displayed price from what it +sent -- it has to be read back. +0x3c is that read-back, and it is the ground +truth for what the server must credit on a quick sell. + +Usage: + python3 discard_probe.py # table of every resident card + python3 discard_probe.py --kind staff # only the staff families + python3 discard_probe.py --json out.json +""" +import argparse +import json +import os +import sys + +import card_identity_probe as P +import watch_club_model as W + +TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables") + +F_SERVER_DISCARD = 0x38 +F_CLIENT_DISCARD = 0x3C +F_LEVEL = 0x54 +F_RARE = 0x58 +F_RATING = 0xB4 + + +def cardtype_for_subtype(sub): + """FUN_1800d8330, read out of its raw two-level jump table.""" + if 0 <= sub <= 3: + return 1 + if sub == 4: + return 2 + if sub == 5: + return 3 + if sub == 6: + return 10 + if sub == 7: + return 5 + if sub == 8: + return 4 + if 9 <= sub <= 11: + return 7 + if sub in (30, 31, 236) or 145 <= sub <= 150 or 231 <= sub <= 233: + return 9 + if 51 <= sub <= 136 or 201 <= sub <= 220 or 250 <= sub <= 273 or 300 <= sub <= 341: + return 6 + return 0 + + +def load_prices(): + """{(cardtype, level, rare): price} from the client's own dumped table.""" + path = os.path.join(TABLES, "fcc_discardcoins.json") + if not os.path.isfile(path): + return None + doc = json.load(open(path)) + rows = doc["rows"] if isinstance(doc, dict) else doc + return {(r["cardtype"], r["level"], r["rare"]): r["price"] for r in rows} + + +def predict(prices, cardtype, rating, rare): + """The client's formula, reproduced. An absent key pays 0, never a floor.""" + if prices is None or cardtype == 0 or rating is None: + return None + level = 3 if rating >= 0x4B else (2 if rating >= 0x41 else 1) + price = prices.get((cardtype, level, rare), 0) + if price == 0: + return 0 + return (rating * price + 50) // 100 + + +STAFF_SUBTYPES = (4, 5, 6, 7, 8) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--kind", choices=("all", "staff", "player", "other"), default="all") + ap.add_argument("--json", metavar="PATH") + a = ap.parse_args() + + prices = load_prices() + if prices is None: + print("WARNING: no fcc_discardcoins.json under %s -- predictions disabled\n" % TABLES) + + pid = W.find_pid() + if pid is None: + print("FIFA17.exe is not running.") + return 1 + base = W.dll_base(pid) + if base is None: + print("pid %d is up but %s is not mapped yet." % (pid, W.DLL)) + return 1 + mem = W.Mem(pid) + obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE)) + if not obj: + print("CardsDb singleton is NULL (no FUT session loaded).") + return 1 + + ns = P.nodes(mem, obj) + print("pid=%d CardsDb=%#x walked=%d\n" % (pid, obj, len(ns))) + + out = [] + for n in ns: + buf = mem.read(n + P.REC, P.REC_LEN) + if buf is None or len(buf) < P.REC_LEN: + continue + sub = P.u32(buf, P.F_SUBTYPE) + ct = P.u32(buf, P.F_CARDTYPE) + rating = P.u8(buf, F_RATING) + rare = P.u32(buf, F_RARE) + rec = { + "resourceId": P.u32(buf, P.F_RESOURCE), + "subtype": sub, + "cardtype": ct, + "decoded_cardtype": cardtype_for_subtype(sub), + "rating": rating, + "level": P.u32(buf, F_LEVEL), + "rare": rare, + "server_discard": P.u32(buf, F_SERVER_DISCARD), + "client_discard": P.u32(buf, F_CLIENT_DISCARD), + "predicted": predict(prices, ct, rating, rare), + } + if a.kind == "staff" and sub not in STAFF_SUBTYPES: + continue + if a.kind == "player" and ct != 1: + continue + if a.kind == "other" and (ct == 1 or sub in STAFF_SUBTYPES): + continue + out.append(rec) + + out.sort(key=lambda r: (r["cardtype"], r["subtype"], r["resourceId"])) + print("%-10s %-4s %-4s %-4s %-4s %-4s %-9s %-9s %-9s %s" + % ("resource", "sub", "ct", "rat", "lvl", "rar", "sent+38", "calc+3c", + "predict", "verdict")) + agree = disagree = notcomputed = 0 + for r in out: + if r["server_discard"]: + verdict = "SERVER-SHOWN (local calc skipped)" + notcomputed += 1 + elif r["predicted"] is None: + verdict = "?" + elif r["client_discard"] == r["predicted"]: + verdict = "AGREES" + agree += 1 + else: + verdict = "DISAGREES" + disagree += 1 + print("%-10s %-4s %-4s %-4s %-4s %-4s %-9s %-9s %-9s %s" + % (r["resourceId"], r["subtype"], r["cardtype"], r["rating"], + r["level"], r["rare"], r["server_discard"], r["client_discard"], + r["predicted"], verdict)) + + print("\nAGREES=%d DISAGREES=%d server-shown=%d total=%d" + % (agree, disagree, notcomputed, len(out))) + if a.json: + json.dump(out, open(a.json, "w"), indent=2) + print("wrote %s" % a.json) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/openfut-import-fifa17/src/lib.rs b/openfut-import-fifa17/src/lib.rs index 035093b..7fe0635 100644 --- a/openfut-import-fifa17/src/lib.rs +++ b/openfut-import-fifa17/src/lib.rs @@ -87,16 +87,37 @@ pub fn load_roster(path: impl AsRef) -> Result { // --------------------------------------------------------------- entities -/// Forward numeric-id -> name resolution for the committed FIFA 17 tables -/// (`leagues.json`/`nations.json`/`teams.json`, `{schema, rows:[…]}` dump). -/// Mirrors `scripts/seed_fifa17_cards.py` (`_table_map`). +/// Forward lookups over the committed FIFA 17 tables: numeric id -> name for +/// `leagues.json`/`nations.json`/`teams.json` (`{schema, rows:[…]}` dump, mirrors +/// `scripts/seed_fifa17_cards.py`'s `_table_map`), plus EA's authored rating and +/// rare flag for the five STAFF families. #[derive(Default)] pub struct Entities { leagues: BTreeMap, nations: BTreeMap, teams: BTreeMap, + /// `(cardsubtypeid, carddbid)` -> `(value, rare)` for the five staff tables. + /// + /// `value` is the RATING the client re-rates a staff card to, and `rare` + /// selects its discard price column. Both are LIVE-VERIFIED: reading the + /// running client's own card records back out of memory + /// (`tools/coach_probe.py`, 4/4 HIT) shows record `+0xb4` == this `value` + /// and `+0x58` == this `rare`, and the discard value the client computes at + /// record `+0x3c` (`tools/discard_probe.py`) matches the price those two + /// select — 36 for the `value`-66 GK coach, 282 for the `rare`-1, + /// `value`-88 manager. + staff: BTreeMap<(i64, i64), (i64, i64)>, } +/// `cardsubtypeid` -> staff table basename (`FUN_1800d8330`'s family selector). +const STAFF_TABLES: [(i64, &str); 5] = [ + (4, "managercards"), + (5, "headcoachcards"), + (6, "gkcoachcards"), + (7, "physiocards"), + (8, "fitnesscoachcards"), +]; + fn load_table(path: &Path, id_key: &str, name_key: &str) -> Result> { let raw = std::fs::read_to_string(path) .with_context(|| format!("reading table {}", path.display()))?; @@ -118,17 +139,48 @@ fn load_table(path: &Path, id_key: &str, name_key: &str) -> Result (value, rare)` from one staff table dump. +/// +/// `value` is EA's authored rating for the card and `rare` its rare flag; both +/// are read verbatim, never defaulted, and a row missing either is skipped so a +/// gap stays a gap. +fn load_staff_table(path: &Path) -> Result> { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("reading staff table {}", path.display()))?; + let doc: serde_json::Value = serde_json::from_str(&raw) + .with_context(|| format!("parsing staff table {}", path.display()))?; + let rows = doc + .get("rows") + .and_then(|r| r.as_array()) + .with_context(|| format!("staff table {} has no rows[]", path.display()))?; + let mut map = BTreeMap::new(); + for row in rows { + let get = |k: &str| row.get(k).and_then(serde_json::Value::as_i64); + if let (Some(id), Some(value), Some(rare)) = (get("carddbid"), get("value"), get("rare")) { + map.insert(id, (value, rare)); + } + } + Ok(map) +} + impl Entities { pub fn from_tables_dir(dir: impl AsRef) -> Result { let dir = dir.as_ref(); + let mut staff = BTreeMap::new(); + for (subtype, table) in STAFF_TABLES { + for (carddbid, row) in load_staff_table(&dir.join(format!("{table}.json")))? { + staff.insert((subtype, carddbid), row); + } + } Ok(Entities { leagues: load_table(&dir.join("leagues.json"), "leagueid", "leaguename")?, nations: load_table(&dir.join("nations.json"), "nationid", "nationname")?, teams: load_table(&dir.join("teams.json"), "teamid", "teamname")?, + staff, }) } - /// Build directly from id->name maps (tests). + /// Build directly from id->name maps (tests). Carries no staff table. pub fn from_maps( leagues: BTreeMap, nations: BTreeMap, @@ -138,6 +190,32 @@ impl Entities { leagues, nations, teams, + staff: BTreeMap::new(), + } + } + + /// EA's `(rating, rare)` for one staff definition, or `None` when the id is + /// absent from its family's table (the client would draw its own miss-fill). + pub fn staff_stats(&self, subtype: i64, carddbid: i64) -> Option<(i64, i64)> { + self.staff.get(&(subtype, carddbid)).copied() + } + + /// Fill in the rating and rare flag the client re-rates STAFF cards to. + /// + /// The wire never sends either (measured: a staff record arrives with no + /// `rating`, no `rareflag` and no `discardValue`), so without this a staff + /// definition reaches the catalog with `rating: None` and the server cannot + /// price a quick sell to match what the client displays. Players and + /// consumables are untouched — their wire values are authoritative. + pub fn enrich_staff(&self, plan: &mut NonPlayerPlan) { + for def in &mut plan.supported { + if def.kind != ContentKind::Staff && def.kind != ContentKind::Manager { + continue; + } + if let Some((value, rare)) = self.staff_stats(def.subtype, def.resource_id) { + def.rating.get_or_insert(value); + def.rareflag.get_or_insert(rare); + } } } @@ -604,6 +682,11 @@ pub struct NonPlayerDefinition { pub contract: Option, /// Card rating, when the source carries one (matches the `fcc_*` row). pub rating: Option, + /// EA's authored `rare` flag for a STAFF definition, from the same table row + /// as [`Self::rating`]. The wire never carries it (the client re-rates staff + /// from its own DB), and it is NOT cosmetic: it selects the discard price + /// column, so a `rare`-1 manager and a `rare`-0 coach price differently. + pub rareflag: Option, /// Honest functional label (e.g. "Player Contract", "GK Coach", "Kit"). pub name: String, /// Wire ids of every owned copy of this resourceId (preserved). @@ -789,6 +872,7 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan { amount, contract, rating, + rareflag: None, name: label.to_string(), wire_ids, }); @@ -1013,7 +1097,10 @@ pub fn analyze( let identity = plan_identity(profile, &supported_rids); let supported_wire: BTreeSet = identity.import_wire_ids.iter().copied().collect(); let squad = plan_squad(profile, &supported_wire); - let non_player = plan_non_player_definitions(profile); + let mut non_player = plan_non_player_definitions(profile); + // Staff carry no rating or rare flag on the wire; fill them from the tables + // the client itself re-rates from, so a quick sell can be priced to match. + entities.enrich_staff(&mut non_player); Report { game: "fifa17".to_string(), persona_id: profile.persona_id, @@ -1270,7 +1357,7 @@ pub fn emit_content( } for d in &report.non_player.supported { // asset_id falls back to resource_id (staff carry no assetId); version 0, - // rareflag 0 — a consumable/staff never renders as a special card. + // because a consumable/staff never renders as a versioned special card. cards.insert( d.card_id.clone(), serde_json::json!({ @@ -1286,7 +1373,10 @@ pub fn emit_content( "rating": d.rating, "asset_id": d.asset_id.unwrap_or(d.resource_id), "version": 0, - "rareflag": 0, + // NOT cosmetic: `rareflag` selects the discard price column, and + // a staff card's is EA's own `rare`, filled from its family table + // by `Entities::enrich_staff`. 0 for everything else, as before. + "rareflag": d.rareflag.unwrap_or(0), "kind": d.kind.as_str(), "subtype": d.subtype, }), diff --git a/openfut-import-fifa17/src/tests.rs b/openfut-import-fifa17/src/tests.rs index 1dc64ea..fc938f9 100644 --- a/openfut-import-fifa17/src/tests.rs +++ b/openfut-import-fifa17/src/tests.rs @@ -1068,3 +1068,76 @@ fn deferred_non_player_instances_gate_a_production_apply() { assert!(gate_staging(&plan, false).is_err(), "production blocks"); assert!(gate_staging(&plan, true).unwrap(), "staging opt-in allows"); } + +/// The staff rating and rare flag come from the client's OWN tables, and these +/// exact values were read back out of the RUNNING client's memory: +/// `tools/coach_probe.py` graded all four resident staff records HIT (record +/// `+0xb4` == `value`, `+0x58` == `rare`), and `tools/discard_probe.py` read the +/// discard value the client computed for itself at record `+0x3c` — 36 for both +/// `value`-66 coaches and 282 for the `rare`-1, `value`-88 manager. +/// +/// So this is not a table-parsing test. It pins the importer to numbers the live +/// client demonstrably uses. +#[test] +fn staff_stats_are_the_values_the_live_client_re_rates_to() { + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/data/tables"); + let ent = Entities::from_tables_dir(dir).expect("committed tables load"); + + // (subtype, carddbid) -> (value, rare), verified live. + assert_eq!(ent.staff_stats(6, 9000081), Some((66, 0)), "GK coach"); + assert_eq!(ent.staff_stats(8, 3000083), Some((66, 0)), "fitness coach"); + assert_eq!(ent.staff_stats(4, 1000509), Some((88, 1)), "manager"); + + // Keyed per family: a coach id must not resolve through another's table. + assert_eq!( + ent.staff_stats(4, 9000081), + None, + "gkcoach id is not a manager" + ); + assert_eq!(ent.staff_stats(6, 12345678), None, "absent id stays absent"); +} + +/// `enrich_staff` fills ONLY staff, and only where the wire left a gap. +#[test] +fn enrich_staff_fills_staff_and_leaves_everything_else_alone() { + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/data/tables"); + let ent = Entities::from_tables_dir(dir).expect("committed tables load"); + + let items = vec![ + // A GK coach (subtype 6) and a contract consumable, which carries its own + // rating on the wire and must not be touched. + staff(100000280, 9000081, 6), + consumable(100000300, 5001004, 201), + ]; + let mut plan = plan_non_player_definitions(&profile(&items, "[]", 100000500)); + let before: Vec> = plan.supported.iter().map(|d| d.rating).collect(); + ent.enrich_staff(&mut plan); + + let coach = plan + .supported + .iter() + .find(|d| d.resource_id == 9000081) + .expect("coach planned"); + assert_eq!( + coach.rating, + Some(66), + "rating filled from gkcoachcards.value" + ); + assert_eq!(coach.rareflag, Some(0), "rare filled from the same row"); + + let cons = plan + .supported + .iter() + .find(|d| d.resource_id == 5001004) + .expect("consumable planned"); + assert_eq!(cons.rareflag, None, "a consumable gets no staff rare flag"); + let cons_before = before[plan + .supported + .iter() + .position(|d| d.resource_id == 5001004) + .unwrap()]; + assert_eq!( + cons.rating, cons_before, + "the wire rating is left untouched" + ); +}