diff --git a/fifa17-recon/tools/test_match_rewards.py b/fifa17-recon/tools/test_match_rewards.py index 8c50db6..aa7d368 100644 --- a/fifa17-recon/tools/test_match_rewards.py +++ b/fifa17-recon/tools/test_match_rewards.py @@ -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: diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index c165ec0..4943757 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -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.