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}")