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.
This commit is contained in:
funman300
2026-08-18 01:31:27 +00:00
parent cb32fe9b84
commit 571c5f9261
19 changed files with 2059 additions and 4 deletions
+241
View File
@@ -0,0 +1,241 @@
# 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 |