openfut-protocol-blaze: generic Blaze protocol layer, oracle-tested
First Rust component of the Python -> Rust migration. Chosen first because
it is the lowest genuinely game-independent layer, it has an executable
oracle, and both existing Rust implementations of it are wrong.
Contents:
* fire2 -- the proven 16-byte frame header, frame/stream splitting
* heat2 -- tag packing, varints, all 11 TDF value types
* message -- frame + decoded body, routed by NUMERIC component/command
* diagnostics -- dumps for capture review
No FIFA 17 command tables, response schemas or notification IDs: this layer
knows 0x0009/0x0007 is component 9, command 7, not that it means
Util::preAuth. That mapping belongs to a game adapter, which is what lets a
future FIFA 18/23 adapter reuse this.
Parity is tested, not asserted. fixtures/generate.py drives the proven
Python responders (heat2.py, blaze_responder_v3b.py) and freezes 56 vectors
-- 31 of them real payloads from the responder's own builders, including
the 11.8 KB preAuth reply. tests/oracle_parity.rs replays every one
byte-for-byte. 54 tests green; clippy clean.
Supersedes two wrong framings, neither of which is removed yet:
* fifa-blaze/crates/blaze-proto/frame.rs -- a 12-byte header with a u16
length, nibble-packed type/options, an error field and a JUMBO flag.
A documented guess at FIFA 23 predating the FIFA 17 recon.
* heat2.py::build_fire2_frame -- packs >IHHHHB3s, msgId at [10:12] and
msgType at [12]. Dead code, but its docstring still states that layout.
Confidence is carried in the types: TypeId::is_verified() reports which
layouts are capture-backed (int/string/blob/struct) and which the oracle
marks UNVERIFIED (list/map/union/varlist/objtype/objid/float), with a test
asserting the unverified ones stay flagged.
Cargo.lock is deliberately NOT included: it re-resolves ~240 lines against
the current registry even without this crate, so that churn is pre-existing
and does not belong in a foundation commit.
The Python backend remains the live runtime and is untouched. Nothing
consumes this crate yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"openfut-core",
|
||||
"openfut-protocol-blaze",
|
||||
"openfut-bridge",
|
||||
"openfut-launcher",
|
||||
"openfut-launcher/openfut-hook",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "openfut-protocol-blaze"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Game-independent EA Blaze wire protocol: Fire2 framing and Heat2/TDF codec"
|
||||
publish = false
|
||||
|
||||
# NO RUNTIME DEPENDENCIES, ON PURPOSE.
|
||||
#
|
||||
# This crate is a byte-level codec. Its oracle (fifa17-recon/tools/heat2.py) is
|
||||
# stdlib-only, and every byte it emits has to match that oracle exactly, so
|
||||
# there is nothing here for a third-party crate to do that std cannot.
|
||||
#
|
||||
# The obvious candidate would be the crates.io `tdf` crate, which fifa-blaze
|
||||
# already depends on. It is deliberately NOT used: it targets a generic
|
||||
# BlazeSDK 15.x dialect, and adopting it would silently substitute someone
|
||||
# else's reading of the format for our own captured evidence. It stays useful
|
||||
# as an independent cross-check, not as the implementation.
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
# Fixture vectors are JSONL produced by the Python oracle; tests need to read
|
||||
# them. Test-only, so it never reaches a shipped artifact.
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,103 @@
|
||||
# openfut-protocol-blaze
|
||||
|
||||
Game-independent EA Blaze wire protocol: **Fire2** framing and the **Heat2/TDF**
|
||||
codec.
|
||||
|
||||
This is the first Rust component of the Python → Rust migration. It was chosen
|
||||
first because it is the lowest layer that is genuinely game-independent, it has
|
||||
an executable oracle, and both existing Rust implementations of it are wrong.
|
||||
|
||||
## Scope
|
||||
|
||||
| In | Out |
|
||||
|---|---|
|
||||
| Fire2 16-byte frame header, frame/stream splitting | Component and command *name* tables |
|
||||
| Heat2 tag packing, varints, the 11 value types | Notification IDs, response schemas |
|
||||
| A message = frame + decoded body, routed by number | Login sequencing, session identity |
|
||||
| Diagnostics dumps | Anything FIFA-17-specific |
|
||||
|
||||
The boundary test: *could FIFA 18 or FIFA 23 use this without importing FIFA
|
||||
17's command tables?* This crate knows `0x0009/0x0007` is component 9, command
|
||||
7. That it means `Util::preAuth` is a FIFA 17 fact, and belongs in a FIFA 17
|
||||
adapter.
|
||||
|
||||
## Provenance
|
||||
|
||||
Ported from `fifa17-recon/tools/heat2.py` (TDF codec) and
|
||||
`fifa17-recon/tools/blaze_responder_v3b.py` (Fire2 framing) — the Python
|
||||
implementation that drove a retail FIFA 17 client from Origin login to an
|
||||
opened FUT pack. That implementation is the project's behavioural oracle, and
|
||||
this crate does not claim parity with it, it *tests* parity against it.
|
||||
|
||||
### Two superseded framing implementations
|
||||
|
||||
Both other Fire2 implementations in this repository are wrong for FIFA 17, and
|
||||
this crate exists partly to replace them:
|
||||
|
||||
* **`fifa-blaze/crates/blaze-proto/src/frame.rs`** — a **12-byte** header with a
|
||||
`u16` length, nibble-packed type/options, an `error` field, and a JUMBO-frame
|
||||
escape flag. Its own doc comment says FIFA 23's variant "is UNKNOWN" and that
|
||||
Fire2 was picked because it "is the most likely candidate". It predates the
|
||||
FIFA 17 recon and nothing has since validated it.
|
||||
* **`fifa17-recon/tools/heat2.py::build_fire2_frame`** — packs `>IHHHHB3s`,
|
||||
putting a `u16 msgId` at `[10:12]` and `msgType` at `[12]`. Dead code: the
|
||||
responder carries a comment telling callers not to use it, and it is not on
|
||||
any live path. Its module docstring still describes the old layout.
|
||||
|
||||
The proven layout is 16 bytes, with a `u24 msgNum` at `[10:13]` and
|
||||
`(msgType << 5) | userIndex` in byte 13. There is **no error field** (that is
|
||||
Fire v1) and **no jumbo flag** (the length is already a `u32`). The
|
||||
`payload_over_64kib_needs_no_jumbo_flag` test is the direct refutation.
|
||||
|
||||
## Confidence is not uniform
|
||||
|
||||
`int`, `string`, `blob` and `struct` are validated byte-exact against captured
|
||||
FIFA 17 traffic. `list`, `map`, `union`, `varlist`, `objtype`, `objid` and
|
||||
`float` are marked UNVERIFIED in the oracle — they are absent from every capture
|
||||
we hold. `TypeId::is_verified()` reports which is which, and a test asserts the
|
||||
unverified ones stay flagged, so "Rust and Python agree" can never be quietly
|
||||
read as "this is how EA does it".
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cargo test -p openfut-protocol-blaze # 43 unit + 10 differential + 1 doc
|
||||
./check-parity.sh # regenerate fixtures, then re-verify
|
||||
```
|
||||
|
||||
Differential vectors live in `fixtures/`, generated by `fixtures/generate.py`
|
||||
from the Python oracle. 25 of the TDF vectors and 6 of the Fire2 vectors are
|
||||
`origin: "live"` — real payloads from the responder's own builders, including
|
||||
the preAuth reply (~11.8 KB) that is the first RPC FIFA 17 sends.
|
||||
|
||||
Comparison is **byte-for-byte**. FIFA 17's deserialisers hard-freeze on an
|
||||
unexpected shape, so "semantically equivalent" is not a useful category at this
|
||||
layer. (It *is* the right call one layer up at UTAS/JSON — see
|
||||
`fifa17-recon/tools/test_fut_contract.py`.)
|
||||
|
||||
Regenerate fixtures after any change to the Python oracle:
|
||||
|
||||
```bash
|
||||
python3 fixtures/generate.py # rewrite
|
||||
python3 fixtures/generate.py --check # assert committed files are current
|
||||
```
|
||||
|
||||
## No runtime dependencies
|
||||
|
||||
Deliberate. This is a byte-level codec whose oracle is stdlib-only Python; there
|
||||
is nothing here a third-party crate can do that `std` cannot. In particular the
|
||||
crates.io `tdf` crate (already a `fifa-blaze` dependency) is **not** used: it
|
||||
targets a generic BlazeSDK 15.x dialect, and adopting it would substitute
|
||||
someone else's reading of the format for our own captured evidence. It remains
|
||||
useful as an independent cross-check, not as the implementation.
|
||||
|
||||
`serde_json` is a dev-dependency only, for reading fixture files.
|
||||
|
||||
## Not yet done
|
||||
|
||||
* No TLS, no sockets, no async. Framing and codec only; transport belongs to
|
||||
whatever hosts this.
|
||||
* The Blaze **redirector** (HTTPS + XML `getServerInstance`) is a different
|
||||
protocol and is not here.
|
||||
* Nothing consumes this crate yet. The Python backend remains the live runtime,
|
||||
untouched.
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Differential check: Rust vs the proven Python FIFA 17 backend.
|
||||
#
|
||||
# 1. assert the committed fixtures still match what the Python oracle emits
|
||||
# 2. replay every fixture through the Rust implementation, byte-for-byte
|
||||
#
|
||||
# Read-only with respect to the running backend: the oracle is imported as a
|
||||
# library, no responder is started, no port is bound, no live service is
|
||||
# touched. Safe to run while the Python backend is serving a live FIFA client.
|
||||
#
|
||||
# Use --regen to rewrite the fixtures after an intentional oracle change.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$(readlink -f "$0")")"
|
||||
|
||||
if [[ "${1:-}" == "--regen" ]]; then
|
||||
echo "==> regenerating fixtures from the Python oracle"
|
||||
python3 fixtures/generate.py
|
||||
else
|
||||
echo "==> checking committed fixtures against the Python oracle"
|
||||
python3 fixtures/generate.py --check
|
||||
fi
|
||||
|
||||
echo "==> replaying fixtures through the Rust implementation"
|
||||
cargo test -p openfut-protocol-blaze
|
||||
|
||||
echo
|
||||
echo "PARITY OK — Rust matches the Python oracle byte-for-byte."
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate protocol fixtures from the PROVEN Python FIFA 17 backend.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
The Python backend in `fifa17-recon/tools/` is the behavioural oracle: it is the
|
||||
implementation that actually walked a retail FIFA 17 client from Origin login to
|
||||
an opened FUT pack. Nothing in Rust may claim parity with it by assertion; parity
|
||||
has to be *replayable*.
|
||||
|
||||
The `captures/` directory that earlier findings cite is gitignored (`captures/`,
|
||||
`*.bin`), so the original wire captures are not recoverable from Git. The next
|
||||
best oracle is the Python code that produced the bytes the client accepted:
|
||||
`heat2.encode_tdf` (Heat2/TDF codec) and `blaze_responder_v3b.fire2` (Fire2
|
||||
framing). This script freezes their output into committed, machine-testable
|
||||
vectors so the Rust port can be diffed byte-for-byte, forever, without a running
|
||||
FIFA client.
|
||||
|
||||
WHAT IS AND IS NOT AN ORACLE HERE
|
||||
---------------------------------
|
||||
* "live" vectors are built from the responder's real payload builders — the
|
||||
exact field trees FIFA 17 accepted in the working run. Highest confidence.
|
||||
* "synthetic" vectors exercise codec edge cases (varint boundaries, tag
|
||||
padding, empty string, nesting, every type byte). They prove the Rust codec
|
||||
matches the Python codec; they do NOT prove either matches EA, because the
|
||||
complex types (list/map/union/varlist/objtype/objid/float) are flagged
|
||||
UNVERIFIED in heat2.py's own docstring. Vectors carry that flag through so a
|
||||
future reader cannot mistake self-consistency for wire truth.
|
||||
|
||||
NO SECRETS. The FIFA 17 identity in these fixtures (persona 33068179 / "CAGE")
|
||||
is the project's fixed synthetic offline identity, not a credential. Session keys
|
||||
are generated from a seeded PRNG below so output is deterministic and carries no
|
||||
entropy from any real session.
|
||||
|
||||
Usage: python3 fixtures/generate.py (writes tdf.jsonl + fire2.jsonl)
|
||||
python3 fixtures/generate.py --check (verify committed files are current)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
TOOLS = os.path.normpath(os.path.join(HERE, "..", "..", "fifa17-recon", "tools"))
|
||||
if not os.path.isdir(TOOLS):
|
||||
sys.exit("cannot find the Python oracle at %s" % TOOLS)
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
# Read our own flags BEFORE clearing argv. The responders inspect sys.argv at
|
||||
# import time (blaze_responder_v3b honours --selftest), so it has to be emptied
|
||||
# for them — but doing that first would silently swallow --check and turn the
|
||||
# staleness guard into a no-op that always rewrites and always passes.
|
||||
CHECK_ONLY = "--check" in sys.argv[1:]
|
||||
sys.argv = [sys.argv[0]]
|
||||
import heat2 # noqa: E402
|
||||
import blaze_responder_v3b as B # noqa: E402
|
||||
|
||||
TYPE_NAME = {
|
||||
heat2.INT: "int", heat2.STRING: "string", heat2.BLOB: "blob",
|
||||
heat2.STRUCT: "struct", heat2.LIST: "list", heat2.MAP: "map",
|
||||
heat2.UNION: "union", heat2.VARLIST: "varlist", heat2.OBJTYPE: "objtype",
|
||||
heat2.OBJID: "objid", heat2.FLOAT: "float",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- value model
|
||||
#
|
||||
# A TDF value is serialised to JSON as {"t": <type-name>, ...}. The shape per
|
||||
# type mirrors heat2.py's Python representation exactly so the Rust side can
|
||||
# rebuild an identical tree and re-encode it.
|
||||
|
||||
def val_to_json(typ: int, value):
|
||||
t = TYPE_NAME[typ]
|
||||
if typ == heat2.INT:
|
||||
return {"t": t, "v": int(value)}
|
||||
if typ == heat2.STRING:
|
||||
s = value.decode("utf-8") if isinstance(value, (bytes, bytearray)) else value
|
||||
return {"t": t, "v": s}
|
||||
if typ == heat2.BLOB:
|
||||
return {"t": t, "v": bytes(value).hex()}
|
||||
if typ == heat2.STRUCT:
|
||||
return {"t": t, "v": fields_to_json(value)}
|
||||
if typ == heat2.LIST:
|
||||
etype, items = value
|
||||
return {"t": t, "elem": TYPE_NAME[etype],
|
||||
"v": [val_to_json(etype, it) for it in items]}
|
||||
if typ == heat2.MAP:
|
||||
ktype, vtype, items = value
|
||||
return {"t": t, "key": TYPE_NAME[ktype], "val": TYPE_NAME[vtype],
|
||||
"v": [[val_to_json(ktype, k), val_to_json(vtype, v)]
|
||||
for k, v in items]}
|
||||
if typ == heat2.UNION:
|
||||
key, member = value
|
||||
if member is None:
|
||||
return {"t": t, "key": key, "member": None}
|
||||
mtag, mtype, mval = member
|
||||
return {"t": t, "key": key,
|
||||
"member": {"tag": mtag, "value": val_to_json(mtype, mval)}}
|
||||
if typ == heat2.VARLIST:
|
||||
return {"t": t, "v": [int(n) for n in value]}
|
||||
if typ == heat2.OBJTYPE:
|
||||
return {"t": t, "v": [int(value[0]), int(value[1])]}
|
||||
if typ == heat2.OBJID:
|
||||
return {"t": t, "v": [int(value[0]), int(value[1]), int(value[2])]}
|
||||
if typ == heat2.FLOAT:
|
||||
return {"t": t, "v": float(value)}
|
||||
raise ValueError("unhandled TDF type 0x%02x" % typ)
|
||||
|
||||
|
||||
def fields_to_json(fields):
|
||||
"""Ordered [[tag, value], ...]. Order is informational: both encoders sort
|
||||
by packed tag, and the fixture's encoded_hex is what actually binds."""
|
||||
items = list(fields.items()) if isinstance(fields, dict) else list(fields)
|
||||
return [[tag, val_to_json(typ, val)] for tag, (typ, val) in items]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- collection
|
||||
|
||||
TDF_VECTORS = []
|
||||
FIRE2_VECTORS = []
|
||||
|
||||
|
||||
def tdf_case(name, fields, *, origin, verified, note=""):
|
||||
"""Encode with the oracle, prove it round-trips, and record the vector."""
|
||||
encoded = heat2.encode_tdf(fields)
|
||||
decoded = heat2.decode_tdf(encoded)
|
||||
reencoded = heat2.encode_tdf(decoded)
|
||||
if reencoded != encoded:
|
||||
raise AssertionError("oracle does not round-trip: %s" % name)
|
||||
TDF_VECTORS.append(OrderedDict((
|
||||
("name", name),
|
||||
("origin", origin), # "live" | "synthetic"
|
||||
("verified", verified), # False => heat2.py marks the layout UNVERIFIED
|
||||
("note", note),
|
||||
("fields", fields_to_json(fields)),
|
||||
("encoded_hex", encoded.hex()),
|
||||
)))
|
||||
|
||||
|
||||
def fire2_case(name, *, component, command, msg_num, msg_type, payload,
|
||||
metadata=b"", user_index=0, options=0, origin, note=""):
|
||||
frame = B.fire2(component, command, msg_num, msg_type, payload,
|
||||
metadata=metadata, user_index=user_index, options=options)
|
||||
hdr = B.parse_fire2_header(frame)
|
||||
assert hdr["component"] == component and hdr["command"] == command
|
||||
assert hdr["msg_num"] == msg_num and hdr["msg_type"] == msg_type
|
||||
assert hdr["user_index"] == user_index and hdr["options"] == options
|
||||
assert hdr["payload_len"] == len(payload)
|
||||
assert hdr["metadata_len"] == len(metadata)
|
||||
FIRE2_VECTORS.append(OrderedDict((
|
||||
("name", name),
|
||||
("origin", origin),
|
||||
("note", note),
|
||||
("component", component),
|
||||
("command", command),
|
||||
("msg_num", msg_num),
|
||||
("msg_type", msg_type),
|
||||
("user_index", user_index),
|
||||
("options", options),
|
||||
("metadata_hex", metadata.hex()),
|
||||
("payload_hex", payload.hex()),
|
||||
("frame_hex", frame.hex()),
|
||||
)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- live vectors
|
||||
#
|
||||
# These field trees come from the responder's own builders — the payloads the
|
||||
# retail client accepted during the working end-to-end run.
|
||||
|
||||
# A fixed epoch (2025-08-11T00:00:00Z) so regeneration is reproducible and
|
||||
# `--check` is meaningful.
|
||||
FIXED_NOW = 1754870400
|
||||
|
||||
|
||||
def collect_live():
|
||||
random.seed(0xF17A) # deterministic forged session key
|
||||
sess = B.Session()
|
||||
now = FIXED_NOW
|
||||
|
||||
builders = [
|
||||
("preauth_response", lambda: B.preauth_response_fields()),
|
||||
("ping_response", lambda: B.ping_response_fields()),
|
||||
("qos_config", lambda: B.qos_config()),
|
||||
("census_subscribe_response",
|
||||
lambda: B.subscribe_census_data_updates_response_fields()),
|
||||
("persona_details", lambda: B.persona_details_fields(now)),
|
||||
("user_login_info", lambda: B.user_login_info_fields(sess, now)),
|
||||
("login_response", lambda: B.login_response_fields(sess)),
|
||||
("account_info", lambda: B.account_info_fields(sess, now)),
|
||||
("persona_info", lambda: B.persona_info_fields(now)),
|
||||
("get_persona_response", lambda: B.get_persona_response_fields(now)),
|
||||
("list_personas_response", lambda: B.list_personas_response_fields(now)),
|
||||
("user_session_login_info",
|
||||
lambda: B.user_session_login_info_fields(sess, now)),
|
||||
("network_qos_data", lambda: B.network_qos_data_fields()),
|
||||
("user_session_extended_data",
|
||||
lambda: B.user_session_extended_data_fields()),
|
||||
("user_session_extended_data_update",
|
||||
lambda: B.user_session_extended_data_update_fields()),
|
||||
("user_identification", lambda: B.user_identification_fields(sess)),
|
||||
("user_data", lambda: B.user_data_fields(sess)),
|
||||
("post_auth_response", lambda: B.post_auth_response_fields(sess)),
|
||||
("entitlements_response", lambda: B.entitlements_response_fields()),
|
||||
("get_auth_token_response",
|
||||
lambda: B.get_auth_token_response_fields(sess)),
|
||||
("user_settings_response", lambda: B.user_settings_response_fields()),
|
||||
("get_lists_response", lambda: B.get_lists_response_fields()),
|
||||
]
|
||||
for name, fn in builders:
|
||||
fields = fn()
|
||||
if not isinstance(fields, (dict, OrderedDict)):
|
||||
raise AssertionError("%s did not return a TDF struct" % name)
|
||||
tdf_case("live/" + name, fields, origin="live", verified=True,
|
||||
note="payload builder from blaze_responder_v3b.py")
|
||||
|
||||
for cfid in ("BlazeSDK", "UTAS", "FUT"):
|
||||
try:
|
||||
fields = B.fetch_config_response_fields(cfid)
|
||||
except Exception:
|
||||
continue
|
||||
tdf_case("live/fetch_config_%s" % cfid, fields, origin="live",
|
||||
verified=True, note="fetchClientConfig CFID=%s" % cfid)
|
||||
|
||||
# --- framing: the real login burst, exactly as the client received it.
|
||||
login_payload = heat2.encode_tdf(B.login_response_fields(sess))
|
||||
fire2_case("live/login_reply", component=B.COMP_AUTH, command=B.CMD_LOGIN,
|
||||
msg_num=7, msg_type=B.REPLY, payload=login_payload,
|
||||
origin="live", note="Authentication::login REPLY")
|
||||
|
||||
for tag, frame in B.build_login_notifications(sess, now):
|
||||
hdr = B.parse_fire2_header(frame)
|
||||
body = frame[16 + hdr["metadata_len"]:]
|
||||
fire2_case("live/notify_%s" % str(tag).lower().replace(" ", "_"),
|
||||
component=hdr["component"], command=hdr["command"],
|
||||
msg_num=hdr["msg_num"], msg_type=hdr["msg_type"],
|
||||
payload=body, user_index=hdr["user_index"],
|
||||
options=hdr["options"], origin="live",
|
||||
note="post-login server push")
|
||||
|
||||
fire2_case("live/preauth_reply", component=B.COMP_UTIL,
|
||||
command=B.CMD_PREAUTH, msg_num=1, msg_type=B.REPLY,
|
||||
payload=heat2.encode_tdf(B.preauth_response_fields()),
|
||||
origin="live", note="Util::preAuth REPLY, the first RPC")
|
||||
fire2_case("live/ping_reply", component=B.COMP_UTIL, command=B.CMD_PING,
|
||||
msg_num=2, msg_type=B.REPLY,
|
||||
payload=heat2.encode_tdf(B.ping_response_fields()),
|
||||
origin="live", note="Util::ping REPLY")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ synthetic vectors
|
||||
|
||||
def collect_synthetic():
|
||||
I, S, BL, ST = heat2.INT, heat2.STRING, heat2.BLOB, heat2.STRUCT
|
||||
L, M, U = heat2.LIST, heat2.MAP, heat2.UNION
|
||||
VL, OT, OI, F = heat2.VARLIST, heat2.OBJTYPE, heat2.OBJID, heat2.FLOAT
|
||||
|
||||
def case(name, fields, verified=True, note=""):
|
||||
tdf_case("synthetic/" + name, fields, origin="synthetic",
|
||||
verified=verified, note=note)
|
||||
|
||||
# Varint boundaries. The first byte carries only 6 data bits, so 0x3F/0x40
|
||||
# is the one-to-two byte edge; the rest are the 7-bit group edges.
|
||||
case("varint_boundaries", OrderedDict([
|
||||
("V000", (I, 0)), ("V001", (I, 1)), ("V03F", (I, 0x3F)),
|
||||
("V040", (I, 0x40)), ("V07F", (I, 0x7F)), ("V080", (I, 0x80)),
|
||||
("V1FF", (I, 0x1FFF)), ("V200", (I, 0x2000)),
|
||||
("VBIG", (I, 0xFFFFFFFF)), ("VMAX", (I, 0x7FFFFFFFFFFFFFFF)),
|
||||
]), note="6-bit first group then 7-bit groups")
|
||||
|
||||
case("varint_negative", OrderedDict([("NEG1", (I, -1)), ("NEGB", (I, -300))]),
|
||||
verified=False,
|
||||
note="sign bit 0x40 is UNVERIFIED in heat2.py; never seen on the wire")
|
||||
|
||||
# Tag packing: 6-bit chars, space padded to 4. Short tags and the full
|
||||
# 4-char case must both survive a round trip.
|
||||
case("tag_padding", OrderedDict([
|
||||
("A", (I, 1)), ("AB", (I, 2)), ("ABC", (I, 3)), ("ABCD", (I, 4)),
|
||||
("ENV", (S, "prod")), ("Z", (I, 26)),
|
||||
]), note="trailing-space padding must strip on decode")
|
||||
|
||||
case("tag_ordering", OrderedDict([
|
||||
("ZZZZ", (I, 3)), ("AAAA", (I, 1)), ("MMMM", (I, 2)),
|
||||
]), note="members must serialise in ascending packed-tag order")
|
||||
|
||||
case("strings", OrderedDict([
|
||||
("EMPT", (S, "")), ("ONEC", (S, "x")),
|
||||
("LONG", (S, "x" * 200)),
|
||||
("UTF8", (S, "Grün-Weiß Ünïcode")),
|
||||
]), note="length varint INCLUDES the NUL terminator")
|
||||
|
||||
case("blobs", OrderedDict([
|
||||
("BEMP", (BL, b"")), ("BONE", (BL, b"\x00")),
|
||||
("BFUL", (BL, bytes(range(256)))),
|
||||
]), note="blob length varint EXCLUDES any terminator")
|
||||
|
||||
case("nested_structs", OrderedDict([
|
||||
("OUTR", (ST, OrderedDict([
|
||||
("INNR", (ST, OrderedDict([
|
||||
("DEEP", (ST, OrderedDict([("LEAF", (I, 42))]))),
|
||||
("SIBL", (S, "sibling")),
|
||||
]))),
|
||||
("AFTR", (I, 7)),
|
||||
]))),
|
||||
("TAIL", (I, 9)),
|
||||
]), note="nested structs are 0x00 terminated; the top level is not")
|
||||
|
||||
case("empty_struct", OrderedDict([("MTST", (ST, OrderedDict()))]),
|
||||
note="an empty nested struct is a bare 0x00")
|
||||
|
||||
case("lists", OrderedDict([
|
||||
("LINT", (L, (I, [1, 2, 3, 0x40, 0x2000]))),
|
||||
("LSTR", (L, (S, ["a", "bb", ""]))),
|
||||
("LEMP", (L, (I, []))),
|
||||
("LST2", (L, (ST, [OrderedDict([("A", (I, 1))]),
|
||||
OrderedDict([("B", (I, 2))])]))),
|
||||
]), verified=False, note="list layout UNVERIFIED (absent from the capture)")
|
||||
|
||||
# A tag is exactly 4 packed 6-bit chars. Longer labels are TRUNCATED, not
|
||||
# rejected, so "LSTR2" and "LSTR" are the same wire tag and one silently
|
||||
# overwrites the other in a struct. Byte-level round-trip still holds.
|
||||
case("tag_truncation", OrderedDict([("LONGTAG", (I, 1))]),
|
||||
note="labels over 4 chars truncate silently — a collision hazard")
|
||||
|
||||
case("maps", OrderedDict([
|
||||
("MSI", (M, (S, I, [("one", 1), ("two", 2)]))),
|
||||
("MII", (M, (I, I, [(1, 10), (2, 20)]))),
|
||||
("MEMP", (M, (S, I, []))),
|
||||
]), verified=False, note="map layout UNVERIFIED")
|
||||
|
||||
case("unions", OrderedDict([
|
||||
("USET", (U, (0, ("VALU", I, 1234)))),
|
||||
("UUNS", (U, (heat2.UNION_UNSET, None))),
|
||||
]), verified=False, note="union layout UNVERIFIED")
|
||||
|
||||
case("scalar_misc", OrderedDict([
|
||||
("VLST", (VL, [1, 0x40, 0x2000])),
|
||||
("OTYP", (OT, (4, 1))),
|
||||
("OIDD", (OI, (4, 1, 33068179))),
|
||||
("FLTP", (F, 1.5)),
|
||||
("FLTN", (F, -0.25)),
|
||||
("FLTZ", (F, 0.0)),
|
||||
]), verified=False,
|
||||
note="varintlist/objtype/objid/float layouts UNVERIFIED; float is >f")
|
||||
|
||||
# Framing edge cases.
|
||||
fire2_case("synthetic/empty_payload", component=0x0009, command=0x0002,
|
||||
msg_num=0, msg_type=B.MESSAGE, payload=b"", origin="synthetic",
|
||||
note="a zero-length body is legal (ping)")
|
||||
fire2_case("synthetic/msgnum_24bit", component=0x0001, command=0x000A,
|
||||
msg_num=0xFFFFFF, msg_type=B.REPLY, payload=b"\x01\x02",
|
||||
origin="synthetic", note="msgNum is a 24-bit field at [10:13]")
|
||||
fire2_case("synthetic/user_index_max", component=0x7802, command=0x0008,
|
||||
msg_num=1, msg_type=B.NOTIFICATION, payload=b"\xaa",
|
||||
user_index=0x1F, origin="synthetic",
|
||||
note="byte[13] = (msgType<<5) | (userIndex & 0x1F)")
|
||||
fire2_case("synthetic/with_metadata", component=0x0009, command=0x0007,
|
||||
msg_num=5, msg_type=B.MESSAGE, payload=b"payload",
|
||||
metadata=b"\xde\xad\xbe\xef", origin="synthetic",
|
||||
note="wire = header(16) || metadata || payload")
|
||||
fire2_case("synthetic/options_byte", component=0x0009, command=0x0007,
|
||||
msg_num=5, msg_type=B.MESSAGE, payload=b"x", options=0x42,
|
||||
origin="synthetic", note="byte[14] is the options byte")
|
||||
for mt, label in ((B.MESSAGE, "message"), (B.REPLY, "reply"),
|
||||
(B.NOTIFICATION, "notification"),
|
||||
(B.ERROR_REPLY, "error_reply"), (B.PING, "ping"),
|
||||
(B.PING_REPLY, "ping_reply")):
|
||||
fire2_case("synthetic/msgtype_%s" % label, component=0x0009,
|
||||
command=0x0001, msg_num=3, msg_type=mt, payload=b"\x00",
|
||||
origin="synthetic", note="msgType %d in the top 3 bits of byte[13]" % mt)
|
||||
fire2_case("synthetic/large_payload", component=0x0001, command=0x000A,
|
||||
msg_num=1, msg_type=B.REPLY, payload=bytes(70000 * [0x5A]),
|
||||
origin="synthetic",
|
||||
note="payloadLen is a full u32 — Fire2 has no jumbo-flag escape")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- output
|
||||
|
||||
def write(path, vectors):
|
||||
body = "".join(json.dumps(v, separators=(",", ":")) + "\n" for v in vectors)
|
||||
if CHECK_ONLY:
|
||||
if not os.path.exists(path):
|
||||
sys.exit("MISSING: %s has never been generated" % path)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
if fh.read() != body:
|
||||
sys.exit("STALE: %s does not match the oracle; re-run without "
|
||||
"--check" % path)
|
||||
print("current: %s (%d vectors)" % (os.path.basename(path), len(vectors)))
|
||||
return
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(body)
|
||||
print("wrote %s (%d vectors)" % (os.path.basename(path), len(vectors)))
|
||||
|
||||
|
||||
def frozen_clock():
|
||||
"""Pin the oracle's clock for the duration of sampling.
|
||||
|
||||
Some builders take a `now` argument; others (`ping_response_fields`,
|
||||
`login_response_fields`) call `int(time.time())` internally and ignore it.
|
||||
Left alone, every regeneration produces different bytes for those vectors,
|
||||
which makes `--check` permanently red and the committed fixtures
|
||||
unreproducible.
|
||||
|
||||
This patches the clock the oracle reads, not the oracle's logic — the
|
||||
encoding path under test is untouched. Restored in a finally block so an
|
||||
imported responder is never left with a frozen clock.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
original = _time.time
|
||||
_time.time = lambda: float(FIXED_NOW)
|
||||
return original, _time
|
||||
|
||||
|
||||
def main():
|
||||
original_time, time_mod = frozen_clock()
|
||||
try:
|
||||
collect_live()
|
||||
finally:
|
||||
time_mod.time = original_time
|
||||
collect_synthetic()
|
||||
write(os.path.join(HERE, "tdf.jsonl"), TDF_VECTORS)
|
||||
write(os.path.join(HERE, "fire2.jsonl"), FIRE2_VECTORS)
|
||||
live = sum(1 for v in TDF_VECTORS if v["origin"] == "live")
|
||||
print("TDF: %d vectors (%d live, %d synthetic); Fire2: %d vectors"
|
||||
% (len(TDF_VECTORS), live, len(TDF_VECTORS) - live, len(FIRE2_VECTORS)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,202 @@
|
||||
//! Human-readable dumps for debugging and capture review.
|
||||
//!
|
||||
//! Diagnostics only: nothing here is part of the wire contract, and no output
|
||||
//! format should ever be parsed back.
|
||||
|
||||
use crate::fire2::{Frame, Header};
|
||||
use crate::heat2::{Struct, Value};
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// Render a TDF struct as an indented tree, mirroring `heat2.py::dump`.
|
||||
pub fn dump_struct(s: &Struct) -> String {
|
||||
let mut out = String::new();
|
||||
write_struct(s, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn write_struct(s: &Struct, depth: usize, out: &mut String) {
|
||||
for (tag, value) in s.iter() {
|
||||
let pad = " ".repeat(depth);
|
||||
let _ = write!(out, "{pad}{tag} ({})", value.type_id().name());
|
||||
match value {
|
||||
Value::Struct(inner) => {
|
||||
let _ = writeln!(out, " {{");
|
||||
write_struct(inner, depth + 1, out);
|
||||
let _ = writeln!(out, "{pad}}}");
|
||||
}
|
||||
Value::List { elem, items } => {
|
||||
let _ = writeln!(out, " [{} x {}]", elem.name(), items.len());
|
||||
for item in items {
|
||||
write_value(item, depth + 1, out);
|
||||
}
|
||||
}
|
||||
Value::Map { key, val, entries } => {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {{{} -> {} x {}}}",
|
||||
key.name(),
|
||||
val.name(),
|
||||
entries.len()
|
||||
);
|
||||
for (k, v) in entries {
|
||||
write_value(k, depth + 1, out);
|
||||
write_value(v, depth + 2, out);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let _ = writeln!(out, " = {}", scalar(other));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_value(value: &Value, depth: usize, out: &mut String) {
|
||||
let pad = " ".repeat(depth);
|
||||
match value {
|
||||
Value::Struct(inner) => {
|
||||
let _ = writeln!(out, "{pad}{{");
|
||||
write_struct(inner, depth + 1, out);
|
||||
let _ = writeln!(out, "{pad}}}");
|
||||
}
|
||||
other => {
|
||||
let _ = writeln!(out, "{pad}{}", scalar(other));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Int(v) => format!("{v}"),
|
||||
Value::String(s) => format!("{s:?}"),
|
||||
Value::Blob(b) => format!("<{} bytes> {}", b.len(), hex(&b[..b.len().min(32)])),
|
||||
Value::VarList(v) => format!("{v:?}"),
|
||||
Value::ObjType { component, ty } => format!("({component}, {ty})"),
|
||||
Value::ObjId { component, ty, id } => format!("({component}, {ty}, {id})"),
|
||||
Value::Float(f) => format!("{f}"),
|
||||
Value::Union { key, member } => match member {
|
||||
Some(m) => format!("union[{key}] {} = {}", m.0, scalar(&m.1)),
|
||||
None => format!("union[{key}] unset"),
|
||||
},
|
||||
Value::Struct(_) | Value::List { .. } | Value::Map { .. } => String::from("..."),
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line summary of a Fire2 header.
|
||||
pub fn describe_header(h: &Header) -> String {
|
||||
format!(
|
||||
"component=0x{:04x} command=0x{:04x} {} msgNum={} userIdx={} opts=0x{:02x} \
|
||||
payload={}B metadata={}B",
|
||||
h.component,
|
||||
h.command,
|
||||
h.msg_type.name(),
|
||||
h.msg_num,
|
||||
h.user_index,
|
||||
h.options,
|
||||
h.payload_len,
|
||||
h.metadata_len
|
||||
)
|
||||
}
|
||||
|
||||
/// Header summary plus a decoded body, falling back to hex when it will not
|
||||
/// parse. A capture is most valuable exactly when the body is malformed, so
|
||||
/// this must never fail.
|
||||
pub fn describe_frame(frame: &Frame) -> String {
|
||||
let mut out = describe_header(&frame.header);
|
||||
out.push('\n');
|
||||
if frame.payload.is_empty() {
|
||||
out.push_str("(empty payload)\n");
|
||||
return out;
|
||||
}
|
||||
match crate::heat2::decode(&frame.payload) {
|
||||
Ok(body) => out.push_str(&dump_struct(&body)),
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "(TDF decode failed: {e})");
|
||||
out.push_str(&hexdump(&frame.payload, 256));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
let _ = write!(s, "{b:02x}");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Classic offset / hex / ASCII dump, truncated to `limit` bytes.
|
||||
pub fn hexdump(bytes: &[u8], limit: usize) -> String {
|
||||
let shown = &bytes[..bytes.len().min(limit)];
|
||||
let mut out = String::new();
|
||||
for (row, chunk) in shown.chunks(16).enumerate() {
|
||||
let _ = write!(out, "{:08x} ", row * 16);
|
||||
for i in 0..16 {
|
||||
match chunk.get(i) {
|
||||
Some(b) => {
|
||||
let _ = write!(out, "{b:02x} ");
|
||||
}
|
||||
None => out.push_str(" "),
|
||||
}
|
||||
if i == 7 {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
out.push_str(" |");
|
||||
for b in chunk {
|
||||
out.push(if (0x20..0x7F).contains(b) {
|
||||
*b as char
|
||||
} else {
|
||||
'.'
|
||||
});
|
||||
}
|
||||
out.push_str("|\n");
|
||||
}
|
||||
if bytes.len() > limit {
|
||||
let _ = writeln!(out, "... {} more bytes", bytes.len() - limit);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fire2::MsgType;
|
||||
use crate::heat2;
|
||||
|
||||
#[test]
|
||||
fn dumps_nested_structures() {
|
||||
let s = Struct::new().with("PID", Value::Int(33068179)).with(
|
||||
"CINF",
|
||||
Value::Struct(Struct::new().with("ENV", Value::String("prod".into()))),
|
||||
);
|
||||
let text = dump_struct(&s);
|
||||
assert!(text.contains("PID (int) = 33068179"), "{text}");
|
||||
assert!(text.contains("ENV (string) = \"prod\""), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describes_a_frame_with_an_undecodable_body() {
|
||||
// 0xFF is not a TDF type byte, so this must fall back to hex.
|
||||
let frame = Frame::new(9, 7, 1, MsgType::Reply, vec![0xFF; 8]);
|
||||
let text = describe_frame(&frame);
|
||||
assert!(text.contains("decode failed"), "{text}");
|
||||
assert!(text.contains("ff ff ff"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describes_a_frame_with_a_good_body() {
|
||||
let body = Struct::new().with("A", Value::Int(1));
|
||||
let frame = Frame::new(9, 7, 1, MsgType::Reply, heat2::encode(&body));
|
||||
let text = describe_frame(&frame);
|
||||
assert!(text.contains("REPLY"), "{text}");
|
||||
assert!(text.contains("A (int) = 1"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hexdump_truncates_and_says_so() {
|
||||
let text = hexdump(&[0x41; 100], 32);
|
||||
assert!(text.contains("68 more bytes"), "{text}");
|
||||
assert!(text.contains("AAAA"), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Decode errors.
|
||||
//!
|
||||
//! Encoding cannot fail: every `Value` is representable on the wire. Decoding
|
||||
//! is fallible because the input is attacker-shaped bytes from a socket.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// Ran off the end of the buffer while reading `what`.
|
||||
Truncated {
|
||||
what: &'static str,
|
||||
need: usize,
|
||||
have: usize,
|
||||
},
|
||||
/// A type byte that is not one of the eleven Heat2 types.
|
||||
UnknownType { type_byte: u8, at: usize },
|
||||
/// A varint whose continuation bits never terminated within 64 bits.
|
||||
VarintOverflow { at: usize },
|
||||
/// A Fire2 header whose declared lengths cannot be satisfied.
|
||||
ShortFrame { need: usize, have: usize },
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::Truncated { what, need, have } => write!(
|
||||
f,
|
||||
"truncated while reading {what}: need {need} bytes, have {have}"
|
||||
),
|
||||
Error::UnknownType { type_byte, at } => {
|
||||
write!(f, "unknown TDF type byte 0x{type_byte:02x} at offset {at}")
|
||||
}
|
||||
Error::VarintOverflow { at } => {
|
||||
write!(f, "varint at offset {at} exceeds 64 bits")
|
||||
}
|
||||
Error::ShortFrame { need, have } => {
|
||||
write!(f, "short Fire2 frame: need {need} bytes, have {have}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,416 @@
|
||||
//! Fire2 framing.
|
||||
//!
|
||||
//! ```text
|
||||
//! [0:4] u32 payload length
|
||||
//! [4:6] u16 metadata length
|
||||
//! [6:8] u16 component
|
||||
//! [8:10] u16 command (notification id when msgType == NOTIFICATION)
|
||||
//! [10:13] u24 msgNum
|
||||
//! [13] u8 (msgType << 5) | (userIndex & 0x1F)
|
||||
//! [14] u8 options
|
||||
//! [15] u8 reserved
|
||||
//!
|
||||
//! wire = header(16) || metadata || payload
|
||||
//! ```
|
||||
//!
|
||||
//! All fields big-endian. Sixteen bytes, not twelve.
|
||||
//!
|
||||
//! # Two wrong layouts this replaces
|
||||
//!
|
||||
//! This is the layout in `blaze_responder_v3b.py::fire2` — the code that
|
||||
//! actually drove a retail FIFA 17 client through login. Two other
|
||||
//! implementations in this repository disagree and are both wrong for FIFA 17:
|
||||
//!
|
||||
//! * `fifa17-recon/tools/heat2.py::build_fire2_frame` packs
|
||||
//! `>IHHHHB3s`, which puts a `u16 msgId` at `[10:12]` and `msgType` at
|
||||
//! `[12]`. Its own module docstring still describes that layout. The
|
||||
//! responder carries a comment telling callers not to use it.
|
||||
//! * `fifa-blaze/crates/blaze-proto/src/frame.rs` implements a *12-byte*
|
||||
//! header with a `u16` length, nibble-packed type/options, and a JUMBO
|
||||
//! flag. That was a documented guess at FIFA 23's variant, written before
|
||||
//! the FIFA 17 recon; nothing has since validated it.
|
||||
//!
|
||||
//! There is **no error field** in a Fire2 header — that belongs to Fire v1's
|
||||
//! 12-byte frame. There is also no jumbo-frame escape: the length is already a
|
||||
//! full `u32`.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Header size in bytes.
|
||||
pub const HEADER_LEN: usize = 16;
|
||||
|
||||
/// `msgType`, the top three bits of byte 13.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MsgType {
|
||||
Message,
|
||||
Reply,
|
||||
Notification,
|
||||
ErrorReply,
|
||||
Ping,
|
||||
PingReply,
|
||||
/// 6 and 7 are representable in three bits but have never been observed.
|
||||
Unknown(u8),
|
||||
}
|
||||
|
||||
impl MsgType {
|
||||
pub fn from_bits(bits: u8) -> MsgType {
|
||||
match bits & 0x07 {
|
||||
0 => MsgType::Message,
|
||||
1 => MsgType::Reply,
|
||||
2 => MsgType::Notification,
|
||||
3 => MsgType::ErrorReply,
|
||||
4 => MsgType::Ping,
|
||||
5 => MsgType::PingReply,
|
||||
other => MsgType::Unknown(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_bits(self) -> u8 {
|
||||
match self {
|
||||
MsgType::Message => 0,
|
||||
MsgType::Reply => 1,
|
||||
MsgType::Notification => 2,
|
||||
MsgType::ErrorReply => 3,
|
||||
MsgType::Ping => 4,
|
||||
MsgType::PingReply => 5,
|
||||
MsgType::Unknown(v) => v & 0x07,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
MsgType::Message => "MESSAGE",
|
||||
MsgType::Reply => "REPLY",
|
||||
MsgType::Notification => "NOTIFICATION",
|
||||
MsgType::ErrorReply => "ERROR_REPLY",
|
||||
MsgType::Ping => "PING",
|
||||
MsgType::PingReply => "PING_REPLY",
|
||||
MsgType::Unknown(_) => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed Fire2 header.
|
||||
///
|
||||
/// `component`/`command` are plain integers on purpose. Naming them is a
|
||||
/// game-specific concern: a FIFA 17 adapter owns the tables that turn
|
||||
/// `0x0009/0x0007` into `Util::preAuth`, and a future FIFA 18 or FIFA 23
|
||||
/// adapter may map the same numbers differently.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Header {
|
||||
pub payload_len: u32,
|
||||
pub metadata_len: u16,
|
||||
pub component: u16,
|
||||
pub command: u16,
|
||||
/// 24-bit request correlator.
|
||||
pub msg_num: u32,
|
||||
pub msg_type: MsgType,
|
||||
/// 5-bit local user slot.
|
||||
pub user_index: u8,
|
||||
pub options: u8,
|
||||
pub reserved: u8,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
pub fn new(component: u16, command: u16, msg_num: u32, msg_type: MsgType) -> Header {
|
||||
Header {
|
||||
payload_len: 0,
|
||||
metadata_len: 0,
|
||||
component,
|
||||
command,
|
||||
msg_num,
|
||||
msg_type,
|
||||
user_index: 0,
|
||||
options: 0,
|
||||
reserved: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total wire size of this frame: header + metadata + payload.
|
||||
pub fn frame_len(&self) -> usize {
|
||||
HEADER_LEN + self.metadata_len as usize + self.payload_len as usize
|
||||
}
|
||||
|
||||
/// Parse a header from the first 16 bytes of `buf`.
|
||||
pub fn parse(buf: &[u8]) -> Result<Header> {
|
||||
if buf.len() < HEADER_LEN {
|
||||
return Err(Error::ShortFrame {
|
||||
need: HEADER_LEN,
|
||||
have: buf.len(),
|
||||
});
|
||||
}
|
||||
Ok(Header {
|
||||
payload_len: u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]),
|
||||
metadata_len: u16::from_be_bytes([buf[4], buf[5]]),
|
||||
component: u16::from_be_bytes([buf[6], buf[7]]),
|
||||
command: u16::from_be_bytes([buf[8], buf[9]]),
|
||||
msg_num: ((buf[10] as u32) << 16) | ((buf[11] as u32) << 8) | buf[12] as u32,
|
||||
msg_type: MsgType::from_bits(buf[13] >> 5),
|
||||
user_index: buf[13] & 0x1F,
|
||||
options: buf[14],
|
||||
reserved: buf[15],
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialise the 16 header bytes.
|
||||
pub fn write(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.payload_len.to_be_bytes());
|
||||
out.extend_from_slice(&self.metadata_len.to_be_bytes());
|
||||
out.extend_from_slice(&self.component.to_be_bytes());
|
||||
out.extend_from_slice(&self.command.to_be_bytes());
|
||||
out.push(((self.msg_num >> 16) & 0xFF) as u8);
|
||||
out.push(((self.msg_num >> 8) & 0xFF) as u8);
|
||||
out.push((self.msg_num & 0xFF) as u8);
|
||||
out.push((self.msg_type.as_bits() << 5) | (self.user_index & 0x1F));
|
||||
out.push(self.options);
|
||||
out.push(self.reserved);
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> [u8; HEADER_LEN] {
|
||||
let mut v = Vec::with_capacity(HEADER_LEN);
|
||||
self.write(&mut v);
|
||||
let mut out = [0u8; HEADER_LEN];
|
||||
out.copy_from_slice(&v);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete Fire2 frame: header plus its metadata and payload bytes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Frame {
|
||||
pub header: Header,
|
||||
pub metadata: Vec<u8>,
|
||||
/// Raw payload bytes. Undecoded on purpose — the payload is Heat2/TDF, but
|
||||
/// framing must stay usable for capture and diagnostics even when the body
|
||||
/// does not parse.
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// Build a frame, deriving the length fields from the buffers.
|
||||
pub fn new(
|
||||
component: u16,
|
||||
command: u16,
|
||||
msg_num: u32,
|
||||
msg_type: MsgType,
|
||||
payload: Vec<u8>,
|
||||
) -> Frame {
|
||||
let mut header = Header::new(component, command, msg_num, msg_type);
|
||||
header.payload_len = payload.len() as u32;
|
||||
Frame {
|
||||
header,
|
||||
metadata: Vec::new(),
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_metadata(mut self, metadata: Vec<u8>) -> Frame {
|
||||
self.header.metadata_len = metadata.len() as u16;
|
||||
self.metadata = metadata;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_user_index(mut self, user_index: u8) -> Frame {
|
||||
self.header.user_index = user_index & 0x1F;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_options(mut self, options: u8) -> Frame {
|
||||
self.header.options = options;
|
||||
self
|
||||
}
|
||||
|
||||
/// A reply echoes component, command, msgNum and userIndex verbatim, and
|
||||
/// changes only the msgType bits.
|
||||
pub fn reply_to(request: &Header, payload: Vec<u8>) -> Frame {
|
||||
let mut frame = Frame::new(
|
||||
request.component,
|
||||
request.command,
|
||||
request.msg_num,
|
||||
MsgType::Reply,
|
||||
payload,
|
||||
);
|
||||
frame.header.user_index = request.user_index;
|
||||
frame
|
||||
}
|
||||
|
||||
/// An unsolicited server push. `msgNum` is 0: notifications are not
|
||||
/// correlated to any request.
|
||||
pub fn notification(component: u16, notify_id: u16, payload: Vec<u8>) -> Frame {
|
||||
Frame::new(component, notify_id, 0, MsgType::Notification, payload)
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(self.wire_len());
|
||||
self.encode_into(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn encode_into(&self, out: &mut Vec<u8>) {
|
||||
// Trust the buffers over any stale length in the header.
|
||||
let mut header = self.header;
|
||||
header.payload_len = self.payload.len() as u32;
|
||||
header.metadata_len = self.metadata.len() as u16;
|
||||
header.write(out);
|
||||
out.extend_from_slice(&self.metadata);
|
||||
out.extend_from_slice(&self.payload);
|
||||
}
|
||||
|
||||
pub fn wire_len(&self) -> usize {
|
||||
HEADER_LEN + self.metadata.len() + self.payload.len()
|
||||
}
|
||||
|
||||
/// Parse one frame from the front of `buf`, returning it and the number of
|
||||
/// bytes consumed.
|
||||
pub fn parse(buf: &[u8]) -> Result<(Frame, usize)> {
|
||||
let header = Header::parse(buf)?;
|
||||
let total = header.frame_len();
|
||||
if buf.len() < total {
|
||||
return Err(Error::ShortFrame {
|
||||
need: total,
|
||||
have: buf.len(),
|
||||
});
|
||||
}
|
||||
let meta_end = HEADER_LEN + header.metadata_len as usize;
|
||||
Ok((
|
||||
Frame {
|
||||
header,
|
||||
metadata: buf[HEADER_LEN..meta_end].to_vec(),
|
||||
payload: buf[meta_end..total].to_vec(),
|
||||
},
|
||||
total,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// How many bytes a frame starting at `buf` needs in total, if its header is
|
||||
/// complete. `None` means the header itself has not arrived yet.
|
||||
///
|
||||
/// This is what a stream reader needs: Blaze frames arrive coalesced and split
|
||||
/// across TCP segments, so a reader must size each frame before consuming it.
|
||||
pub fn frame_size_hint(buf: &[u8]) -> Option<usize> {
|
||||
Header::parse(buf).ok().map(|h| h.frame_len())
|
||||
}
|
||||
|
||||
/// Split a buffer into as many whole frames as it contains.
|
||||
///
|
||||
/// Returns the frames plus the number of bytes consumed; any trailing partial
|
||||
/// frame is left for the caller to retry once more bytes arrive.
|
||||
pub fn parse_all(buf: &[u8]) -> Result<(Vec<Frame>, usize)> {
|
||||
let mut frames = Vec::new();
|
||||
let mut off = 0;
|
||||
while off < buf.len() {
|
||||
match Frame::parse(&buf[off..]) {
|
||||
Ok((frame, used)) => {
|
||||
off += used;
|
||||
frames.push(frame);
|
||||
}
|
||||
Err(Error::ShortFrame { .. }) => break,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok((frames, off))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn header_is_sixteen_bytes() {
|
||||
let h = Header::new(0x0009, 0x0007, 1, MsgType::Reply);
|
||||
assert_eq!(h.to_bytes().len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_offsets_match_the_proven_layout() {
|
||||
let mut h = Header::new(0x0009, 0x0007, 0x123456, MsgType::Reply);
|
||||
h.payload_len = 0x11223344;
|
||||
h.metadata_len = 0x5566;
|
||||
h.user_index = 3;
|
||||
h.options = 0x42;
|
||||
let b = h.to_bytes();
|
||||
assert_eq!(&b[0..4], &[0x11, 0x22, 0x33, 0x44]); // payload len u32
|
||||
assert_eq!(&b[4..6], &[0x55, 0x66]); // metadata len u16
|
||||
assert_eq!(&b[6..8], &[0x00, 0x09]); // component
|
||||
assert_eq!(&b[8..10], &[0x00, 0x07]); // command
|
||||
assert_eq!(&b[10..13], &[0x12, 0x34, 0x56]); // msgNum u24
|
||||
assert_eq!(b[13], (1 << 5) | 3); // msgType | userIndex
|
||||
assert_eq!(b[14], 0x42);
|
||||
assert_eq!(b[15], 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_bit_pattern_is_0x20_and_notification_is_0x40() {
|
||||
let reply = Frame::new(1, 0x0A, 7, MsgType::Reply, vec![]).encode();
|
||||
assert_eq!(reply[13], 0x20);
|
||||
let notify = Frame::notification(0x7802, 0x0008, vec![]).encode();
|
||||
assert_eq!(notify[13], 0x40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_length_exceeds_sixteen_bits_without_a_jumbo_flag() {
|
||||
// The stale 12-byte implementation would have needed an escape here.
|
||||
let frame = Frame::new(1, 1, 1, MsgType::Reply, vec![0x5A; 70_000]);
|
||||
let bytes = frame.encode();
|
||||
let parsed = Header::parse(&bytes).unwrap();
|
||||
assert_eq!(parsed.payload_len, 70_000);
|
||||
assert_eq!(bytes.len(), 16 + 70_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_with_metadata() {
|
||||
let frame = Frame::new(0x0009, 0x0007, 5, MsgType::Message, b"payload".to_vec())
|
||||
.with_metadata(vec![0xde, 0xad, 0xbe, 0xef]);
|
||||
let bytes = frame.encode();
|
||||
let (back, used) = Frame::parse(&bytes).unwrap();
|
||||
assert_eq!(used, bytes.len());
|
||||
assert_eq!(back.metadata, vec![0xde, 0xad, 0xbe, 0xef]);
|
||||
assert_eq!(back.payload, b"payload");
|
||||
assert_eq!(back.encode(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_echoes_routing_fields() {
|
||||
let req = Header {
|
||||
user_index: 5,
|
||||
..Header::new(0x0001, 0x000A, 0x2222, MsgType::Message)
|
||||
};
|
||||
let reply = Frame::reply_to(&req, vec![1, 2, 3]);
|
||||
assert_eq!(reply.header.component, 0x0001);
|
||||
assert_eq!(reply.header.command, 0x000A);
|
||||
assert_eq!(reply.header.msg_num, 0x2222);
|
||||
assert_eq!(reply.header.user_index, 5);
|
||||
assert_eq!(reply.header.msg_type, MsgType::Reply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msg_num_is_twenty_four_bits() {
|
||||
let frame = Frame::new(1, 1, 0xFFFFFF, MsgType::Reply, vec![]);
|
||||
let back = Header::parse(&frame.encode()).unwrap();
|
||||
assert_eq!(back.msg_num, 0xFFFFFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_coalesced_frames_and_leaves_a_partial_tail() {
|
||||
let a = Frame::new(9, 2, 1, MsgType::Reply, vec![1]).encode();
|
||||
let b = Frame::new(1, 10, 2, MsgType::Reply, vec![2, 3]).encode();
|
||||
let mut stream = a.clone();
|
||||
stream.extend_from_slice(&b);
|
||||
stream.extend_from_slice(&[0x00, 0x00]); // partial third header
|
||||
|
||||
let (frames, used) = parse_all(&stream).unwrap();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(used, a.len() + b.len());
|
||||
assert_eq!(frames[1].payload, vec![2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_buffer_reports_what_it_needs() {
|
||||
assert_eq!(
|
||||
Header::parse(&[0u8; 8]),
|
||||
Err(Error::ShortFrame { need: 16, have: 8 })
|
||||
);
|
||||
assert_eq!(frame_size_hint(&[0u8; 8]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Heat2 decoder.
|
||||
//!
|
||||
//! Input is bytes off a socket, so every read is bounds-checked and every
|
||||
//! failure is an `Error`, never a panic. The oracle (`heat2.py`) indexes
|
||||
//! optimistically and would raise on malformed input; matching its *bytes* is
|
||||
//! required, matching its *crash behaviour* is not.
|
||||
|
||||
use super::tag::Tag;
|
||||
use super::value::{Struct, TypeId, Value, UNION_UNSET};
|
||||
use super::varint;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Decode a top-level payload body (unterminated, delimited by `buf`).
|
||||
pub fn decode(buf: &[u8]) -> Result<Struct> {
|
||||
let (s, _) = decode_members(buf, 0, buf.len(), false)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Read members until `end` (top level) or a `0x00` terminator (nested).
|
||||
fn decode_members(
|
||||
buf: &[u8],
|
||||
mut i: usize,
|
||||
end: usize,
|
||||
terminated: bool,
|
||||
) -> Result<(Struct, usize)> {
|
||||
let mut fields = Vec::new();
|
||||
while i < end {
|
||||
if terminated && buf[i] == 0x00 {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
if i + 4 > end {
|
||||
return Err(Error::Truncated {
|
||||
what: "field header",
|
||||
need: 4,
|
||||
have: end - i,
|
||||
});
|
||||
}
|
||||
let tag = Tag([buf[i], buf[i + 1], buf[i + 2]]);
|
||||
let type_byte = buf[i + 3];
|
||||
let ty = TypeId::from_byte(type_byte).ok_or(Error::UnknownType {
|
||||
type_byte,
|
||||
at: i + 3,
|
||||
})?;
|
||||
i += 4;
|
||||
let (value, next) = decode_value(buf, i, end, ty)?;
|
||||
i = next;
|
||||
fields.push((tag, value));
|
||||
}
|
||||
Ok((Struct { fields }, i))
|
||||
}
|
||||
|
||||
fn decode_value(buf: &[u8], i: usize, end: usize, ty: TypeId) -> Result<(Value, usize)> {
|
||||
match ty {
|
||||
TypeId::Int => {
|
||||
let (v, i) = varint::decode(buf, i)?;
|
||||
Ok((Value::Int(v), i))
|
||||
}
|
||||
|
||||
TypeId::String => {
|
||||
let (len, i) = varint::decode(buf, i)?;
|
||||
let len = checked_len(len, i, end, "string")?;
|
||||
let raw = &buf[i..i + len];
|
||||
// The declared length includes the NUL; strip any trailing NULs so
|
||||
// the value round-trips through the encoder unchanged.
|
||||
let cut = raw.iter().rposition(|&b| b != 0).map_or(0, |p| p + 1);
|
||||
let s = String::from_utf8_lossy(&raw[..cut]).into_owned();
|
||||
Ok((Value::String(s), i + len))
|
||||
}
|
||||
|
||||
TypeId::Blob => {
|
||||
let (len, i) = varint::decode(buf, i)?;
|
||||
let len = checked_len(len, i, end, "blob")?;
|
||||
Ok((Value::Blob(buf[i..i + len].to_vec()), i + len))
|
||||
}
|
||||
|
||||
TypeId::Struct => {
|
||||
let (s, i) = decode_members(buf, i, end, true)?;
|
||||
Ok((Value::Struct(s), i))
|
||||
}
|
||||
|
||||
TypeId::List => {
|
||||
let elem_byte = *byte_at(buf, i, end, "list element type")?;
|
||||
let elem = TypeId::from_byte(elem_byte).ok_or(Error::UnknownType {
|
||||
type_byte: elem_byte,
|
||||
at: i,
|
||||
})?;
|
||||
let (count, mut i) = varint::decode(buf, i + 1)?;
|
||||
let count = checked_count(count, end - i.min(end), "list")?;
|
||||
let mut items = Vec::with_capacity(count.min(1024));
|
||||
for _ in 0..count {
|
||||
let (v, next) = decode_value(buf, i, end, elem)?;
|
||||
i = next;
|
||||
items.push(v);
|
||||
}
|
||||
Ok((Value::List { elem, items }, i))
|
||||
}
|
||||
|
||||
TypeId::Map => {
|
||||
let key_byte = *byte_at(buf, i, end, "map key type")?;
|
||||
let val_byte = *byte_at(buf, i + 1, end, "map value type")?;
|
||||
let key = TypeId::from_byte(key_byte).ok_or(Error::UnknownType {
|
||||
type_byte: key_byte,
|
||||
at: i,
|
||||
})?;
|
||||
let val = TypeId::from_byte(val_byte).ok_or(Error::UnknownType {
|
||||
type_byte: val_byte,
|
||||
at: i + 1,
|
||||
})?;
|
||||
let (count, mut i) = varint::decode(buf, i + 2)?;
|
||||
let count = checked_count(count, end - i.min(end), "map")?;
|
||||
let mut entries = Vec::with_capacity(count.min(1024));
|
||||
for _ in 0..count {
|
||||
let (k, next) = decode_value(buf, i, end, key)?;
|
||||
let (v, next) = decode_value(buf, next, end, val)?;
|
||||
i = next;
|
||||
entries.push((k, v));
|
||||
}
|
||||
Ok((Value::Map { key, val, entries }, i))
|
||||
}
|
||||
|
||||
TypeId::Union => {
|
||||
let key = *byte_at(buf, i, end, "union discriminator")?;
|
||||
let i = i + 1;
|
||||
if key == UNION_UNSET {
|
||||
return Ok((Value::Union { key, member: None }, i));
|
||||
}
|
||||
if i + 4 > end {
|
||||
return Err(Error::Truncated {
|
||||
what: "union member header",
|
||||
need: 4,
|
||||
have: end.saturating_sub(i),
|
||||
});
|
||||
}
|
||||
let tag = Tag([buf[i], buf[i + 1], buf[i + 2]]);
|
||||
let mtype_byte = buf[i + 3];
|
||||
let mtype = TypeId::from_byte(mtype_byte).ok_or(Error::UnknownType {
|
||||
type_byte: mtype_byte,
|
||||
at: i + 3,
|
||||
})?;
|
||||
let (value, i) = decode_value(buf, i + 4, end, mtype)?;
|
||||
Ok((
|
||||
Value::Union {
|
||||
key,
|
||||
member: Some(Box::new((tag, value))),
|
||||
},
|
||||
i,
|
||||
))
|
||||
}
|
||||
|
||||
TypeId::VarList => {
|
||||
let (count, mut i) = varint::decode(buf, i)?;
|
||||
let count = checked_count(count, end - i.min(end), "varlist")?;
|
||||
let mut items = Vec::with_capacity(count.min(1024));
|
||||
for _ in 0..count {
|
||||
let (v, next) = varint::decode(buf, i)?;
|
||||
i = next;
|
||||
items.push(v);
|
||||
}
|
||||
Ok((Value::VarList(items), i))
|
||||
}
|
||||
|
||||
TypeId::ObjType => {
|
||||
let (component, i) = varint::decode(buf, i)?;
|
||||
let (ty, i) = varint::decode(buf, i)?;
|
||||
Ok((Value::ObjType { component, ty }, i))
|
||||
}
|
||||
|
||||
TypeId::ObjId => {
|
||||
let (component, i) = varint::decode(buf, i)?;
|
||||
let (ty, i) = varint::decode(buf, i)?;
|
||||
let (id, i) = varint::decode(buf, i)?;
|
||||
Ok((Value::ObjId { component, ty, id }, i))
|
||||
}
|
||||
|
||||
TypeId::Float => {
|
||||
if i + 4 > end {
|
||||
return Err(Error::Truncated {
|
||||
what: "float",
|
||||
need: 4,
|
||||
have: end.saturating_sub(i),
|
||||
});
|
||||
}
|
||||
let f = f32::from_be_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]);
|
||||
Ok((Value::Float(f), i + 4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn byte_at<'a>(buf: &'a [u8], i: usize, end: usize, what: &'static str) -> Result<&'a u8> {
|
||||
if i >= end {
|
||||
return Err(Error::Truncated {
|
||||
what,
|
||||
need: 1,
|
||||
have: 0,
|
||||
});
|
||||
}
|
||||
buf.get(i).ok_or(Error::Truncated {
|
||||
what,
|
||||
need: 1,
|
||||
have: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn checked_len(len: i64, i: usize, end: usize, what: &'static str) -> Result<usize> {
|
||||
let available = end.saturating_sub(i);
|
||||
if len < 0 || len as u64 > available as u64 {
|
||||
return Err(Error::Truncated {
|
||||
what,
|
||||
need: len.max(0) as usize,
|
||||
have: available,
|
||||
});
|
||||
}
|
||||
Ok(len as usize)
|
||||
}
|
||||
|
||||
/// Reject a declared element count that cannot fit in the remaining bytes.
|
||||
///
|
||||
/// Without this a two-byte varint can ask for a billion elements and the
|
||||
/// allocation, not the parse, becomes the failure.
|
||||
fn checked_count(count: i64, remaining: usize, what: &'static str) -> Result<usize> {
|
||||
if count < 0 || count as u64 > remaining as u64 {
|
||||
return Err(Error::Truncated {
|
||||
what,
|
||||
need: count.max(0) as usize,
|
||||
have: remaining,
|
||||
});
|
||||
}
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::encode::encode;
|
||||
use super::*;
|
||||
|
||||
fn round_trip(s: Struct) {
|
||||
let bytes = encode(&s);
|
||||
let back = decode(&bytes).expect("decodes");
|
||||
assert_eq!(encode(&back), bytes, "re-encode must be byte-identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_scalars() {
|
||||
round_trip(
|
||||
Struct::new()
|
||||
.with("INTV", Value::Int(0x2000))
|
||||
.with("STRV", Value::String("hello".into()))
|
||||
.with("BLBV", Value::Blob(vec![0, 1, 2, 255]))
|
||||
.with("FLTV", Value::Float(-0.25)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_nesting() {
|
||||
round_trip(
|
||||
Struct::new()
|
||||
.with(
|
||||
"OUTR",
|
||||
Value::Struct(
|
||||
Struct::new()
|
||||
.with(
|
||||
"INNR",
|
||||
Value::Struct(Struct::new().with("LEAF", Value::Int(42))),
|
||||
)
|
||||
.with("SIBL", Value::String("s".into())),
|
||||
),
|
||||
)
|
||||
.with("TAIL", Value::Int(9)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_terminator_does_not_swallow_following_members() {
|
||||
let s = Struct::new()
|
||||
.with(
|
||||
"AAAA",
|
||||
Value::Struct(Struct::new().with("X", Value::Int(1))),
|
||||
)
|
||||
.with("BBBB", Value::Int(7));
|
||||
let back = decode(&encode(&s)).unwrap();
|
||||
assert_eq!(back.len(), 2);
|
||||
assert_eq!(back.get("BBBB").and_then(Value::as_int), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_unknown_type_byte() {
|
||||
// tag "AAAA" then type 0x0B, which is not a Heat2 type.
|
||||
let mut bytes = Tag::from_label("AAAA").as_bytes().to_vec();
|
||||
bytes.push(0x0B);
|
||||
assert!(matches!(
|
||||
decode(&bytes),
|
||||
Err(Error::UnknownType {
|
||||
type_byte: 0x0B,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_string_longer_than_the_buffer() {
|
||||
let mut bytes = Tag::from_label("S").as_bytes().to_vec();
|
||||
bytes.push(TypeId::String.as_byte());
|
||||
bytes.push(0x3F); // claims 63 bytes
|
||||
bytes.extend_from_slice(b"short");
|
||||
assert!(matches!(decode(&bytes), Err(Error::Truncated { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_absurd_list_count_without_allocating() {
|
||||
let mut bytes = Tag::from_label("L").as_bytes().to_vec();
|
||||
bytes.push(TypeId::List.as_byte());
|
||||
bytes.push(TypeId::Int.as_byte());
|
||||
bytes.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0x7F]); // huge count
|
||||
assert!(matches!(decode(&bytes), Err(Error::Truncated { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_truncated_field_header() {
|
||||
let bytes = vec![0x96, 0xed]; // two bytes of a three-byte tag
|
||||
assert!(decode(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_panics_on_arbitrary_bytes() {
|
||||
// Cheap structured fuzz: every 3-byte prefix followed by each type byte.
|
||||
for type_byte in 0x00u8..=0x0C {
|
||||
for pattern in [0x00u8, 0x01, 0x7F, 0x80, 0xFF] {
|
||||
let bytes = vec![pattern, pattern, pattern, type_byte, pattern, pattern];
|
||||
let _ = decode(&bytes); // must return, not panic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Heat2 encoder.
|
||||
//!
|
||||
//! Field layout is `3-byte packed tag || 1 type byte || value`.
|
||||
//!
|
||||
//! Two rules are easy to get wrong and both are load-bearing:
|
||||
//!
|
||||
//! 1. **Members serialise in ascending packed-tag order.** Not source order,
|
||||
//! not alphabetical order of the label — packed-tag order. (They coincide
|
||||
//! for equal-length uppercase labels, which is why a bug here hides.)
|
||||
//! 2. **A nested struct is terminated by `0x00`; the top-level payload is
|
||||
//! not.** The top level is delimited by the Fire2 length instead.
|
||||
//!
|
||||
//! Encoding is infallible: every `Value` has a wire form.
|
||||
|
||||
use super::tag::Tag;
|
||||
use super::value::{Struct, Value, UNION_UNSET};
|
||||
use super::varint;
|
||||
|
||||
/// Encode a top-level payload body (no trailing terminator).
|
||||
pub fn encode(s: &Struct) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_into(s, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Encode a top-level payload body, appending to `out`.
|
||||
pub fn encode_into(s: &Struct, out: &mut Vec<u8>) {
|
||||
encode_members(s, out);
|
||||
}
|
||||
|
||||
fn encode_members(s: &Struct, out: &mut Vec<u8>) {
|
||||
// Stable sort by packed tag: equal tags keep their relative order, so a
|
||||
// decoded frame containing duplicates re-encodes identically.
|
||||
let mut ordered: Vec<&(Tag, Value)> = s.fields.iter().collect();
|
||||
ordered.sort_by_key(|(tag, _)| *tag);
|
||||
|
||||
for (tag, value) in ordered {
|
||||
out.extend_from_slice(&tag.as_bytes());
|
||||
out.push(value.type_id().as_byte());
|
||||
encode_value(value, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_value(value: &Value, out: &mut Vec<u8>) {
|
||||
match value {
|
||||
Value::Int(v) => varint::encode(*v, out),
|
||||
|
||||
Value::String(s) => {
|
||||
// Trailing NULs are stripped before framing so the length and the
|
||||
// single terminator stay consistent; the length INCLUDES that NUL.
|
||||
let raw = s.as_bytes();
|
||||
let end = raw.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
|
||||
let raw = &raw[..end];
|
||||
varint::encode(raw.len() as i64 + 1, out);
|
||||
out.extend_from_slice(raw);
|
||||
out.push(0x00);
|
||||
}
|
||||
|
||||
Value::Blob(b) => {
|
||||
// Blob length EXCLUDES a terminator — there isn't one.
|
||||
varint::encode(b.len() as i64, out);
|
||||
out.extend_from_slice(b);
|
||||
}
|
||||
|
||||
Value::Struct(s) => {
|
||||
encode_members(s, out);
|
||||
out.push(0x00);
|
||||
}
|
||||
|
||||
Value::List { elem, items } => {
|
||||
out.push(elem.as_byte());
|
||||
varint::encode(items.len() as i64, out);
|
||||
for it in items {
|
||||
encode_value(it, out);
|
||||
}
|
||||
}
|
||||
|
||||
Value::Map { key, val, entries } => {
|
||||
out.push(key.as_byte());
|
||||
out.push(val.as_byte());
|
||||
varint::encode(entries.len() as i64, out);
|
||||
for (k, v) in entries {
|
||||
encode_value(k, out);
|
||||
encode_value(v, out);
|
||||
}
|
||||
}
|
||||
|
||||
Value::Union { key, member } => {
|
||||
out.push(*key);
|
||||
if *key != UNION_UNSET {
|
||||
if let Some(m) = member {
|
||||
let (tag, val) = m.as_ref();
|
||||
out.extend_from_slice(&tag.as_bytes());
|
||||
out.push(val.type_id().as_byte());
|
||||
encode_value(val, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Value::VarList(items) => {
|
||||
varint::encode(items.len() as i64, out);
|
||||
for n in items {
|
||||
varint::encode(*n, out);
|
||||
}
|
||||
}
|
||||
|
||||
Value::ObjType { component, ty } => {
|
||||
varint::encode(*component, out);
|
||||
varint::encode(*ty, out);
|
||||
}
|
||||
|
||||
Value::ObjId { component, ty, id } => {
|
||||
varint::encode(*component, out);
|
||||
varint::encode(*ty, out);
|
||||
varint::encode(*id, out);
|
||||
}
|
||||
|
||||
Value::Float(f) => out.extend_from_slice(&f.to_be_bytes()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a value on its own, without a tag or type byte.
|
||||
///
|
||||
/// Useful for a nested struct that a caller frames itself; note this DOES emit
|
||||
/// the `0x00` terminator for `Value::Struct`.
|
||||
pub fn encode_value_only(value: &Value) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_value(value, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Convenience: the type byte a value will be written with.
|
||||
pub fn type_byte_of(value: &Value) -> u8 {
|
||||
value.type_id().as_byte()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn members_sort_by_packed_tag_not_source_order() {
|
||||
let s = Struct::new()
|
||||
.with("ZZZZ", Value::Int(3))
|
||||
.with("AAAA", Value::Int(1))
|
||||
.with("MMMM", Value::Int(2));
|
||||
let bytes = encode(&s);
|
||||
// Each member is 3 tag + 1 type + 1 varint = 5 bytes; values must come
|
||||
// out 1, 2, 3.
|
||||
assert_eq!(bytes.len(), 15);
|
||||
assert_eq!([bytes[4], bytes[9], bytes[14]], [1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_is_unterminated_but_nested_is_terminated() {
|
||||
let flat = encode(&Struct::new().with("A", Value::Int(1)));
|
||||
assert_eq!(
|
||||
flat.last(),
|
||||
Some(&1u8),
|
||||
"no trailing terminator at top level"
|
||||
);
|
||||
|
||||
let nested = encode(&Struct::new().with(
|
||||
"OUTR",
|
||||
Value::Struct(Struct::new().with("A", Value::Int(1))),
|
||||
));
|
||||
assert_eq!(
|
||||
nested.last(),
|
||||
Some(&0u8),
|
||||
"nested struct is 0x00 terminated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_nested_struct_is_a_bare_terminator() {
|
||||
let bytes = encode(&Struct::new().with("MTST", Value::Struct(Struct::new())));
|
||||
assert_eq!(bytes.len(), 5); // 3 tag + 1 type + 1 terminator
|
||||
assert_eq!(bytes[4], 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_length_includes_the_nul() {
|
||||
let bytes = encode(&Struct::new().with("S", Value::String("ab".into())));
|
||||
// tag(3) type(1) len(1)=3 'a' 'b' NUL
|
||||
assert_eq!(&bytes[4..], &[0x03, b'a', b'b', 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_is_length_one_plus_nul() {
|
||||
let bytes = encode(&Struct::new().with("S", Value::String(String::new())));
|
||||
assert_eq!(&bytes[4..], &[0x01, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_length_excludes_a_terminator() {
|
||||
let bytes = encode(&Struct::new().with("B", Value::Blob(vec![1, 2, 3])));
|
||||
assert_eq!(&bytes[4..], &[0x03, 1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_is_big_endian_f32() {
|
||||
let bytes = encode(&Struct::new().with("F", Value::Float(1.5)));
|
||||
assert_eq!(&bytes[4..], &1.5f32.to_be_bytes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Heat2 TDF: EA Blaze's tagged binary serialisation.
|
||||
//!
|
||||
//! A field is `3-byte packed tag || 1 type byte || value`. Structs nest and are
|
||||
//! `0x00` terminated; the outermost payload is delimited by the Fire2 length
|
||||
//! instead.
|
||||
//!
|
||||
//! # Provenance
|
||||
//!
|
||||
//! Ported from `fifa17-recon/tools/heat2.py`, which was derived clean-room from
|
||||
//! the wire bytes of our own FIFA 17 client and validated byte-exact against
|
||||
//! that capture. The int/string/blob/struct layouts are proven; list, map,
|
||||
//! union, varlist, objtype, objid and float are marked UNVERIFIED there and
|
||||
//! that flag is preserved on [`TypeId::is_verified`].
|
||||
//!
|
||||
//! Byte-for-byte parity with the Python oracle is enforced by fixture tests
|
||||
//! (`tests/oracle_parity.rs`) over vectors in `fixtures/tdf.jsonl`.
|
||||
|
||||
pub mod decode;
|
||||
pub mod encode;
|
||||
pub mod tag;
|
||||
pub mod value;
|
||||
pub mod varint;
|
||||
|
||||
pub use decode::decode;
|
||||
pub use encode::{encode, encode_into};
|
||||
pub use tag::Tag;
|
||||
pub use value::{Struct, TypeId, Value, UNION_UNSET};
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Heat2 field tags: four characters packed into three bytes.
|
||||
//!
|
||||
//! Each character contributes six bits (`(c - 0x20) & 0x3F`), concatenated
|
||||
//! MSB-first. Labels shorter than four characters are space-padded, and code 0
|
||||
//! decodes back to a space, so `"ENV"` survives a round trip as `"ENV"`.
|
||||
//!
|
||||
//! Evidence: byte-exact round trip against the FIFA 17 preAuth capture, via
|
||||
//! `fifa17-recon/tools/heat2.py::encode_tag`.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// A packed three-byte Heat2 tag.
|
||||
///
|
||||
/// Ordering is by packed bytes, which is exactly the member ordering Blaze
|
||||
/// requires on the wire — so `sort()` on a slice of `Tag` is the wire rule.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Tag(pub [u8; 3]);
|
||||
|
||||
impl Tag {
|
||||
/// Pack a label. Labels longer than four characters are TRUNCATED, not
|
||||
/// rejected — matching the oracle. Note the hazard: `"LSTR"` and `"LSTR2"`
|
||||
/// are the same wire tag, so they collide inside one struct.
|
||||
pub fn from_label(label: &str) -> Tag {
|
||||
let mut c = [0u8; 4];
|
||||
for (i, ch) in label.chars().take(4).enumerate() {
|
||||
// Non-ASCII cannot appear in a real tag; masking keeps this total
|
||||
// instead of panicking on hostile input.
|
||||
c[i] = ((ch as u32).wrapping_sub(0x20) & 0x3F) as u8;
|
||||
}
|
||||
Tag([
|
||||
(c[0] << 2) | (c[1] >> 4),
|
||||
((c[1] & 0x0F) << 4) | (c[2] >> 2),
|
||||
((c[2] & 0x03) << 6) | c[3],
|
||||
])
|
||||
}
|
||||
|
||||
/// Unpack to a label with trailing padding stripped.
|
||||
pub fn to_label(self) -> String {
|
||||
let [a, b, c] = self.0;
|
||||
let v = [
|
||||
(a >> 2) & 0x3F,
|
||||
((a & 0x03) << 4) | ((b >> 4) & 0x0F),
|
||||
((b & 0x0F) << 2) | ((c >> 6) & 0x03),
|
||||
c & 0x3F,
|
||||
];
|
||||
let s: String = v
|
||||
.iter()
|
||||
.map(|&x| if x == 0 { ' ' } else { (x + 0x20) as char })
|
||||
.collect();
|
||||
s.trim_end().to_string()
|
||||
}
|
||||
|
||||
pub fn as_bytes(self) -> [u8; 3] {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Tag {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.to_label())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Tag {
|
||||
fn from(s: &str) -> Tag {
|
||||
Tag::from_label(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trips_common_labels() {
|
||||
for label in ["CDAT", "CINF", "ENV", "A", "LOC", "BSDK", "PTVR"] {
|
||||
assert_eq!(Tag::from_label(label).to_label(), label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packs_to_three_bytes_msb_first() {
|
||||
// "ENV" pads to "ENV ": codes 0x25 0x2E 0x36 0x00.
|
||||
assert_eq!(Tag::from_label("ENV").as_bytes(), [0x96, 0xed, 0x80]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_is_packed_byte_order() {
|
||||
let mut tags = [Tag::from("ZZZZ"), Tag::from("AAAA"), Tag::from("MMMM")];
|
||||
tags.sort();
|
||||
assert_eq!(
|
||||
tags.map(|t| t.to_label()),
|
||||
["AAAA".to_string(), "MMMM".to_string(), "ZZZZ".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_labels_truncate_and_collide() {
|
||||
assert_eq!(Tag::from("LSTR2"), Tag::from("LSTR"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! The Heat2 value model.
|
||||
//!
|
||||
//! Deliberately a *generic* Blaze value tree: it knows the eleven wire types
|
||||
//! and nothing about any game. No FIFA 17 command IDs, field names, or response
|
||||
//! schemas appear here or anywhere else in this crate — those belong to a game
|
||||
//! adapter one layer up.
|
||||
|
||||
use super::tag::Tag;
|
||||
|
||||
/// The eleven Heat2 type bytes.
|
||||
///
|
||||
/// `Int` through `Struct` are validated against captured FIFA 17 traffic. The
|
||||
/// rest are marked UNVERIFIED in the oracle (`heat2.py`): they are absent from
|
||||
/// every capture we hold, and their layouts are consistent-with rather than
|
||||
/// proven-against EA. `TypeId::is_verified` carries that distinction into code
|
||||
/// so it cannot be lost by a reader who skips the comments.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[repr(u8)]
|
||||
pub enum TypeId {
|
||||
Int = 0x00,
|
||||
String = 0x01,
|
||||
Blob = 0x02,
|
||||
Struct = 0x03,
|
||||
List = 0x04,
|
||||
Map = 0x05,
|
||||
Union = 0x06,
|
||||
VarList = 0x07,
|
||||
ObjType = 0x08,
|
||||
ObjId = 0x09,
|
||||
Float = 0x0A,
|
||||
}
|
||||
|
||||
/// Union discriminator meaning "no member set". UNVERIFIED.
|
||||
pub const UNION_UNSET: u8 = 0x7F;
|
||||
|
||||
impl TypeId {
|
||||
pub fn from_byte(b: u8) -> Option<TypeId> {
|
||||
Some(match b {
|
||||
0x00 => TypeId::Int,
|
||||
0x01 => TypeId::String,
|
||||
0x02 => TypeId::Blob,
|
||||
0x03 => TypeId::Struct,
|
||||
0x04 => TypeId::List,
|
||||
0x05 => TypeId::Map,
|
||||
0x06 => TypeId::Union,
|
||||
0x07 => TypeId::VarList,
|
||||
0x08 => TypeId::ObjType,
|
||||
0x09 => TypeId::ObjId,
|
||||
0x0A => TypeId::Float,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn as_byte(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
TypeId::Int => "int",
|
||||
TypeId::String => "string",
|
||||
TypeId::Blob => "blob",
|
||||
TypeId::Struct => "struct",
|
||||
TypeId::List => "list",
|
||||
TypeId::Map => "map",
|
||||
TypeId::Union => "union",
|
||||
TypeId::VarList => "varlist",
|
||||
TypeId::ObjType => "objtype",
|
||||
TypeId::ObjId => "objid",
|
||||
TypeId::Float => "float",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<TypeId> {
|
||||
Some(match name {
|
||||
"int" => TypeId::Int,
|
||||
"string" => TypeId::String,
|
||||
"blob" => TypeId::Blob,
|
||||
"struct" => TypeId::Struct,
|
||||
"list" => TypeId::List,
|
||||
"map" => TypeId::Map,
|
||||
"union" => TypeId::Union,
|
||||
"varlist" => TypeId::VarList,
|
||||
"objtype" => TypeId::ObjType,
|
||||
"objid" => TypeId::ObjId,
|
||||
"float" => TypeId::Float,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// True when the layout is proven against captured FIFA 17 traffic.
|
||||
///
|
||||
/// A `false` here is a standing warning: the codec will round-trip such a
|
||||
/// value against itself and against the Python oracle, and that still says
|
||||
/// nothing about what a real Blaze server emits.
|
||||
pub fn is_verified(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
TypeId::Int | TypeId::String | TypeId::Blob | TypeId::Struct
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoded Heat2 value.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
Int(i64),
|
||||
String(String),
|
||||
Blob(Vec<u8>),
|
||||
Struct(Struct),
|
||||
List {
|
||||
elem: TypeId,
|
||||
items: Vec<Value>,
|
||||
},
|
||||
Map {
|
||||
key: TypeId,
|
||||
val: TypeId,
|
||||
entries: Vec<(Value, Value)>,
|
||||
},
|
||||
Union {
|
||||
key: u8,
|
||||
member: Option<Box<(Tag, Value)>>,
|
||||
},
|
||||
VarList(Vec<i64>),
|
||||
ObjType {
|
||||
component: i64,
|
||||
ty: i64,
|
||||
},
|
||||
ObjId {
|
||||
component: i64,
|
||||
ty: i64,
|
||||
id: i64,
|
||||
},
|
||||
Float(f32),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
pub fn type_id(&self) -> TypeId {
|
||||
match self {
|
||||
Value::Int(_) => TypeId::Int,
|
||||
Value::String(_) => TypeId::String,
|
||||
Value::Blob(_) => TypeId::Blob,
|
||||
Value::Struct(_) => TypeId::Struct,
|
||||
Value::List { .. } => TypeId::List,
|
||||
Value::Map { .. } => TypeId::Map,
|
||||
Value::Union { .. } => TypeId::Union,
|
||||
Value::VarList(_) => TypeId::VarList,
|
||||
Value::ObjType { .. } => TypeId::ObjType,
|
||||
Value::ObjId { .. } => TypeId::ObjId,
|
||||
Value::Float(_) => TypeId::Float,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_int(&self) -> Option<i64> {
|
||||
match self {
|
||||
Value::Int(v) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Value::String(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_struct(&self) -> Option<&Struct> {
|
||||
match self {
|
||||
Value::Struct(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An ordered set of tagged members.
|
||||
///
|
||||
/// A `Vec`, not a map: the wire format is a sequence, duplicate tags are
|
||||
/// physically representable, and encoding has to sort by packed tag anyway.
|
||||
/// Keeping the sequence means a decoded frame can be re-encoded byte-for-byte
|
||||
/// even when it contains something a map would silently drop.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Struct {
|
||||
pub fields: Vec<(Tag, Value)>,
|
||||
}
|
||||
|
||||
impl Struct {
|
||||
pub fn new() -> Struct {
|
||||
Struct { fields: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn with(mut self, tag: &str, value: Value) -> Struct {
|
||||
self.fields.push((Tag::from_label(tag), value));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn push(&mut self, tag: &str, value: Value) {
|
||||
self.fields.push((Tag::from_label(tag), value));
|
||||
}
|
||||
|
||||
/// First member with this tag, if any.
|
||||
pub fn get(&self, tag: &str) -> Option<&Value> {
|
||||
let t = Tag::from_label(tag);
|
||||
self.fields.iter().find(|(k, _)| *k == t).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.fields.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.fields.is_empty()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &(Tag, Value)> {
|
||||
self.fields.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<(Tag, Value)> for Struct {
|
||||
fn from_iter<I: IntoIterator<Item = (Tag, Value)>>(iter: I) -> Struct {
|
||||
Struct {
|
||||
fields: iter.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn type_bytes_round_trip() {
|
||||
for b in 0x00u8..=0x0A {
|
||||
let t = TypeId::from_byte(b).expect("known type");
|
||||
assert_eq!(t.as_byte(), b);
|
||||
assert_eq!(TypeId::from_name(t.name()), Some(t));
|
||||
}
|
||||
assert_eq!(TypeId::from_byte(0x0B), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_capture_backed_types_claim_verification() {
|
||||
assert!(TypeId::Int.is_verified());
|
||||
assert!(TypeId::Struct.is_verified());
|
||||
assert!(!TypeId::List.is_verified());
|
||||
assert!(!TypeId::Float.is_verified());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struct_lookup_finds_members() {
|
||||
let s = Struct::new()
|
||||
.with("PID", Value::Int(33068179))
|
||||
.with("NAME", Value::String("x".into()));
|
||||
assert_eq!(s.get("PID").and_then(Value::as_int), Some(33068179));
|
||||
assert_eq!(s.get("NOPE"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Heat2 varint.
|
||||
//!
|
||||
//! The first byte is the odd one: only six data bits (`0x3F`), with `0x40` as a
|
||||
//! sign flag and `0x80` as "more". Every later byte is a conventional 7-bit
|
||||
//! group. Groups are little-endian: byte 0 holds the low six bits, then seven
|
||||
//! bits at shifts 6, 13, 20, ...
|
||||
//!
|
||||
//! Evidence: validated byte-exact against the FIFA 17 preAuth capture
|
||||
//! (`LANG = 0x656E5553` encodes as `93 d5 f2 d6 0c`).
|
||||
//!
|
||||
//! The sign flag is UNVERIFIED — it never appears in any captured frame. It is
|
||||
//! implemented to match the oracle so negatives round-trip, but no claim is
|
||||
//! made that EA encodes negatives this way.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Append the canonical (shortest) encoding of `value`.
|
||||
pub fn encode(value: i64, out: &mut Vec<u8>) {
|
||||
let neg = value < 0;
|
||||
// unsigned_abs, not -value: i64::MIN has no positive counterpart.
|
||||
let mut v = value.unsigned_abs();
|
||||
|
||||
let mut first = (v & 0x3F) as u8;
|
||||
v >>= 6;
|
||||
if neg {
|
||||
first |= 0x40;
|
||||
}
|
||||
if v == 0 {
|
||||
out.push(first);
|
||||
return;
|
||||
}
|
||||
out.push(first | 0x80);
|
||||
while v >= 0x80 {
|
||||
out.push(((v & 0x7F) as u8) | 0x80);
|
||||
v >>= 7;
|
||||
}
|
||||
out.push(v as u8);
|
||||
}
|
||||
|
||||
/// Decode at `pos`, returning the value and the index just past it.
|
||||
pub fn decode(buf: &[u8], pos: usize) -> Result<(i64, usize)> {
|
||||
let mut i = pos;
|
||||
let b = *buf.get(i).ok_or(Error::Truncated {
|
||||
what: "varint",
|
||||
need: 1,
|
||||
have: 0,
|
||||
})?;
|
||||
i += 1;
|
||||
|
||||
let mut val: u64 = (b & 0x3F) as u64;
|
||||
let neg = b & 0x40 != 0;
|
||||
|
||||
if b & 0x80 != 0 {
|
||||
let mut shift = 6u32;
|
||||
loop {
|
||||
let b = *buf.get(i).ok_or(Error::Truncated {
|
||||
what: "varint continuation",
|
||||
need: 1,
|
||||
have: 0,
|
||||
})?;
|
||||
i += 1;
|
||||
if shift >= 64 {
|
||||
return Err(Error::VarintOverflow { at: pos });
|
||||
}
|
||||
val |= ((b & 0x7F) as u64) << shift;
|
||||
shift += 7;
|
||||
if b & 0x80 == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let signed = if neg {
|
||||
(val as i64).wrapping_neg()
|
||||
} else {
|
||||
val as i64
|
||||
};
|
||||
Ok((signed, i))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn enc(v: i64) -> Vec<u8> {
|
||||
let mut o = Vec::new();
|
||||
encode(v, &mut o);
|
||||
o
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_byte_below_0x40() {
|
||||
assert_eq!(enc(0), vec![0x00]);
|
||||
assert_eq!(enc(1), vec![0x01]);
|
||||
assert_eq!(enc(0x3F), vec![0x3F]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spills_to_a_second_group_at_0x40() {
|
||||
// Six data bits in byte 0, so 0x40 is the first value that needs two.
|
||||
assert_eq!(enc(0x40), vec![0x80, 0x01]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_the_captured_lang_field() {
|
||||
assert_eq!(enc(0x656E5553), vec![0x93, 0xd5, 0xf2, 0xd6, 0x0c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_boundaries() {
|
||||
for v in [
|
||||
0,
|
||||
1,
|
||||
0x3F,
|
||||
0x40,
|
||||
0x7F,
|
||||
0x80,
|
||||
0x1FFF,
|
||||
0x2000,
|
||||
0xFFFF_FFFF,
|
||||
i64::MAX,
|
||||
-1,
|
||||
-300,
|
||||
] {
|
||||
let bytes = enc(v);
|
||||
assert_eq!(decode(&bytes, 0).unwrap(), (v, bytes.len()), "value {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_never_terminating_varint() {
|
||||
let runaway = vec![0xFF; 32];
|
||||
assert!(matches!(
|
||||
decode(&runaway, 0),
|
||||
Err(Error::VarintOverflow { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncation() {
|
||||
assert!(decode(&[0x80], 0).is_err());
|
||||
assert!(decode(&[], 0).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! # openfut-protocol-blaze
|
||||
//!
|
||||
//! Game-independent EA Blaze wire protocol: Fire2 framing and the Heat2/TDF
|
||||
//! codec.
|
||||
//!
|
||||
//! ## What belongs here
|
||||
//!
|
||||
//! Only things that are true of Blaze itself:
|
||||
//!
|
||||
//! * [`fire2`] — the 16-byte frame header and frame/stream splitting
|
||||
//! * [`heat2`] — tag packing, varints, and the eleven TDF value types
|
||||
//! * [`message`] — a frame plus its decoded body, routed by numeric
|
||||
//! component/command
|
||||
//! * [`diagnostics`] — dumps for capture review
|
||||
//!
|
||||
//! ## What does not belong here
|
||||
//!
|
||||
//! Anything that would have to change for a different title: command and
|
||||
//! component name tables, notification IDs, response schemas, login sequencing,
|
||||
//! session identity. FIFA 17 says `0x0009/0x0007` is `Util::preAuth`; this
|
||||
//! crate only knows it is component 9, command 7. A game adapter owns the rest.
|
||||
//!
|
||||
//! The test for a change landing in the right place: *could FIFA 18 or FIFA 23
|
||||
//! use this without importing FIFA 17's command tables?* If not, it belongs in
|
||||
//! an adapter.
|
||||
//!
|
||||
//! ## Provenance and confidence
|
||||
//!
|
||||
//! Ported from the Python implementation in `fifa17-recon/tools/` that drove a
|
||||
//! retail FIFA 17 client from Origin login to an opened FUT pack — the
|
||||
//! project's behavioural oracle. Parity is not asserted, it is tested: the
|
||||
//! vectors in `fixtures/` are generated from that Python code and replayed
|
||||
//! byte-for-byte by `tests/oracle_parity.rs`.
|
||||
//!
|
||||
//! Confidence is not uniform, and the code says so rather than leaving it in a
|
||||
//! comment. Int, string, blob and struct layouts are proven against captured
|
||||
//! traffic; list, map, union, varlist, objtype, objid and float are not, and
|
||||
//! [`heat2::TypeId::is_verified`] reports which is which.
|
||||
//!
|
||||
//! ```
|
||||
//! use openfut_protocol_blaze::fire2::{Frame, MsgType};
|
||||
//! use openfut_protocol_blaze::heat2::{self, Struct, Value};
|
||||
//!
|
||||
//! let body = Struct::new()
|
||||
//! .with("PID", Value::Int(33068179))
|
||||
//! .with("NAME", Value::String("CAGE".into()));
|
||||
//!
|
||||
//! let frame = Frame::new(0x0001, 0x000A, 7, MsgType::Reply, heat2::encode(&body));
|
||||
//! let wire = frame.encode();
|
||||
//!
|
||||
//! let (parsed, used) = Frame::parse(&wire).unwrap();
|
||||
//! assert_eq!(used, wire.len());
|
||||
//! assert_eq!(parsed.header.component, 0x0001);
|
||||
//! assert_eq!(wire[13], 0x20); // msgType REPLY in the top 3 bits, userIndex 0
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod diagnostics;
|
||||
pub mod error;
|
||||
pub mod fire2;
|
||||
pub mod heat2;
|
||||
pub mod message;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use fire2::{Frame, Header, MsgType};
|
||||
pub use heat2::{Struct, Tag, TypeId, Value};
|
||||
pub use message::Message;
|
||||
@@ -0,0 +1,103 @@
|
||||
//! A Blaze message: a Fire2 frame whose payload is decoded Heat2/TDF.
|
||||
//!
|
||||
//! This is the highest level this crate goes. It routes by *numbers*, never by
|
||||
//! names: mapping `0x0009/0x0007` to `Util::preAuth`, or knowing which fields a
|
||||
//! login reply must carry, is a per-title concern that belongs in a game
|
||||
//! adapter. Keeping that out is what lets a second title reuse this layer
|
||||
//! without inheriting FIFA 17's command tables.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::fire2::{Frame, Header, MsgType};
|
||||
use crate::heat2::{self, Struct};
|
||||
|
||||
/// A frame plus its decoded body.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Message {
|
||||
pub header: Header,
|
||||
pub body: Struct,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn new(
|
||||
component: u16,
|
||||
command: u16,
|
||||
msg_num: u32,
|
||||
msg_type: MsgType,
|
||||
body: Struct,
|
||||
) -> Message {
|
||||
Message {
|
||||
header: Header::new(component, command, msg_num, msg_type),
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a frame's payload as TDF.
|
||||
pub fn from_frame(frame: &Frame) -> Result<Message> {
|
||||
Ok(Message {
|
||||
header: frame.header,
|
||||
body: heat2::decode(&frame.payload)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-frame this message.
|
||||
///
|
||||
/// Note this is not guaranteed byte-identical to the frame a `Message` was
|
||||
/// decoded from: encoding sorts members by packed tag, so a peer that sent
|
||||
/// them out of order would see its ordering normalised. For byte-exact
|
||||
/// round trips (capture replay, differential tests) keep the [`Frame`] and
|
||||
/// its raw payload.
|
||||
pub fn to_frame(&self) -> Frame {
|
||||
let payload = heat2::encode(&self.body);
|
||||
let mut frame = Frame::new(
|
||||
self.header.component,
|
||||
self.header.command,
|
||||
self.header.msg_num,
|
||||
self.header.msg_type,
|
||||
payload,
|
||||
);
|
||||
frame.header.user_index = self.header.user_index;
|
||||
frame.header.options = self.header.options;
|
||||
frame
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
self.to_frame().encode()
|
||||
}
|
||||
|
||||
/// Component and command as a pair — the key an adapter dispatches on.
|
||||
pub fn route(&self) -> (u16, u16) {
|
||||
(self.header.component, self.header.command)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::heat2::Value;
|
||||
|
||||
#[test]
|
||||
fn decodes_a_frame_body_and_reframes_it() {
|
||||
let body = Struct::new()
|
||||
.with("PID", Value::Int(33068179))
|
||||
.with("NAME", Value::String("CAGE".into()));
|
||||
let msg = Message::new(0x0001, 0x000A, 7, MsgType::Reply, body.clone());
|
||||
|
||||
let bytes = msg.encode();
|
||||
let (frame, used) = Frame::parse(&bytes).unwrap();
|
||||
assert_eq!(used, bytes.len());
|
||||
|
||||
let back = Message::from_frame(&frame).unwrap();
|
||||
assert_eq!(back.route(), (0x0001, 0x000A));
|
||||
assert_eq!(back.header.msg_type, MsgType::Reply);
|
||||
assert_eq!(back.body.get("NAME").and_then(Value::as_str), Some("CAGE"));
|
||||
assert_eq!(back.encode(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_body_is_a_valid_message() {
|
||||
let msg = Message::new(0x0009, 0x0002, 1, MsgType::Reply, Struct::new());
|
||||
let (frame, _) = Frame::parse(&msg.encode()).unwrap();
|
||||
assert_eq!(frame.header.payload_len, 0);
|
||||
assert!(Message::from_frame(&frame).unwrap().body.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
//! Differential tests against the proven Python backend.
|
||||
//!
|
||||
//! The Python responders in `fifa17-recon/tools/` are the behavioural oracle:
|
||||
//! they are what actually walked a retail FIFA 17 client from Origin login to
|
||||
//! an opened FUT pack. This crate may only claim to replace them if it produces
|
||||
//! *the same bytes*, and that claim has to be re-checkable without a FIFA
|
||||
//! client in the loop.
|
||||
//!
|
||||
//! `fixtures/*.jsonl` are generated by `fixtures/generate.py` from that Python
|
||||
//! code. Each vector carries both the input tree and the bytes the oracle
|
||||
//! produced. These tests rebuild the tree in Rust, encode it, and require an
|
||||
//! exact match.
|
||||
//!
|
||||
//! Comparison is **byte-for-byte**, not semantic. These are wire formats read
|
||||
//! by a game binary that hard-freezes on a shape it does not expect, so
|
||||
//! "equivalent" is not a category that exists here. (Semantic comparison is the
|
||||
//! right call one layer up, at UTAS/JSON, where key order genuinely does not
|
||||
//! matter — see `fifa17-recon/tools/test_fut_contract.py`.)
|
||||
//!
|
||||
//! Regenerate after any oracle change: python3 fixtures/generate.py
|
||||
|
||||
use openfut_protocol_blaze::fire2::{Frame, Header, MsgType};
|
||||
use openfut_protocol_blaze::heat2::{self, Struct, Tag, TypeId, Value};
|
||||
use serde_json::Value as J;
|
||||
|
||||
fn load(name: &str) -> Vec<J> {
|
||||
let path = format!("{}/fixtures/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|e| panic!("cannot read {path}: {e}\nrun: python3 fixtures/generate.py"));
|
||||
text.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| serde_json::from_str(l).expect("fixture line is valid JSON"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
assert!(s.len().is_multiple_of(2), "odd-length hex");
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
fn type_of(name: &str) -> TypeId {
|
||||
TypeId::from_name(name).unwrap_or_else(|| panic!("unknown type name {name:?}"))
|
||||
}
|
||||
|
||||
/// Rebuild a `Value` from the generator's JSON encoding.
|
||||
fn to_value(j: &J) -> Value {
|
||||
let t = j["t"].as_str().expect("value has a type tag");
|
||||
match t {
|
||||
"int" => Value::Int(j["v"].as_i64().expect("int fits i64")),
|
||||
"string" => Value::String(j["v"].as_str().expect("string").to_string()),
|
||||
"blob" => Value::Blob(unhex(j["v"].as_str().expect("blob hex"))),
|
||||
"struct" => Value::Struct(to_struct(&j["v"])),
|
||||
"list" => Value::List {
|
||||
elem: type_of(j["elem"].as_str().expect("elem type")),
|
||||
items: j["v"]
|
||||
.as_array()
|
||||
.expect("list")
|
||||
.iter()
|
||||
.map(to_value)
|
||||
.collect(),
|
||||
},
|
||||
"map" => Value::Map {
|
||||
key: type_of(j["key"].as_str().expect("key type")),
|
||||
val: type_of(j["val"].as_str().expect("val type")),
|
||||
entries: j["v"]
|
||||
.as_array()
|
||||
.expect("map entries")
|
||||
.iter()
|
||||
.map(|pair| {
|
||||
let p = pair.as_array().expect("k/v pair");
|
||||
(to_value(&p[0]), to_value(&p[1]))
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
"union" => Value::Union {
|
||||
key: j["key"].as_u64().expect("union key") as u8,
|
||||
member: match j.get("member") {
|
||||
Some(J::Null) | None => None,
|
||||
Some(m) => Some(Box::new((
|
||||
Tag::from_label(m["tag"].as_str().expect("member tag")),
|
||||
to_value(&m["value"]),
|
||||
))),
|
||||
},
|
||||
},
|
||||
"varlist" => Value::VarList(
|
||||
j["v"]
|
||||
.as_array()
|
||||
.expect("varlist")
|
||||
.iter()
|
||||
.map(|n| n.as_i64().expect("varlist int"))
|
||||
.collect(),
|
||||
),
|
||||
"objtype" => {
|
||||
let a = j["v"].as_array().expect("objtype pair");
|
||||
Value::ObjType {
|
||||
component: a[0].as_i64().unwrap(),
|
||||
ty: a[1].as_i64().unwrap(),
|
||||
}
|
||||
}
|
||||
"objid" => {
|
||||
let a = j["v"].as_array().expect("objid triple");
|
||||
Value::ObjId {
|
||||
component: a[0].as_i64().unwrap(),
|
||||
ty: a[1].as_i64().unwrap(),
|
||||
id: a[2].as_i64().unwrap(),
|
||||
}
|
||||
}
|
||||
"float" => Value::Float(j["v"].as_f64().expect("float") as f32),
|
||||
other => panic!("unhandled fixture type {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_struct(j: &J) -> Struct {
|
||||
let mut s = Struct::new();
|
||||
for entry in j.as_array().expect("struct is a list of [tag, value]") {
|
||||
let pair = entry.as_array().expect("[tag, value]");
|
||||
s.fields.push((
|
||||
Tag::from_label(pair[0].as_str().expect("tag label")),
|
||||
to_value(&pair[1]),
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
// ───────────────────────────────── Heat2 / TDF ─────────────────────────────────
|
||||
|
||||
/// Encoding a fixture's tree must reproduce the oracle's bytes exactly.
|
||||
#[test]
|
||||
fn tdf_encode_matches_python_oracle_byte_for_byte() {
|
||||
let vectors = load("tdf.jsonl");
|
||||
assert!(vectors.len() >= 30, "fixture set looks truncated");
|
||||
|
||||
for v in &vectors {
|
||||
let name = v["name"].as_str().unwrap();
|
||||
let expected = unhex(v["encoded_hex"].as_str().unwrap());
|
||||
let actual = heat2::encode(&to_struct(&v["fields"]));
|
||||
assert_eq!(
|
||||
hex(&actual),
|
||||
hex(&expected),
|
||||
"\n{name}: Rust encoding differs from the Python oracle"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoding the oracle's bytes and re-encoding must be a fixed point. This is
|
||||
/// what proves the decoder agrees with the encoder on every field, not just
|
||||
/// that both are self-consistent.
|
||||
#[test]
|
||||
fn tdf_decode_then_reencode_is_byte_identical() {
|
||||
for v in load("tdf.jsonl") {
|
||||
let name = v["name"].as_str().unwrap();
|
||||
let bytes = unhex(v["encoded_hex"].as_str().unwrap());
|
||||
let decoded =
|
||||
heat2::decode(&bytes).unwrap_or_else(|e| panic!("{name}: decode failed: {e}"));
|
||||
assert_eq!(
|
||||
hex(&heat2::encode(&decoded)),
|
||||
hex(&bytes),
|
||||
"\n{name}: decode/re-encode is not a fixed point"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort struct members by packed tag at every depth.
|
||||
///
|
||||
/// The recorded tree is in the oracle's source order; the wire is in packed-tag
|
||||
/// order, and that reordering applies to nested structs too. Normalising both
|
||||
/// sides compares the content while ignoring the ordering the wire imposes —
|
||||
/// the byte-level tests above are what pin the ordering itself.
|
||||
fn normalized(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Struct(s) => Value::Struct(normalized_struct(s)),
|
||||
Value::List { elem, items } => Value::List {
|
||||
elem: *elem,
|
||||
items: items.iter().map(normalized).collect(),
|
||||
},
|
||||
Value::Map { key, val, entries } => Value::Map {
|
||||
key: *key,
|
||||
val: *val,
|
||||
entries: entries
|
||||
.iter()
|
||||
.map(|(k, v)| (normalized(k), normalized(v)))
|
||||
.collect(),
|
||||
},
|
||||
Value::Union { key, member } => Value::Union {
|
||||
key: *key,
|
||||
member: member.as_ref().map(|m| Box::new((m.0, normalized(&m.1)))),
|
||||
},
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_struct(s: &Struct) -> Struct {
|
||||
let mut fields: Vec<_> = s.iter().map(|(t, v)| (*t, normalized(v))).collect();
|
||||
fields.sort_by_key(|(t, _)| *t);
|
||||
Struct { fields }
|
||||
}
|
||||
|
||||
/// Decoding must also reproduce the same tree the generator recorded, not just
|
||||
/// bytes that happen to re-encode the same way.
|
||||
#[test]
|
||||
fn tdf_decode_reproduces_the_recorded_tree() {
|
||||
for v in load("tdf.jsonl") {
|
||||
let name = v["name"].as_str().unwrap();
|
||||
let bytes = unhex(v["encoded_hex"].as_str().unwrap());
|
||||
let decoded = normalized_struct(&heat2::decode(&bytes).unwrap());
|
||||
let expected = normalized_struct(&to_struct(&v["fields"]));
|
||||
|
||||
assert_eq!(
|
||||
decoded.len(),
|
||||
expected.len(),
|
||||
"\n{name}: field count differs"
|
||||
);
|
||||
for ((gt, gv), (wt, wv)) in decoded.iter().zip(expected.iter()) {
|
||||
assert_eq!(gt, wt, "\n{name}: tag mismatch");
|
||||
// Over-long labels truncate to the same wire tag, so compare the
|
||||
// decoded value, which is what the wire actually carries.
|
||||
assert_eq!(gv, wv, "\n{name}: value mismatch under tag {gt}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The live vectors are the ones with real evidence behind them; if the
|
||||
/// generator ever stops emitting them the suite would still pass while proving
|
||||
/// much less.
|
||||
#[test]
|
||||
fn live_vectors_are_present_and_substantial() {
|
||||
let vectors = load("tdf.jsonl");
|
||||
let live: Vec<_> = vectors.iter().filter(|v| v["origin"] == "live").collect();
|
||||
assert!(
|
||||
live.len() >= 20,
|
||||
"expected the responder's payload builders to be captured; got {}",
|
||||
live.len()
|
||||
);
|
||||
|
||||
// preAuth is the first RPC FIFA 17 sends and the largest proven payload;
|
||||
// it is the single most valuable vector in the set.
|
||||
let preauth = vectors
|
||||
.iter()
|
||||
.find(|v| v["name"] == "live/preauth_response")
|
||||
.expect("preauth vector present");
|
||||
assert!(
|
||||
unhex(preauth["encoded_hex"].as_str().unwrap()).len() > 1000,
|
||||
"preauth payload is suspiciously small"
|
||||
);
|
||||
}
|
||||
|
||||
/// Vectors whose layout the oracle marks UNVERIFIED must stay marked. This test
|
||||
/// exists so that "Rust and Python agree" is never quietly read as "this is how
|
||||
/// EA does it".
|
||||
#[test]
|
||||
fn unverified_layouts_remain_flagged() {
|
||||
let vectors = load("tdf.jsonl");
|
||||
for name in ["synthetic/lists", "synthetic/maps", "synthetic/unions"] {
|
||||
let v = vectors
|
||||
.iter()
|
||||
.find(|v| v["name"] == name)
|
||||
.unwrap_or_else(|| panic!("{name} missing"));
|
||||
assert_eq!(v["verified"], false, "{name} must stay flagged UNVERIFIED");
|
||||
}
|
||||
assert!(!TypeId::List.is_verified());
|
||||
assert!(!TypeId::Map.is_verified());
|
||||
assert!(!TypeId::Union.is_verified());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────── Fire2 ────────────────────────────────────
|
||||
|
||||
/// Building a frame must reproduce the oracle's bytes exactly — header layout,
|
||||
/// field offsets, and the msgType/userIndex packing in byte 13.
|
||||
#[test]
|
||||
fn fire2_encode_matches_python_oracle_byte_for_byte() {
|
||||
let vectors = load("fire2.jsonl");
|
||||
assert!(vectors.len() >= 15, "fixture set looks truncated");
|
||||
|
||||
for v in &vectors {
|
||||
let name = v["name"].as_str().unwrap();
|
||||
let expected = unhex(v["frame_hex"].as_str().unwrap());
|
||||
|
||||
let frame = Frame::new(
|
||||
v["component"].as_u64().unwrap() as u16,
|
||||
v["command"].as_u64().unwrap() as u16,
|
||||
v["msg_num"].as_u64().unwrap() as u32,
|
||||
MsgType::from_bits(v["msg_type"].as_u64().unwrap() as u8),
|
||||
unhex(v["payload_hex"].as_str().unwrap()),
|
||||
)
|
||||
.with_metadata(unhex(v["metadata_hex"].as_str().unwrap()))
|
||||
.with_user_index(v["user_index"].as_u64().unwrap() as u8)
|
||||
.with_options(v["options"].as_u64().unwrap() as u8);
|
||||
|
||||
let actual = frame.encode();
|
||||
assert_eq!(
|
||||
actual.len(),
|
||||
expected.len(),
|
||||
"\n{name}: frame length differs"
|
||||
);
|
||||
assert_eq!(
|
||||
hex(&actual[..16]),
|
||||
hex(&expected[..16]),
|
||||
"\n{name}: HEADER differs from the oracle"
|
||||
);
|
||||
assert_eq!(actual, expected, "\n{name}: frame body differs");
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsing the oracle's frames must recover every header field.
|
||||
#[test]
|
||||
fn fire2_parse_recovers_every_header_field() {
|
||||
for v in load("fire2.jsonl") {
|
||||
let name = v["name"].as_str().unwrap();
|
||||
let bytes = unhex(v["frame_hex"].as_str().unwrap());
|
||||
let (frame, used) =
|
||||
Frame::parse(&bytes).unwrap_or_else(|e| panic!("{name}: parse failed: {e}"));
|
||||
|
||||
assert_eq!(used, bytes.len(), "\n{name}: consumed length differs");
|
||||
let h = &frame.header;
|
||||
assert_eq!(
|
||||
h.component as u64,
|
||||
v["component"].as_u64().unwrap(),
|
||||
"{name} component"
|
||||
);
|
||||
assert_eq!(
|
||||
h.command as u64,
|
||||
v["command"].as_u64().unwrap(),
|
||||
"{name} command"
|
||||
);
|
||||
assert_eq!(
|
||||
h.msg_num as u64,
|
||||
v["msg_num"].as_u64().unwrap(),
|
||||
"{name} msg_num"
|
||||
);
|
||||
assert_eq!(
|
||||
h.msg_type.as_bits() as u64,
|
||||
v["msg_type"].as_u64().unwrap(),
|
||||
"{name} msg_type"
|
||||
);
|
||||
assert_eq!(
|
||||
h.user_index as u64,
|
||||
v["user_index"].as_u64().unwrap(),
|
||||
"{name} user_index"
|
||||
);
|
||||
assert_eq!(
|
||||
h.options as u64,
|
||||
v["options"].as_u64().unwrap(),
|
||||
"{name} options"
|
||||
);
|
||||
assert_eq!(
|
||||
hex(&frame.payload),
|
||||
v["payload_hex"].as_str().unwrap(),
|
||||
"{name} payload"
|
||||
);
|
||||
assert_eq!(
|
||||
hex(&frame.metadata),
|
||||
v["metadata_hex"].as_str().unwrap(),
|
||||
"{name} metadata"
|
||||
);
|
||||
assert_eq!(frame.encode(), bytes, "\n{name}: re-encode differs");
|
||||
}
|
||||
}
|
||||
|
||||
/// The live frames carry real TDF bodies; those must decode and re-encode
|
||||
/// cleanly. This is the end-to-end check: framing and codec together, on the
|
||||
/// exact bytes FIFA 17 accepted.
|
||||
#[test]
|
||||
fn live_frames_decode_as_tdf_and_survive_a_round_trip() {
|
||||
let mut checked = 0;
|
||||
for v in load("fire2.jsonl") {
|
||||
if v["origin"] != "live" {
|
||||
continue;
|
||||
}
|
||||
let name = v["name"].as_str().unwrap();
|
||||
let bytes = unhex(v["frame_hex"].as_str().unwrap());
|
||||
let (frame, _) = Frame::parse(&bytes).unwrap();
|
||||
|
||||
let body = heat2::decode(&frame.payload)
|
||||
.unwrap_or_else(|e| panic!("{name}: live payload is not valid TDF: {e}"));
|
||||
assert!(!body.is_empty(), "{name}: live payload decoded to nothing");
|
||||
assert_eq!(
|
||||
hex(&heat2::encode(&body)),
|
||||
hex(&frame.payload),
|
||||
"\n{name}: live payload does not survive a codec round trip"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
assert!(
|
||||
checked >= 5,
|
||||
"expected several live frames, checked {checked}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A frame larger than 64 KiB must work. The superseded 12-byte implementation
|
||||
/// in `fifa-blaze` carried a `u16` length plus a JUMBO escape flag; Fire2's
|
||||
/// length is a plain `u32`, and the oracle emits no flag. This vector is the
|
||||
/// direct refutation of that assumption.
|
||||
#[test]
|
||||
fn payload_over_64kib_needs_no_jumbo_flag() {
|
||||
let vectors = load("fire2.jsonl");
|
||||
let v = vectors
|
||||
.iter()
|
||||
.find(|v| v["name"] == "synthetic/large_payload")
|
||||
.expect("large payload vector present");
|
||||
|
||||
let bytes = unhex(v["frame_hex"].as_str().unwrap());
|
||||
let header = Header::parse(&bytes).unwrap();
|
||||
assert!(header.payload_len > u16::MAX as u32);
|
||||
assert_eq!(bytes.len(), 16 + header.payload_len as usize);
|
||||
assert_eq!(header.options, 0, "no option flag is set for a large frame");
|
||||
}
|
||||
|
||||
/// Frames arrive coalesced on a real socket; splitting them must work on the
|
||||
/// oracle's actual bytes, not just synthetic ones.
|
||||
#[test]
|
||||
fn coalesced_live_frames_split_correctly() {
|
||||
let live: Vec<Vec<u8>> = load("fire2.jsonl")
|
||||
.iter()
|
||||
.filter(|v| v["origin"] == "live")
|
||||
.map(|v| unhex(v["frame_hex"].as_str().unwrap()))
|
||||
.collect();
|
||||
|
||||
let stream: Vec<u8> = live.concat();
|
||||
let (frames, used) = openfut_protocol_blaze::fire2::parse_all(&stream).unwrap();
|
||||
assert_eq!(frames.len(), live.len());
|
||||
assert_eq!(used, stream.len());
|
||||
for (got, want) in frames.iter().zip(live.iter()) {
|
||||
assert_eq!(&got.encode(), want);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user