571c5f9261
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.
242 lines
11 KiB
Markdown
242 lines
11 KiB
Markdown
# 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 |
|