feat(fifa17): map training subtypes to attribute slots
Which attribute a FIFA 17 training card trains is recoverable after all -- not from a table, but from the client's own dispatch: `FUN_18013f4d0` derives a consumable's whole presentation from `cardsubtypeid` and writes an attribute selector to `rec+0xbc`. Paired with `fcc_trainingcards.amount` (TABLE_PROVEN, matching the wire 8/8), that settles both halves of the effect for all 36 attribute training cards -- 12 families x 5/10/15, 18 goalkeeper and 18 outfield. The subtype order is NOT the slot order: 54 is SPEED at slot 4 while 56 is REFLEXES at slot 3, and 65 is HEADING at slot 5 while 66 is DEFENDING at slot 4. Reading them sequentially trains a different stat from the one on the card, invisibly, so the table is explicit and a test pins those four. The two SQUAD training cards (57, 67) share the table and the client's training UI bucket but are the only ones whose single-target byte is 0: they act on a squad and move fitness, not an attribute. They resolve to None and fail closed rather than being mistaken for a +3 attribute card. A keeper's six slots mean DIV/HAN/KIC/REF/SPD/POS and an outfielder's mean PAC/SHO/PAS/DRI/DEF/PHY -- same numbers, different attributes -- so the class gate is not cosmetic.
This commit is contained in:
@@ -27,3 +27,4 @@ pub mod squad_ext;
|
||||
pub mod squad_projection;
|
||||
pub mod store_catalog;
|
||||
pub mod store_session;
|
||||
pub mod training_cards;
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! FIFA 17 attribute training cards: which attribute a card trains, and by how
|
||||
//! much.
|
||||
//!
|
||||
//! ## Where this comes from
|
||||
//!
|
||||
//! Two independent shipped sources, no invention:
|
||||
//!
|
||||
//! * **which attribute** — `cardsubtypeid`. `FUN_18013f4d0` derives a
|
||||
//! consumable's whole presentation from that one field, and for the training
|
||||
//! families it writes an attribute selector to `rec+0xbc` and the magnitude to
|
||||
//! `rec+0xbf`. The selector per subtype is recorded in
|
||||
//! `fifa17-recon/data/consumables.json` (`subtypes[].bc`, with the client's own
|
||||
//! `FUT_UC_*` / `FUT_MC_*` string for each). STATIC_REVERSED.
|
||||
//! * **how much** — `fcc_trainingcards.amount`, EA's shipped table. Every owned
|
||||
//! consumable's wire `amount` matches that column 8/8. TABLE_PROVEN.
|
||||
//!
|
||||
//! ## Why the effect is ours to define at all
|
||||
//!
|
||||
//! No binary in the FIFA 17 install reads `fcc_trainingcards` at any casing, so
|
||||
//! unlike quick-sell (`fcc_discardcoins`, which the client DOES read) there is no
|
||||
//! client-side oracle for a consumable effect and never will be. The client ACKs
|
||||
//! an apply on transport code alone and then re-reads state. Whatever the server
|
||||
//! durably stores and re-serves IS what the player sees. That makes the
|
||||
//! *magnitude* and the *target attribute* recoverable facts — the two above — and
|
||||
//! everything about the effect's LIFECYCLE a server policy we must state
|
||||
//! explicitly rather than pretend to have reversed. See
|
||||
//! `TRAINING_MATCH_EXPIRY` below.
|
||||
//!
|
||||
//! ## Slot numbering
|
||||
//!
|
||||
//! The `attribute_index` this module produces is a slot in CORE's six-attribute
|
||||
//! model (0 pace, 1 shooting, 2 passing, 3 dribbling, 4 defending, 5 physical),
|
||||
//! not a FIFA attribute id. A goalkeeper's six attributes occupy those same six
|
||||
//! slots on the wire — DIV/HAN/KIC/REF/SPD/POS in that order — which is why a GK
|
||||
//! card and an outfield card can share one slot vocabulary.
|
||||
|
||||
/// Which class of player a training card may be applied to.
|
||||
///
|
||||
/// FIFA 17 authors the two families separately (`FUT_UC_*` for keepers,
|
||||
/// `FUT_MC_*` for outfielders) and their slots mean different attributes, so
|
||||
/// applying one to the wrong class would silently train the wrong stat.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrainingClass {
|
||||
Goalkeeper,
|
||||
Outfield,
|
||||
}
|
||||
|
||||
/// A resolved training effect: one attribute slot, 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,
|
||||
pub amount: i64,
|
||||
}
|
||||
|
||||
/// The largest magnitude EA authors for an 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.
|
||||
pub const TRAINING_MAX_AMOUNT: i64 = 15;
|
||||
|
||||
/// GK attribute training subtypes → Core attribute slot.
|
||||
///
|
||||
/// The client's own order is DIV, HAN, KIC, REF, SPD, POS, and `bc` follows it;
|
||||
/// note that the subtype ids do NOT (54 is SPEED at slot 4, 56 is REFLEXES at
|
||||
/// slot 3). Reading these off in subtype order instead of `bc` order is exactly
|
||||
/// the mistake this table exists to prevent.
|
||||
const GK_TRAINING: &[(i64, i64)] = &[
|
||||
(51, 0), // FUT_UC_DIVING
|
||||
(52, 1), // FUT_UC_HANDLING
|
||||
(53, 2), // FUT_UC_KICKING
|
||||
(56, 3), // FUT_UC_REFLEXES
|
||||
(54, 4), // FUT_UC_SPEED
|
||||
(55, 5), // FUT_UC_POSITIONING
|
||||
];
|
||||
|
||||
/// Outfield attribute training subtypes → Core attribute slot.
|
||||
///
|
||||
/// Same trap as the keepers: 65 is HEADING at slot 5 (Core's `physical`) and 66
|
||||
/// is DEFENDING at slot 4.
|
||||
const OUTFIELD_TRAINING: &[(i64, i64)] = &[
|
||||
(61, 0), // FUT_MC_PACE
|
||||
(62, 1), // FUT_MC_SHOOTING
|
||||
(63, 2), // FUT_MC_PASSING
|
||||
(64, 3), // FUT_MC_DRIBBLING
|
||||
(66, 4), // FUT_MC_DEFENDING
|
||||
(65, 5), // FUT_MC_HEADING -> Core's `physical` slot
|
||||
];
|
||||
|
||||
/// The two SQUAD training subtypes, deliberately NOT supported.
|
||||
///
|
||||
/// 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];
|
||||
|
||||
/// Resolve a consumable into a training effect, or `None` if it is not an
|
||||
/// attribute training card.
|
||||
///
|
||||
/// `amount` is the wire/catalog magnitude for the card. It is required: the
|
||||
/// client parser initialises its amount temp to `-1` and reads it signed, so a
|
||||
/// 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
|
||||
.iter()
|
||||
.find(|&&(s, _)| s == subtype)
|
||||
.map(|&(_, slot)| (TrainingClass::Goalkeeper, slot))
|
||||
.or_else(|| {
|
||||
OUTFIELD_TRAINING
|
||||
.iter()
|
||||
.find(|&&(s, _)| s == subtype)
|
||||
.map(|&(_, slot)| (TrainingClass::Outfield, slot))
|
||||
})?;
|
||||
|
||||
let amount = amount?;
|
||||
if !(1..=TRAINING_MAX_AMOUNT).contains(&amount) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(TrainingEffect {
|
||||
class,
|
||||
attribute_index,
|
||||
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
|
||||
/// its own id, so the distinction is exactly "is the target a keeper".
|
||||
pub fn class_accepts_position(class: TrainingClass, position: &str) -> bool {
|
||||
let is_gk = position.eq_ignore_ascii_case("GK");
|
||||
match class {
|
||||
TrainingClass::Goalkeeper => is_gk,
|
||||
TrainingClass::Outfield => !is_gk,
|
||||
}
|
||||
}
|
||||
|
||||
/// What clears an applied training effect, if anything.
|
||||
///
|
||||
/// UNKNOWN, and deliberately recorded as a constant so it cannot be quietly
|
||||
/// assumed. FIFA 17 ships no table describing a training lifetime, the client
|
||||
/// holds no consumable-effect logic to reverse one from, and "training is
|
||||
/// temporary in FUT" is a recollection about other titles, not evidence about
|
||||
/// this one. Until an experiment settles it, an applied effect PERSISTS, and no
|
||||
/// code decrements or expires it.
|
||||
pub const TRAINING_MATCH_EXPIRY: &str = "UNKNOWN";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The slot must come from `bc`, never from the subtype's ordinal position.
|
||||
/// 54/56 (keeper) and 65/66 (outfield) are the pairs that catch a
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Every attribute training subtype resolves, and the two families cover
|
||||
/// Core's six slots exactly once each.
|
||||
#[test]
|
||||
fn both_families_cover_all_six_slots_exactly_once() {
|
||||
for (family, subtypes) in [
|
||||
(TrainingClass::Goalkeeper, GK_TRAINING),
|
||||
(TrainingClass::Outfield, OUTFIELD_TRAINING),
|
||||
] {
|
||||
let mut slots: Vec<i64> = subtypes
|
||||
.iter()
|
||||
.map(|&(s, _)| {
|
||||
let e = training_effect(s, Some(5)).expect("subtype resolves");
|
||||
assert_eq!(e.class, family);
|
||||
e.attribute_index
|
||||
})
|
||||
.collect();
|
||||
slots.sort_unstable();
|
||||
assert_eq!(slots, vec![0, 1, 2, 3, 4, 5]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn squad_training_subtypes_are_not_attribute_training() {
|
||||
for &s in SQUAD_TRAINING_SUBTYPES {
|
||||
assert_eq!(training_effect(s, Some(3)), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// A missing magnitude is a refusal, not a zero: the client reads the byte
|
||||
/// signed from a -1 initial value.
|
||||
#[test]
|
||||
fn a_missing_or_impossible_amount_refuses() {
|
||||
assert_eq!(training_effect(52, None), None);
|
||||
assert_eq!(training_effect(52, Some(0)), None);
|
||||
assert_eq!(training_effect(52, Some(-1)), None);
|
||||
assert_eq!(training_effect(52, Some(TRAINING_MAX_AMOUNT + 1)), None);
|
||||
}
|
||||
|
||||
/// Only the shipped magnitudes are accepted, and all three are.
|
||||
#[test]
|
||||
fn the_three_authored_magnitudes_all_resolve() {
|
||||
for a in [5, 10, 15] {
|
||||
assert_eq!(training_effect(61, Some(a)).unwrap().amount, a);
|
||||
}
|
||||
}
|
||||
|
||||
/// Family/target gating is the whole reason `class` exists.
|
||||
#[test]
|
||||
fn each_family_accepts_only_its_own_target_class() {
|
||||
assert!(class_accepts_position(TrainingClass::Goalkeeper, "GK"));
|
||||
assert!(!class_accepts_position(TrainingClass::Goalkeeper, "ST"));
|
||||
assert!(class_accepts_position(TrainingClass::Outfield, "ST"));
|
||||
assert!(!class_accepts_position(TrainingClass::Outfield, "GK"));
|
||||
// The wire's casing is not guaranteed to be ours.
|
||||
assert!(class_accepts_position(TrainingClass::Goalkeeper, "gk"));
|
||||
}
|
||||
|
||||
/// A non-training consumable must never resolve here — contracts (201/202),
|
||||
/// healing (211-218), fitness (219/220), position (91-110) and play styles
|
||||
/// (250-273) all share the consumable space.
|
||||
#[test]
|
||||
fn other_consumable_families_do_not_resolve_as_training() {
|
||||
for s in [201, 202, 211, 218, 219, 220, 91, 110, 250, 271, 300] {
|
||||
assert_eq!(training_effect(s, Some(5)), None, "subtype {s} resolved");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user