755f237f17
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.
191 lines
6.4 KiB
Python
Executable File
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())
|