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
@@ -24,16 +24,18 @@
use std::collections::HashMap;
use openfut_adapter_fifa17::fut::club_response::ActiveKitAssignments;
use openfut_adapter_fifa17::fut::item::{
CoreOwnedItem, Fifa17Identity, Fifa17StaffIdentity, ItemIdentityResolver, STAFF_CONTRACT,
CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
STAFF_CONTRACT,
};
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, Fifa17SquadPut, SquadWireResolver};
use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild};
use openfut_adapter_fifa17::fut::squad_projection::{
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
project_squad, squad_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
SquadProjection, SquadProjectionInput,
};
use serde_json::Value;
use serde_json::{json, Value};
const PUT_BASELINE: &str = include_str!("../fixtures/utas/squad_put_f442.json");
const PUT_SWAP: &str = include_str!("../fixtures/utas/squad_put_swap_f442.json");
@@ -503,11 +505,17 @@ fn one_projector_serves_every_endpoint_no_divergence() {
&ident,
);
// userMassInfo.squad = the projected object + session envelope.
let ummi = user_mass_info_squad(projected.clone(), 33068179);
// userMassInfo.squad = the projected object + session envelope. `actives` is
// supplied by the caller now, so the envelope must carry it through verbatim
// rather than hardcoding an empty array.
let actives = json!([{ "id": 100004874, "itemState": "activeHomeKit" }]);
let ummi = user_mass_info_squad(projected.clone(), 33068179, actives.clone());
assert_eq!(ummi["personaId"], 33068179);
assert_eq!(ummi["changed"], 0);
assert!(ummi["actives"].is_array());
assert_eq!(
ummi["actives"], actives,
"the envelope must pass actives through, not replace it"
);
assert_eq!(ummi["players"], projected["players"], "same projected body");
assert_eq!(ummi["formation"], projected["formation"]);
@@ -530,3 +538,153 @@ fn one_projector_serves_every_endpoint_no_divergence() {
// The summary carries only those six keys — no divergent squad shape.
assert_eq!(entry.as_object().unwrap().len(), 6);
}
// ---- squad.actives: the only carrier that makes a club item resident --------
/// A resolver that can answer `resolve_kit`, which the default trait method
/// cannot (it returns `None` for player-only resolvers).
struct KitIdentity(HashMap<String, Fifa17KitIdentity>);
impl ItemIdentityResolver for KitIdentity {
fn resolve(&self, _it: &CoreOwnedItem) -> Option<Fifa17Identity> {
None
}
fn resolve_kit(&self, it: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
self.0.get(&it.owned_card_id).copied()
}
}
fn kit_owned(owned_card_id: &str) -> CoreOwnedItem {
CoreOwnedItem {
owned_card_id: owned_card_id.to_string(),
card_id: format!("def-{owned_card_id}"),
rating: 0,
position: String::new(),
nation: String::new(),
league: String::new(),
club: String::new(),
attributes: [0; 6],
contract_matches: None,
source_rating: None,
core_content_kind: Some("kit".to_string()),
}
}
fn home_away_fixture() -> (HashMap<String, CoreOwnedItem>, KitIdentity) {
let owned = HashMap::from([
("oc-home".to_string(), kit_owned("oc-home")),
("oc-away".to_string(), kit_owned("oc-away")),
]);
// Real `fcc_kitcards` rows for team 21: 6300006 is the home card (category 2,
// assetid 14) and 6400003 the away card (category 3, assetid 15).
let ident = KitIdentity(HashMap::from([
(
"oc-home".to_string(),
Fifa17KitIdentity {
item_id: 100004874,
asset_id: 14,
resource_id: 6300006,
card_asset_id: 35,
subtype: 9,
team_id: 21,
category: 2,
year: 0,
},
),
(
"oc-away".to_string(),
Fifa17KitIdentity {
item_id: 100004873,
asset_id: 15,
resource_id: 6400003,
card_asset_id: 35,
subtype: 9,
team_id: 21,
category: 3,
year: 0,
},
),
]));
(owned, ident)
}
/// The client's squad parser reads at most five `actives` entries and parses each
/// one straight into a slot of its five-element club-item array, so each element
/// must be a full item object carrying a non-zero `id` — an id reference alone
/// installs nothing.
#[test]
fn squad_actives_emits_full_items_for_the_designated_kits() {
let (owned, ident) = home_away_fixture();
let actives = squad_actives(
&owned,
&ident,
ActiveKitAssignments {
home: Some("oc-home"),
away: Some("oc-away"),
},
);
let arr = actives.as_array().expect("actives is an array");
assert_eq!(arr.len(), 2, "one entry per designated kit");
assert_eq!(arr[0]["id"], 100004874);
assert_eq!(arr[0]["itemState"], "activeHomeKit");
assert_eq!(arr[0]["resourceId"], 6300006);
assert_eq!(arr[1]["id"], 100004873);
assert_eq!(arr[1]["itemState"], "activeAwayKit");
assert_eq!(arr[1]["resourceId"], 6400003);
for entry in arr {
assert_eq!(entry["itemType"], "kit");
assert_eq!(
entry["cardsubtypeid"], 9,
"cardsubtypeid 9 derives cardtype 7"
);
assert_eq!(entry["teamid"], 21, "the clone path keys kit art on teamid");
assert_ne!(entry["id"], 0, "a zero id is never made resident");
}
}
/// Undesignated slots contribute nothing, and an unresolvable designation is
/// omitted rather than emitted with a fabricated id — the same policy `/club`
/// applies when a card has no FIFA identity.
#[test]
fn squad_actives_omits_absent_and_unresolvable_designations() {
let (owned, ident) = home_away_fixture();
let home_only = squad_actives(
&owned,
&ident,
ActiveKitAssignments {
home: Some("oc-home"),
away: None,
},
);
assert_eq!(home_only.as_array().unwrap().len(), 1);
assert_eq!(home_only[0]["itemState"], "activeHomeKit");
// Designated but not present in the owned collection.
let dangling = squad_actives(
&owned,
&ident,
ActiveKitAssignments {
home: Some("oc-missing"),
away: None,
},
);
assert_eq!(dangling.as_array().unwrap().len(), 0);
// Present and designated, but with no resolvable FIFA kit identity.
let unresolvable = squad_actives(
&HashMap::from([("oc-x".to_string(), kit_owned("oc-x"))]),
&ident,
ActiveKitAssignments {
home: Some("oc-x"),
away: None,
},
);
assert_eq!(unresolvable.as_array().unwrap().len(), 0);
// Nothing designated at all is an empty array, which is what left every
// club-item slot null before this projector existed.
let none = squad_actives(&owned, &ident, ActiveKitAssignments::default());
assert_eq!(none.as_array().unwrap().len(), 0);
}