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.
187 lines
7.6 KiB
Rust
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);
|
|
}
|
|
}
|