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
}
@@ -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);
}
+35 -11
View File
@@ -81,7 +81,7 @@ use openfut_adapter_fifa17::fut::squad_ext::{
build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION,
};
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 openfut_adapter_fifa17::fut::store_catalog::{
@@ -2663,8 +2663,10 @@ pub struct SquadLog {
/// The active squad projected from Core, with the freshness policy applied.
enum HostProjection {
/// Fresh: the projected FIFA squad object (before any endpoint envelope).
Squad(Value),
/// Fresh: the projected FIFA squad object (before any endpoint envelope),
/// plus the active club items for its `actives` array. They travel together
/// because both are derived from the SAME bounded Core fetch.
Squad { squad: Value, actives: Value },
/// Stored extension is stale vs the canonical squad — NEVER applied.
Stale,
/// No extension stored — nothing fabricated.
@@ -2747,8 +2749,28 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
owned: &owned_by_id,
manager,
};
// The active club designations Core already owns (`/club/active-items`), shaped
// into the `actives` array the client needs to make a club item resident. A
// transport error here is non-fatal and yields no actives: the squad still
// renders, exactly as it did before this array was populated. It is reported,
// because silently empty actives is precisely the failure that left the
// pre-match kit selector blank.
let actives = match deps.core.get_active_kits() {
Ok(assignments) => squad_actives(
&owned_by_id,
deps.resolver,
ActiveKitAssignments {
home: assignments.home_owned_card_id.as_deref(),
away: assignments.away_owned_card_id.as_deref(),
},
),
Err(error) => {
eprintln!("utas-host WARN active club items unavailable, squad.actives empty: {error}");
json!([])
}
};
match project_squad(&input, deps.resolver, deps.entities) {
Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v),
Ok(SquadProjection::Projected(squad)) => HostProjection::Squad { squad, actives },
Ok(SquadProjection::Stale) => HostProjection::Stale,
Ok(SquadProjection::Missing) => HostProjection::Missing,
Err(e) => HostProjection::Error(e.to_string()),
@@ -2917,8 +2939,8 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
/// to an empty list, NEVER served from Python and NEVER projected from stale ext.
pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
match project_active_squad(deps) {
HostProjection::Squad(v) => (
json_response(&squad_list(&v)),
HostProjection::Squad { squad, .. } => (
json_response(&squad_list(&squad)),
SquadLog {
outcome: "ok",
detail: String::new(),
@@ -2955,8 +2977,8 @@ pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
/// (never 401/403, never a Python fallback that could mask split authority).
pub fn handle_squad_active(deps: &SquadDeps<'_>, persona_id: i64) -> (WireResponse, SquadLog) {
match project_active_squad(deps) {
HostProjection::Squad(v) => (
json_response(&user_mass_info_squad(v, persona_id)),
HostProjection::Squad { squad, actives } => (
json_response(&user_mass_info_squad(squad, persona_id, actives)),
SquadLog {
outcome: "ok",
detail: String::new(),
@@ -3081,8 +3103,8 @@ pub fn handle_user_mass_info(
})
.unwrap_or(0);
let (squad_val, log) = match project_active_squad(deps) {
HostProjection::Squad(v) => (
user_mass_info_squad(v, persona),
HostProjection::Squad { squad, actives } => (
user_mass_info_squad(squad, persona, actives),
SquadLog {
outcome: "ok",
detail: String::new(),
@@ -4556,7 +4578,9 @@ impl Server {
};
let deps = self.squad_deps();
let (squad, squad_outcome) = match project_active_squad(&deps) {
HostProjection::Squad(v) => (user_mass_info_squad(v, self.persona_id), "ok"),
HostProjection::Squad { squad, actives } => {
(user_mass_info_squad(squad, self.persona_id, actives), "ok")
}
HostProjection::Stale => (empty_squad_overlay(self.persona_id), "stale_integrity"),
HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),