fifa17: price quick-sell from the client's own discard table

Quick-sell paid an invented five-tier rating ladder (its own comment said
"PLACEHOLDER, not EA-authentic"). It was blind to card type and rareflag, so a
94-rated TOTW special and a 94-rated gold common both sold for 1500, and every
non-player -- whose Core overall is 0 -- sold for the flat 150 floor. The ladder
existed in three places (adapter wire, host payout, an integration test's private
copy), which is a drift waiting to happen.

Add openfut-adapter-fifa17::fut::discard: the client's own fcc_discardcoins
table and its formula, round_half_up(rating * price / 100), keyed
(cardtype, level, rare). All of it is already reversed in
plan-2026-08-05-store-subsystem.md 3.6 and was verified there against 22 live
club items, 22 of 22 exact. DISCARD_COINS is generated from
fifa17-recon/data/tables/fcc_discardcoins.json and a test re-reads that file and
asserts row-for-row agreement, so the transcription cannot drift.

Collapse the three ladders into one method. ItemIdentityResolver::discard_value
both stamps the wire discardValue and prices the sale, because a non-zero
discardValue suppresses the client's local computation -- whatever is sent is
what the player is promised. The host's quick_sell_value is deleted and the
integration test's copy now calls the single implementation. A test with a
resolver double returning an impossible price proves the credit follows the wire;
reverting the payout to a ladder fails it.

Gated on OPENFUT_FIFA17_DISCARD_TABLE=1, default off: switching revalues the real
1991-item club 10.5x (1,820,400 -> 19,128,955 coins if wholly liquidated), up for
specials and DOWN for consumables, which the ladder overpaid 5.5x. That is an
operator's decision.

Staff decline to the ladder rather than pay 0: the client re-rates cardtypes
2/3/4/5/10 from its own DB and their rating is not imported. Deliberately not
guessed -- see the falsifier in the doc.

Verified on staging with the real club, both modes: flag off 1500 wire / 1500
paid; flag on 23760 wire / 23760 paid on an r99 rareflag-11 card (99*24000/100).
Consumables price from their catalog rating and agree with the client's own
computation. Adapter 244 lib tests, host 121 lib + 45 host_test, fmt and clippy
clean.
This commit is contained in:
funman300
2026-08-21 22:40:04 +00:00
parent 274838cc2e
commit 49b18dd4ac
10 changed files with 693 additions and 35 deletions
@@ -54,7 +54,7 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
match ident.kind_of(item) {
ContentKind::Player => match ident.resolve(item) {
Some(id) => {
out.push(shape_item(item, id, ent));
out.push(shape_item(item, id, ent, ident.discard_value(item)));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
+380
View File
@@ -0,0 +1,380 @@
//! FIFA 17 **discard (quick-sell) pricing** — the client's OWN table and formula.
//!
//! Nothing here is invented. The client computes a card's discard value locally
//! whenever the server sends `discardValue == 0` (`FUN_18013fe00` stores our
//! value at item `+0x38`; `0x180141025` `cmp dword [rbp+0x198],0` / `ja` skips
//! the local path when it is non-zero). The local path runs
//!
//! ```sql
//! SELECT "price" FROM "fcc_discardcoins" WHERE "cardtype"==? AND "level"==? AND "rare"==?
//! ```
//!
//! and then, at `0x180141119..0x180141140`:
//!
//! ```text
//! value = (rating * price) / 100, rounded half up
//! ```
//!
//! Sources, all in-repo:
//! * `fifa17-recon/docs/plan-2026-08-05-store-subsystem.md` §3.6 — the SQL, the
//! formula, the `cardsubtypeid -> cardtype` decode (checked against every
//! subtype `0..599` with zero disagreements) and the `level` ladder.
//! * `fifa17-recon/data/tables/fcc_discardcoins.json` — the 141-row table itself.
//! [`DISCARD_COINS`] is generated from that file and a test re-reads the file
//! and asserts they still agree row for row, so the two cannot drift.
//!
//! The reversal was **verified against 22 live club items, 22 of 22 exact**.
//!
//! A key that is not in the table pays `0` (the client's price register stays 0),
//! so [`table_price`] returns `0` rather than panicking or substituting a floor.
/// `(cardtype, level, rare, price)`, generated verbatim from the client's
/// `fcc_discardcoins` table. Sorted; keys are unique.
const DISCARD_COINS: [(u8, u8, u8, i64); 141] = [
(1, 1, 0, 30),
(1, 1, 1, 75),
(1, 1, 2, 2000),
(1, 1, 3, 2000),
(1, 1, 4, 6000),
(1, 1, 5, 20000),
(1, 1, 6, 20000),
(1, 1, 7, 1500),
(1, 1, 8, 6000),
(1, 1, 9, 6000),
(1, 1, 10, 2000),
(1, 1, 11, 10000),
(1, 1, 12, 120000),
(1, 1, 13, 2000),
(1, 1, 17, 2000),
(1, 1, 18, 2000),
(1, 1, 19, 2000),
(1, 1, 20, 2000),
(1, 1, 21, 2000),
(1, 1, 22, 2000),
(1, 1, 23, 2000),
(1, 1, 24, 2000),
(1, 1, 25, 2000),
(1, 1, 26, 2000),
(1, 1, 27, 2000),
(1, 1, 28, 2000),
(1, 1, 29, 2000),
(1, 1, 30, 2000),
(1, 1, 31, 2000),
(1, 2, 0, 150),
(1, 2, 1, 350),
(1, 2, 2, 7000),
(1, 2, 3, 7000),
(1, 2, 4, 10000),
(1, 2, 5, 40000),
(1, 2, 6, 40000),
(1, 2, 7, 5000),
(1, 2, 8, 10000),
(1, 2, 9, 10000),
(1, 2, 10, 7000),
(1, 2, 11, 15000),
(1, 2, 12, 120000),
(1, 2, 13, 7000),
(1, 2, 17, 7000),
(1, 2, 18, 7000),
(1, 2, 19, 7000),
(1, 2, 20, 7000),
(1, 2, 21, 7000),
(1, 2, 22, 7000),
(1, 2, 23, 7000),
(1, 2, 24, 7000),
(1, 2, 25, 7000),
(1, 2, 26, 7000),
(1, 2, 27, 7000),
(1, 2, 28, 7000),
(1, 2, 29, 7000),
(1, 2, 30, 7000),
(1, 2, 31, 7000),
(1, 3, 0, 400),
(1, 3, 1, 800),
(1, 3, 2, 12200),
(1, 3, 3, 12200),
(1, 3, 4, 18000),
(1, 3, 5, 80000),
(1, 3, 6, 80000),
(1, 3, 7, 9000),
(1, 3, 8, 18000),
(1, 3, 9, 18000),
(1, 3, 10, 12200),
(1, 3, 11, 24000),
(1, 3, 12, 120000),
(1, 3, 13, 12200),
(1, 3, 17, 12200),
(1, 3, 18, 12200),
(1, 3, 19, 12200),
(1, 3, 20, 12200),
(1, 3, 21, 12200),
(1, 3, 22, 12200),
(1, 3, 23, 12200),
(1, 3, 24, 12200),
(1, 3, 25, 12200),
(1, 3, 26, 12200),
(1, 3, 27, 12200),
(1, 3, 28, 12200),
(1, 3, 29, 12200),
(1, 3, 30, 12200),
(1, 3, 31, 12200),
(2, 1, 0, 20),
(2, 1, 1, 25),
(2, 2, 0, 70),
(2, 2, 1, 120),
(2, 3, 0, 110),
(2, 3, 1, 320),
(3, 1, 0, 10),
(3, 1, 1, 50),
(3, 2, 0, 55),
(3, 2, 1, 100),
(3, 3, 0, 110),
(3, 3, 1, 300),
(4, 1, 0, 10),
(4, 1, 1, 50),
(4, 2, 0, 55),
(4, 2, 1, 100),
(4, 3, 0, 110),
(4, 3, 1, 300),
(5, 1, 0, 10),
(5, 1, 1, 50),
(5, 2, 0, 55),
(5, 2, 1, 100),
(5, 3, 0, 110),
(5, 3, 1, 300),
(6, 1, 0, 5),
(6, 1, 1, 20),
(6, 2, 0, 20),
(6, 2, 1, 50),
(6, 3, 0, 40),
(6, 3, 1, 70),
(7, 1, 0, 5),
(7, 1, 1, 20),
(7, 2, 0, 20),
(7, 2, 1, 50),
(7, 3, 0, 40),
(7, 3, 1, 70),
(8, 1, 0, 5),
(8, 1, 1, 20),
(8, 2, 0, 20),
(8, 2, 1, 50),
(8, 3, 0, 40),
(8, 3, 1, 70),
(9, 1, 0, 5),
(9, 1, 1, 20),
(9, 2, 0, 20),
(9, 2, 1, 50),
(9, 3, 0, 40),
(9, 3, 1, 70),
(10, 1, 0, 10),
(10, 1, 1, 50),
(10, 2, 0, 55),
(10, 2, 1, 100),
(10, 3, 0, 110),
(10, 3, 1, 300),
];
/// The `cardsubtypeid -> cardtype` decode (`FUN_1800d8330`, read out of its raw
/// two-level jump table). `0` = no table row, which prices at `0`.
///
/// The staff arms agree independently with
/// [`super::content_taxonomy::staff_role`]'s family selector (4=manager,
/// 5=headcoach, 6=gkcoach, 7=physio, 8=fitnesscoach) and with the five card
/// tables the client re-queries for those cardtypes — see [`client_rerates`].
pub fn cardtype_for_subtype(subtype: i64) -> u8 {
match subtype {
0..=3 => 1,
4 => 2,
5 => 3,
6 => 10,
7 => 5,
8 => 4,
9..=11 => 7,
30 | 31 | 145..=150 | 231..=233 | 236 => 9,
51..=136 | 201..=220 | 250..=273 | 300..=341 => 6,
_ => 0,
}
}
/// Discard `level` from rating: `3` if `>= 75`, `2` if `65..=74`, else `1`.
///
/// Derived purely from rating at the tail of `FUN_180141660`
/// (`0x180141e8a..0x180141ea3`). It is NOT a wire field — the slot at item
/// `+0x54` is never written through the deserializer's frame.
pub const fn discard_level(rating: u8) -> u8 {
if rating >= 75 {
3
} else if rating >= 65 {
2
} else {
1
}
}
/// Whether the client OVERWRITES the rating and rare flag we send with values
/// from its own card database before pricing.
///
/// True for cardtypes 2, 3, 4, 5 and 10 (the staff families — it re-queries
/// `managercards`, `headcoachcards`, `fitnesscoachcards`, `physiocards` and
/// `gkcoachcards` by `carddbid`). For cardtypes 6, 7, 8 and 9 the jump table at
/// rva `0x141eb4` goes straight to the default arm with no DB query and no
/// overwrite, so for consumables and club items the server's values are
/// authoritative.
pub const fn client_rerates(cardtype: u8) -> bool {
matches!(cardtype, 2 | 3 | 4 | 5 | 10)
}
/// `fcc_discardcoins` price for a key, or `0` when the table has no such row.
pub fn table_price(cardtype: u8, level: u8, rare: i64) -> i64 {
if !(0..=255).contains(&rare) {
return 0;
}
let rare = rare as u8;
DISCARD_COINS
.iter()
.find(|&&(c, l, r, _)| c == cardtype && l == level && r == rare)
.map_or(0, |&(_, _, _, price)| price)
}
/// The client's discard value for a card: `round_half_up(rating * price / 100)`.
///
/// Returns `0` for a key the table does not carry, exactly as the client does.
pub fn discard_value(cardtype: u8, rating: u8, rare: i64) -> i64 {
let price = table_price(cardtype, discard_level(rating), rare);
if price == 0 {
return 0;
}
(i64::from(rating) * price + 50) / 100
}
#[cfg(test)]
mod tests {
use super::*;
/// The generated table MUST still equal the client's own file, row for row.
/// This is what makes [`DISCARD_COINS`] a transcription of evidence rather
/// than a hand-authored economy.
#[test]
fn the_table_still_matches_the_clients_own_file() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../fifa17-recon/data/tables/fcc_discardcoins.json"
);
let raw = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("client discard table missing at {path}: {e}"));
let doc: serde_json::Value = serde_json::from_str(&raw).expect("table is JSON");
assert_eq!(doc["table"], "fcc_discardcoins");
let declared = doc["rowcount"].as_u64().expect("rowcount") as usize;
let rows = doc["rows"].as_array().expect("rows array");
assert_eq!(rows.len(), declared, "file disagrees with its own rowcount");
let mut from_file: Vec<(u8, u8, u8, i64)> = rows
.iter()
.map(|r| {
let g = |k: &str| r.get(k).and_then(serde_json::Value::as_i64).unwrap();
(
g("cardtype") as u8,
g("level") as u8,
g("rare") as u8,
g("price"),
)
})
.collect();
from_file.sort_unstable();
let mut ours = DISCARD_COINS.to_vec();
ours.sort_unstable();
assert_eq!(
ours, from_file,
"generated table drifted from the client file"
);
assert_eq!(from_file.len(), 141);
}
/// The four worked examples stated in the reversal, which was checked
/// against 22 live club items.
#[test]
fn the_documented_live_verified_prices_reproduce() {
// "A gold rare player is 8 * rating: 75 gives 600, 94 gives 752."
assert_eq!(discard_value(1, 94, 1), 752);
assert_eq!(discard_value(1, 75, 1), 600);
for rating in 75..=99u8 {
assert_eq!(discard_value(1, rating, 1), 8 * i64::from(rating));
}
// "A gold common is 4 * rating."
for rating in 75..=99u8 {
assert_eq!(discard_value(1, rating, 0), 4 * i64::from(rating));
}
// "A 50-rated bronze common is 15."
assert_eq!(discard_value(1, 50, 0), 15);
}
/// "A key we do not have pays 0, so use a `.get(key, 0)`, not a subscript."
#[test]
fn an_absent_key_pays_zero_and_never_a_floor() {
// rare 14, 15, 16 are absent for cardtype 1 ...
for rare in [14, 15, 16] {
assert_eq!(table_price(1, 3, rare), 0);
assert_eq!(discard_value(1, 94, rare), 0);
}
// ... and cardtypes 2..=10 carry only rare 0 and 1.
for cardtype in 2..=10u8 {
assert_eq!(table_price(cardtype, 3, 2), 0);
}
// A subtype outside every documented range decodes to cardtype 0.
assert_eq!(cardtype_for_subtype(600), 0);
assert_eq!(discard_value(0, 94, 1), 0);
}
#[test]
fn the_level_ladder_is_the_clients() {
assert_eq!(discard_level(74), 2);
assert_eq!(discard_level(75), 3);
assert_eq!(discard_level(65), 2);
assert_eq!(discard_level(64), 1);
assert_eq!(discard_level(0), 1);
}
/// Rounding is half UP, not truncation: 66 * 55 / 100 = 36.3 -> 36, but
/// 67 * 55 / 100 = 36.85 -> 37.
#[test]
fn the_rounding_is_half_up() {
assert_eq!(discard_value(10, 66, 0), 36);
assert_eq!(discard_value(10, 67, 0), 37);
}
/// The subtype decode must agree with the settled club-item subtypes and the
/// staff family selector this crate already carries.
#[test]
fn the_decode_agrees_with_the_settled_subtypes() {
use crate::fut::content_taxonomy as tax;
// Kit, stadium and badge are cardtype 7 ...
for s in [tax::KIT_SUBTYPE, tax::STADIUM_SUBTYPE, tax::BADGE_SUBTYPE] {
assert_eq!(cardtype_for_subtype(s), 7, "subtype {s}");
}
// ... ball and league logo are cardtype 9 ...
for s in [tax::BALL_SUBTYPE, tax::LEAGUE_LOGO_SUBTYPE] {
assert_eq!(cardtype_for_subtype(s), 9, "subtype {s}");
}
// ... and `fcc_misccards` is cardtype 9 too.
for s in [231, 232, 233, 236] {
assert_eq!(cardtype_for_subtype(s), 9, "subtype {s}");
}
// A manager is cardtype 2, and every staff subtype is one the client
// re-rates from its own database.
assert_eq!(cardtype_for_subtype(tax::MANAGER_SUBTYPE), 2);
for subtype in 4..=8 {
assert!(
client_rerates(cardtype_for_subtype(subtype)),
"staff subtype {subtype} must be client-re-rated"
);
assert!(tax::staff_role(subtype).is_some());
}
// Consumables are cardtype 6, and the server's values ARE authoritative
// for them.
for subtype in [52, 54, 92, 98, 100] {
assert_eq!(cardtype_for_subtype(subtype), 6);
assert!(!client_rerates(6));
}
}
}
+37 -4
View File
@@ -208,6 +208,25 @@ pub trait ItemIdentityResolver {
None
}
/// The card's discard (quick-sell) value in coins — BOTH the number the
/// client displays on the card and the number the server MUST credit when
/// it is sold. One method serves both so the wire and the wallet cannot
/// disagree: a non-zero `discardValue` suppresses the client's own local
/// computation (`0x180141025`), so whatever is sent here is what the player
/// is promised.
///
/// NON-MINTING by contract, like [`Self::subtype_of`] — it takes the Core
/// item, never a resolved [`Fifa17Identity`], so pricing a card on a read
/// path cannot allocate a wire id.
///
/// The default is the [`legacy_discard_value`] placeholder ladder, which
/// preserves the behaviour of every resolver without a catalog behind it.
/// The catalog-backed FIFA17 resolver overrides it with the client's own
/// `fcc_discardcoins` table (see [`super::discard`]).
fn discard_value(&self, item: &CoreOwnedItem) -> i64 {
legacy_discard_value(item.rating)
}
/// The FIFA `cardsubtypeid` of a Core item's definition, or `0` when unknown
/// or a player. NON-MINTING by contract: `/club`'s per-family filters call it
/// for every owned row, so allocating a wire id here would pollute the
@@ -245,9 +264,17 @@ pub struct ShapeStats {
pub excluded_non_player: usize,
}
/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table;
/// non-fatal display field).
fn discard_value(rating: u8) -> i64 {
/// The ORIGINAL rating-only quick-sell ladder. **A placeholder, not
/// EA-authentic**: it is blind to both card type and `rareflag`, so it prices a
/// 94-rated TOTW special and a 94-rated gold common identically, and it pays a
/// flat floor for every non-player (whose Core rating is 0).
///
/// The client's real value is `round_half_up(rating * fcc_discardcoins.price /
/// 100)` — see [`super::discard`], which reproduces it exactly. This ladder is
/// retained as the [`ItemIdentityResolver::discard_value`] default so a resolver
/// with no catalog behind it keeps its existing behaviour, and so the deployed
/// economy only changes when an operator opts in.
pub fn legacy_discard_value(rating: u8) -> i64 {
match rating {
r if r >= 85 => 1500,
r if r >= 80 => 900,
@@ -271,6 +298,7 @@ pub fn shape_item(
item: &CoreOwnedItem,
id: Fifa17Identity,
ent: &impl ReverseEntityResolver,
discard_value: i64,
) -> Value {
let asset = id.asset_id;
let league_id = ent.league_id(&item.league).unwrap_or(0);
@@ -308,7 +336,7 @@ pub fn shape_item(
"untradeable": false,
"contract": 7,
"fitness": 99,
"discardValue": discard_value(item.rating),
"discardValue": discard_value,
})
}
@@ -533,6 +561,7 @@ mod tests {
rareflag: 1,
},
&ent,
legacy_discard_value(86),
);
assert_eq!(it["id"], 100000001, "wire instance id");
assert_eq!(it["resourceId"], 20801);
@@ -566,6 +595,7 @@ mod tests {
rareflag: 1,
},
&ent,
legacy_discard_value(84),
);
let b = shape_item(
&item("oc-b", "fifa17_101490", 84, "ST"),
@@ -576,6 +606,7 @@ mod tests {
rareflag: 1,
},
&ent,
legacy_discard_value(84),
);
assert_eq!(
a["resourceId"], b["resourceId"],
@@ -604,6 +635,7 @@ mod tests {
rareflag: 3,
},
&ent,
legacy_discard_value(92),
);
assert_eq!(
it["resourceId"], 117617092,
@@ -708,6 +740,7 @@ mod tests {
rareflag: 1,
},
&ent,
legacy_discard_value(86),
);
emitted.push(player["itemState"].as_str().unwrap().to_string());
let staff = shape_staff_item(
+1
View File
@@ -9,6 +9,7 @@ pub mod club_response;
pub mod club_stats;
pub mod consumables;
pub mod content_taxonomy;
pub mod discard;
pub mod economy;
pub mod economy_policy;
pub mod entities;
@@ -166,7 +166,7 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
.unwrap_or(0);
players.push(json!({
"index": index,
"itemData": shape_item(item, id, ent),
"itemData": shape_item(item, id, ent, ident.discard_value(item)),
"kitNumber": kit,
}));
}