fix(fifa17): subtypes 57/67 are the rare all-six training card

They were failing closed as "squad fitness". Four facts say otherwise: each
family holds exactly 21 rows = 7 types x 3 levels matching the published
"6 single attributes + 1 ALL" list; those six rows are the ONLY ones with
weightrare=2 while all 36 single-attribute rows are 0, and the published list
marks ALL rare; their amounts are exactly 3/6/10 against the documented ALL
card's +3/+6/+10; and bc=6 is one past the six real slots, an "all" sentinel,
with c0=0 reading as "not single-ATTRIBUTE" rather than "not single-target".

The misreading came from consumables.json's FUT_FITNESS_UC/MC label, which is
tool-authored -- build_consumables.py names the 7th element of its attribute
array -- and has no documented provenance. The bc/c0 values beside it ARE
reversed; only the name was not. The genuine squad-fitness card is subtype
220 in fcc_healingcards (10/20/30) and player fitness is 219; that family
stays unsupported.

The two families carry different authored ceilings, 15 single-attribute and
10 all-six, so ceiling_for() reports the right one per effect -- sending the
single-attribute ceiling for an all-six card would let a +15 all-six boost
through, granting 90 attribute points from a card worth 60.

Also corrects ENDPOINT_MAP row 7: ApplyCardByRes carries urlIndex 0x0e, which
resolves to ut/%s/item/resource, and the live verb is POST. The row claimed
PUT ut/%s/item for both apply RPCs, and that conflation is what kept the
"apply must ride PUT ut/%s/item" hypothesis alive until the POST capture
settled it -- every observed PUT ut/%s/item is a pile move.
This commit is contained in:
funman300
2026-08-22 23:33:43 +00:00
parent a9bac8be8e
commit 3bb6b4fc9d
2 changed files with 114 additions and 32 deletions
+12 -1
View File
@@ -939,7 +939,18 @@ freezes any of these — GAPs are "feature missing", not "crash".
| 4 | FutViewCards | `0x1801293d0` | GET `ut/%s/item` | `itemData`(0x16b) → **array[card-item]** via `0x18013fe00` [FREEZE-RISK] | HANDLED (utas `/item` `defs_route` serves `itemData`) | HIGH |
| 5 | FutActivateCard | `0x1801642c0` | PUT `ut/%s/item` (FUT_CLUB_ACTIVATE_ITEM_DP) | **none** (immediate `ret`) | ack — `{}` fine | HIGH |
| 6 | FutApplyCard | `0x18012a710` | PUT `ut/%s/item` (apply by itemId) | `itemData`(0x16b) → **array[updated card-item]** via `0x18013fe00` [FREEZE-RISK] | GAP | HIGH |
| 7 | FutApplyCardByRes | `0x18012ad10` | PUT `ut/%s/item` (apply by resourceId) | `itemData`(0x16b) → **array[updated card-item]** [FREEZE-RISK] | GAP | HIGH |
| 7 | FutApplyCardByRes | `0x18012ad10` | **POST** `ut/%s/item/resource/<rid>` (apply by resourceId) | `itemData`(0x16b) → **array[updated card-item]** [FREEZE-RISK] | **SERVED** (Rust host, contracts + attribute training) | HIGH |
> **Rows 6 and 7 are NOT the same route.** `ApplyCardByRes` carries urlIndex
> `0x0e`, which resolves to `ut/%s/item/resource` — not `ut/%s/item`
> (`plan-2026-08-05-pack-opening.md:505-506`, shared with `DiscardCardByRes` and
> `MoveCardByRes`). The verb is **POST**, live-proven by a real-client capture:
> `POST /ut/game/fifa17/item/resource/5001004` `{"apply":[{"id":100000003}]}`.
> This row previously read `PUT ut/%s/item` for both, and that conflation is what
> kept the "apply must ride `PUT ut/%s/item`" hypothesis alive
> (`CLIENT_ROUTE_SURFACE.md:104-106`) until the POST capture settled it — every
> observed `PUT ut/%s/item` is a pile MOVE, never an apply.
| 8 | FutDiscardCard | `0x180127300` | DELETE `ut/delete/%s/item` (CardsDiscardCard) | `items`(0x171) → **array[int ids]** [FREEZE-RISK]; `totalCredits`(0x326) → int; `id`(0x15c) → int | GAP | HIGH |
| 9 | FutDiscardCardByRes | `0x1801279c0` | DELETE `ut/delete/%s/item` (by res) | `totalCredits`(0x326) → int | GAP | HIGH |
| 10 | FutMoveCard | `0x180128600` | PUT `ut/%s/item` (move) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool | GAP | HIGH |
+102 -31
View File
@@ -45,24 +45,31 @@ pub enum TrainingClass {
Outfield,
}
/// A resolved training effect: one attribute slot, one magnitude, one legal
/// target class.
/// A resolved training effect: one attribute slot (or all six), one magnitude,
/// one legal target class.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TrainingEffect {
pub class: TrainingClass,
/// Slot in Core's six-attribute model.
pub attribute_index: i64,
/// Slot in Core's six-attribute model, or `None` for the rare card that
/// boosts ALL SIX.
pub attribute_index: Option<i64>,
pub amount: i64,
}
/// The largest magnitude EA authors for an attribute training card.
/// The largest magnitude EA authors for a SINGLE-attribute training card.
///
/// `fcc_trainingcards` authors exactly 5, 10 and 15 for every attribute family.
/// It is declared to Core on every apply so Core can refuse a larger boost than
/// any real card could grant, which is what keeps the closed vocabulary from
/// being a blank cheque.
/// `fcc_trainingcards` authors exactly 5, 10 and 15 for all twelve
/// single-attribute families. Declared to Core on every apply so Core can refuse
/// a larger boost than any real card could grant, which is what keeps the closed
/// vocabulary from being a blank cheque.
pub const TRAINING_MAX_AMOUNT: i64 = 15;
/// The largest magnitude EA authors for the RARE all-six training card.
///
/// The two all-six subtypes author 3/6/10 only, so a +15 all-six card does not
/// exist and must not be describable to Core.
pub const TRAINING_ALL_MAX_AMOUNT: i64 = 10;
/// GK attribute training subtypes → Core attribute slot.
///
/// The client's own order is DIV, HAN, KIC, REF, SPD, POS, and `bc` follows it;
@@ -91,15 +98,30 @@ const OUTFIELD_TRAINING: &[(i64, i64)] = &[
(65, 5), // FUT_MC_HEADING -> Core's `physical` slot
];
/// The two SQUAD training subtypes, deliberately NOT supported.
/// The RARE training cards, which boost ALL SIX attributes at once.
///
/// 57 (`FUT_FITNESS_UC`) and 67 (`FUT_FITNESS_MC`) sit in the client's training
/// UI bucket and live in `fcc_trainingcards`, but they are the only two whose
/// `single-target` byte (`rec+0xc0`) is 0: they act on a SQUAD, not on one
/// instance, and they move fitness rather than an attribute. Neither the squad
/// scope nor a fitness model is reversed, so they fail closed rather than being
/// mistaken for a +3 attribute card.
pub const SQUAD_TRAINING_SUBTYPES: &[i64] = &[57, 67];
/// These were previously mistaken for "squad fitness" on the strength of the
/// reversed label `FUT_FITNESS_UC` / `FUT_FITNESS_MC`. That label is
/// tool-authored (`build_consumables.py` names the 7th element of its attribute
/// array) and has no documented provenance; four independent facts say all-six:
///
/// * each family holds exactly 21 rows = 7 card types x 3 levels, and the
/// published FIFA 17 card list is 6 single attributes + 1 "ALL";
/// * these six rows are the ONLY ones in the table with `weightrare = 2`; all 36
/// single-attribute rows are `weightrare = 0`. The published list marks the
/// ALL card RARE and every single-attribute card non-rare;
/// * their amounts are exactly 3/6/10, matching the published ALL card's
/// +3 bronze / +6 silver / +10 gold, while single attributes are 5/10/15;
/// * `bc = 6` is one past the six real slots (0..5) -- an "all" sentinel -- and
/// `c0 = 0` reads as "not single-ATTRIBUTE", not "not single-target".
///
/// The genuine squad-fitness card is subtype 220 in a DIFFERENT table
/// (`fcc_healingcards`, amounts 10/20/30), and player fitness is 219 — that
/// family is separately identified and remains unsupported.
const ALL_ATTRIBUTE_TRAINING: &[(i64, TrainingClass)] = &[
(57, TrainingClass::Goalkeeper),
(67, TrainingClass::Outfield),
];
/// Resolve a consumable into a training effect, or `None` if it is not an
/// attribute training card.
@@ -109,19 +131,25 @@ pub const SQUAD_TRAINING_SUBTYPES: &[i64] = &[57, 67];
/// missing magnitude is not "zero", it is a card that would draw and grant
/// nonsense. Absent or out-of-range, this refuses.
pub fn training_effect(subtype: i64, amount: Option<i64>) -> Option<TrainingEffect> {
let (class, attribute_index) = GK_TRAINING
let (class, attribute_index, ceiling) = GK_TRAINING
.iter()
.find(|&&(s, _)| s == subtype)
.map(|&(_, slot)| (TrainingClass::Goalkeeper, slot))
.map(|&(_, slot)| (TrainingClass::Goalkeeper, Some(slot), TRAINING_MAX_AMOUNT))
.or_else(|| {
OUTFIELD_TRAINING
.iter()
.find(|&&(s, _)| s == subtype)
.map(|&(_, slot)| (TrainingClass::Outfield, slot))
.map(|&(_, slot)| (TrainingClass::Outfield, Some(slot), TRAINING_MAX_AMOUNT))
})
.or_else(|| {
ALL_ATTRIBUTE_TRAINING
.iter()
.find(|&&(s, _)| s == subtype)
.map(|&(_, class)| (class, None, TRAINING_ALL_MAX_AMOUNT))
})?;
let amount = amount?;
if !(1..=TRAINING_MAX_AMOUNT).contains(&amount) {
if !(1..=ceiling).contains(&amount) {
return None;
}
@@ -132,6 +160,16 @@ pub fn training_effect(subtype: i64, amount: Option<i64>) -> Option<TrainingEffe
})
}
/// The ceiling Core must be told for a resolved effect: the two families author
/// different maxima, and sending the wrong one either lets an impossible boost
/// through or refuses a legitimate card.
pub fn ceiling_for(effect: &TrainingEffect) -> i64 {
match effect.attribute_index {
Some(_) => TRAINING_MAX_AMOUNT,
None => TRAINING_ALL_MAX_AMOUNT,
}
}
/// Whether a target playing in `position` may receive `class` training.
///
/// The client's own `pos` vocabulary numbers GK 0 and gives every outfield role
@@ -163,10 +201,22 @@ mod tests {
/// sequential misreading.
#[test]
fn out_of_order_subtypes_map_to_their_reversed_slots() {
assert_eq!(training_effect(54, Some(10)).unwrap().attribute_index, 4); // SPEED
assert_eq!(training_effect(56, Some(10)).unwrap().attribute_index, 3); // REFLEXES
assert_eq!(training_effect(65, Some(10)).unwrap().attribute_index, 5); // HEADING
assert_eq!(training_effect(66, Some(10)).unwrap().attribute_index, 4); // DEFENDING
assert_eq!(
training_effect(54, Some(10)).unwrap().attribute_index,
Some(4)
); // SPEED
assert_eq!(
training_effect(56, Some(10)).unwrap().attribute_index,
Some(3)
); // REFLEXES
assert_eq!(
training_effect(65, Some(10)).unwrap().attribute_index,
Some(5)
); // HEADING
assert_eq!(
training_effect(66, Some(10)).unwrap().attribute_index,
Some(4)
); // DEFENDING
}
/// Every attribute training subtype resolves, and the two families cover
@@ -183,6 +233,7 @@ mod tests {
let e = training_effect(s, Some(5)).expect("subtype resolves");
assert_eq!(e.class, family);
e.attribute_index
.expect("single-attribute card names a slot")
})
.collect();
slots.sort_unstable();
@@ -190,16 +241,36 @@ mod tests {
}
}
/// The squad-scoped pair share the training table and UI bucket but are not
/// attribute training; resolving them would apply a fitness magnitude to
/// whatever attribute slot 0 happens to be.
/// The rare pair boost ALL SIX attributes, so they resolve with NO slot.
/// Reading them as single-attribute would apply their magnitude to whatever
/// slot 0 happens to be and drop the other five.
#[test]
fn squad_training_subtypes_are_not_attribute_training() {
for &s in SQUAD_TRAINING_SUBTYPES {
assert_eq!(training_effect(s, Some(3)), None);
fn the_rare_cards_boost_all_six_attributes() {
for (subtype, class) in [
(57, TrainingClass::Goalkeeper),
(67, TrainingClass::Outfield),
] {
for amount in [3, 6, 10] {
let e = training_effect(subtype, Some(amount)).expect("rare card resolves");
assert_eq!(e.attribute_index, None, "subtype {subtype} must be all-six");
assert_eq!(e.class, class);
assert_eq!(e.amount, amount);
assert_eq!(ceiling_for(&e), TRAINING_ALL_MAX_AMOUNT);
}
}
}
/// The all-six card authors 3/6/10 only. A +15 all-six card does not exist,
/// and letting one through would grant 90 attribute points from a card that
/// grants at most 60.
#[test]
fn the_rare_card_cannot_carry_a_single_attribute_magnitude() {
assert_eq!(training_effect(57, Some(15)), None);
assert_eq!(training_effect(67, Some(15)), None);
// ...while a single-attribute card still may.
assert_eq!(training_effect(51, Some(15)).unwrap().amount, 15);
}
/// A missing magnitude is a refusal, not a zero: the client reads the byte
/// signed from a -1 initial value.
#[test]