Files
OpenFUT/docs/FIFA17_SOLD_WIRE_RE.md
T
funman300 571c5f9261 docs(market): recover the FIFA17 sold wire contract from CardsDLL (Ghidra)
Task A, static phase. Ghidra 12.1.2 headless via the repo's own pyghidra harness
over CardsDLL_Win64_retail.dll (13,382 functions). Queries and raw decompiler
output committed under docs/evidence/market-sold-re-2026-08-17/.

RECOVERED FROM THE BINARY

1. No sold token, now EXHAUSTIVELY: both vocabularies dumped to their sentinels
   rather than sampled. tradeState is exactly 4 rows; itemState is exactly 12
   (invalid/free/WAITING_FOR_GAME/inGame/forSale/offered/activeBadge/
   activeHomeKit/activeAwayKit/activeBall/activeStadium/active=255). A sold row
   MUST therefore be a combination of existing atoms.

2. What closed does, complete, from the auctionInfo deserializer 0x18013e410:
     IS_GLOW = (tradeState==closed) ? bidState != none
                                    : bidState in {outbid, buyNow}
     INBOX   = bidState in {highest, buyNow}

3. The full record -> Flash map from the publisher 0x1801bf030, superseding the
   partial list. The prize: record +0xbf is published as COINS_AWARDED, fed by the
   coinsProcessed atom 0x2f4. The corpus had recorded that atom's type and noted
   its consumer was never found; it is now traced. DURATION also renders the
   localised FUT_AUCTION_EXPIRED when expires underflows.

4. highest vs buyNow on a closed row is UNDECIDABLE from CardsDLL, by proof: both
   yield IS_GLOW=1/INBOX=1, bit-identical. But bidState is ALSO published verbatim
   as YOURBID alongside STATE and COINS_AWARDED, so the movie does receive the raw
   values - the discrimination exists and lives entirely in unread ActionScript.
   This retires the question as a static target, and it contradicts the
   third-party lore that a seller's sold row is closed+buyNow (the corpus's own
   lifecycle table says closed+highest and assigns buyNow to the buyer).

5. The clear-sold verb EXISTS. Builder 0x1801647c0 emits "/sold" when the tradeId
   field is zero and "/%lld" otherwise, on route base ut/delete/%s/trade, response
   class RS4 FutISRemoveTradeServerResponse. Confirmed by the client's own
   request-name table entry RemoveAllSoldFromTradePile. A BULK clear-sold verb only
   makes sense if sold rows PERSIST in the seller's pile until cleared, which is
   incompatible with our Fix A invariant - so the sold path will require revisiting
   it under live validation.

6. The seller's SOLD counter is real, proven end to end with no inference: the hub
   tradePile sub-deserializer 0x18013ead0 writes atom sold 0x2c9 to +0x1d8, and the
   tile publisher 0x1800b1dc0 renders +0x1d8 as Flash TEXT3 under the localised
   caption FUT_TF_SOLD. Siblings: selling -> +0x1d2 -> FUT_TF_SELLING,
   count -> +0x1d4 -> FUT_UC_ITEMS, plus FUT_TF_WINNING/FUT_TF_OUTBID on the
   Transfer Targets tile. We and the Python oracle both hardcode sold:0, so that
   bucket can never fill.

7. Reusable method: an atom id is the INDEX into the alphabetical atom-name pointer
   table at base 0x1802d2760. Validated 12/12 against the known auctionInfo atoms
   and cross-checked against fifa17-recon/docs/fut_atoms.tsv. Documented gotcha:
   resolve a name by the pointer slot INSIDE the table, never by the first matching
   string in the binary, or you get confident nonsense.

8. An auction-outcome vocabulary exists (auctionSoldBid 0x39, auctionSoldBuyNow
   0x3a, auctionWon*/auctionLost*) but NO deserializer consumes it - every
   candidate function was checked for the value-SKIP/atom-loop signature and none
   qualifies. Server-side or telemetry only; it does not carry sold state here.

TASK B IS UNDECIDABLE FROM THE CLIENT, and this is a proof of absence: no 0.95 or
0.05 constant of either width, no tax/fee/net/proceeds caption, and no fee
arithmetic anywhere. The client never computes or displays a net, so no experiment
against our own server can measure the rounding - whatever we credit is what it
displays, and there is no oracle. Only an original EA-era seller-balance capture
could settle it. The rule stays an explicit CHOICE (floor the fee, so
fee + proceeds == gross exactly) and is now pinned at the requested boundaries
100/101/119/120/149/150/151/199/200 plus 15,000 and i64::MAX.

Settlement NOT promoted. No production process, port or database was touched.
2026-08-18 01:32:02 +00:00

242 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FIFA 17 seller-facing SOLD state — static recovery from CardsDLL
Reverse engineering of `CardsDLL_Win64_retail.dll`
(md5 `4de3493131d7d2ff7f8b360c5ac9b655`), Ghidra 12.1.2 headless via pyghidra,
13,382 functions, image base `0x180000000`. Queries and raw output:
`docs/evidence/market-sold-re-2026-08-17/`.
Confidence tags: **[PE]** read from the binary · **[PE-PROOF]** a proven *negative*
(an exhaustive absence) · **[PLAN]** corpus prose, not a decompile · **[UNDECIDABLE]**
proven not answerable from this binary.
---
## 1. There is no `sold` token, and now it is exhaustive
Both vocabularies dumped in full to their sentinels, not sampled. **[PE]**
`tradeState` table `0x180229e40` — exactly 4 rows, then a `{0,0}` terminator:
```text
active=1 inactive=2 expired=3 closed=4
```
`itemState` table `0x180229cc0` — exactly 12 rows, then a `{0,-1}` terminator:
```text
invalid=0 free=1 WAITING_FOR_GAME=2 inGame=2 forSale=5 offered=6
activeBadge=100 activeHomeKit=101 activeAwayKit=102 activeBall=103
activeStadium=104 active=255
```
Neither contains `sold`. So the seller's sold state **must** be a combination of
existing atoms. This closes the question that previously rested on a partial dump.
## 2. What `closed` actually does — the complete flag computation
Decompiled from the `auctionInfo` deserializer `0x18013e410`. `local_70` is
`tradeState`, `local_40` is `bidState`. **[PE]**
```c
if (local_70 == 4) { /* tradeState == closed */
local_3b = local_40 != 0; /* bidState != none */
} else {
local_3b = (local_40 - 1U & 0xfffffffd) == 0; /* bidState in {1,3} */
}
local_3a = local_40 - 2U < 2; /* bidState in {2,3} */
```
Evaluated:
| | `none`(0) | `outbid`(1) | `highest`(2) | `buyNow`(3) |
|---|---|---|---|---|
| `local_3b`, tradeState==closed | 0 | **1** | **1** | **1** |
| `local_3b`, otherwise | 0 | 1 | 0 | 1 |
| `local_3a` (any tradeState) | 0 | 0 | **1** | **1** |
## 3. The complete record → Flash mapping
From the publisher `0x1801bf030`, every property it sets, with its record offset.
This supersedes the previous partial list. **[PE]**
| Flash property | record | meaning |
|---|---|---|
| `TRADEID_LOWER` / `TRADEID_UPPER` | +0x38 | tradeId, split into two 32-bit halves |
| `DURATION` | +0x90 | formatted; **`FUT_AUCTION_EXPIRED`** when the value underflows (i.e. `expires == 0`) |
| `TIME_REMAINING` | +0x90 | expires, seconds |
| `MIN_CREDITS` | +0x78 | currentBid |
| `MAX_CREDITS` | +0x70 | buyNowPrice |
| `RESERVEDPRICE` | +0x74 | startingBid |
| `YOURBID` | +0xb8 | **bidState, passed through verbatim** |
| `STATE` | +0x88 | **tradeState, passed through verbatim** |
| **`COINS_AWARDED`** | **+0xbf** | **the `coinsProcessed` atom (0x2f4), u8** |
| `UUID_UPPER` / `UUID_LOWER` | itemData+8 | |
| `CARD_ID` / `FIFA_ID` | itemData+0x18 | `FIFA_ID` masks `& 0xffffff` |
| `CARD_TYPE` | itemData+0x4c | |
| `CARD_OFFERSTATE` | itemData+0x5c | itemState |
| `IS_WATCHED` | +0xbc | `watched` atom |
| `INBOX` | +0xbe | `local_3a` — bidState ∈ {highest, buyNow} |
| `IS_GLOW` | +0xbd | `local_3b` — the table in §2 |
| `TRADE_DATA_AVAILABLE` | — | constant 1 |
**`coinsProcessed`'s consumer is now traced.** The corpus recorded its type and
noted that no consumer had ever been found; it is published to the movie as
**`COINS_AWARDED`**. That is a settlement/"you have been paid" signal, exactly as
the corpus guessed but never demonstrated.
## 4. `highest` vs `buyNow` on a closed row is UNDECIDABLE from CardsDLL
**[UNDECIDABLE]**, and this is a proof, not a failed search.
For `tradeState == closed`, §2 gives `IS_GLOW = (bidState != none)` and
`INBOX = (bidState ∈ {highest, buyNow})`. Both `highest`(2) and `buyNow`(3)
therefore produce **`IS_GLOW=1, INBOX=1`** — bit-identical. No native consumer can
tell them apart.
But §3 sharpens *why* it is undecidable: `bidState` is **not** consumed only through
those flags. It is published verbatim as `YOURBID`, alongside `STATE` and
`COINS_AWARDED`. The movie receives the raw values. So the discrimination exists —
it just lives entirely in the APT/ActionScript front end, which is unread.
Consequence: **no amount of further CardsDLL work can answer "which `bidState` does
a seller see".** Only an AVM1 read of `external/ion_fut/screens/trading/tradepile`,
or a live behavioural A/B, can. This retires the question as a static target.
The corpus's own lifecycle table (`fifa17-recon/docs/plan-2026-08-06-transfer-market.md:720-729`)
asserts the seller sees `closed` / **`highest`** with `coinsProcessed 1`, and assigns
`closed` / `buyNow` to the *buyer*. That is **[PLAN]**, and it **contradicts** the
common third-party lore that a sold seller row is `closed` + `buyNow`. Given §4 the
contradiction cannot be resolved statically — but note the corpus reading is the one
that leaves `buyNow` meaning "*I* bought it now", which is self-consistent with
`YOURBID` being a property about the viewer's own bid.
## 5. The clear-sold verb EXISTS — PE-proven
The request builder `0x1801647c0`: **[PE]**
```c
if (*(longlong *)(param_1 + 0x10) == 0) {
FUN_180007f80(&local_38, 0x20, "/sold"); /* no tradeId -> bulk */
} else {
FUN_180007f80(&local_38, 0x20, "/%lld"); /* one specific tradeId */
}
```
One builder, two forms, on route base `ut/delete/%s/trade` (`DELETETRADE`), response
class `RS4:FutISRemoveTradeServerResponse` (`0x180228bc8`, with the `/sold` literal
at `0x180228bec` immediately after it):
```text
DELETE ut/delete/{ns}/trade/{tradeId} remove one trade
DELETE ut/delete/{ns}/trade/sold remove ALL sold trades
```
Corroborated by the client's own request-name table, where
**`RemoveAllSoldFromTradePile`** (`0x1801efae8`) sits beside `RemoveFromTradePile`,
`AddToWatchList` and `RemoveFromWatchList`.
**Architectural consequence.** A bulk "remove all sold" verb only makes sense if
sold rows **persist in the seller's pile until explicitly cleared**. That is
incompatible with our current host, where a sold/cancelled listing leaves
`/tradePile` the instant the CAS lands (the Fix A invariant). Implementing the sold
path will require revisiting that invariant — and doing so needs live validation,
because Fix A itself was a live-confirmed correction.
## 6. The seller-facing SOLD counter is real, and we hardcode it to 0
A complete chain, wire atom → struct → Flash → localised caption, with no inference
at any step. **[PE]**
Hub `tradePile` sub-deserializer `0x18013ead0`:
| atom | id | writes | Flash slot (publisher `0x1800b1dc0`, tile `0x1c0`) |
|---|---|---|---|
| `count` | 0xbc | +0x1d4 | `TEXT0` with caption `FUT_UC_ITEMS` |
| `notification` | 0x1da | +0x1d6 | `NOTIFICATION`, capped at 99 |
| `selling` | 0x2b8 | +0x1d2 (**and** counts-struct +0x36) | `TEXT2` with caption `FUT_TF_SELLING` |
| **`sold`** | **0x2c9** | **+0x1d8** | **`TEXT3` with caption `FUT_TF_SOLD`** |
The sibling tile `0x1d0` (Transfer Targets) uses `FUT_TF_WINNING` (+0x1c8) and
`FUT_TF_OUTBID`. `selling` writing two structs independently re-confirms the
corpus's counts-struct offset for `selling`.
So FIFA 17 renders a **SOLD** count to the seller, sourced from atom `sold` (0x2c9).
Our host and the Python oracle both hardcode `sold: 0`, so that bucket can never
populate. This is direct client evidence about `sold` — the thing
`MARKET_SOLD_SETTLEMENT.md` required before touching `/tradePile/counts`. It
establishes that `sold` is *displayed*; it does **not** yet establish what should be
counted in it (rows awaiting clear? rows sold this session?).
## 7. Atom-name decoder (method note, reusable)
Atom IDs are **indices into an alphabetically sorted pointer table** of atom-name
strings, base `0x1802d2760`. Validated against all twelve known `auctionInfo`
atoms — 12/12 agree — and cross-checked against `fifa17-recon/docs/fut_atoms.tsv`.
**[PE]**
```text
atom_id = (pointer_slot_address - 0x1802d2760) / 8
```
Newly resolved: `sold`=0x2c9, `count`=0xbc, `offered`=0x1e5, `selling`=0x2b8,
`maxAuctionsAllowed`=0x1bf, `credits`=0xc0, `auctionInfo`=0x35, `total`=0x325,
`duplicateItemIdList`=0xec, `itemState`=0x172, `offers`=0x1e6, `coins`=0x95.
Caution when using it: look up a name by finding the pointer slot **inside the table
range**, not by taking the first matching string in the binary. Common words such as
`sold`, `offered` and `count` appear in several unrelated tables, and taking the
first hit produces confident nonsense (it initially reported `sold` as ABSENT and
`offered` as a negative index).
## 8. An auction-outcome vocabulary exists but this client ignores it
The atom table contains a 7-value outcome vocabulary: **[PE]**
```text
0x36 auctionLostBidRejected 0x39 auctionSoldBid 0x3b auctionWonBid
0x37 auctionLostOutbid 0x3a auctionSoldBuyNow 0x3c auctionWonBuyNow
0x38 auctionLostOutbidSelf
```
These would distinguish seller-sold-by-bid from seller-sold-by-buy-now explicitly.
**No CardsDLL deserializer consumes them.** Every candidate function that compares
against three or more of `0x36..0x3c` was checked and none is an atom dispatcher
(no value-SKIP `0x180135ff0`, no atom loop `0x1801c7f10`) — they are small-immediate
coincidences. **[PE-PROOF]** So the vocabulary is server-side or telemetry, and it
does not carry the sold state to this client.
## 9. The 5% fee is NOT in the client — Task B is undecidable from here
**[PE-PROOF]**, three independent absences:
* no `0.95` or `0.05` constant, `double` or `float`, anywhere in the binary;
* no localisation key for tax/fee/net/proceeds/commission/"you will receive" — the
only `FUT_TF_*` keys in the binary are `SELLING`, `SOLD`, `WINNING`, `OUTBID`;
* the 17 functions using both `5`/`95` and `100` as immediates are all unrelated
(they include the item deserializer) — no fee arithmetic exists.
The client therefore never computes or displays a net. It learns the seller's
balance only from `credits`.
**This makes the rounding rule unmeasurable by experiment against our own server.**
Whatever we credit is what the client displays; there is no client-side expectation
to compare against, so there is no oracle. The only evidence that could settle
floor-the-fee (150 → 143) versus floor-the-proceeds (150 → 142) is an original
EA-era capture of a seller's balance across a known-price sale, which we do not
have.
Accordingly the rule stays a **documented choice**: fee = `floor(gross × 5 / 100)`,
proceeds = `gross fee`, chosen because `fee + proceeds == gross` holds exactly at
every input. It is pinned by regression tests at 0, 1, 19, 20, 21, 39, 40, 100, 101,
119, 120, 149, 150, 151, 199, 200, 1 000, 15 000, 15 000 000 and `i64::MAX`, so it
cannot drift silently.
## 10. What would close the remaining gaps
| Gap | Only remaining route |
|---|---|
| `highest` vs `buyNow` on a seller's sold row | AVM1 disassembly of `tradepile.isInActiveAuction` / `PreCheckCardOptions`, or a live behavioural A/B (the movie gets `YOURBID` verbatim, so the two ARE separable by the client's behaviour) |
| What `sold` should count | live observation with a real sold row |
| Whether sold rows persist until cleared | live: does the client issue `DELETE .../trade/sold`? |
| The 5% rounding rule | an original EA-era seller-balance capture; nothing in reach |