wip: checkpoint FIFA 17 SBC research for Windows migration

This commit is contained in:
funman300
2026-08-07 12:03:22 -07:00
parent 3d3239bab9
commit cc694774a3
47 changed files with 9105 additions and 4 deletions
+251
View File
@@ -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.