5d5198f5d1
Second session. FUT core loop, the EASFC/POW online layer, a central account
backend, and a lot of corrections. Everything risky is behind an env flag with
the default set to whatever was live-proven.
WORKING END TO END (live-verified this session):
* match loop -- POST/PUT/POST/DELETE ut/%s/match, rewards via FutDestroyMatch
(0x180121b60). Play a match, get coins, W/D/L updates.
* packs -- buy, cards land in the club, session survives (FUT_PACK_AUTOCLUB=1)
* quick sell -- POST ut/delete/%s/item was UNMAPPED and paid NOTHING; six cards
were destroyed for 0 coins. Now credits discardValue.
* POW/EASFC online -- the "EA FC servers unreachable" banner is powdll's layer,
a THIRD http api on :8094 nobody had served. Redirect needs no root: powdll
FUN_18005a460 reads FIFA_POW_URL from the same client-config store as
ROSTERUPDATE_URL. FUT_POW=1.
* account backend -- fut_account.py replaces 7 hardcoded copies of the persona
across 5 files; club/persona/online-profile editable via CLI.
CORRECTIONS TO ENDPOINT_MAP (all re-extracted from the deserializers):
* FutStoreGetPackTypes: id/packType/isPremium/quantity/saleType/purchaseLimit/
purchaseCount are NOT skipped no-ops -- all are parsed. extPrice inner objects
take externalPriceId(0x11a), not amount/currency.
* FutMoveCard 0x180128600 has NO skip handler (FUN_180135ff0 appears zero times,
unique among FUT deserializers) and parses only itemData -> dreamSquads.
* class -> deserializer resolution: the name literal is preceded by a 4-BYTE
HEADER and the factory LEA points at the header, so look up name_addr - 4.
Six attempts failed on this; now ghidra_env.class_deser(). Unlocked 11 SBC/
Draft schemas.
* live-only endpoints the request table never lists: ut/%s/squad/list,
ut/%s/user/club, ut/%s/club/stats/*, ut/%s/clientdata/<key>. The template
table is a floor, not a ceiling -- the log is the only ground truth.
* 163 RS4 call names exist; we served 17. All now served.
FIXED: club/stats/* was answering with the entire 28-item club inventory on every
poll (it fell through to the generic /club route).
UNSOLVED: the pack reveal's "Send to Club" (PUT ut/%s/item) kills the FUT session
whatever we answer -- {} included -- while its sibling quick-sell endpoint accepts
a bare {}. Seven hypotheses eliminated by live test, documented in
REBUILD_RESEARCH.md S14c so none get re-walked. FUT_PACK_AUTOCLUB routes around it.
Also unfixed: store tiles render "unknown" (displayGroup is parsed RECURSIVELY by
the same element parser; sending it FROZE the store, so FUT_STORE_GROUPS=1 is
default off).
Tests: test_fut_contract.py 380 (live, read-only) + test_match_rewards.py 51 (pure).
Note: fut_store.py carries some pre-existing uncommitted changes from before this
session (pack catalogue ids, pending-pile behaviour) that could not be separated
from this session's additions in the same file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
204 lines
9.0 KiB
Python
204 lines
9.0 KiB
Python
# ---------------------------------------------------------------------------
|
|
# OpenFUT / FIFA 17 UTAS -- forged squad ladder (clean-room). Imported by
|
|
# utas_server.py. Derived from CardsDLL_Win64_retail.dll (PE base 0x180000000).
|
|
#
|
|
# The prior full 11-player squad HUNG FIFA. Workflow wf_0bc80ab3 (5 agents,
|
|
# adversarially verified) proved: the deserializer PARSES our JSON fine; the
|
|
# freeze is POST-PARSE, at the per-item finalize resolve 0x180141176 (call
|
|
# singleton 0x18011a830 -> [r9+0xa08]) that fires for EVERY parsed item object
|
|
# (manager, players[].itemData, actives[]). Whether that resolve BLOCKS offline
|
|
# on an unresolvable item is UNVERIFIED -> we bisect it empirically with a ladder.
|
|
#
|
|
# The ladder (select via env FUT_SQUAD_STEP, default "s1"). Each step is one
|
|
# small change so a single FIFA relaunch isolates one variable:
|
|
# s1 zero-resolve: players are bare {index,kitNumber}, no itemData, manager=[]
|
|
# -> item deser NEVER entered, resolve fires 0 times. Tests envelope+HTTP
|
|
# framing only. Reaches hub => hang IS item-resolve. Freezes => framing.
|
|
# s1b players[0] gets an EMPTY itemData {id:0,dream:false} (still no real asset)
|
|
# -> resolve fires once on id 0. Freezes => id-0 resolve itself blocks
|
|
# offline. Reaches hub => id-0 is fine, the asset value is what matters.
|
|
# s2v0 players[0] = ONE real item, resourceId==assetId (version byte 0x00);
|
|
# club serves the same item. Renders => version 0 is correct.
|
|
# s2v1 same but version byte 0x01 (resourceId = 0x01<<24|assetId).
|
|
# s3v0 full XI with the winning version byte (default 0x00); club in lockstep.
|
|
# s3v1 full XI, version 0x01.
|
|
# resourceId decompose 0x180166ca0 CONFIRMED: assetId = resourceId & 0xffffff,
|
|
# high byte = version. Version byte value is the open question s2v0/s2v1 answer.
|
|
# ---------------------------------------------------------------------------
|
|
import os, sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from fut_account import ACCOUNT # single source of truth for identity
|
|
|
|
# Back-compat snapshot; prefer ACCOUNT.persona_id in new code.
|
|
PERSONA_ID = ACCOUNT.persona_id
|
|
ITEM_ID_BASE = 100000000
|
|
|
|
# Real FIFA17 assetIds read earlier from the live InGameDB. assetId 41 (Iniesta)
|
|
# was flagged by the verifier as possibly having NO InGameDB definition -> it is
|
|
# DROPPED from the XI until a live test confirms a replacement. (asset, rating, pos, kit)
|
|
REAL_XI = [
|
|
(20801, 94, "LW", 7), # Ronaldo -- STEP-2 uses this one
|
|
(158023, 93, "RW", 10), # Messi
|
|
(200389, 87, "GK", 1), # Oblak
|
|
(183907, 90, "CB", 5), # Boateng
|
|
(155862, 89, "CB", 4), # Ramos
|
|
(197445, 87, "LB", 2), # Alaba
|
|
(189332, 86, "LB", 3), # Alba
|
|
(182521, 88, "CM", 8), # Kroos
|
|
(183277, 88, "LM", 11), # Hazard
|
|
(176580, 92, "ST", 9), # Suarez
|
|
# (41, 88, "CM", ..) DROPPED: verifier says no InGameDB def -> stall risk
|
|
]
|
|
|
|
|
|
def player_item(asset, rating, pos, version=0x00, nation=38, team=243, league=53,
|
|
attrs=(90, 93, 82, 91, 33, 80)):
|
|
"""FULL item -- in case the card system needs more than the minimal set to
|
|
PLACE + render a real player (the minimal item rendered generic + rating 0).
|
|
Defaults are Ronaldo (Portugal 38 / Real Madrid 243 / La Liga 53)."""
|
|
rid = (version << 24) | asset
|
|
return {
|
|
"id": ITEM_ID_BASE + (asset & 0xffffff) + 1, # unique, != 0
|
|
"resourceId": rid,
|
|
"assetId": asset,
|
|
"cardassetid": asset,
|
|
# definitionId is INERT in FIFA 17: it is not in the atom table at all, so
|
|
# the client's key hash never matches and it routes straight to the value-SKIP
|
|
# handler 0x180135ff0 -- same class as the itemDbVersion/checkServerDbVersion
|
|
# keys proven phantom in blaze_responder. Kept (harmless, and other FIFA
|
|
# versions do use it) but it is NOT read here; the live key is resourceId.
|
|
"definitionId": rid,
|
|
"cardsubtypeid": 0, # 0..3 => PLAYER
|
|
"itemType": "player",
|
|
"rareflag": 1,
|
|
"rating": rating,
|
|
"preferredPosition": pos, # STRING enum "GK"/"CB"/...
|
|
"nation": nation,
|
|
"teamid": team,
|
|
"leagueId": league,
|
|
"playStyle": 250,
|
|
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
|
|
"itemState": "free",
|
|
"owners": 1,
|
|
"untradeable": True,
|
|
"contract": 7,
|
|
"fitness": 99,
|
|
"loans": 0,
|
|
"discardValue": 0,
|
|
"statsList": [],
|
|
"lifetimeStats": [],
|
|
}
|
|
|
|
|
|
def _base_squad():
|
|
"""Envelope shared by every step: valid formation/custom/kicktakers, but
|
|
players are bare {index,kitNumber} (index is the direct hash bucket key,
|
|
MUST be unique 0..22) and manager empty -> zero item-deser calls by default."""
|
|
return {
|
|
"id": 0,
|
|
"personaId": ACCOUNT.persona_id, # must equal logged-in persona (0x18014659c)
|
|
"squadName": ACCOUNT.squad_name,
|
|
"formation": "f442",
|
|
"squadType": "REGULAR_SQUAD",
|
|
"chemistry": 100,
|
|
"starRating": 5,
|
|
"captain": 0,
|
|
"changed": 0,
|
|
"manager": [],
|
|
"actives": [],
|
|
"custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,"
|
|
"50,50,0,50,40,65,0,65,50,50,1]",
|
|
"players": [{"index": i, "kitNumber": 0} for i in range(23)],
|
|
"kicktakers": [{"index": i, "id": 0, "dream": False} for i in range(5)],
|
|
}
|
|
|
|
|
|
def _put_item(squad, index, itemdata, kit=0):
|
|
squad["players"][index] = {"index": index, "itemData": itemdata, "kitNumber": kit}
|
|
|
|
|
|
def make_squad(step="s1"):
|
|
"""Return (squad, club) for a ladder step. club is {"itemData":[...]}."""
|
|
step = step.lower()
|
|
squad = _base_squad()
|
|
club_items = []
|
|
|
|
if step == "s0":
|
|
# ABSOLUTE minimal squad: no custom (only field re-parsed by a sub-reader,
|
|
# 0x1801c8270 -- prime suspect for the reader EOF-spin), empty players &
|
|
# kicktakers arrays. Reaches hub => envelope OK, spin is custom/players/
|
|
# kicktakers -> add back one at a time. Freezes => any squad object spins.
|
|
squad.pop("custom", None)
|
|
squad["players"] = []
|
|
squad["kicktakers"] = []
|
|
|
|
elif step == "s1":
|
|
pass # bare players, empty club
|
|
|
|
elif step == "s1b":
|
|
_put_item(squad, 0, {"id": 0, "dream": False}) # one empty item, no asset
|
|
|
|
elif step in ("s2v0", "s2v1"):
|
|
version = 0x00 if step.endswith("v0") else 0x01
|
|
asset, rating, pos, kit = REAL_XI[0] # Ronaldo
|
|
it = player_item(asset, rating, pos, version)
|
|
_put_item(squad, 0, it, kit)
|
|
squad["captain"] = it["id"]
|
|
club_items = [it]
|
|
|
|
elif step in ("s3v0", "s3v1"):
|
|
version = 0x00 if step.endswith("v0") else 0x01
|
|
for slot, (asset, rating, pos, kit) in enumerate(REAL_XI):
|
|
it = player_item(asset, rating, pos, version)
|
|
_put_item(squad, slot, it, kit)
|
|
club_items.append(it)
|
|
squad["captain"] = club_items[0]["id"]
|
|
|
|
else:
|
|
raise ValueError("unknown FUT_SQUAD_STEP=%r (s1|s1b|s2v0|s2v1|s3v0|s3v1)" % step)
|
|
|
|
return squad, {"itemData": club_items}
|
|
|
|
|
|
def squad_rating(squad):
|
|
"""Squad rating = mean of the rated players actually placed (0 when the squad
|
|
is empty, e.g. the s1 zero-resolve ladder step)."""
|
|
ratings = [(pl.get("itemData") or {}).get("rating", 0)
|
|
for pl in squad.get("players", [])]
|
|
ratings = [r for r in ratings if isinstance(r, int) and r > 0]
|
|
return sum(ratings) // len(ratings) if ratings else 0
|
|
|
|
|
|
def squad_summary(squad):
|
|
"""One FutSquadList element (deser 0x180141fc0, verified 2026-08-03).
|
|
|
|
Exactly six atoms are recognised; everything else is SKIP'd:
|
|
rating 0x274 int (scalar getter 0x1801c79d0)
|
|
chemistry 0x81 int (scalar getter 0x1801c79d0)
|
|
formation 0x12b STRING (string getter 0x1801c7aa0 -> enum conv 0x180166590)
|
|
id 0x15c int
|
|
squadName 0x2d3 STRING
|
|
squadType 0x2d6 STRING (string getter 0x1801c7aa0 -> enum conv 0x1801668e0)
|
|
|
|
NOTE: formation/squadType are STRINGS here, exactly as in the full-squad parser
|
|
0x18013d1f0 -- they go through the same 0x1801c7aa0 + converter pair. (The
|
|
rebuild plan's "<int>" for those two was wrong; feeding ints to a string getter
|
|
is the classic type-mismatch freeze at 0x1801c7f1a.)
|
|
"""
|
|
return {
|
|
"rating": squad_rating(squad),
|
|
"chemistry": int(squad.get("chemistry", 0)),
|
|
"formation": squad.get("formation", "f442"),
|
|
"id": int(squad.get("id", 0)),
|
|
"squadName": squad.get("squadName", ACCOUNT.squad_name),
|
|
"squadType": squad.get("squadType", "REGULAR_SQUAD"),
|
|
}
|
|
|
|
|
|
# Selected at import time from the environment (default s1 = the zero-resolve test).
|
|
STEP = os.environ.get("FUT_SQUAD_STEP", "s1")
|
|
SQUAD, CLUB = make_squad(STEP)
|
|
# Back-compat exports for utas_server.
|
|
USER_LIST = {"user": []}
|