Files
OpenFUT/openfut-adapter-fifa17/src/fut/economy_policy.rs
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

187 lines
7.6 KiB
Rust

//! FIFA 17 economy POLICY mappers (pure, game-specific).
//!
//! These translate FIFA 17 wire semantics into the generic amounts the host
//! feeds to Core economy authority. They own NO state — Core owns balances and
//! inventory; these are the FIFA-specific numbers/derivations. Values are the
//! current OpenFUT economy (match rewards are the Python oracle's
//! `MATCH_COINS`/`MATCH_PARTICIPATION` at production defaults); pack prices come
//! from the Store catalogue.
use crate::fut::store_catalog::pack_by_id;
/// Normalized match outcome for reward purposes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchResult {
Win,
Draw,
Loss,
}
/// Participation award added to every match reward (oracle `MATCH_PARTICIPATION`
/// default = 0).
pub const MATCH_PARTICIPATION: i64 = 0;
/// Per-result match coins (oracle `MATCH_COINS`: won 400 / draw 200 / loss 100).
pub fn match_result_coins(result: MatchResult) -> i64 {
match result {
MatchResult::Win => 400,
MatchResult::Draw => 200,
MatchResult::Loss => 100,
}
}
/// Total match reward = per-result coins + participation.
pub fn match_reward_total(result: MatchResult) -> i64 {
match_result_coins(result) + MATCH_PARTICIPATION
}
/// Derive the outcome from the match `endReason` enum (the oracle's primary
/// signal, `_END_REASON`). Unknown/absent reasons default to `Draw`, matching
/// the oracle's conservative default. Score-based derivation is a fallback the
/// oracle also supports; the enum is authoritative when present.
pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult {
match end_reason.unwrap_or("").to_ascii_uppercase().as_str() {
"WIN" | "DNF_WIN" => MatchResult::Win,
"LOSS" | "QUIT" | "DNF" | "DNF_LOSS" => MatchResult::Loss,
// "DRAW", "DNF_DRAW", "NO_CONTEST", unknown -> draw.
_ => MatchResult::Draw,
}
}
/// The Store buy-now price for a pack id (`None` for unknown/owned-only packs,
/// which are never purchasable).
pub fn pack_price(pack_id: u64) -> Option<u64> {
pack_by_id(pack_id)
.filter(|p| !p.owned_only)
.map(|p| p.price)
}
/// FIFA 17 transfer-market fee, in PERCENT of the gross sale price.
///
/// FIFA17-HISTORICAL: 5% is the well-documented FUT transfer tax of the era. It
/// was not recovered from our client binary — no `tax`/`netPrice`/`sellerProceeds`
/// wire field exists (`docs/FIFA17_TRANSFER_MARKET_WIRE.md`), because the client
/// is told only the GROSS price and the deduction is server-side.
pub const TRANSFER_MARKET_FEE_PERCENT: i64 = 5;
/// Fee withheld from a completed sale of `gross` coins.
///
/// Integer arithmetic only — coin settlement never touches floating point, where
/// `0.05` is not representable and a large price could round a coin into or out of
/// existence. Widening to `i128` for the multiply makes overflow unreachable for
/// any `i64` price, so no ceiling has to be assumed.
///
/// ROUNDING, and it is a CHOICE that needs live confirmation: the fee is FLOORED,
/// so the seller keeps the fractional coin. That is deliberate — it makes
/// `fee + proceeds == gross` hold exactly for every input, which is the property
/// the accounting invariant depends on. The discriminating case against the
/// alternative (flooring the seller's 95% instead) is a gross of 150: this rule
/// pays 143, the alternative 142. Nothing in the corpus settles which the real
/// server did, so this MUST NOT be treated as confirmed FIFA behaviour.
///
/// A negative gross is not a sale; it yields a zero fee rather than inventing a
/// negative one, and `settle_sale` rejects the price itself.
pub fn transfer_market_fee(gross: i64) -> i64 {
if gross <= 0 {
return 0;
}
((gross as i128 * TRANSFER_MARKET_FEE_PERCENT as i128) / 100) as i64
}
/// What the seller is credited for a completed sale of `gross` coins.
///
/// Defined as `gross - fee` rather than as its own percentage, so the pair can
/// never disagree about where a rounded coin went.
pub fn seller_proceeds(gross: i64) -> i64 {
gross.max(0) - transfer_market_fee(gross)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn match_rewards_match_oracle() {
assert_eq!(match_reward_total(MatchResult::Win), 400);
assert_eq!(match_reward_total(MatchResult::Draw), 200);
assert_eq!(match_reward_total(MatchResult::Loss), 100);
}
#[test]
fn end_reason_maps_to_outcome() {
assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss);
assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Loss);
assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw);
assert_eq!(result_from_end_reason(None), MatchResult::Draw);
assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw);
}
#[test]
fn pack_price_rejects_unknown_and_owned_only() {
assert!(pack_price(1).is_some());
assert_eq!(pack_price(65534), None); // sentinel absent from catalogue
assert_eq!(pack_price(70), None); // owned-only reward pack, not purchasable
}
/// Pins the rounding rule at every boundary the fee can turn over. If one of
/// these ever changes, monetary behaviour changed — that must be deliberate.
#[test]
fn transfer_market_fee_is_floored_five_percent() {
// (gross, expected fee, expected proceeds)
let cases = [
(0i64, 0i64, 0i64),
(1, 0, 1), // 0.05 -> 0
(19, 0, 19), // 0.95 -> 0, the last fee-free price
(20, 1, 19), // exactly 1.0, the first price that pays
(21, 1, 20), // 1.05 -> 1
(39, 1, 38), // 1.95 -> 1
(40, 2, 38), // exactly 2.0
(100, 5, 95),
(101, 5, 96), // 5.05 -> 5
(119, 5, 114), // 5.95 -> 5, last price paying 5
(120, 6, 114), // exactly 6.0
(149, 7, 142), // 7.45 -> 7
(150, 7, 143), // 7.5 -> 7: THE discriminating case (alternative: 8/142)
(151, 7, 144), // 7.55 -> 7 (a HALF-UP rule would pay 8 here too)
(199, 9, 190), // 9.95 -> 9
(200, 10, 190), // exactly 10.0
(1_000, 50, 950),
(15_000, 750, 14_250), // the canonical fixture
(15_000_000, 750_000, 14_250_000), // FUT's practical price ceiling
(i64::MAX, i64::MAX / 20, i64::MAX - i64::MAX / 20), // no overflow
];
for (gross, fee, proceeds) in cases {
assert_eq!(transfer_market_fee(gross), fee, "fee for gross {gross}");
assert_eq!(
seller_proceeds(gross),
proceeds,
"proceeds for gross {gross}"
);
}
}
/// The property the whole settlement's accounting rests on: the fee and the
/// seller's proceeds account for the gross EXACTLY, with no coin created or
/// destroyed by rounding, at every price.
#[test]
fn fee_plus_proceeds_is_exactly_gross() {
for gross in (0i64..2_000).chain([15_000, 999_999, 15_000_000, i64::MAX]) {
assert_eq!(
transfer_market_fee(gross) + seller_proceeds(gross),
gross,
"fee + proceeds != gross at {gross}"
);
}
}
/// A non-sale must not invent a negative fee.
#[test]
fn negative_gross_yields_no_fee() {
assert_eq!(transfer_market_fee(-1), 0);
assert_eq!(transfer_market_fee(i64::MIN), 0);
assert_eq!(seller_proceeds(-100), 0);
}
}