diff --git a/fifa17-recon/tools/fut_store.py b/fifa17-recon/tools/fut_store.py index cf43ded..e32e7d1 100644 --- a/fifa17-recon/tools/fut_store.py +++ b/fifa17-recon/tools/fut_store.py @@ -100,11 +100,28 @@ def _cardtype(sub): def discard_value(item): - """round_half_up(rating * price / 100), price from fcc_discardcoins.""" + """round_half_up(rating * price / 100), price from fcc_discardcoins. + + Returns None when the formula does not apply, so callers fall back instead of + paying nothing. THE UNRATED-CARD CASE IS NOT COVERED BY THE RECOVERED FORMULA: + it was verified 22/22 against club items, all of which were rated players, and + `rating * price / 100` collapses to 0 for a staff card carrying no rating. Found + by running the whole save through it, where exactly one item (a staff card, + cardsubtypeid 8, rating None) came back 0 while the old tier paid 50. Paying 0 for + a card the previous code paid for is a regression, so unrated cards fall back. + What FUT really pays for staff and consumables is UNKNOWN and worth recovering; + the likely answer is the unscaled table price, but that is a guess and is not + shipped as one. + """ + r = item.get("rating") + if not r: + return None ct = _cardtype(item.get("cardsubtypeid")) - r = int(item.get("rating") or 0) + r = int(r) lvl = 3 if r >= 75 else 2 if r >= 65 else 1 price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0) + if not price: + return None # no table row: the client renders 0, we should not n = r * price return n // 100 + (1 if n % 100 >= 50 else 0) @@ -153,7 +170,7 @@ _SQUAD_FITNESS_TRAP = 219 def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00, cardsubtypeid=0, rareflag=1): - return { + return _with_discard({ "id": item_id, "resourceId": (version << 24) | asset, "assetId": asset, @@ -174,7 +191,47 @@ def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00 "untradeable": True, "contract": 7, "fitness": 99, - } + }) + # discardValue is stamped HERE, inside the single item factory, so every path that + # builds an item gets it: pack contents, the starter grant, club reads and market + # listings alike. Stamping it at one call site would leave the reveal screen and + # the club showing different numbers for the same card. + + +# FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS +# the same number the server pays. +# +# Measured live 2026-08-06. With FUT_DISCARD_TABLE on, the server correctly paid 600 +# for a 75-rated rare gold (9,844,900 -> 9,845,500, exact) while the reveal screen +# showed "Quick Sell 0", and "Quick Sell all remaining Items" showed 0 too. So the +# figure was right and invisible, and the screen contradicted the wallet. +# +# The cause is the guard the table work reversed. FUN_18013fe00 stores our +# discardValue at item +0x38; at 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` skips +# the client's own local computation when that value is NON-ZERO. We seed 0, so the +# client runs its own fcc_discardcoins lookup, that lookup returns no row for our +# cards, the price register stays 0, and it renders 0. WHY its lookup misses is still +# UNKNOWN and worth knowing, but it does not have to be answered to fix the display: +# sending a non-zero value bypasses the lookup entirely and the client uses ours. +# +# Freeze risk: low and in the safe direction. discardValue is a plain INT read by the +# scalar getter 0x1801c79d0. The freezes on this project have all come from feeding an +# object or array where a scalar was expected, never the reverse. +# +# Requires FUT_DISCARD_TABLE, since without the real table this would put the invented +# tier on screen and make a wrong number authoritative-looking rather than merely paid. +DISCARD_SEND = os.environ.get("FUT_DISCARD_SEND", "0") == "1" and DISCARD_TABLE + + +def _with_discard(it): + """Stamp discardValue when armed. Omits the key entirely when the formula does + not apply, rather than sending 0, because a 0 makes the client fall back to its + own lookup, which is exactly the broken path we are routing around.""" + if DISCARD_SEND: + v = discard_value(it) + if v: + it["discardValue"] = v + return it def _new_profile(): @@ -271,7 +328,13 @@ class Store: return self.load()["coins"] def items(self): - return self.load()["items"] + # Stamp discardValue on READ as well as on creation. _item() only covers cards + # minted from now on, and the save already holds 246 items built before the + # flag existed; without this the reveal screen would show real values while + # the club showed 0 for everything older. Stamped on the way out and NOT + # persisted, so the save stays clean and turning the flag off is a true revert. + its = self.load()["items"] + return [_with_discard(dict(it)) for it in its] if DISCARD_SEND else its def add_items(self, new_items): with _LOCK: @@ -305,9 +368,12 @@ class Store: if dv: return int(dv) if DISCARD_TABLE: - # The real table. Matches what the client already displays, so the - # coins paid and the coins shown finally agree. - return discard_value(it) + # The real table. Matches what the client displays once + # FUT_DISCARD_SEND puts the value on the wire. + v = discard_value(it) + if v is not None: + return v + # else: unrated card, formula does not apply, fall through # The invented tier. Wrong for every card, kept only as the live-proven # default until FUT_DISCARD_TABLE has been in front of the game once. r = it.get("rating") or 0