wip: checkpoint FIFA 17 SBC research for Windows migration
This commit is contained in:
@@ -1145,6 +1145,20 @@ ROUTES = [
|
||||
(re.compile(G + r"/tournament"), lambda m, h: (200, tournament_list() if (_MODES and h.command == "GET") else {})),
|
||||
(re.compile(G + r"/leaderboards"), lambda m, h: leaderboard_route(h) if _MODES else (200, {})),
|
||||
(re.compile(G + r"/champion"), lambda m, h: champion_route(h) if _MODES else (200, {})),
|
||||
# ---- Squad Building Challenges (SBC). Path family is `sbs/*` (not `sbc`).
|
||||
# Baseline had NO sbs routes -> GET sbs/sets fell through to the catch-all {} and
|
||||
# the client threw "problem communicating with the FUT servers" (empty set list,
|
||||
# not a parse fault: the response root is object + skip-tolerant, so {} parses but
|
||||
# carries no data). See sbc_sets_route for the reversed shape and the freeze rules.
|
||||
# Order matters (first-match-wins, rx.search is unanchored): the more specific
|
||||
# sbs paths MUST precede the generic /sbs/sets below, which would otherwise
|
||||
# substring-match /sbs/sets/tag. squad save/load precedes start/submit; start vs
|
||||
# submit split by body/method inside sbc_challenge_route.
|
||||
(re.compile(G + r"/sbs/sets/tag/?$"), lambda m, h: sbc_tag_route(h) if _SBC else (200, {})),
|
||||
(re.compile(G + r"/sbs/setId/\d+/challenges"), lambda m, h: sbc_challenges_route(h) if _SBC else (200, {})),
|
||||
(re.compile(G + r"/sbs/challenge/\d+/squad/?$"), lambda m, h: sbc_squad_route(h) if _SBC else (200, {})),
|
||||
(re.compile(G + r"/sbs/challenge/\d+"), lambda m, h: sbc_challenge_route(h) if _SBC else (200, {})),
|
||||
(re.compile(G + r"/sbs/sets"), lambda m, h: sbc_sets_route(h) if _SBC else (200, {})),
|
||||
# FutGetCaptcha 0x18014e78d: encodedImg(str b64) sequence(int) sizeBeforeEncode(int).
|
||||
# Served always -- an empty captcha is strictly better than the catch-all {},
|
||||
# and all three fields are scalars (no freeze risk).
|
||||
@@ -2238,6 +2252,243 @@ def leaderboard_route(h):
|
||||
return 200, {"entries": []}
|
||||
|
||||
|
||||
# ---- Squad Building Challenges (SBC) -----------------------------------------
|
||||
# GET ut/%s/sbs/sets is the FIRST call the SBC hub makes. Two response classes both
|
||||
# bind to sbs/sets:
|
||||
# FutSBCSetDataServerResponse deser 0x18016fe90 -- OBJECT root, parses ONLY
|
||||
# `reset`(0x283 bool); every other key is
|
||||
# value-SKIP'd. Carries NO set list. This is
|
||||
# why {} produced "problem communicating": the
|
||||
# body parsed fine but held no data.
|
||||
# FutSBCLoadCategoryDetailsServerResponse deser 0x18017ac08 -- OBJECT root, parses
|
||||
# categoryId(0x73 int), name(0x1d0 str),
|
||||
# priority(0x250 int), sets(0x2be ARRAY). Each
|
||||
# set element (parser 0x18017ad60) reads
|
||||
# categoryId(0x73), name(0x1d0), description
|
||||
# (0xd1), priority(0x250), challengesCount
|
||||
# (0x78), challengesCompletedCount(0x77),
|
||||
# awards(0x47 ARRAY). This is the one that
|
||||
# renders the list.
|
||||
# Both parsers are object-root and skip-tolerant, so a MERGED body satisfies whichever
|
||||
# class the client instantiates for a given sbs/sets request with zero freeze risk.
|
||||
# FREEZE-CRITICAL: `sets` and every element's `awards` MUST be JSON arrays if present
|
||||
# (a scalar/object there desyncs the array reader into the 0x1801c7f1a busy loop).
|
||||
# Omitting them defaults to empty (safe). Reversed 2026-08-06 from the on-disk
|
||||
# CardsDLL (control-checked against FutLoadSetChallengesResponse 0x18017bbbb).
|
||||
#
|
||||
# DEFAULT ON: the current behaviour is a hard "problem communicating" modal, so there
|
||||
# is no working state to protect. FUT_SBC=0 restores the catch-all {}.
|
||||
_SBC = os.environ.get("FUT_SBC", "1") == "1"
|
||||
|
||||
# Moddable SBC content. Each category holds its sets; each set optionally holds a
|
||||
# `challenges` list served by the sbs/setId/{id}/challenges route (that key is an
|
||||
# internal detail, stripped before it goes on the sbs/sets wire). Keep `sets`,
|
||||
# `awards`, and each set's `challenges` as lists. setId ties a set to its challenge
|
||||
# records so the two calls stay consistent.
|
||||
SBC_CATEGORIES = [
|
||||
{
|
||||
"categoryId": 1, "name": "Foundations", "priority": 1,
|
||||
"sets": [
|
||||
{"setId": 1, "categoryId": 1, "name": "Bronze Challenge",
|
||||
"description": "Submit an 11-player squad.", "priority": 1,
|
||||
"challengesCount": 1, "challengesCompletedCount": 0, "awards": [],
|
||||
"hidden": False, "endTime": 4102444800,
|
||||
"challenges": [
|
||||
{"challengeId": 101, "name": "League Basics",
|
||||
"description": "Submit an 11-player squad.",
|
||||
"formation": "f442", "type": "OPEN_CHALLENGE", "status": "OPEN"},
|
||||
]},
|
||||
{"setId": 2, "categoryId": 1, "name": "Simple Start",
|
||||
"description": "Get started with your first SBC.", "priority": 2,
|
||||
"challengesCount": 1, "challengesCompletedCount": 0, "awards": [],
|
||||
"hidden": False, "endTime": 4102444800,
|
||||
"challenges": [
|
||||
{"challengeId": 201, "name": "First Steps",
|
||||
"description": "Get started with your first SBC.",
|
||||
"formation": "f442", "type": "OPEN_CHALLENGE", "status": "OPEN"},
|
||||
]},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# Non-container / non-key scalars every challenge record carries, with freeze-safe
|
||||
# defaults. FutLoadSetChallengesResponse per-record parser 0x18017bb50: ints
|
||||
# challengeId/setId/categoryId/index/endTime/trophyId/timesCompleted; strings
|
||||
# type/name/description/challengeImageId/status/formation (formation MUST be a string
|
||||
# -- int desyncs the string mapper 0x180166590); bool repeatable; ARRAYS awards +
|
||||
# elgReq (a scalar there hits the type-desync busy-loop 0x1801c7f1a).
|
||||
_SBC_CHALLENGE_DEFAULTS = {
|
||||
"categoryId": 0, "index": 0, "type": "OPEN_CHALLENGE",
|
||||
"name": "SBC Challenge", "description": "Submit a squad.",
|
||||
"challengeImageId": "", "formation": "f442", "endTime": 0,
|
||||
"repeatable": False, "trophyId": 0, "status": "OPEN",
|
||||
"timesCompleted": 0, "awards": [], "elgReq": [],
|
||||
}
|
||||
|
||||
|
||||
def _sbc_sets_payload():
|
||||
"""Categories for FutSBCLoadCategoryDetailsServerResponse, internal keys removed.
|
||||
|
||||
Strips the internal `challenges` list from each set: the sbs/sets set-element
|
||||
parser 0x18017ad60 only reads categoryId/name/description/priority/
|
||||
challengesCount/challengesCompletedCount/awards, and we keep the wire minimal
|
||||
and identical to the proven success body rather than lean on value-SKIP."""
|
||||
cats = []
|
||||
for cat in SBC_CATEGORIES:
|
||||
sets = [{k: v for k, v in s.items() if k != "challenges"}
|
||||
for s in cat.get("sets", [])]
|
||||
c = {k: v for k, v in cat.items() if k != "sets"}
|
||||
c["sets"] = sets
|
||||
cats.append(c)
|
||||
return cats
|
||||
|
||||
|
||||
def _sbc_find_set(set_id):
|
||||
"""Return (category, set) for setId, or (None, None) -- keeps setIds consistent
|
||||
between sbs/sets and sbs/setId/{id}/challenges."""
|
||||
for cat in SBC_CATEGORIES:
|
||||
for s in cat.get("sets", []):
|
||||
if s.get("setId") == set_id:
|
||||
return cat, s
|
||||
return None, None
|
||||
|
||||
|
||||
def _sbc_challenge_record(set_id, cat, s, idx, ch):
|
||||
"""Build one freeze-safe FutLoadSetChallengesResponse record. awards/elgReq are
|
||||
forced to lists; overrides from the moddable `challenges` entry win over the
|
||||
defaults, but only for the recognised scalar/array fields."""
|
||||
rec = dict(_SBC_CHALLENGE_DEFAULTS)
|
||||
rec["challengeId"] = ch.get("challengeId", set_id * 100 + idx + 1)
|
||||
rec["setId"] = set_id
|
||||
rec["categoryId"] = (cat or {}).get("categoryId", rec["categoryId"])
|
||||
rec["index"] = idx
|
||||
for k, v in ch.items():
|
||||
if k in _SBC_CHALLENGE_DEFAULTS:
|
||||
rec[k] = v
|
||||
rec["awards"] = list(rec.get("awards") or [])
|
||||
rec["elgReq"] = list(rec.get("elgReq") or [])
|
||||
return rec
|
||||
|
||||
|
||||
def sbc_sets_route(h):
|
||||
"""GET ut/%s/sbs/sets -- the SBC category/set list.
|
||||
|
||||
Deser: FutSBCLoadCategoryDetailsServerResponse body 0x18017b2b0, dispatch
|
||||
0x18017b64c. The root is a JSON OBJECT (END_OBJECT cmp eax,0xa @0x18017b66a);
|
||||
the SOLE recognised top-level key is atom 0x6f = "categories", read as an ARRAY
|
||||
(discriminator 0x18017b697; END_ARRAY cmp eax,0xd @0x18017b6b7). GATE (by
|
||||
elimination, no scalar/status/reset is parsed at top level): the categories
|
||||
array must be NON-EMPTY, else the client shows the "problem communicating" modal
|
||||
(no freeze -- object-root accepts {} cleanly). Earlier single-object /
|
||||
merged-with-reset bodies failed because every top-level key other than
|
||||
"categories" is value-SKIP'd -> empty categories -> the modal.
|
||||
FREEZE-CRITICAL: categories[], each category's sets[], and each set's awards[]
|
||||
MUST be JSON arrays; each category is an object. Reversed from CardsDLL."""
|
||||
if h.command != "GET":
|
||||
return 200, {}
|
||||
cats = _sbc_sets_payload()
|
||||
log(" SBC: sbs/sets -> %d categor(y/ies), %d set(s) total"
|
||||
% (len(cats), sum(len(c.get("sets", [])) for c in cats)))
|
||||
return 200, {"categories": cats}
|
||||
|
||||
|
||||
def sbc_challenges_route(h):
|
||||
"""GET ut/%s/sbs/setId/{setId}/challenges -- FutLoadSetChallengesResponse.
|
||||
|
||||
Top-level deser 0x18017c4e0 is OBJECT-root (END_OBJECT cmp eax,0xa @0x18017c8a7);
|
||||
the SOLE recognised key is atom 0x76 = "challenges", read as an ARRAY (END_ARRAY
|
||||
cmp eax,0xd @0x18017c8e7; per-record deser 0x18017bb50). Return an OBJECT
|
||||
{"challenges":[...]}, never a bare array (that would desync the object-root).
|
||||
Records are wired to the set's own challenges (or synthesised to challengesCount)
|
||||
so challengeIds/setIds stay consistent with sbs/sets.
|
||||
FREEZE-CRITICAL: challenges[], and per-record awards[] (0x47) + elgReq[] (0xf7)
|
||||
are arrays; formation is a string."""
|
||||
m = re.search(r"/setId/(\d+)/challenges", h.path)
|
||||
set_id = int(m.group(1)) if m else 0
|
||||
cat, s = _sbc_find_set(set_id)
|
||||
records = []
|
||||
if s is not None:
|
||||
chs = s.get("challenges")
|
||||
if not chs:
|
||||
chs = [{} for _ in range(max(1, int(s.get("challengesCount", 1))))]
|
||||
for idx, ch in enumerate(chs):
|
||||
records.append(_sbc_challenge_record(set_id, cat, s, idx, ch))
|
||||
log(" SBC: challenges setId=%d -> %d challenge(s)" % (set_id, len(records)))
|
||||
return 200, {"challenges": records}
|
||||
|
||||
|
||||
def sbc_start_route(h):
|
||||
"""POST ut/%s/sbs/challenge/{id} with EMPTY body -- START a challenge ->
|
||||
FutSBCStartChallengeResponse (deser 0x180155949, OBJECT-root; {} freeze-safe).
|
||||
|
||||
FREEZE-CRITICAL: "squad" (atom 0x2cd) MUST be a JSON OBJECT -- its value is
|
||||
delegated to the OBJECT-root squad deser 0x18013d1f0; an array there desyncs.
|
||||
"playerRequirements" (0x237) MUST be a JSON ARRAY (END_ARRAY cmp eax,0xd
|
||||
@0x1801559e7). No non-empty gate; empty {} / [] are accepted."""
|
||||
m = re.search(r"/challenge/(\d+)", h.path)
|
||||
cid = int(m.group(1)) if m else 0
|
||||
log(" SBC: start challenge id=%d" % cid)
|
||||
return 200, {"challengeId": cid, "squad": {}, "playerRequirements": []}
|
||||
|
||||
|
||||
def sbc_submit_route(h):
|
||||
"""POST/PUT ut/%s/sbs/challenge/{id} with a BODY -- SUBMIT a challenge ->
|
||||
FutSBCSubmitChallengeServerResponse (deser 0x180161b00, dispatch 0x180161bda,
|
||||
OBJECT-root; {} freeze-safe).
|
||||
|
||||
FREEZE-CRITICAL: grantedChallengeAwards (atom 0x14a) and grantedSetAwards
|
||||
(atom 0x14b) MUST each be a JSON ARRAY (END_ARRAY cmp eax,0xd), never a
|
||||
scalar/object; empty [] is safe. Scalars challengeId/setId/credits/
|
||||
preOrderPacks/recoveredPacks are ints."""
|
||||
m = re.search(r"/challenge/(\d+)", h.path)
|
||||
cid = int(m.group(1)) if m else 0
|
||||
log(" SBC: submit challenge id=%d" % cid)
|
||||
return 200, {"challengeId": cid, "setId": 0, "credits": 0,
|
||||
"preOrderPacks": 0, "recoveredPacks": 0,
|
||||
"grantedChallengeAwards": [], "grantedSetAwards": []}
|
||||
|
||||
|
||||
def sbc_challenge_route(h):
|
||||
"""Dispatch the shared path ut/%s/sbs/challenge/{id}: START vs SUBMIT.
|
||||
|
||||
Both builders format the identical "/challenge/%d" (str 0x180227300). The proven
|
||||
discriminator is the REQUEST BODY -- START emits an empty body (POST), SUBMIT a
|
||||
populated JSON body (PUT/POST). We route to SUBMIT on a PUT or any non-empty
|
||||
body, else START (covers both the body-presence and method discriminators)."""
|
||||
if h.command == "PUT" or getattr(h, "_body", b""):
|
||||
return sbc_submit_route(h)
|
||||
return sbc_start_route(h)
|
||||
|
||||
|
||||
def sbc_squad_route(h):
|
||||
"""ut/%s/sbs/challenge/{id}/squad -- method-discriminated save/load.
|
||||
|
||||
PUT -> FutSBCSaveSquadChallengeServerResponse (deser 0x18017cff0; parses only
|
||||
id 0x15c -> return {"id":<id>}). GET -> FutLoadSetTypesServerResponse (deser
|
||||
0x180154990; reads squad 0x2cd + playerRequirements 0x237). Both roots are JSON
|
||||
objects. NOTE: unlike START, here "squad" (0x2cd) is parsed as an ARRAY (callback
|
||||
0x18013d1f0, state [rbx+0xb8]=3), and "playerRequirements" (0x237) is an ARRAY
|
||||
(END_ARRAY cmp eax,0xd @0x180154e07). The unified body is freeze-safe under both
|
||||
verbs: PUT reads id and value-SKIPs the arrays; GET reads the arrays and
|
||||
value-SKIPs id."""
|
||||
m = re.search(r"/challenge/(\d+)/squad", h.path)
|
||||
cid = int(m.group(1)) if m else 0
|
||||
if h.command == "PUT":
|
||||
log(" SBC: save squad challenge id=%d" % cid)
|
||||
return 200, {"id": cid}
|
||||
log(" SBC: load squad challenge id=%d" % cid)
|
||||
return 200, {"id": cid, "squad": [], "playerRequirements": []}
|
||||
|
||||
|
||||
def sbc_tag_route(h):
|
||||
"""POST/PUT ut/%s/sbs/sets/tag -- FutSBCTagSetsServerResponse (deser 0x1801542f0,
|
||||
OBJECT-root, parses NO fields: every key value-SKIP'd, always returns success).
|
||||
Pure ack. The one hard rule is the container: the root MUST be a JSON object --
|
||||
{} is fully accepted; an array/scalar root would busy-loop at 0x1801c7f1a."""
|
||||
log(" SBC: sets/tag ack")
|
||||
return 200, {}
|
||||
|
||||
|
||||
# ---- Draft current state -----------------------------------------------------
|
||||
# GET ut/%s/squad/mode/draft/state?mode=ONLINE|SINGLE_PLAYER
|
||||
# -> FutGetDraftCurrentStateServerResponse, deser 0x180147070.
|
||||
|
||||
Reference in New Issue
Block a user