# 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 |