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.
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
Executable
+190
@@ -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())
|
||||
Reference in New Issue
Block a user