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:
@@ -61,8 +61,13 @@ def test_result_detection():
|
||||
|
||||
|
||||
# ---- destroy_match_body: the reward record ----------------------------------
|
||||
REQUIRED_INT = ("coins", "allCoins", "matchCoins", "seasonCoins", "tournamentCoins",
|
||||
"boostConis", "participationAward", "qualifiedChampionEventId")
|
||||
# These expectations were REWRITTEN on 2026-08-04 after reversing deser 0x180121b60
|
||||
# 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():
|
||||
@@ -73,19 +78,71 @@ def test_reward_body():
|
||||
isinstance(b.get(k), int) and not isinstance(b.get(k), bool), repr(b.get(k)))
|
||||
check("reward.teamOfTournamentWinner is bool",
|
||||
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"]))
|
||||
# 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,
|
||||
repr(sorted(b)))
|
||||
# nested members must stay OUT (all SKIP-safe; userData is a freeze-risk)
|
||||
for k in ("userData", "gameModeAward", "matchCoinMultipliers"):
|
||||
# userData and matchCoinMultipliers stay OUT (SKIP-safe; userData is freeze-risk)
|
||||
for k in ("userData", "matchCoinMultipliers"):
|
||||
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():
|
||||
if k == "gameModeAward":
|
||||
continue
|
||||
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():
|
||||
for res in ("won", "draw", "loss"):
|
||||
b = U.destroy_match_body(res, U.MATCH_COINS[res], 0)
|
||||
@@ -96,7 +153,8 @@ def test_payout_table():
|
||||
|
||||
|
||||
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:
|
||||
t()
|
||||
except Exception as e:
|
||||
|
||||
@@ -1126,17 +1126,54 @@ MATCH_COINS = {
|
||||
}
|
||||
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):
|
||||
"""Work out win/draw/loss from whatever the client posted.
|
||||
|
||||
The PlayGame/DestroyMatch request shape is NOT reversed -- the response side is
|
||||
(that is what we serve), but nobody has captured the request yet. So probe the
|
||||
plausible spellings and fall back to a draw, which is the neutral outcome: it
|
||||
still credits coins and advances the record without inventing a win. Every body
|
||||
is logged, so the first live match tells us the real shape."""
|
||||
Primary: endReason, the string enum the serializer actually writes. Fallback:
|
||||
the old spelling probe, then a draw, which is the neutral outcome -- it credits
|
||||
coins and advances the record without inventing a win. Every body is logged, so
|
||||
the first live match still tells us if the static read was incomplete."""
|
||||
if not isinstance(body, dict):
|
||||
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
|
||||
for key in ("match", "matchStats", "stats", "result", "gameResult"):
|
||||
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
|
||||
(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
|
||||
nested members gameModeAward(310)/matchCoinMultipliers(437)/userData(877) are
|
||||
SKIP-safe and deliberately omitted (userData is a documented freeze-risk)."""
|
||||
return {
|
||||
"coins": int(coins),
|
||||
nested members matchCoinMultipliers(437)/userData(877) are SKIP-safe and
|
||||
deliberately omitted (userData is a documented freeze-risk).
|
||||
|
||||
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),
|
||||
"matchCoins": int(MATCH_COINS.get(result, 0)),
|
||||
"seasonCoins": 0,
|
||||
"tournamentCoins": 0,
|
||||
"boostConis": 0, # EA's spelling, atom 96
|
||||
"participationAward": int(MATCH_PARTICIPATION),
|
||||
"qualifiedChampionEventId": 0,
|
||||
"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):
|
||||
@@ -1191,7 +1251,24 @@ def match_route(h):
|
||||
body = {}
|
||||
m = re.search(r"/match/(\d+)", h.path)
|
||||
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:
|
||||
# FutDestroyMatch -- the ONLY place a match awards anything.
|
||||
|
||||
Reference in New Issue
Block a user