From 74768693ec44fd1bbc4df73c6227f7290808fb20 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 24 Aug 2026 20:35:58 +0000 Subject: [PATCH] fix(fifa17): send a club item's real wire assetId, not its carddbid A club item's `assetId` (record +0x20) is family specific and is NOT the carddbid: per the client's own tables a kit carries the art class from fcc_kitcards.assetid - 14 for the 63xxxxx home/third band, 15 for the 64xxxxx away band - a badge carries its team id, and a ball and stadium their own asset number. The catalog shipped `asset_id`, the carddbid, in that slot. Measured on the live client with both kits resident: record +0x20 held 6300006 (home) and 6400003 (away) where the table says 14 and 15, while every other field - resourceId, cardassetid 35, category 2/3, teamid 21, year 0, itemState 101/102 - already matched. Operator reports both pre-match kit tiles rendering identically. assetId is the only field that diverges from the client's own data, and an assetId that is not a valid kit art class cannot resolve to distinct art. `resource_id` is derived from `asset_id`, and every home kit shares art class 14, so the two cannot be the same field: catalogs now carry an optional `club_asset_id`, defaulting to `asset_id` so a catalog predating the field and every non-club kind are unchanged. resolve_kit emits it as the wire `assetId`. Fixed at the source too - scripts/sold-staging-up.py emitted asset_id as the wire assetId for all four club families, so a re-emit would have regressed it. Field-offset note: club_items.json's _record_map is authoritative and my earlier working note had these transposed - assetId is +0x20 and cardassetid is +0x1c, not the reverse. Adds tools/live/diff_kit_records.py, which byte-diffs the two resident kit records and names the fields the decoded clone query consumes. Staging wire now reads assetId 14/15 with cardassetid 35 on both squad.actives and /club?type=equippables. Workspace 1250 passed, 0 failed. Client re-parse still to be confirmed visually. --- fifa17-recon/tools/live/diff_kit_records.py | 135 ++++++++++++++++++++ openfut-adapter-fifa17/src/fut/catalog.rs | 61 +++++++++ openfut-utas-host/src/lib.rs | 4 +- scripts/sold-staging-up.py | 44 ++++--- 4 files changed, 227 insertions(+), 17 deletions(-) create mode 100755 fifa17-recon/tools/live/diff_kit_records.py diff --git a/fifa17-recon/tools/live/diff_kit_records.py b/fifa17-recon/tools/live/diff_kit_records.py new file mode 100755 index 0000000..357d6d5 --- /dev/null +++ b/fifa17-recon/tools/live/diff_kit_records.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Byte-level diff of the two resident kit records in a live FIFA17 client. + +The pre-match selector draws each kit from a clone query keyed on the record's +own fields, so if both tiles render identically the question is precisely: which +bytes of the home record differ from the away record? This prints every differing +offset with the known field names attached, and dumps the fields the decoded +clone query consumes. + +Read-only. Never writes to the process. + +Decoded query (FUN_1801c3480 -> FUN_1801c44b0): + teamtechid == record+0x94 + teamkittypetechid == derived from itemState (101 -> 0 home, 102 -> 1 away) + year == record+0xba +""" +import re +import struct +import sys + +PID = int(sys.argv[1]) +WANT = [int(a) for a in sys.argv[2:]] or [100004874, 100004873] + +mem = open(f"/proc/{PID}/mem", "rb", buffering=0) + + +def rd(a, n): + mem.seek(a) + return mem.read(n) + + +def q(a): + return struct.unpack(" (name, width) +FIELDS = { + 0x08: ("id", 8), + 0x18: ("resourceId/definitionId", 4), + 0x1C: ("assetId", 4), + 0x20: ("cardassetid", 4), + 0x38: ("discardValue", 4), + 0x4C: ("cardtype", 4), + 0x50: ("cardsubtypeid", 4), + 0x5C: ("itemState", 4), + 0x60: ("category(club slot)", 4), + 0x8C: ("contract", 4), + 0x94: ("teamid", 4), + 0xB4: ("rating", 4), + 0xB8: ("wire category", 1), + 0xBA: ("year", 2), + 0x148: ("nation", 4), + 0x154: ("leagueId", 4), +} + + +def walk(node, out): + if not node or node == sentinel: + return + walk(q(node + 0x00), out) + # The record is EMBEDDED at node+0x28 — NOT a pointer stored there. + out.append((struct.unpack(", + /// Wire `assetId` for a club item; defaults to `asset_id`. See + /// [`Fifa17CardIdentity::club_asset_id`]. + club_asset_id: Option, /// Source team id for a kit or manager definition; absent → `0`. #[serde(default)] team_id: Option, @@ -287,6 +304,7 @@ impl Fifa17CardCatalog { kind: ContentKind::from_str(&rc.kind), subtype: rc.subtype, card_asset_id: rc.card_asset_id.unwrap_or(rc.asset_id), + club_asset_id: rc.club_asset_id.unwrap_or(rc.asset_id), team_id: rc.team_id.unwrap_or(0), category: rc.category.unwrap_or(0), year: rc.year.unwrap_or(0), @@ -380,6 +398,49 @@ mod tests { assert_eq!(cat.lookup("card_missing"), None); } + /// A club item's wire `assetId` is family specific and is NOT the carddbid. + /// + /// Regression: the catalog shipped `asset_id` (the carddbid) as the wire + /// `assetId`, so the client held `assetId 6300006` at record `+0x20` where + /// its own `fcc_kitcards` says `14`, and both pre-match kit tiles rendered + /// identically. `resource_id` is derived from `asset_id`, and every home kit + /// shares art class 14, so the two genuinely cannot be one field. + #[test] + fn club_items_carry_their_own_wire_asset_id_distinct_from_the_carddbid() { + let cat = Fifa17CardCatalog::from_json_str( + r#"{"schema_version":1,"game":"fifa17","cards":{ + "fifa17_6300006":{"asset_id":6300006,"kind":"kit","subtype":9, + "card_asset_id":35,"club_asset_id":14,"team_id":21,"category":2,"year":0}, + "fifa17_6400003":{"asset_id":6400003,"kind":"kit","subtype":9, + "card_asset_id":35,"club_asset_id":15,"team_id":21,"category":3,"year":0}, + "fifa17_20801":{"asset_id":20801} + }}"#, + ) + .unwrap(); + + let home = cat.lookup("fifa17_6300006").unwrap(); + let away = cat.lookup("fifa17_6400003").unwrap(); + + // resourceId stays the carddbid — it is what the staff/kit merge keys on. + assert_eq!(home.resource_id, 6300006); + assert_eq!(away.resource_id, 6400003); + // The card frame art is shared by the whole kit family. + assert_eq!(home.card_asset_id, 35); + assert_eq!(away.card_asset_id, 35); + // The art class is what distinguishes home from away on the wire. + assert_eq!(home.club_asset_id, 14); + assert_eq!(away.club_asset_id, 15); + assert_ne!( + home.club_asset_id, away.club_asset_id, + "home and away must not present the same assetId" + ); + + // Absent: defaults to asset_id, which is correct for every non-club kind + // and preserves the behaviour of a catalog that predates the field. + let player = cat.lookup("fifa17_20801").unwrap(); + assert_eq!(player.club_asset_id, 20801); + } + /// The non-player definition fields a consumable needs, and the ABSENCE that /// must stay an absence: a defaulted `amount` would draw "-1" on the card and /// a defaulted `contract` would invent the number of matches a card grants. diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 25e71f1..2970f09 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -2128,7 +2128,9 @@ impl ItemIdentityResolver for Fifa17IdentityResolver { } Some(Fifa17KitIdentity { item_id: self.wire_for(item)?, - asset_id: ident.asset_id, + // The club-item wire `assetId` is family specific and is NOT the + // carddbid — see `Fifa17CardIdentity::club_asset_id`. + asset_id: ident.club_asset_id, resource_id: ident.resource_id, card_asset_id: ident.card_asset_id, subtype: ident.subtype, diff --git a/scripts/sold-staging-up.py b/scripts/sold-staging-up.py index 928d918..55ec0ed 100755 --- a/scripts/sold-staging-up.py +++ b/scripts/sold-staging-up.py @@ -182,9 +182,15 @@ DISPOSABLE_CARD = "fifa17_232273" # Nelson Atiagli LB 51, rareflag 1 # Two authoritative modern kit-card definitions for one real source team. These # are staging fixtures derived from fcc_kitcards, not synthetic FIFA identities. KIT_TEAM_ID = 21 +# The trailing value is the client's own `fcc_kitcards.assetid`, the wire +# `assetId` (record +0x20). It is the ART CLASS, not the carddbid: every +# 63xxxxx (home/third) kit carries 14 and every 64xxxxx (away) kit carries 15, +# per club_items.json and fifa17-kit-map.json's band_x_assetid evidence +# (6300000/14 x828, 6400000/15 x654). Shipping the carddbid here left both +# pre-match kit tiles rendering identically. STAGING_KITS = [ - ("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006), - ("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003), + ("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006, 14), + ("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003, 15), ] # The remaining club-item families, so the rig exercises EVERY ownable class @@ -198,11 +204,15 @@ STAGING_KITS = [ # badge 39, logo 40), which is what the importer gates on. A league logo has no # equipped slot, so it is owned as generic `misc` content. STAGING_CLUB_ITEMS = [ - # (slot, owned_id, card_id, resource_id, kind, subtype, card_asset_id, team_id) - ("badge", "owned-a-badge", "fifa17_6000005", 6_000_005, "badge", 11, 39, 21), - ("ball", "owned-a-ball", "fifa17_8120194", 8_120_194, "ball", 30, 37, None), - ("stadium", "owned-a-stadium", "fifa17_6200000", 6_200_000, "stadium", 10, 36, None), - (None, "owned-a-leaguelogo", "fifa17_8010015", 8_010_015, "misc", 31, 40, None), + # (slot, owned_id, card_id, resource_id, kind, subtype, card_asset_id, team_id, + # club_asset_id) + # club_asset_id is the wire `assetId` from club_items.json, family specific + # and never the carddbid: badge 6000005 -> its teamid 21, ball 8120194 -> 100, + # stadium 6200000 -> 1. A league logo has no equipped slot and keeps its own. + ("badge", "owned-a-badge", "fifa17_6000005", 6_000_005, "badge", 11, 39, 21, 21), + ("ball", "owned-a-ball", "fifa17_8120194", 8_120_194, "ball", 30, 37, None, 100), + ("stadium", "owned-a-stadium", "fifa17_6200000", 6_200_000, "stadium", 10, 36, None, 1), + (None, "owned-a-leaguelogo", "fifa17_8010015", 8_010_015, "misc", 31, 40, None, None), ] # The club manager. FIFA refuses to start a match without one ("your player or @@ -524,7 +534,7 @@ def materialise(lay: Layout) -> None: with open(safe_path(lay.catalog)) as fh: catalog = json.load(fh) existing = {definition["id"] for definition in definitions} - for _slot, _owned_id, card_id, resource_id in STAGING_KITS: + for _slot, _owned_id, card_id, resource_id, club_asset_id in STAGING_KITS: if card_id not in existing: definitions.append({ "id": card_id, @@ -550,10 +560,11 @@ def materialise(lay: Layout) -> None: "kind": "kit", "subtype": 9, "card_asset_id": 35, + "club_asset_id": club_asset_id, "team_id": KIT_TEAM_ID, } - for _slot, _owned, card_id, resource_id, kind, subtype, art, team in STAGING_CLUB_ITEMS: + for _slot, _owned, card_id, resource_id, kind, subtype, art, team, club_asset in STAGING_CLUB_ITEMS: if card_id not in existing: definitions.append({ "id": card_id, @@ -580,6 +591,8 @@ def materialise(lay: Layout) -> None: "subtype": subtype, "card_asset_id": art, } + if club_asset is not None: + entry["club_asset_id"] = club_asset if team is not None: entry["team_id"] = team catalog["cards"][card_id] = entry @@ -701,7 +714,7 @@ def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None: wanted = set( [card for _, card in SELLER_SQUAD_CARDS] + [DISPOSABLE_CARD] - + [card for _, _, card, _ in STAGING_KITS] + + [card for _, _, card, _, _ in STAGING_KITS] + [STAGING_MANAGER["card_id"]] ) what = f"all {len(wanted)} fixture seed card ids" @@ -712,7 +725,7 @@ def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None: conn.execute("SELECT DISTINCT card_id FROM owned_cards")} finally: conn.close() - wanted |= {card for _, _, card, _ in STAGING_KITS} + wanted |= {card for _, _, card, _, _ in STAGING_KITS} wanted.add(STAGING_MANAGER["card_id"]) what = (f"all {len(wanted)} distinct card ids owned by the real club " "(plus the kit and manager fixtures)") @@ -983,7 +996,7 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: # calling a kit a player, and Core is the ownership authority. owned = [ (owned_id, seller_club, card_id, "kit", TS) - for _slot, owned_id, card_id, _resource_id in STAGING_KITS + for _slot, owned_id, card_id, _resource_id, _ca in STAGING_KITS ] owned.append( ( @@ -996,7 +1009,7 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: ) owned.extend( (owned_id, seller_club, card_id, kind, TS) - for _slot, owned_id, card_id, _rid, kind, _st, _art, _team + for _slot, owned_id, card_id, _rid, kind, _st, _art, _team, _ca in STAGING_CLUB_ITEMS ) if real_club is None: @@ -1010,7 +1023,6 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: "content_kind, acquired_at) VALUES (?, ?, ?, 0, ?, ?)", owned, ) - if real_club is None: conn.execute( "INSERT INTO squads (id, club_id, name, formation, created_at, " @@ -1034,14 +1046,14 @@ def seed_core_db(lay: Layout, real_club: dict | None) -> None: "VALUES (?, ?, ?, ?)", [ (seller_club, f"{slot}_kit", owned_id, TS) - for slot, owned_id, _card_id, _resource_id in STAGING_KITS + for slot, owned_id, _card_id, _resource_id, _ca in STAGING_KITS ] # badge / ball / stadium are slot-keyed exactly like the kits. # A league logo has no slot, so it stays owned-but-unequipped — # which is itself worth exercising. + [ (seller_club, slot, owned_id, TS) - for slot, owned_id, _c, _r, _k, _s, _a, _t in STAGING_CLUB_ITEMS + for slot, owned_id, _c, _r, _k, _s, _a, _t, _ca in STAGING_CLUB_ITEMS if slot is not None ], )