From 9823bdac78d29f74402bd0cce7db7d89d64bd054 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 21 Aug 2026 20:45:29 +0000 Subject: [PATCH] feat(fifa17): project badges and stadiums, the other two cardtype-7 club items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kit, badge and stadium are ONE record with ONE client-side resolver (`FUN_180119bd0`, dispatched on `item+0x4c == 7`); they differ only in the field their caption reads. Kits already ship and render, so the record is live-proven — badges and stadiums were being withheld as if unreversed when the authority (`plan-2026-08-06-card-subsystem.md`) marks both CONFIRMED, and its own rollout order is "kits first, then badges, then stadia". So the shaper generalises to the family, and carries exactly what each caption resolves: `teamid` for kit and badge (`TeamName_Abbr15_`), withheld for stadium, whose resolver reads `StadiumName_` and never looks at teamid. Sending a field the resolver does not read is how this project earned a client freeze. Ball (30) and league logo (31) stay withheld. They are cardtype 9 with NO database name resolver, so their name can only come from `localizedName`: the offset is confirmed, but "the parser reads it" is not "sending it is safe". Verified against the real club on staging: badge 6000005 emits cardsubtypeid 11 / cardassetid 39 / teamid 21, stadium 6200000 emits cardsubtypeid 10 / cardassetid 36 and no teamid, ball and logo emit nothing. --- .../src/fut/club_response.rs | 126 ++++++++++++++---- .../src/fut/content_taxonomy.rs | 23 +++- openfut-adapter-fifa17/src/fut/item.rs | 99 ++++++++++++-- openfut-core | 2 +- openfut-utas-host/src/lib.rs | 4 +- 5 files changed, 217 insertions(+), 37 deletions(-) diff --git a/openfut-adapter-fifa17/src/fut/club_response.rs b/openfut-adapter-fifa17/src/fut/club_response.rs index 94d7bee..a64e662 100644 --- a/openfut-adapter-fifa17/src/fut/club_response.rs +++ b/openfut-adapter-fifa17/src/fut/club_response.rs @@ -11,7 +11,7 @@ use serde_json::{json, Value}; use crate::fut::content_taxonomy::ContentKind; use crate::fut::entities::ReverseEntityResolver; -use crate::fut::item::{shape_item, shape_kit_item, shape_staff_item, STAFF_CONTRACT}; +use crate::fut::item::{shape_club_item, shape_item, shape_staff_item, STAFF_CONTRACT}; use crate::fut::item_state; // Re-exported so existing `club_response::{…}` callers keep working; the types // are now defined once in `fut::item`. @@ -59,20 +59,24 @@ pub fn shape_club_response_with_kits( } None => stats.dropped_no_asset += 1, }, - ContentKind::Kit => match ident.resolve_kit(item) { - Some(id) => { - let state = if active_kits.home == Some(item.owned_card_id.as_str()) { - item_state::ACTIVE_HOME_KIT - } else if active_kits.away == Some(item.owned_card_id.as_str()) { - item_state::ACTIVE_AWAY_KIT - } else { - item_state::FREE - }; - out.push(shape_kit_item(id, state)); - stats.emitted += 1; + // Kit, badge and stadium are ONE cardtype-7 record with one + // client-side resolver; only the equipped designation differs. + ContentKind::Kit | ContentKind::Badge | ContentKind::Stadium => { + match ident.resolve_kit(item) { + Some(id) => { + let state = if active_kits.home == Some(item.owned_card_id.as_str()) { + item_state::ACTIVE_HOME_KIT + } else if active_kits.away == Some(item.owned_card_id.as_str()) { + item_state::ACTIVE_AWAY_KIT + } else { + item_state::FREE + }; + out.push(shape_club_item(id, state)); + stats.emitted += 1; + } + None => stats.dropped_no_asset += 1, } - None => stats.dropped_no_asset += 1, - }, + } // A manager is a staff card: both Core kinds resolve through the one // staff record shape, discriminated on the wire by `cardsubtypeid` // (the same set as `ContentKind::is_staff_family`, spelled out here @@ -93,16 +97,15 @@ pub fn shape_club_response_with_kits( ContentKind::Consumable => { stats.excluded_non_player += 1; } - // Club customisation. The SUBTYPES are settled (kit 9, stadium 10, - // badge 11, ball 30, league logo 31), but the RECORD is not: a - // badge/stadium still needs the narrow `teamid`/`assetId` test that - // the 2026-08-05 crash denied us, and a ball has no display name at - // all except `localizedName`, which this project's own rule scores as - // "the parser reads it" and NOT "sending it is safe". Counted and - // withheld rather than guessed — ownership stays authoritative in - // Core either way, and club/stats still counts these families so the - // screen's own numbers are right. - ContentKind::Badge | ContentKind::Ball | ContentKind::Stadium | ContentKind::Misc => { + // The cardtype-9 families. Unlike kits/badges/stadia these have NO + // database name resolver at all, so the displayed name can only come + // from `localizedName` on the wire. That offset is confirmed + // (`+0xd9`), but "the parser reads it" is NOT "sending it is safe", + // and this project pays for that distinction with a client freeze. + // Counted and withheld rather than guessed: ownership stays + // authoritative in Core either way, and club/stats still counts the + // families so the screen's own numbers are right. + ContentKind::Ball | ContentKind::Misc => { stats.excluded_non_player += 1; } } @@ -465,4 +468,79 @@ mod tests { assert!(body["itemData"][0].get("attributeList").is_none()); assert!(body["itemData"][0].get("itemType").is_none()); } + + /// Kit, badge and stadium are one cardtype-7 record and MUST all project. + /// Ball and league logo are cardtype 9, have no database name resolver, and + /// stay withheld until `localizedName` is established as safe to send. + /// Counting a family in club/stats while never shaping it is the divergence + /// this test pins: the wire set and the withheld set are both asserted. + #[test] + fn cardtype7_club_items_project_and_cardtype9_stay_withheld() { + let ent = entities(); + let kit_id = |item_id, resource, subtype, art| Fifa17KitIdentity { + item_id, + asset_id: resource, + resource_id: resource, + card_asset_id: art, + subtype, + team_id: 21, + }; + let ident = KindMapIdentity { + ids: HashMap::new(), + kinds: HashMap::from([ + ("c_kit".to_string(), ContentKind::Kit), + ("c_badge".to_string(), ContentKind::Badge), + ("c_stadium".to_string(), ContentKind::Stadium), + ("c_ball".to_string(), ContentKind::Ball), + ("c_logo".to_string(), ContentKind::Misc), + ]), + kits: HashMap::from([ + ("c_kit".to_string(), kit_id(1, 6_300_006, 9, 35)), + ("c_badge".to_string(), kit_id(2, 6_000_005, 11, 39)), + ("c_stadium".to_string(), kit_id(3, 6_200_000, 10, 36)), + // Resolvable on purpose: withholding must be a decision about the + // FAMILY, not an accident of a missing identity. + ("c_ball".to_string(), kit_id(4, 8_120_194, 30, 37)), + ("c_logo".to_string(), kit_id(5, 8_010_015, 31, 40)), + ]), + staff: HashMap::new(), + }; + let items: Vec = ["c_kit", "c_badge", "c_stadium", "c_ball", "c_logo"] + .iter() + .map(|c| item(&format!("oc_{c}"), c, 0, "", "", "", "")) + .collect(); + + let (body, stats) = shape_club_response_with_kits( + &items, + &ent, + &ident, + ActiveKitAssignments { + home: None, + away: None, + }, + ); + let arr = body["itemData"].as_array().unwrap(); + assert_eq!(stats.emitted, 3, "kit + badge + stadium"); + assert_eq!(stats.excluded_non_player, 2, "ball + league logo withheld"); + assert_eq!(stats.dropped_no_asset, 0, "withholding is not a drop"); + + let subtypes: Vec = arr + .iter() + .map(|i| i["cardsubtypeid"].as_i64().unwrap()) + .collect(); + assert_eq!(subtypes, vec![9, 11, 10]); + // teamid only where the caption resolves TeamName_Abbr15_. + assert_eq!(arr[0]["teamid"], 21, "kit"); + assert_eq!(arr[1]["teamid"], 21, "badge"); + assert!( + arr[2].get("teamid").is_none(), + "stadium caption reads assetId" + ); + for it in arr { + assert!( + item_state::is_recovered(it["itemState"].as_str().unwrap()), + "every emitted state must be a recovered token" + ); + } + } } diff --git a/openfut-adapter-fifa17/src/fut/content_taxonomy.rs b/openfut-adapter-fifa17/src/fut/content_taxonomy.rs index ab9909b..11d1bac 100644 --- a/openfut-adapter-fifa17/src/fut/content_taxonomy.rs +++ b/openfut-adapter-fifa17/src/fut/content_taxonomy.rs @@ -8,8 +8,8 @@ //! * Consumable families and their contiguous `cardsubtypeid` ranges are taken //! verbatim from `fifa17-recon/tools/fut_consumables.py` //! (`BY_SUBTYPE`/`CORE_KINDS`, Ghidra-derived from `FUN_18013f4d0` / -//! `FUN_1801bfac0`) and `docs/CARD_TAXONOMY.md` (verified against the `.105` -//! `fcc_*.json` tables). +//! `FUN_1801bfac0`) and `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md` +//! (verified against the `.105` `fcc_*.json` tables). //! * Staff roles are the `FUN_1800d8330` family selector: 4=manager, 5=headcoach, //! 6=gkcoach, 7=physio, 8=fitnesscoach. //! @@ -95,6 +95,25 @@ impl ContentKind { pub fn is_staff_family(&self) -> bool { matches!(self, ContentKind::Manager | ContentKind::Staff) } + + /// True for the three club-customisation kinds that share the **cardtype-7** + /// record: kit (9), stadium (10) and badge (11). + /// + /// `FUN_1800d8330` maps all three subtypes to cardtype 7, and one client-side + /// resolver (`FUN_180119bd0`, dispatched on `item+0x4c == 7`) captions all + /// three. They therefore share ONE wire record + /// ([`crate::fut::item::shape_club_item`]) and one identity resolver. + /// + /// Ball (30) and league logo (31) are cardtype 9 and are deliberately NOT in + /// this family: they have no database name resolver, so their name can only + /// come from `localizedName` on the wire, which is not established as safe + /// to send. + pub fn is_cardtype7_club_item(&self) -> bool { + matches!( + self, + ContentKind::Kit | ContentKind::Stadium | ContentKind::Badge + ) + } } /// The functional family + honest display label for a consumable `cardsubtypeid`, diff --git a/openfut-adapter-fifa17/src/fut/item.rs b/openfut-adapter-fifa17/src/fut/item.rs index 0d1e9b9..0fc6f1e 100644 --- a/openfut-adapter-fifa17/src/fut/item.rs +++ b/openfut-adapter-fifa17/src/fut/item.rs @@ -25,7 +25,8 @@ use serde_json::{json, Value}; use crate::fut::content_taxonomy::{ - consumable_family, consumable_needs, ConsumableNeeds, ContentKind, MANAGER_SUBTYPE, + consumable_family, consumable_needs, ConsumableNeeds, ContentKind, BADGE_SUBTYPE, KIT_SUBTYPE, + MANAGER_SUBTYPE, }; use crate::fut::entities::ReverseEntityResolver; use crate::fut::item_state; @@ -311,11 +312,29 @@ pub fn shape_item( }) } -/// Build one FIFA 17 club-kit item. `item_state` is the proven wire enum token: -/// `free`, `activeHomeKit`, or `activeAwayKit`; the client deserializes the -/// latter two to runtime values 101 and 102. -pub fn shape_kit_item(id: Fifa17KitIdentity, item_state: &str) -> Value { - json!({ +/// Build one FIFA 17 **cardtype-7 club item**: a kit (subtype 9), a badge (11) +/// or a stadium (10). `item_state` is the proven wire enum token — `free`, or +/// one of the `active*` designations the client deserializes to 100..104. +/// +/// All three families share one record and one client-side resolver +/// (`FUN_180119bd0`, dispatched when `item+0x4c == 7`), differing only in the +/// field their caption reads: +/// +/// * kit `FUT_UC_KITS` + `TeamName_Abbr15_` — needs `teamid` +/// * badge `Badge` + `TeamName_Abbr15_` — needs `teamid` +/// * stadium `Stadium` + `StadiumName_` — needs `assetId`, which +/// `resourceId` already supplies +/// +/// `teamid` (atom 0x306, record `+0x94`) is therefore emitted for kits and +/// badges and WITHHELD for stadiums, whose resolver never reads it. It is an +/// established scalar field, not a new shape. +/// +/// The cardtype-9 families (ball 30, league logo 31) are deliberately NOT +/// shaped here: they have no DB name resolver, so their display name can only +/// come from `localizedName` on the wire, and while that offset is confirmed +/// it is NOT established that sending it is safe. +pub fn shape_club_item(id: Fifa17KitIdentity, item_state: &str) -> Value { + let mut item = json!({ "id": id.item_id, "resourceId": id.resource_id, "assetId": id.asset_id, @@ -324,8 +343,11 @@ pub fn shape_kit_item(id: Fifa17KitIdentity, item_state: &str) -> Value { "itemState": item_state, "owners": 1, "untradeable": false, - "teamid": id.team_id, - }) + }); + if matches!(id.subtype, KIT_SUBTYPE | BADGE_SUBTYPE) { + item["teamid"] = json!(id.team_id); + } + item } /// Contracts remaining on an owned staff card. @@ -474,6 +496,7 @@ pub const CONSUMABLE_UNTRADEABLE: bool = true; #[cfg(test)] mod tests { use super::*; + use crate::fut::content_taxonomy::STADIUM_SUBTYPE; use crate::fut::entities::Fifa17Entities; use std::collections::HashMap; @@ -719,7 +742,7 @@ mod tests { item_state::ACTIVE_HOME_KIT, item_state::ACTIVE_AWAY_KIT, ] { - let it = shape_kit_item(kit, state); + let it = shape_club_item(kit, state); emitted.push(it["itemState"].as_str().unwrap().to_string()); } for state in &emitted { @@ -734,4 +757,62 @@ mod tests { no shaper may emit it deliberately either" ); } + + /// Kit, badge and stadium share ONE cardtype-7 record, but their captions do + /// not read the same field: kit and badge resolve + /// `TeamName_Abbr15_`, while a stadium resolves + /// `StadiumName_` and its resolver never reads teamid. Sending a + /// field the resolver does not read is how this project earned a client + /// freeze, so the record carries exactly what each family consumes. + #[test] + fn a_club_item_carries_only_the_field_its_caption_resolves() { + let ident = |subtype| Fifa17KitIdentity { + item_id: 100_000_500, + asset_id: 6_000_005, + resource_id: 6_000_005, + card_asset_id: 39, + subtype, + team_id: 21, + }; + for subtype in [KIT_SUBTYPE, BADGE_SUBTYPE] { + let it = shape_club_item(ident(subtype), item_state::FREE); + assert_eq!( + it["teamid"], 21, + "subtype {subtype} resolves TeamName_Abbr15" + ); + assert_eq!(it["cardsubtypeid"], subtype); + } + let stadium = shape_club_item(ident(STADIUM_SUBTYPE), item_state::FREE); + assert!( + stadium.get("teamid").is_none(), + "a stadium caption reads assetId, never teamid" + ); + // The art id is the FAMILY's, never a copy of the resource id: a card + // whose card_asset_id equals its asset_id draws the notfound box. + assert_eq!(stadium["cardassetid"], 39); + assert_ne!(stadium["cardassetid"], stadium["resourceId"]); + // Every cardtype-7 family keeps the established minimal key set. + for key in [ + "id", + "resourceId", + "assetId", + "cardassetid", + "cardsubtypeid", + "itemState", + "owners", + "untradeable", + ] { + assert!(stadium.get(key).is_some(), "missing {key}"); + } + // Never a player field: these have no rating, contract or attributes. + for key in [ + "attributeList", + "contract", + "fitness", + "rating", + "discardValue", + ] { + assert!(stadium.get(key).is_none(), "club item must not carry {key}"); + } + } } diff --git a/openfut-core b/openfut-core index c896545..30fae1a 160000 --- a/openfut-core +++ b/openfut-core @@ -1 +1 @@ -Subproject commit c896545cf075cafff8d0e9a7a34caefc300a1e98 +Subproject commit 30fae1a2f9e870e7e6d1ea8be56337d5acce2e7b diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index e26ccbd..df86a3e 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -1802,7 +1802,9 @@ impl ItemIdentityResolver for Fifa17IdentityResolver { fn resolve_kit(&self, item: &CoreOwnedItem) -> Option { let ident = self.catalog.lookup(&item.card_id)?; - if ident.kind != ContentKind::Kit { + // The whole cardtype-7 club family shares this record: kit, stadium and + // badge differ only in which field their caption resolves. + if !ident.kind.is_cardtype7_club_item() { return None; } Some(Fifa17KitIdentity {