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:
Executable
+135
@@ -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}")
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user