70a64e3709
Freeze the running offline FUT backend into version control as fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a fresh checkout: * OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is required for remote mode (compose and entrypoint fail without it) * docker-compose.yml reproducing the frozen baseline container exactly (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart) * .env.example / .env for site config - the LAN IP is never hardcoded in source * tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10, verified byte-identical to the running container at freeze time * client_arm.sh (the 105 client-side arming counterpart) * Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying * docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record, restore instructions and rebuild-equivalence procedure Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored. The live container is untouched pending the .105 launcher audit.
479 lines
16 KiB
Python
479 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
heat2.py -- self-contained Blaze Heat2 TDF encoder/decoder + Fire2 framing.
|
|
|
|
CLEAN ROOM PROVENANCE
|
|
---------------------
|
|
Everything here was derived from:
|
|
* the wire bytes of our own FIFA17 client's first Blaze RPC
|
|
(fifa17-recon/captures/blaze/blaze_fire2_37161.bin), and
|
|
* our own decoder (decode_fire2.py) written against those bytes.
|
|
Complex-type layouts that the capture does NOT exercise (list/map/union/
|
|
varintlist/objtype/objid/float) are marked UNVERIFIED below; they are
|
|
consistent with independent third-party clean-room BlazeSDK-15.x
|
|
reimplementations (the `tdf` crate cloned in this scratchpad), which were used
|
|
only as a cross-check of *structure*, never copied.
|
|
NO EA/FIFA leaked source was consulted.
|
|
|
|
VALIDATED RULES (byte-exact round-trip against the 219-byte capture)
|
|
--------------------------------------------------------------------
|
|
Fire2 frame header, 16 bytes big-endian:
|
|
[0:4] u32 payload length (bytes after the header)
|
|
[4:6] u16 always 0 (observed)
|
|
[6:8] u16 component
|
|
[8:10] u16 command
|
|
[10:12]u16 error / msgId
|
|
[12] u8 msgType (0x01 ping, 0x02 request, 0x03 pong/response)
|
|
[13:16]3 reserved bytes (observed 00 00 00)
|
|
|
|
Heat2 field = 3-byte packed tag + 1 type byte + value.
|
|
|
|
TAG PACKING (validated):
|
|
Take the 4-char label, right-pad with spaces to exactly 4 chars, truncate
|
|
to 4. Each char c -> 6-bit code (ord(c) - 0x20) & 0x3F (so ' ' -> 0).
|
|
The four 6-bit codes are concatenated MSB-first into 24 bits = 3 bytes:
|
|
b0 = c0<<2 | c1>>4
|
|
b1 = (c1 & 0x0F)<<4 | c2>>2
|
|
b2 = (c2 & 0x03)<<6 | c3
|
|
Decode is the exact inverse; code 0 decodes to ' ' and trailing spaces are
|
|
stripped, so "ENV" round-trips as "ENV" (encoded as "ENV ").
|
|
|
|
VARINT (validated):
|
|
First byte carries only 6 data bits (mask 0x3F); bit 0x80 = "more".
|
|
Bit 0x40 of the first byte is the sign/negative flag (UNVERIFIED - never
|
|
set in our capture; we encode non-negative values only by default).
|
|
Every following byte carries 7 data bits (mask 0x7F) with bit 0x80 = more.
|
|
Little-endian group order: first byte = least significant 6 bits, then
|
|
7 bits per byte at shifts 6, 13, 20, 27, ...
|
|
Canonical form: emit the shortest sequence; value < 0x40 is one byte.
|
|
e.g. LANG = 0x656E5553 ("enUS") -> 93 d5 f2 d6 0c
|
|
|
|
STRING (validated):
|
|
varint length INCLUDING the NUL terminator, then that many bytes, the last
|
|
of which is 0x00. Empty string = varint 1 + b"\\x00".
|
|
|
|
STRUCT / group (validated):
|
|
type byte 0x03, then the member fields, then a single 0x00 terminator
|
|
byte. No group-start marker byte. The top-level payload is NOT
|
|
terminated (it is delimited by the Fire2 length).
|
|
|
|
FIELD ORDER (validated):
|
|
Members are serialized in ascending order of the *packed 3-byte tag*
|
|
(equivalently ascending by the space-padded label under this 6-bit
|
|
packing). Observed: CDAT<CINF<FCCR<LADD, and inside CINF:
|
|
BSDK<BTIM<CLNT<CPFT<CSKU<CVER<DSDK<ENV<LOC<PTVR.
|
|
|
|
VALUE REPRESENTATION (python side)
|
|
----------------------------------
|
|
A struct is an ordered dict { "TAG": (type, value) }.
|
|
INT -> int
|
|
STRING -> str (no trailing NUL) or bytes
|
|
BLOB -> bytes
|
|
STRUCT -> dict as above
|
|
LIST -> (elem_type, [value, ...])
|
|
MAP -> (key_type, val_type, [(k, v), ...])
|
|
UNION -> (active_key:int, (tag, type, value) | None)
|
|
VARLIST -> [int, ...]
|
|
OBJTYPE -> (component, type)
|
|
OBJID -> (component, type, id)
|
|
FLOAT -> float
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import struct
|
|
from collections import OrderedDict
|
|
|
|
# ---------------------------------------------------------------- types
|
|
|
|
INT = 0x00
|
|
STRING = 0x01
|
|
BLOB = 0x02
|
|
STRUCT = 0x03
|
|
LIST = 0x04
|
|
MAP = 0x05
|
|
UNION = 0x06
|
|
VARLIST = 0x07
|
|
OBJTYPE = 0x08
|
|
OBJID = 0x09
|
|
FLOAT = 0x0A
|
|
|
|
TYPE_NAMES = {
|
|
INT: "int", STRING: "string", BLOB: "blob", STRUCT: "struct",
|
|
LIST: "list", MAP: "map", UNION: "union", VARLIST: "varintlist",
|
|
OBJTYPE: "objtype", OBJID: "objid", FLOAT: "float",
|
|
}
|
|
|
|
UNION_UNSET = 0x7F # UNVERIFIED (not present in capture)
|
|
|
|
|
|
# ---------------------------------------------------------------- tags
|
|
|
|
def encode_tag(label) -> bytes:
|
|
"""4-char label -> 3 packed bytes. Shorter labels are space padded."""
|
|
if isinstance(label, bytes):
|
|
label = label.decode("ascii")
|
|
s = (label + " ")[:4]
|
|
c = [(ord(ch) - 0x20) & 0x3F for ch in s]
|
|
return bytes((
|
|
(c[0] << 2) | (c[1] >> 4),
|
|
((c[1] & 0x0F) << 4) | (c[2] >> 2),
|
|
((c[2] & 0x03) << 6) | c[3],
|
|
))
|
|
|
|
|
|
def decode_tag(b: bytes) -> str:
|
|
"""3 packed bytes -> label with trailing padding stripped."""
|
|
a, b1, c = b[0], b[1], b[2]
|
|
v = (
|
|
(a >> 2) & 0x3F,
|
|
((a & 0x03) << 4) | ((b1 >> 4) & 0x0F),
|
|
((b1 & 0x0F) << 2) | ((c >> 6) & 0x03),
|
|
c & 0x3F,
|
|
)
|
|
return "".join(chr(x + 0x20) if x else " " for x in v).rstrip()
|
|
|
|
|
|
def tag_key(label) -> bytes:
|
|
"""Sort key enforcing Blaze's ascending-tag member ordering."""
|
|
return encode_tag(label)
|
|
|
|
|
|
# ---------------------------------------------------------------- varint
|
|
|
|
def encode_varint(value: int) -> bytes:
|
|
"""Heat2 varint: 6 data bits in byte 0 (0x80=more), 7 bits thereafter."""
|
|
neg = value < 0
|
|
v = -value if neg else value
|
|
first = v & 0x3F
|
|
v >>= 6
|
|
if neg:
|
|
first |= 0x40 # UNVERIFIED sign convention
|
|
if v == 0:
|
|
return bytes((first,))
|
|
out = bytearray((first | 0x80,))
|
|
while v >= 0x80:
|
|
out.append((v & 0x7F) | 0x80)
|
|
v >>= 7
|
|
out.append(v)
|
|
return bytes(out)
|
|
|
|
|
|
def decode_varint(buf: bytes, i: int):
|
|
"""-> (value, next_index)"""
|
|
b = buf[i]
|
|
i += 1
|
|
val = b & 0x3F
|
|
neg = bool(b & 0x40)
|
|
if b & 0x80:
|
|
shift = 6
|
|
while True:
|
|
b = buf[i]
|
|
i += 1
|
|
val |= (b & 0x7F) << shift
|
|
shift += 7
|
|
if not (b & 0x80):
|
|
break
|
|
return (-val if neg else val), i
|
|
|
|
|
|
# ---------------------------------------------------------------- encoder
|
|
|
|
def _enc_value(typ: int, value, out: bytearray) -> None:
|
|
if typ == INT:
|
|
out += encode_varint(int(value))
|
|
elif typ == STRING:
|
|
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
|
raw = raw.rstrip(b"\x00")
|
|
out += encode_varint(len(raw) + 1)
|
|
out += raw
|
|
out += b"\x00"
|
|
elif typ == BLOB:
|
|
raw = bytes(value)
|
|
out += encode_varint(len(raw))
|
|
out += raw
|
|
elif typ == STRUCT:
|
|
_enc_struct_body(value, out)
|
|
out += b"\x00"
|
|
elif typ == LIST: # UNVERIFIED
|
|
etype, items = value
|
|
out.append(etype & 0xFF)
|
|
out += encode_varint(len(items))
|
|
for it in items:
|
|
_enc_value(etype, it, out)
|
|
elif typ == MAP: # UNVERIFIED
|
|
ktype, vtype, items = value
|
|
out.append(ktype & 0xFF)
|
|
out.append(vtype & 0xFF)
|
|
out += encode_varint(len(items))
|
|
for k, v in items:
|
|
_enc_value(ktype, k, out)
|
|
_enc_value(vtype, v, out)
|
|
elif typ == UNION: # UNVERIFIED
|
|
key, member = value
|
|
out.append(key & 0xFF)
|
|
if key != UNION_UNSET and member is not None:
|
|
mtag, mtype, mval = member
|
|
out += encode_tag(mtag)
|
|
out.append(mtype & 0xFF)
|
|
_enc_value(mtype, mval, out)
|
|
elif typ == VARLIST: # UNVERIFIED
|
|
out += encode_varint(len(value))
|
|
for n in value:
|
|
out += encode_varint(int(n))
|
|
elif typ == OBJTYPE: # UNVERIFIED
|
|
comp, t = value
|
|
out += encode_varint(comp)
|
|
out += encode_varint(t)
|
|
elif typ == OBJID: # UNVERIFIED
|
|
comp, t, oid = value
|
|
out += encode_varint(comp)
|
|
out += encode_varint(t)
|
|
out += encode_varint(oid)
|
|
elif typ == FLOAT: # UNVERIFIED
|
|
out += struct.pack(">f", float(value))
|
|
else:
|
|
raise ValueError("cannot encode unknown TDF type 0x%02x" % typ)
|
|
|
|
|
|
def _enc_struct_body(fields, out: bytearray) -> None:
|
|
"""Serialize members in ascending packed-tag order (Blaze requirement)."""
|
|
if isinstance(fields, dict):
|
|
items = list(fields.items())
|
|
else: # allow [(tag, (type, value)), ...]
|
|
items = list(fields)
|
|
items.sort(key=lambda kv: tag_key(kv[0]))
|
|
for tag, tv in items:
|
|
typ, val = tv
|
|
out += encode_tag(tag)
|
|
out.append(typ & 0xFF)
|
|
_enc_value(typ, val, out)
|
|
|
|
|
|
def encode_tdf(fields) -> bytes:
|
|
"""Serialize a top-level TDF struct body (no trailing 0x00 terminator)."""
|
|
out = bytearray()
|
|
_enc_struct_body(fields, out)
|
|
return bytes(out)
|
|
|
|
|
|
# convenient aliases
|
|
build_tdf = encode_tdf
|
|
encode_struct = encode_tdf
|
|
|
|
|
|
# ---------------------------------------------------------------- decoder
|
|
|
|
def _dec_value(buf: bytes, i: int, typ: int):
|
|
if typ == INT:
|
|
return decode_varint(buf, i)
|
|
if typ == STRING:
|
|
ln, i = decode_varint(buf, i)
|
|
raw = buf[i:i + ln]
|
|
i += ln
|
|
return raw.rstrip(b"\x00").decode("utf-8", "replace"), i
|
|
if typ == BLOB:
|
|
ln, i = decode_varint(buf, i)
|
|
return bytes(buf[i:i + ln]), i + ln
|
|
if typ == STRUCT:
|
|
return _dec_struct_body(buf, i, terminated=True)
|
|
if typ == LIST:
|
|
etype = buf[i]; i += 1
|
|
n, i = decode_varint(buf, i)
|
|
items = []
|
|
for _ in range(n):
|
|
v, i = _dec_value(buf, i, etype)
|
|
items.append(v)
|
|
return (etype, items), i
|
|
if typ == MAP:
|
|
ktype = buf[i]; i += 1
|
|
vtype = buf[i]; i += 1
|
|
n, i = decode_varint(buf, i)
|
|
items = []
|
|
for _ in range(n):
|
|
k, i = _dec_value(buf, i, ktype)
|
|
v, i = _dec_value(buf, i, vtype)
|
|
items.append((k, v))
|
|
return (ktype, vtype, items), i
|
|
if typ == UNION:
|
|
key = buf[i]; i += 1
|
|
if key == UNION_UNSET:
|
|
return (key, None), i
|
|
mtag = decode_tag(buf[i:i + 3]); mtype = buf[i + 3]; i += 4
|
|
mval, i = _dec_value(buf, i, mtype)
|
|
return (key, (mtag, mtype, mval)), i
|
|
if typ == VARLIST:
|
|
n, i = decode_varint(buf, i)
|
|
out = []
|
|
for _ in range(n):
|
|
v, i = decode_varint(buf, i)
|
|
out.append(v)
|
|
return out, i
|
|
if typ == OBJTYPE:
|
|
c, i = decode_varint(buf, i)
|
|
t, i = decode_varint(buf, i)
|
|
return (c, t), i
|
|
if typ == OBJID:
|
|
c, i = decode_varint(buf, i)
|
|
t, i = decode_varint(buf, i)
|
|
o, i = decode_varint(buf, i)
|
|
return (c, t, o), i
|
|
if typ == FLOAT:
|
|
return struct.unpack(">f", buf[i:i + 4])[0], i + 4
|
|
raise ValueError("cannot decode unknown TDF type 0x%02x at %d" % (typ, i))
|
|
|
|
|
|
def _dec_struct_body(buf: bytes, i: int, terminated: bool, end: int = None):
|
|
"""Read fields until 0x00 terminator (nested) or `end` (top level)."""
|
|
if end is None:
|
|
end = len(buf)
|
|
fields = OrderedDict()
|
|
while i < end:
|
|
if terminated and buf[i] == 0x00:
|
|
i += 1
|
|
break
|
|
tag = decode_tag(buf[i:i + 3])
|
|
typ = buf[i + 3]
|
|
i += 4
|
|
val, i = _dec_value(buf, i, typ)
|
|
fields[tag] = (typ, val)
|
|
return fields, i
|
|
|
|
|
|
def decode_tdf(payload: bytes):
|
|
"""Decode a top-level TDF payload -> OrderedDict {tag: (type, value)}."""
|
|
fields, _ = _dec_struct_body(payload, 0, terminated=False)
|
|
return fields
|
|
|
|
|
|
# ---------------------------------------------------------------- Fire2
|
|
|
|
FIRE2_HEADER_LEN = 16
|
|
|
|
MSG_PING = 0x01
|
|
MSG_REQUEST = 0x02
|
|
MSG_RESPONSE = 0x03 # also seen as pong
|
|
MSG_NOTIFY = 0x04 # UNVERIFIED
|
|
MSG_ERROR = 0x05 # UNVERIFIED
|
|
|
|
|
|
def build_fire2_frame(component: int, command: int, msgType: int,
|
|
msgId: int, tdf_bytes: bytes) -> bytes:
|
|
"""16-byte big-endian Fire2 header + TDF payload."""
|
|
tdf_bytes = bytes(tdf_bytes)
|
|
hdr = struct.pack(">IHHHHB3s", len(tdf_bytes), 0, component & 0xFFFF,
|
|
command & 0xFFFF, msgId & 0xFFFF, msgType & 0xFF,
|
|
b"\x00\x00\x00")
|
|
return hdr + tdf_bytes
|
|
|
|
|
|
def parse_fire2_frame(data: bytes):
|
|
"""-> (dict header, bytes payload). Raises if the buffer is short."""
|
|
if len(data) < FIRE2_HEADER_LEN:
|
|
raise ValueError("short Fire2 frame")
|
|
(ln, zero, comp, cmd, msgid, mtype, reserved) = struct.unpack(
|
|
">IHHHHB3s", data[:FIRE2_HEADER_LEN])
|
|
payload = data[FIRE2_HEADER_LEN:FIRE2_HEADER_LEN + ln]
|
|
if len(payload) != ln:
|
|
raise ValueError("truncated Fire2 payload: want %d have %d"
|
|
% (ln, len(payload)))
|
|
hdr = {
|
|
"length": ln, "zero": zero, "component": comp, "command": cmd,
|
|
"msgId": msgid, "msgType": mtype, "reserved": reserved,
|
|
}
|
|
return hdr, payload
|
|
|
|
|
|
def decode_fire2(data: bytes):
|
|
"""-> (header dict, decoded TDF OrderedDict)"""
|
|
hdr, payload = parse_fire2_frame(data)
|
|
return hdr, decode_tdf(payload)
|
|
|
|
|
|
def encode_fire2(hdr: dict, fields) -> bytes:
|
|
"""Inverse of decode_fire2 (uses hdr's component/command/msgType/msgId)."""
|
|
return build_fire2_frame(hdr["component"], hdr["command"],
|
|
hdr["msgType"], hdr["msgId"], encode_tdf(fields))
|
|
|
|
|
|
# ---------------------------------------------------------------- pretty
|
|
|
|
def dump(fields, depth: int = 0) -> str:
|
|
pad = " " * depth
|
|
lines = []
|
|
for tag, (typ, val) in fields.items():
|
|
tn = TYPE_NAMES.get(typ, "0x%02x" % typ)
|
|
if typ == STRUCT:
|
|
lines.append("%s%s (struct) {" % (pad, tag))
|
|
lines.append(dump(val, depth + 1))
|
|
lines.append("%s}" % pad)
|
|
elif typ == BLOB:
|
|
lines.append("%s%s (blob[%d]) = %s" % (pad, tag, len(val), val.hex()))
|
|
else:
|
|
lines.append("%s%s (%s) = %r" % (pad, tag, tn, val))
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ---------------------------------------------------------------- self-test
|
|
|
|
CAPTURE = ("/home/alex/Documents/OpenFUT/fifa17-recon/captures/blaze/"
|
|
"blaze_fire2_37161.bin")
|
|
|
|
|
|
def _selftest(path: str = CAPTURE) -> bool:
|
|
ok = True
|
|
|
|
# unit: tag packing
|
|
for lbl in ("CDAT", "CINF", "FCCR", "LADD", "ENV", "LOC", "BSDK", "PTVR"):
|
|
enc = encode_tag(lbl)
|
|
assert decode_tag(enc) == lbl, (lbl, enc.hex())
|
|
assert encode_tag("CDAT") == bytes.fromhex("8e4874"), encode_tag("CDAT").hex()
|
|
assert encode_tag("ENV") == bytes.fromhex("96ed80"), encode_tag("ENV").hex()
|
|
assert encode_tag("LADD") == bytes.fromhex("b21924"), encode_tag("LADD").hex()
|
|
|
|
# unit: varint
|
|
assert encode_varint(0) == b"\x00"
|
|
assert encode_varint(4) == b"\x04"
|
|
assert encode_varint(0x3F) == b"\x3f"
|
|
assert encode_varint(0x40) == bytes.fromhex("8001")
|
|
assert encode_varint(0x656E5553) == bytes.fromhex("93d5f2d60c")
|
|
for n in (0, 1, 63, 64, 127, 128, 8191, 0x656E5553, 2**40, 2**63 - 1):
|
|
v, j = decode_varint(encode_varint(n), 0)
|
|
assert v == n and j == len(encode_varint(n)), n
|
|
|
|
# round trip the real capture
|
|
original = open(path, "rb").read()
|
|
hdr, payload = parse_fire2_frame(original)
|
|
fields = decode_tdf(payload)
|
|
re_payload = encode_tdf(fields)
|
|
re_frame = encode_fire2(hdr, fields)
|
|
|
|
print("header:", hdr)
|
|
print(dump(fields))
|
|
print()
|
|
print("payload %d -> %d bytes" % (len(payload), len(re_payload)))
|
|
print("frame %d -> %d bytes" % (len(original), len(re_frame)))
|
|
|
|
if re_frame == original:
|
|
print("ROUND-TRIP: PASS (byte-identical, %d bytes)" % len(original))
|
|
else:
|
|
ok = False
|
|
print("ROUND-TRIP: FAIL")
|
|
n = min(len(re_frame), len(original))
|
|
for k in range(n):
|
|
if re_frame[k] != original[k]:
|
|
print(" first diff at 0x%04x: got %02x want %02x"
|
|
% (k, re_frame[k], original[k]))
|
|
print(" got %s" % re_frame[max(0, k - 8):k + 16].hex())
|
|
print(" want %s" % original[max(0, k - 8):k + 16].hex())
|
|
break
|
|
else:
|
|
print(" length differs only: %d vs %d" % (len(re_frame), len(original)))
|
|
return ok
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
p = sys.argv[1] if len(sys.argv) > 1 else CAPTURE
|
|
raise SystemExit(0 if _selftest(p) else 1)
|