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.
This commit is contained in:
funman300
2026-08-24 20:35:58 +00:00
parent 6bbc0eaf4f
commit 74768693ec
4 changed files with 227 additions and 17 deletions
+135
View File
@@ -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("<Q", rd(a, 8))[0]
named = []
for ln in open(f"/proc/{PID}/maps"):
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
if m:
named.append((int(m.group(1), 16), m.group(3).strip()))
named.sort()
base = next((s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll")), None)
if base is None:
sys.exit("CardsDLL mapping not found")
def live(static):
return base + (static - 0x180000000)
if rd(live(0x180026FEA), 5) != bytes.fromhex("ba75750000"):
sys.exit("SANITY FAILED - wrong base")
print(f" CardsDLL base = {base:#x} (sanity ok)")
owner = q(live(0x1802E6398))
sentinel = owner + 0x160C8
root = q(owner + 0x160D8)
# Known record fields, offset -> (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("<q", rd(node + 0x20, 8))[0], node + 0x28))
walk(q(node + 0x08), out)
nodes = []
walk(root, nodes)
recs = {k: v for k, v in nodes}
found = [(w, recs[w]) for w in WANT if w in recs]
if len(found) < 2:
sys.exit(f" need two resident kit records, found {[w for w, _ in found]}")
(id_a, ptr_a), (id_b, ptr_b) = found[0], found[1]
a = rd(ptr_a, 0x180)
b = rd(ptr_b, 0x180)
print(f" A = {id_a} @ {ptr_a:#x}")
print(f" B = {id_b} @ {ptr_b:#x}")
print("\n --- fields the clone query consumes ---")
for off in (0x94, 0x5C, 0xBA):
name = FIELDS[off][0]
w = FIELDS[off][1]
va = int.from_bytes(a[off : off + w], "little")
vb = int.from_bytes(b[off : off + w], "little")
flag = "" if va != vb else " <== IDENTICAL"
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{flag}")
print("\n --- every differing byte range ---")
diffs = [i for i in range(0x180) if a[i] != b[i]]
runs = []
for i in diffs:
if runs and i == runs[-1][1] + 1:
runs[-1][1] = i
else:
runs.append([i, i])
for s, e in runs:
named_field = next(
(n for o, (n, w) in FIELDS.items() if o <= s < o + w), "(unmapped)"
)
va = int.from_bytes(a[s : e + 1], "little")
vb = int.from_bytes(b[s : e + 1], "little")
print(f" +{s:#05x}..{e:#05x} {named_field:24} A={va:<12} B={vb}")
print(f"\n {len(diffs)} differing bytes in {len(runs)} runs")
print("\n --- known fields, side by side ---")
for off in sorted(FIELDS):
name, w = FIELDS[off]
va = int.from_bytes(a[off : off + w], "little")
vb = int.from_bytes(b[off : off + w], "little")
mark = " DIFFERS" if va != vb else ""
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{mark}")
+61
View File
@@ -41,6 +41,20 @@ pub struct Fifa17CardIdentity {
/// FIFA card-art class. Players default to `asset_id`; kit definitions carry
/// the verified `fcc_kitcards.cardassetid` value (`35`).
pub card_asset_id: u32,
/// The wire `assetId` for a CLUB item (record `+0x20`), which is family
/// specific and is NOT the carddbid: a kit carries the art class from
/// `fcc_kitcards.assetid` (`14` home/third band, `15` away band), a badge
/// carries its team id, a stadium and a ball their own asset number.
///
/// Distinct from [`Self::asset_id`], which for these definitions is the
/// carddbid and is what `resource_id` is derived from — so the two cannot be
/// the same field. Shipping the carddbid here is what left the client
/// holding `assetId 6300006` at record `+0x20` where its own table says
/// `14`, with both pre-match kit tiles rendering identically.
///
/// Defaults to `asset_id` when a catalog does not specify it, which is the
/// pre-existing behaviour and is correct for every non-club kind.
pub club_asset_id: u32,
/// Source team id for a club kit, or a manager's real club. Zero for content
/// kinds that do not use it.
pub team_id: i64,
@@ -206,6 +220,9 @@ struct RawCard {
/// Separate card-art id for non-player definitions; absent → `asset_id`.
#[serde(default)]
card_asset_id: Option<u32>,
/// Wire `assetId` for a club item; defaults to `asset_id`. See
/// [`Fifa17CardIdentity::club_asset_id`].
club_asset_id: Option<u32>,
/// Source team id for a kit or manager definition; absent → `0`.
#[serde(default)]
team_id: Option<i64>,
@@ -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.
+3 -1
View File
@@ -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,
+28 -16
View File
@@ -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
],
)