From 755f237f17a9037b4e8a765cb0a1d8bc897f1aa7 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 21 Aug 2026 22:51:16 +0000 Subject: [PATCH] fifa17: carry the staff rating the client re-rates to, verified live Staff quick-sell could not be priced correctly: for cardtypes 2/3/4/5/10 the client overwrites the rating and rare flag we send with values from its own card database, and a staff wire record carries no rating, no rareflag and no discardValue at all. The server had no way to know the displayed price from what it sent, so pricing declined for staff and fell back to the placeholder ladder. The missing input was read straight out of the running client (pid 6580), no UI interaction required: * tools/coach_probe.py grades all four resident staff records HIT, which by construction requires record +0xb4 == the table's `value` and +0x58 == its `rare`. That settles `value`-is-the-rating, which was previously an inference and was deliberately not shipped on that basis. * tools/discard_probe.py (new) reads both discard slots -- +0x38, the value we sent, and +0x3c, the value the client computed for itself: 1000509 sub 4 ct 2 rat 88 rare 1 sent 0 calc 282 predicted 282 9000081 sub 6 ct 10 rat 66 rare 0 sent 0 calc 36 predicted 36 3000083 sub 8 ct 4 rat 66 rare 0 sent 0 calc 36 predicted 36 4 of 4 agree, 0 disagree. 36 on the value-66 GK coach was the exact falsifier written for this last commit. Entities::enrich_staff now fills rating from `value` and rareflag from `rare` for the five staff families, and the catalog emits the real rareflag instead of a hardcoded 0 (it is not cosmetic -- it selects the discard price column, which is why the rare-1 manager prices at 282 and a rare-0 coach at 36). Players and consumables are untouched; their wire values are authoritative. Verified on staging: a GK coach quick-sells for 36, not the 150 floor. The catalog diff is exactly the two coach entries gaining rating 66; 1710 entries in and out, nothing else changed. The same probe shows what production does to PLAYERS today: every resident player carries sent+38 = 1500, which suppresses the client's own computation, against a real 688..752 for a gold rare and 72,800 / 74,400 for the two legends. Still open, and not a discard problem: manager fifa17_1000509 is owned in Core but has no catalog entry or definition (it reaches the client through the opaque squad extension), so it declines to the ladder. That is definition coverage. Importer 41 tests, fmt and clippy clean. --- .../docs/plan-2026-08-05-store-subsystem.md | 45 +++-- fifa17-recon/tools/discard_probe.py | 190 ++++++++++++++++++ openfut-import-fifa17/src/lib.rs | 104 +++++++++- openfut-import-fifa17/src/tests.rs | 73 +++++++ 4 files changed, 391 insertions(+), 21 deletions(-) create mode 100755 fifa17-recon/tools/discard_probe.py 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" + ); +}