fix(fifa17): disable squad.actives by default after it emptied the squad

Populating `squad.actives` is the proven way to make a club item resident —
the squad parser writes each element straight into a club-item slot, both
kits came back resident with the client writing `category 4` itself, and the
pre-match kit selector worked.

But a populated array was then observed to cost the rest of the squad. On a
full client relaunch the resident item map fell from 24 entries to just the
2 kits, the 23-slot player vector came back fully null, and the starting-11
screen was empty; the manager and staff were gone too. Every host response
was 200/outcome=ok with no warning, so the loss is entirely client-side
parse behaviour. `actives` sorts first in the squad object, so `captain`,
`formation`, `manager`, `players` and everything else after it are lost —
consistent with the element loop leaving the tokenizer misaligned.

The same payload produced a correct 24-node map on an earlier relaunch, so
the interaction is not yet understood and is not safely shippable. An empty
squad is far worse than a missing kit, so the array is now gated behind
`HostConfig::squad_actives` (env `OPENFUT_FIFA17_SQUAD_ACTIVES`) and off by
default. The projection, identity plumbing and tests are kept intact: they
are correct and are what the investigation will re-enable.

Staging redeployed with the flag off and verified back to 23/23 populated
player slots and `actives: []`.
This commit is contained in:
funman300
2026-08-24 18:53:28 +00:00
parent 98f30931a0
commit d929efdffe
4 changed files with 88 additions and 18 deletions
+25
View File
@@ -59,6 +59,22 @@ pub struct HostConfig {
/// Disabled by default. Non-off values require three explicit staging guards; /// Disabled by default. Non-off values require three explicit staging guards;
/// see [`parse_sbc_post_commit_fault`]. /// see [`parse_sbc_post_commit_fault`].
pub sbc_post_commit_fault: SbcPostCommitFault, pub sbc_post_commit_fault: SbcPostCommitFault,
/// Emit the club's active club items in `squad.actives`. **Disabled by
/// default.**
///
/// `actives` is the proven carrier that makes a kit resident (the squad
/// parser writes each element straight into a club-item slot), and with it
/// populated the pre-match kit selector works. But a non-empty array was
/// observed to cost the rest of the squad: the resident item map dropped from
/// 24 entries to just the 2 kits, the 23-slot player vector came back fully
/// null, and the starting-11 screen was empty. `actives` sorts first in the
/// object, so everything after it — `players`, `manager`, `captain`,
/// `formation` — is lost.
///
/// Off until that interaction is understood: an empty squad is far worse than
/// a missing kit. Env `OPENFUT_FIFA17_SQUAD_ACTIVES=1` to experiment on
/// staging.
pub squad_actives: bool,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -177,6 +193,15 @@ impl HostConfig {
clientdata_path, clientdata_path,
account_path, account_path,
sbc_post_commit_fault, sbc_post_commit_fault,
// Default OFF: see the field docs. Only an explicit 1/true/on enables it.
squad_actives: matches!(
env::var("OPENFUT_FIFA17_SQUAD_ACTIVES")
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str(),
"1" | "true" | "on" | "yes"
),
}) })
} }
} }
+47 -18
View File
@@ -2652,6 +2652,8 @@ pub struct SquadDeps<'a> {
pub core: &'a dyn CoreAccess, pub core: &'a dyn CoreAccess,
pub resolver: &'a Fifa17IdentityResolver, pub resolver: &'a Fifa17IdentityResolver,
pub entities: &'a Fifa17Entities, pub entities: &'a Fifa17Entities,
/// Emit `squad.actives`. Default OFF — see [`crate::config::HostConfig`].
pub squad_actives: bool,
} }
/// Secret-free structured log line for a handled squad request. /// Secret-free structured log line for a handled squad request.
@@ -2750,23 +2752,33 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
manager, manager,
}; };
// The active club designations Core already owns (`/club/active-items`), shaped // 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 // into the `actives` array that makes a club item resident.
// 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, // DEFAULT OFF (`HostConfig::squad_actives`). Populating this array does make
// because silently empty actives is precisely the failure that left the // the kits resident and the pre-match selector work, but it was also observed
// pre-match kit selector blank. // to cost the rest of the squad: the resident item map fell from 24 entries to
let actives = match deps.core.get_active_kits() { // just the 2 kits, the 23-slot player vector came back fully null, and the
Ok(assignments) => squad_actives( // starting-11 screen was empty. `actives` sorts first in the squad object, so
&owned_by_id, // everything after it is lost. Until that is understood an empty squad is far
deps.resolver, // worse than a missing kit.
ActiveKitAssignments { let actives = if !deps.squad_actives {
home: assignments.home_owned_card_id.as_deref(), json!([])
away: assignments.away_owned_card_id.as_deref(), } else {
}, match deps.core.get_active_kits() {
), Ok(assignments) => squad_actives(
Err(error) => { &owned_by_id,
eprintln!("utas-host WARN active club items unavailable, squad.actives empty: {error}"); deps.resolver,
json!([]) 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) { match project_squad(&input, deps.resolver, deps.entities) {
@@ -3557,6 +3569,8 @@ pub struct Server {
economy_gate: Arc<Mutex<()>>, economy_gate: Arc<Mutex<()>>,
/// Staging-only simulation of losing the successful SBC submit receipt. /// Staging-only simulation of losing the successful SBC submit receipt.
sbc_post_commit_fault: SbcPostCommitFault, sbc_post_commit_fault: SbcPostCommitFault,
/// Emit `squad.actives`. Default OFF; see `HostConfig::squad_actives`.
squad_actives: bool,
} }
impl Server { impl Server {
@@ -3581,6 +3595,7 @@ impl Server {
clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())), clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())),
economy_gate: Arc::new(Mutex::new(())), economy_gate: Arc::new(Mutex::new(())),
sbc_post_commit_fault: SbcPostCommitFault::Off, sbc_post_commit_fault: SbcPostCommitFault::Off,
squad_actives: false,
current_match: Arc::new(PlMutex::new(None)), current_match: Arc::new(PlMutex::new(None)),
} }
} }
@@ -3649,6 +3664,10 @@ impl Server {
"utas-host sbc_post_commit_fault={:?}", "utas-host sbc_post_commit_fault={:?}",
cfg.sbc_post_commit_fault cfg.sbc_post_commit_fault
); );
eprintln!(
"utas-host squad_actives={} (OFF keeps squad.actives empty; ON makes kits resident but has been seen to empty the squad)",
cfg.squad_actives
);
Ok(Server::new( Ok(Server::new(
core, core,
entities, entities,
@@ -3659,7 +3678,8 @@ impl Server {
.with_economy(economy) .with_economy(economy)
.with_clientdata(clientdata) .with_clientdata(clientdata)
.with_account(account) .with_account(account)
.with_sbc_post_commit_fault(cfg.sbc_post_commit_fault)) .with_sbc_post_commit_fault(cfg.sbc_post_commit_fault)
.with_squad_actives(cfg.squad_actives))
} }
/// Assemble the shared squad dependencies (Core access + the one production /// Assemble the shared squad dependencies (Core access + the one production
@@ -3669,6 +3689,7 @@ impl Server {
core: self.core.as_ref(), core: self.core.as_ref(),
resolver: self.resolver.as_ref(), resolver: self.resolver.as_ref(),
entities: self.entities.as_ref(), entities: self.entities.as_ref(),
squad_actives: self.squad_actives,
} }
} }
@@ -3694,6 +3715,14 @@ impl Server {
self self
} }
/// Emit `squad.actives`. Default OFF: a populated array makes the kits
/// resident but was observed to cost the rest of the squad (see
/// `HostConfig::squad_actives`).
fn with_squad_actives(mut self, on: bool) -> Self {
self.squad_actives = on;
self
}
fn with_sbc_post_commit_fault(mut self, fault: SbcPostCommitFault) -> Self { fn with_sbc_post_commit_fault(mut self, fault: SbcPostCommitFault) -> Self {
self.sbc_post_commit_fault = fault; self.sbc_post_commit_fault = fault;
self self
@@ -902,6 +902,7 @@ fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::
.to_string_lossy() .to_string_lossy()
.into_owned(), .into_owned(),
sbc_post_commit_fault: openfut_utas_host::config::SbcPostCommitFault::Off, sbc_post_commit_fault: openfut_utas_host::config::SbcPostCommitFault::Off,
squad_actives: false,
} }
} }
+15
View File
@@ -1150,6 +1150,7 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
let body = put_body( let body = put_body(
"f442", "f442",
@@ -1192,6 +1193,7 @@ fn put_assigns_the_owned_manager_and_a_later_save_clears_it() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
let with_manager = put_body( let with_manager = put_body(
@@ -1234,6 +1236,7 @@ fn put_saves_the_squad_when_the_manager_ref_does_not_resolve() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
let body = put_body( let body = put_body(
@@ -1279,6 +1282,7 @@ fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
let body = put_body( let body = put_body(
"f442", "f442",
@@ -1305,6 +1309,7 @@ fn put_rejects_unknown_wire_id() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
// 999_999_999 was never allocated → unresolvable. // 999_999_999 was never allocated → unresolvable.
let body = put_body( let body = put_body(
@@ -1330,6 +1335,7 @@ fn put_rejects_duplicate_owned_item() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
let body = put_body( let body = put_body(
"f442", "f442",
@@ -1355,6 +1361,7 @@ fn put_core_failure_returns_error_never_python() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None); let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None);
let (resp, log) = handle_put_squad(&body, &deps); let (resp, log) = handle_put_squad(&body, &deps);
@@ -1372,6 +1379,7 @@ fn repeated_identical_put_is_idempotent() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
let body = put_body( let body = put_body(
"f442", "f442",
@@ -1408,6 +1416,7 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
let body = put_body( let body = put_body(
"f442", "f442",
@@ -1513,6 +1522,7 @@ fn squad_active_serves_core_backed_object_with_configured_persona() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
// Commit a squad so Core has a fresh canonical squad + extension. // Commit a squad so Core has a fresh canonical squad + extension.
let body = put_body( let body = put_body(
@@ -1560,6 +1570,7 @@ fn read_path_is_bounded_no_per_slot_lookup() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
let body = put_body( let body = put_body(
"f442", "f442",
@@ -1614,6 +1625,7 @@ fn stale_extension_is_not_applied_on_reads() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
let (resp, log) = handle_squad_list(&deps); let (resp, log) = handle_squad_list(&deps);
assert_eq!(log.outcome, "stale_integrity"); assert_eq!(log.outcome, "stale_integrity");
@@ -1635,6 +1647,7 @@ fn missing_extension_is_explicit_on_reads() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
let (resp, log) = handle_squad_list(&deps); let (resp, log) = handle_squad_list(&deps);
assert_eq!(log.outcome, "missing_integrity"); assert_eq!(log.outcome, "missing_integrity");
@@ -1656,6 +1669,7 @@ fn usermassinfo_never_serves_python_squad_on_integrity_failure() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
let py_body = json!({ let py_body = json!({
"userInfo": {"personaId": 7}, "userInfo": {"personaId": 7},
@@ -1730,6 +1744,7 @@ fn duplicate_definition_instances_stay_distinct_through_host() {
core: &core, core: &core,
resolver: &resolver, resolver: &resolver,
entities: &ent, entities: &ent,
squad_actives: false,
}; };
let body = put_body( let body = put_body(
"f442", "f442",