fifa17-recon: fix the match tail -- real URLs, endReason, and coins in the right place

The whole match family is one RPC descriptor block (rows 49-54, every row using URL
template index 16 = `ut/%s/match`) with a fixed suffix appended per call:

  CREATEMATCH  ut/game/fifa17/match          PLAYGAME    ut/game/fifa17/match
  MATCHREADY   ut/game/fifa17/match/ready    DESTROYMATCH ut/game/fifa17/match/end
  RESETMATCH   ut/game/fifa17/match/reset    KEEPALIVE   ut/game/fifa17/match/keepalive

THERE IS NO /match/{id} URL. The id travels in the body. Our reward path was gated on
`h.command == "DELETE" or "/ut/delete/" in h.path` and extracted the id with
re.search(r"/match/(\d+)"), so it was waiting for a request the client does not make.
The gate is now widened to include a /match/end path with ANY verb, because the verb
genuinely cannot be determined statically: the strings "PUT" and "DELETE" do not exist
anywhere in cardsdll.dll (0 hits each), so verb selection happens outside this DLL. A
reviewer flagged "the reward path can never fire" as overreach on exactly that point;
widening rather than replacing the gate is the response.

THE RESULT SIGNAL IS `endReason` (atom 260), a STRING enum with nine values: WIN DRAW
LOSS DNF QUIT NO_CONTEST DNF_WIN DNF_DRAW DNF_LOSS. Not a score comparison. The score
lives in `myMatchStats.goals` / `opponentMatchStats.goals`, two literal-keyed objects
of 15 int fields each, and the client OMITS both when endReason is DNF or QUIT, so
nothing may require them. _match_result() now reads endReason first and keeps the old
spelling probe only as a fallback, because request-side static findings are a floor:
PUT /item's swap/tradeId appeared in no static listing either.

THREE CORRECTIONS TO THE RESPONSE, all of which were shipping wrong:

1. `coins` (atom 149) is NOT a top-level key. It is read only inside `gameModeAward`.
   The one field most obviously named "the reward" was being silently skipped.
2. `qualifiedChampionEventId` (0x269) has a SIDE EFFECT: its branch calls through a
   manager vtable after storing. Sending a habitual zero poked champion-event
   machinery for no benefit. Removed.
3. `bidTokens` (atom 89) inside gameModeAward is MATCHED and then handled by nothing,
   so its value token is left unconsumed. That is the precondition for the desync
   spin. A freeze trap dressed as an ordinary field; now guarded by a unit check.

test_match_rewards.py rewrote its expectations. The old version asserted a top-level
`coins` and passed happily while the server shipped a body whose reward field the
client never read. A test that encodes the wrong schema converts a bug into a
guarantee. New test_end_reason_is_authoritative covers all nine enum values, the
stats-less DNF case, and that endReason beats a contradictory score probe.

DEFAULT ON (FUT_MATCH_END=0 reverts), a reasoned exception to the flag convention:
nothing here is live-proven because no match has ever been played, and the old
behaviour is not a working screen but a path that provably could not fire.

61 unit checks (58 with the flag off), 392 contract checks green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
funman300
2026-08-04 13:47:05 -07:00
parent 0968cd351b
commit 24bbc32da5
2 changed files with 153 additions and 18 deletions
+65 -7
View File
@@ -61,8 +61,13 @@ def test_result_detection():
# ---- destroy_match_body: the reward record ---------------------------------- # ---- destroy_match_body: the reward record ----------------------------------
REQUIRED_INT = ("coins", "allCoins", "matchCoins", "seasonCoins", "tournamentCoins", # These expectations were REWRITTEN on 2026-08-04 after reversing deser 0x180121b60
"boostConis", "participationAward", "qualifiedChampionEventId") # properly. The previous version asserted a top-level `coins` and a
# `qualifiedChampionEventId`, and it passed happily while the server shipped a body
# whose reward field the client never read. A test that encodes the wrong schema is
# worse than no test: it converts a bug into a guarantee.
REQUIRED_INT = ("allCoins", "matchCoins", "seasonCoins", "tournamentCoins",
"boostConis", "participationAward")
def test_reward_body(): def test_reward_body():
@@ -73,19 +78,71 @@ def test_reward_body():
isinstance(b.get(k), int) and not isinstance(b.get(k), bool), repr(b.get(k))) isinstance(b.get(k), int) and not isinstance(b.get(k), bool), repr(b.get(k)))
check("reward.teamOfTournamentWinner is bool", check("reward.teamOfTournamentWinner is bool",
isinstance(b.get("teamOfTournamentWinner"), bool), repr(b.get("teamOfTournamentWinner"))) isinstance(b.get("teamOfTournamentWinner"), bool), repr(b.get("teamOfTournamentWinner")))
check("coins echoes the credited amount", b["coins"] == 400, repr(b["coins"]))
check("allCoins is the NEW balance", b["allCoins"] == 13000, repr(b["allCoins"])) check("allCoins is the NEW balance", b["allCoins"] == 13000, repr(b["allCoins"]))
# EA's typo is load-bearing: the atom is 96 == "boostConis", not "boostCoins". # EA's typo is load-bearing: the atom is 96 == "boostConis", not "boostCoins".
check("key is EA's misspelled boostConis", "boostConis" in b and "boostCoins" not in b, check("key is EA's misspelled boostConis", "boostConis" in b and "boostCoins" not in b,
repr(sorted(b))) repr(sorted(b)))
# nested members must stay OUT (all SKIP-safe; userData is a freeze-risk) # userData and matchCoinMultipliers stay OUT (SKIP-safe; userData is freeze-risk)
for k in ("userData", "gameModeAward", "matchCoinMultipliers"): for k in ("userData", "matchCoinMultipliers"):
check("reward omits nested %s" % k, k not in b) check("reward omits nested %s" % k, k not in b)
# nothing non-scalar may sneak in
if U.MATCH_END:
# `coins` (atom 149) is read ONLY inside gameModeAward. A top-level one is
# silently skipped, which is what the server used to send.
check("coins is NOT top level", "coins" not in b, repr(sorted(b)))
gma = b.get("gameModeAward")
check("gameModeAward is an object", isinstance(gma, dict), repr(gma))
if isinstance(gma, dict):
check("gameModeAward.coins echoes the credited amount",
gma.get("coins") == 400, repr(gma.get("coins")))
# atom 89 is MATCHED inside gameModeAward and then handled by nothing:
# not read, not skip-routed. Its value token is left unconsumed, which is
# the precondition for the desync spin. This check is the guard rail.
check("gameModeAward NEVER contains bidTokens (freeze trap)",
"bidTokens" not in gma, repr(sorted(gma)))
for k, v in gma.items():
check("gameModeAward.%s is scalar" % k,
isinstance(v, (int, bool, str)), repr(v))
# side-effecting atom 0x269: its branch calls through a manager vtable
check("qualifiedChampionEventId omitted (it has a side effect)",
"qualifiedChampionEventId" not in b, repr(sorted(b)))
else:
check("legacy body keeps top-level coins", b.get("coins") == 400, repr(b.get("coins")))
# nothing non-scalar may sneak in at the top level except gameModeAward
for k, v in b.items(): for k, v in b.items():
if k == "gameModeAward":
continue
check("reward.%s is scalar" % k, isinstance(v, (int, bool, str)), repr(v)) check("reward.%s is scalar" % k, isinstance(v, (int, bool, str)), repr(v))
def test_end_reason_is_authoritative():
"""endReason, not a score comparison, is how the client reports the outcome.
Reversed from the DestroyMatch serializer: atom 260, a string enum with nine
values. Both stats objects are OMITTED by the client when endReason is DNF or
QUIT, so a result parser must not require them.
"""
for reason, want in (("WIN", "won"), ("DNF_WIN", "won"),
("DRAW", "draw"), ("DNF_DRAW", "draw"), ("NO_CONTEST", "draw"),
("LOSS", "loss"), ("DNF_LOSS", "loss"),
("DNF", "loss"), ("QUIT", "loss")):
r, _ = U._match_result({"endReason": reason})
check("endReason %s -> %s" % (reason, want), r == want, r)
# score comes from goals in the two stats objects, first field of each
r, s = U._match_result({"endReason": "WIN",
"myMatchStats": {"goals": 3},
"opponentMatchStats": {"goals": 1}})
check("goals are read from myMatchStats/opponentMatchStats", s == (3, 1), repr(s))
# a DNF with no stats objects must still resolve, not crash or fall back blindly
r, s = U._match_result({"endReason": "DNF"})
check("DNF with no stats resolves to loss with no score", r == "loss" and s is None,
"%s %s" % (r, s))
# endReason must WIN over a contradictory score probe
r, _ = U._match_result({"endReason": "LOSS", "goals": 5, "opponentGoals": 0})
check("endReason beats the legacy score probe", r == "loss", r)
def test_payout_table(): def test_payout_table():
for res in ("won", "draw", "loss"): for res in ("won", "draw", "loss"):
b = U.destroy_match_body(res, U.MATCH_COINS[res], 0) b = U.destroy_match_body(res, U.MATCH_COINS[res], 0)
@@ -96,7 +153,8 @@ def test_payout_table():
def main(): def main():
for t in (test_result_detection, test_reward_body, test_payout_table): for t in (test_result_detection, test_reward_body,
test_end_reason_is_authoritative, test_payout_table):
try: try:
t() t()
except Exception as e: except Exception as e:
+88 -11
View File
@@ -1126,17 +1126,54 @@ MATCH_COINS = {
} }
MATCH_PARTICIPATION = int(os.environ.get("FUT_MATCH_PARTICIPATION", "0")) MATCH_PARTICIPATION = int(os.environ.get("FUT_MATCH_PARTICIPATION", "0"))
# FUT_MATCH_END -- the 2026-08-04 correction of the whole match tail: route /match/end
# as DestroyMatch regardless of verb, and move `coins` inside gameModeAward where the
# deserializer actually reads it. DEFAULT ON, and like FUT_DRAFT_STATE this is a
# reasoned exception to "default to the live-proven value": nothing here is
# live-proven, because no match has ever been played. The old behaviour is not a
# working screen being protected, it is a path that provably could not fire (it
# required a verb and a /match/{id} URL the client does not use). Set to 0 to revert.
MATCH_END = os.environ.get("FUT_MATCH_END", "1") == "1"
# The DestroyMatch REQUEST, reversed 2026-08-04 from the serializer rather than
# guessed from the response side. This replaces the spelling-probe below as the
# PRIMARY path; the probe stays as a fallback because request-side static findings
# are a floor, not a ceiling (PUT /item's swap/tradeId were in no static listing).
#
# endReason (atom 260) -- STRING enum, and it is the AUTHORITATIVE result signal.
# Nine values: WIN DRAW LOSS DNF QUIT NO_CONTEST DNF_WIN DNF_DRAW DNF_LOSS.
# A score comparison is NOT how the client reports the outcome.
# myMatchStats / opponentMatchStats -- literal-keyed objects, 15 int fields each,
# first of which is `goals`. OMITTED BY THE CLIENT when endReason is DNF or QUIT,
# so nothing may require them.
_END_REASON = {
"WIN": "won", "DNF_WIN": "won",
"DRAW": "draw", "DNF_DRAW": "draw", "NO_CONTEST": "draw",
"LOSS": "loss", "DNF_LOSS": "loss", "DNF": "loss", "QUIT": "loss",
}
def _match_result(body): def _match_result(body):
"""Work out win/draw/loss from whatever the client posted. """Work out win/draw/loss from whatever the client posted.
The PlayGame/DestroyMatch request shape is NOT reversed -- the response side is Primary: endReason, the string enum the serializer actually writes. Fallback:
(that is what we serve), but nobody has captured the request yet. So probe the the old spelling probe, then a draw, which is the neutral outcome -- it credits
plausible spellings and fall back to a draw, which is the neutral outcome: it coins and advances the record without inventing a win. Every body is logged, so
still credits coins and advances the record without inventing a win. Every body the first live match still tells us if the static read was incomplete."""
is logged, so the first live match tells us the real shape."""
if not isinstance(body, dict): if not isinstance(body, dict):
return "draw", None return "draw", None
reason = body.get("endReason")
if isinstance(reason, str) and reason.upper() in _END_REASON:
mine = body.get("myMatchStats") or {}
theirs = body.get("opponentMatchStats") or {}
score = None
if isinstance(mine, dict) and isinstance(theirs, dict):
a, b = mine.get("goals"), theirs.get("goals")
if isinstance(a, int) and isinstance(b, int):
score = (a, b)
return _END_REASON[reason.upper()], score
# a nested match/stats object is as likely as a flat one # a nested match/stats object is as likely as a flat one
for key in ("match", "matchStats", "stats", "result", "gameResult"): for key in ("match", "matchStats", "stats", "result", "gameResult"):
inner = body.get(key) inner = body.get(key)
@@ -1168,19 +1205,42 @@ def destroy_match_body(result, coins, total):
Split out of match_route so it can be unit-tested: the match loop mutates Split out of match_route so it can be unit-tested: the match loop mutates
(credits coins, bumps W/D/L), so it cannot live in the read-only HTTP contract (credits coins, bumps W/D/L), so it cannot live in the read-only HTTP contract
suite. See tools/test_match_rewards.py. Every field is a top-level scalar; the suite. See tools/test_match_rewards.py. Every field is a top-level scalar; the
nested members gameModeAward(310)/matchCoinMultipliers(437)/userData(877) are nested members matchCoinMultipliers(437)/userData(877) are SKIP-safe and
SKIP-safe and deliberately omitted (userData is a documented freeze-risk).""" deliberately omitted (userData is a documented freeze-risk).
return {
"coins": int(coins), THREE CORRECTIONS from the 2026-08-04 pass over deser 0x180121b60, all of which
were shipping wrong before:
1. `coins` (atom 149) is NOT a top-level key of this response. It is read ONLY
inside the `gameModeAward` object. The top-level "coins" we were sending was
silently skipped and never reached the client, which means the one field most
obviously named "the reward" was the one field going nowhere.
2. `qualifiedChampionEventId` (atom 0x269) HAS A SIDE EFFECT. Its branch does not
just store the int, it calls through a manager vtable afterwards. Sending it
as a habitual zero pokes champion-event machinery for no benefit. Removed.
3. NEVER emit `bidTokens` (atom 89) inside gameModeAward. That atom is explicitly
matched there and then handled by NOTHING: not read, not routed to the skip
handler. Its value token is left unconsumed in the stream, which is the exact
precondition for the type-desync spin. It is a freeze trap wearing the costume
of an ordinary field.
FUT_MATCH_END=0 restores the previous body if the new one misbehaves live."""
body = {
"allCoins": int(total), "allCoins": int(total),
"matchCoins": int(MATCH_COINS.get(result, 0)), "matchCoins": int(MATCH_COINS.get(result, 0)),
"seasonCoins": 0, "seasonCoins": 0,
"tournamentCoins": 0, "tournamentCoins": 0,
"boostConis": 0, # EA's spelling, atom 96 "boostConis": 0, # EA's spelling, atom 96
"participationAward": int(MATCH_PARTICIPATION), "participationAward": int(MATCH_PARTICIPATION),
"qualifiedChampionEventId": 0,
"teamOfTournamentWinner": False, "teamOfTournamentWinner": False,
} }
if MATCH_END:
# `coins` lives here and nowhere else. No bidTokens, ever.
body["gameModeAward"] = {"coins": int(coins)}
else:
body["coins"] = int(coins)
body["qualifiedChampionEventId"] = 0
return body
def match_route(h): def match_route(h):
@@ -1191,7 +1251,24 @@ def match_route(h):
body = {} body = {}
m = re.search(r"/match/(\d+)", h.path) m = re.search(r"/match/(\d+)", h.path)
match_id = int(m.group(1)) if m else None match_id = int(m.group(1)) if m else None
is_delete = h.command == "DELETE" or "/ut/delete/" in h.path # THE REAL URLS, from the RPC descriptor block (rows 49-54, all using template
# index 16 = `ut/%s/match`, each appending a fixed suffix via the params object
# at slot +0x08): CREATEMATCH and PLAYGAME append nothing, MATCHREADY `/ready`,
# DESTROYMATCH `/end`, RESETMATCH `/reset`, KEEPALIVE `/keepalive`.
#
# THERE IS NO /match/{id} URL ANYWHERE. The id travels in the body. So the old
# `re.search(r"/match/(\d+)")` could never match a real request, and the reward
# path was gated on DELETE-or-/ut/delete/ which the client also never sends --
# it would have fired on nothing. match_id is retained only for hand probes.
#
# The HTTP VERB for each call cannot be determined statically: the strings "PUT"
# and "DELETE" do not exist anywhere in cardsdll.dll (0 hits each), so verb
# selection happens in the HTTP layer outside this DLL. Hence: match on the PATH
# and accept any verb. A reviewer specifically flagged the claim "the reward path
# can never fire" as overreach on exactly this point, since the verb is unknown
# rather than known-wrong, so this widens the gate instead of replacing it.
is_delete = (h.command == "DELETE" or "/ut/delete/" in h.path
or (MATCH_END and h.path.split("?")[0].endswith("/match/end")))
if is_delete: if is_delete:
# FutDestroyMatch -- the ONLY place a match awards anything. # FutDestroyMatch -- the ONLY place a match awards anything.