Files
OpenFUT/fifa17-recon/tools/discard_probe.py
T
funman300 755f237f17 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.
2026-08-21 22:51:16 +00:00

191 lines
6.4 KiB
Python
Executable File

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