fifa17-recon: the client now shows the quick-sell value it is actually paid

Follow-on to 21a81ad, both halves confirmed live.

FUT_DISCARD_TABLE IS PROVEN. Quick selling a 75-rated rare gold paid 600 and the
balance moved 9,844,900 -> 9,845,500, exact. The invented tier would have paid 150.
75 * 800 / 100 = 600, straight off the recovered fcc_discardcoins row.

BUT THE SCREEN SAID 0, which is why this commit exists. The value was right and
invisible: "Quick Sell 0" on the card and "Quick Sell all remaining Items 0" too, so
the wallet contradicted the display on every card. Cause is the guard the table work
had already reversed. FUN_18013fe00 stores our discardValue (atom 0xd7) at item +0x38
and 0x180141025 skips the client's own fcc_discardcoins lookup only when that value is
NON-ZERO. We seeded 0, so the client ran its own lookup, that lookup returns no row for
our cards, and it rendered 0.

FUT_DISCARD_SEND puts the value on the wire and the client uses ours verbatim. Live
result, one launch:
    a 77-rated rare gold shows "Quick Sell 616"   (77 * 800 / 100 = 616, exact)
    "Quick Sell all remaining Items" shows 5,640
and 5,640 is exactly the sum of the ten rated PLAYER cards in the pending pile. The
eleventh, a consumable, is excluded by the client from the bulk figure; the twelfth is
a staff card we deliberately send no value for. Both halves of the display now agree
with what the server credits.

WHY THE CLIENT'S OWN LOOKUP MISSES for our cards is still UNKNOWN. This routes around
that question rather than answering it, and it is worth answering.

TWO CORRECTNESS FIXES THE REPORT DID NOT COVER, both found by running the whole save
through the formula rather than trusting the 22/22 sample:
  * The recovered formula scales by rating, so a rating-less STAFF card collapses to 0.
    The old tier paid 50, so shipping it as-is was a regression. discard_value() now
    returns None when the formula does not apply and callers fall back. What FUT really
    pays for staff and consumables is UNKNOWN; the likely answer is the unscaled table
    price, but that is a guess and is not shipped as one.
  * A missing table row also returns None rather than 0, for the same reason.
  Verified across all 246 club items plus the pending pile: not one pays 0 coins.

discardValue is stamped inside the single item factory so every path gets it (pack
contents, starter grant, club, market), and additionally on the club READ path, because
_item() only covers cards minted from now on while the save already holds 246 built
before the flag existed. Stamped on the way out and NOT persisted, so the save stays
clean and turning the flag off is a true revert. Confirmed: 0 items on disk carry the
key.

FUT_DISCARD_SEND requires FUT_DISCARD_TABLE and silently stays off without it, so the
invented tier can never reach the screen and become authoritative-looking.

Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
scalar getter 0x1801c79d0; every freeze on this project has come from an object or
array where a scalar was expected, never the reverse.

Both flags still default OFF. Live: 439 contract checks pass, market suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-06 07:55:05 -07:00
parent 21a81ad63c
commit e3092ca0f9
+74 -8
View File
@@ -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