fix(fifa17): squad.manager elements are bare item objects, not itemData wrappers

An owned manager assigned in Core was present everywhere on the server -- in
/club/manager, in club?type=staff, and in userMassInfo -- but the squad UI
showed no manager after a cold client load.

The squad parser FUN_18013d1f0 reaches the item parser FUN_18013fe00 by two
different routes:

  players: atom 568 -> per-element atoms 355 `index`, 363 `itemData`,
           378 `kitNumber`; the 363 arm at 0x18013d8d9 calls the item parser
           on the NESTED itemData object.
  manager: atom 424 -> array loop at 0x18013da29 calls that same item parser
           DIRECTLY on the array ELEMENT, into squad+0xC0. No `itemData` step.

So a manager element IS an item. We were nesting the fields one level deeper,
so the parser read only the two keys that happen to be item atoms -- `id` and
`dream` -- and left everything else at its default. Measured on a cold client,
the manager record existed at squad+0xC0 with the correct id and resourceId 0,
while sibling players in the same response carried theirs. resourceId is the
merge key compared RAW against carddbid, so 0 resolves no manager: no name, no
rating, no art, empty slot.

The client's own save corroborates the shape: it PUTs
`"manager":[{"id":...,"dream":false}]` -- flat, and both keys are item atoms.

Flatten the element to the item plus `dream`. Cold-load proven on staging: the
manager record now carries resourceId 1000509 in the same layout as its player
siblings (83906881, 84053575) in the same array region, and the operator
confirms a manager is assigned in the squad management screen.

Two earlier shapes are now both explained and covered by tests: `{id, dream}`
carries no merge key, and `{id, itemData, dream}` hides it from this path.
This commit is contained in:
funman300
2026-08-25 02:50:22 +00:00
parent c3d0e56f69
commit b91e707a7e
2 changed files with 78 additions and 46 deletions
@@ -188,23 +188,37 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
}
}
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref
// AND carrying its item, as `[{id, itemData, dream}]`.
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref and
// emitted as a BARE ITEM OBJECT with `dream` beside the item's own fields —
// NOT wrapped in `itemData`.
//
// The bare `[{id, dream}]` form is NOT sufficient, which cost a real
// debugging round: the operator picked a manager in the hub, the save
// persisted (Core `squad_managers` row written, `outcome=ok`, no unresolved
// ref), and the pre-match squad still showed no manager. Every retail
// capture that shows the bare form has `id: 0` — an EMPTY manager — so none
// of them ever demonstrated that a POPULATED ref resolves without its item.
// This is a wire-shape contract, recovered from the client rather than
// guessed, after two earlier shapes both failed:
//
// The squad response is self-contained for players: `players[].itemData`
// carries the whole card rather than an id the client resolves out of band.
// The manager is the same kind of slot in the same object, and the one
// implementation that ever drove a working manager (the Python oracle's
// squad) emits `id` BESIDE `itemData` exactly like this. Note the element
// shape differs from a player slot: `{index, itemData, kitNumber}` there,
// `{id, itemData, dream}` here.
// `[{id, dream}]` — no merge key, so nothing resolves.
// `[{id, itemData, dream}]` — `itemData` is never read on this path.
//
// The squad parser FUN_18013d1f0 treats the two slots differently, and that
// is the whole point:
//
// players: atom 568 -> per-element atoms 355 `index`, 363 `itemData`,
// 378 `kitNumber`; the 363 arm (0x18013d8d9) calls the ITEM
// parser FUN_18013fe00 on the NESTED itemData object.
// manager: atom 424 -> array loop at 0x18013da29 calls that same item
// parser DIRECTLY on the array ELEMENT, into squad+0xC0. There is
// no `itemData` step at all.
//
// So a manager element IS an item. Nesting the fields one level deeper left
// the parser reading only the two keys that happen to be item atoms — `id`
// (0x14c) and `dream` (0xe7) — and leaving `resourceId` at 0. Measured on a
// cold client: the manager record existed at squad+0xC0 with the correct id
// and `resourceId == 0`, while sibling players in the same response carried
// theirs (83906881, 84053575). `resourceId` is the merge key compared RAW
// against `carddbid`, so zero can never hit the managercards table: no name,
// no rating, no art, and an empty manager slot in the UI.
//
// The client's own save corroborates the shape: it PUTs
// `"manager":[{"id":…,"dream":false}]` — flat, and both keys are item atoms.
//
// An owned manager with no resolvable FIFA staff identity is omitted
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
@@ -214,11 +228,13 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
.as_ref()
.and_then(|m| ident.resolve_staff(m).map(|id| (m, id)))
{
Some((mgr, id)) => json!([{
"id": id.item_id,
"itemData": shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT)),
"dream": false,
}]),
Some((mgr, id)) => {
let mut item = shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT));
if let Some(obj) = item.as_object_mut() {
obj.insert("dream".to_string(), json!(false));
}
json!([item])
}
None => json!([]),
};
let squad = json!({
@@ -258,9 +274,10 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
/// 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.
/// a `squad.manager[]` element — which reaches this same deserializer the same
/// way, called directly on the array element with no `itemData` step. An id
/// reference alone installs nothing, because the 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
@@ -588,29 +605,38 @@ mod tests {
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
// The item must ride ALONG with the ref: a bare `{id, dream}` left the
// pre-match squad with no manager even though the assignment had been
// saved, because nothing in the response described the card.
// The element IS the item: the squad parser's manager branch calls the
// item parser on the array element itself, with no `itemData` step, so
// the fields must be flat. Nesting them left `resourceId` — the merge
// key — at 0 on a cold client and the slot rendered empty.
assert_eq!(
v["manager"],
json!([{
"id": 100000427,
"itemData": {
"id": 100000427,
"resourceId": 1_000_509,
"cardsubtypeid": 4,
"itemType": "staff",
"nation": 45,
"leagueId": 53,
"teamid": 241,
"contract": 12,
"itemState": "free",
"owners": 1,
"untradeable": false,
},
"resourceId": 1_000_509,
"cardsubtypeid": 4,
"itemType": "staff",
"nation": 45,
"leagueId": 53,
"teamid": 241,
"contract": 12,
"itemState": "free",
"owners": 1,
"untradeable": false,
"dream": false,
}]),
"manager is the ownership-backed wire ref WITH its item"
"manager element is a bare item object carrying `dream`"
);
let element = &v["manager"][0];
assert!(
element.get("itemData").is_none(),
"an `itemData` wrapper is never descended into on the manager path, \
so its presence means the merge key is invisible to the client"
);
assert_eq!(
element["resourceId"], 1_000_509,
"resourceId must be readable at element level: it is the merge key \
compared RAW against carddbid, and 0 resolves no manager"
);
}
@@ -410,10 +410,13 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
}
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
assert_eq!(projected["custom"], oracle["custom"]);
// The manager REF round-trips; the item now rides with it. The capture this
// oracle came from carried a bare `{id, dream}`, but its manager was the
// dangling one every retail capture has, so it never showed that a populated
// ref renders on its own — and in practice it did not.
// The manager REF round-trips; the item now rides AT ELEMENT LEVEL. The
// capture this oracle came from carried a bare `{id, dream}`, but its
// manager was the dangling one every retail capture has, so it never showed
// that a populated ref renders on its own — and in practice it did not.
// Wrapping the fields in `itemData` did not work either: the squad parser's
// manager branch calls the item parser on the element itself, so a nested
// item is never read and the merge key stays 0.
assert_eq!(
projected["manager"][0]["id"], oracle["manager"][0]["id"],
"the manager wire ref itself must still round-trip"
@@ -422,8 +425,11 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
projected["manager"][0]["dream"],
oracle["manager"][0]["dream"]
);
let mgr_item = &projected["manager"][0]["itemData"];
assert_eq!(mgr_item["id"], oracle["manager"][0]["id"]);
let mgr_item = &projected["manager"][0];
assert!(
mgr_item.get("itemData").is_none(),
"the manager element IS the item; a wrapper hides the merge key"
);
assert_eq!(mgr_item["cardsubtypeid"], 4);
assert_eq!(mgr_item["resourceId"], 1_000_509);
assert_eq!(