feat(fifa17): project badges and stadiums, the other two cardtype-7 club items
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_<teamid>`), withheld for stadium, whose resolver reads `StadiumName_<assetId>` 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.
This commit is contained in:
@@ -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<I: ItemIdentityResolver + ?Sized>(
|
||||
}
|
||||
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<I: ItemIdentityResolver + ?Sized>(
|
||||
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<CoreOwnedItem> = ["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<i64> = 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_<teamid>.
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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_<teamid>` — needs `teamid`
|
||||
/// * badge `Badge` + `TeamName_Abbr15_<teamid>` — needs `teamid`
|
||||
/// * stadium `Stadium` + `StadiumName_<assetId>` — 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_<teamid>`, while a stadium resolves
|
||||
/// `StadiumName_<assetId>` 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule openfut-core updated: c896545cf0...30fae1a2f9
@@ -1802,7 +1802,9 @@ impl ItemIdentityResolver for Fifa17IdentityResolver {
|
||||
|
||||
fn resolve_kit(&self, item: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user