#!/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())