fix(fifa17): project active club items through squad.actives

FIFA 17 makes a club item resident ONLY through squad.actives. The squad
parser's arm for atom 11 computes the address of the i-th element of the
client's five-element club-item array and hands it to the item
deserializer as the out-handle:

    cmp  edi,0x5                    ; at most five entries are read
    mov  rax,QWORD PTR [r13+0x108]  ; the club-item array
    lea  rcx,[rax+rcx*8]            ; &array[edi]
    call 0x18013fe00                ; item deserializer, writing that slot

That deserializer inserts the record into the client's resident item map
- keyed by wire instance id, gated only on the id being non-zero - and
binds the slot handle to it. So each element must be a full item object
like squad.manager[].itemData; an id reference alone installs nothing,
because the manager installer looks its id up in that same map and does
nothing on a miss.

We emitted actives: [] as an 'observed constant', which was circular: it
came from our own captures and the Python oracle seeded it. The client
then read the array-end token immediately, parsed nothing, and left all
five slots null, so every lookup resolved to the static not-found
sentinel whose item pointer is NULL. That is why the pre-match kit
selector had no kits, and it is also why /club?type=kit could never fix
it: no /club response feeds that array.

Core already owns the designations via /club/active-items, so the host
reuses get_active_kits() and the adapter shapes each entry with the same
shape_club_item primitive /club?type=kit uses, keeping one wire dialect.
A Core transport error yields no actives and is reported rather than
silently empty. userInfo.actives already mirrors the squad's.

Verified against the client's own fcc_kitcards table: 6300006 is team
21's home card (category 2) and 6400003 the away card (category 3).
This commit is contained in:
funman300
2026-08-24 17:59:11 +00:00
parent 2fc335c37d
commit 0e200758f0
3 changed files with 267 additions and 22 deletions
@@ -35,11 +35,14 @@ use std::collections::HashMap;
use serde_json::{json, Value};
use crate::fut::club_response::ActiveKitAssignments;
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::{
shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT,
shape_club_item, shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver,
STAFF_CONTRACT,
};
use crate::fut::item_state;
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
@@ -235,15 +238,75 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
Ok(SquadProjection::Projected(squad))
}
/// The active club items for `squad.actives`, in slot order.
///
/// ## Why this exists, and why it is NOT `[]`
///
/// `actives` is the ONLY carrier that makes a club item resident in FIFA 17.
/// The squad parser's arm for atom 11 (`actives`) computes the address of the
/// i-th element of the client's five-element club-item array and hands it to the
/// item deserializer as the out-handle:
///
/// ```text
/// cmp edi,0x5 ; at most five entries are read
/// jge <skip>
/// mov rax,QWORD PTR [r13+0x108] ; the club-item array
/// lea rcx,[rax+rcx*8] ; &array[edi] (edi * 24)
/// call 0x18013fe00 ; the item deserializer, writing that slot
/// ```
///
/// That deserializer inserts the record into the client's resident item map
/// (keyed by wire instance id, and its only gate is a non-zero id) and binds the
/// slot handle to it. So each element must be a FULL item object, exactly like
/// `squad.manager[].itemData` — an id reference alone installs nothing, because
/// the manager installer looks its id up in that same map and does nothing when
/// it misses.
///
/// An empty array makes the client read the array-end token immediately and
/// parse nothing, which leaves all five slots null. Every later consumer then
/// resolves to the client's static not-found sentinel, whose item pointer is
/// NULL — which is exactly why the pre-match kit selector had no kits.
///
/// Elements are shaped by the shared [`shape_club_item`], the same primitive
/// `/club?type=kit` uses, so the two routes cannot drift. Ordering is positional
/// on the wire but not semantic: both client consumers (the activate path and
/// the store lookup) search the five slots by content — itemState, or
/// cardtype/cardsubtypeid — never by index.
///
/// A designated kit whose owned row or FIFA kit identity cannot be resolved is
/// omitted rather than emitted with a fabricated id, matching `/club`.
pub fn squad_actives<I: ItemIdentityResolver + ?Sized>(
owned: &HashMap<String, CoreOwnedItem>,
ident: &I,
active_kits: ActiveKitAssignments<'_>,
) -> Value {
let mut out = Vec::new();
for (owned_card_id, state) in [
(active_kits.home, item_state::ACTIVE_HOME_KIT),
(active_kits.away, item_state::ACTIVE_AWAY_KIT),
] {
let Some(owned_card_id) = owned_card_id else {
continue;
};
let Some(item) = owned.get(owned_card_id) else {
continue;
};
if let Some(id) = ident.resolve_kit(item) {
out.push(shape_club_item(id, state));
}
}
Value::Array(out)
}
/// Wrap a projected squad object into the `userMassInfo.squad` shape, injecting
/// the session-envelope fields the projector does not own (`personaId`, plus the
/// observed constants `changed: 0`, `actives: []`).
pub fn user_mass_info_squad(projected: Value, persona_id: i64) -> Value {
/// the session-envelope fields the projector does not own: `personaId`, the
/// observed constant `changed: 0`, and `actives` from [`squad_actives`].
pub fn user_mass_info_squad(projected: Value, persona_id: i64, actives: Value) -> Value {
let mut obj = projected;
if let Value::Object(map) = &mut obj {
map.insert("personaId".into(), json!(persona_id));
map.insert("changed".into(), json!(0));
map.insert("actives".into(), json!([]));
map.insert("actives".into(), actives);
}
obj
}