fifa17-recon: reconcile authoritative tools with running backend (B)
- Add 8 files present in docker/fifa17-python/tools but missing from the top-level tree: fut_accounts.py + 7 test_*.py contracts (all committed in the server's docker tree; byte-identical to the running image). - Preserve newer responder work already matching the running container: utas_server.py (offlineSeason), lsx_responder_v2.py (OPENFUT_BIND), blaze_responder_v3b.py, autopatch.py, pow_server.py, fut_store.py, test_fut_contract.py, fifa17-hook-m1.sh. - Add 30 newer ghidra_queries (draft purchase/state, SBC 9-26, runtime registries). Local tree is now a strict superset of B with all shared files byte-identical.
This commit is contained in:
@@ -11,11 +11,13 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
|
||||
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
|
||||
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
|
||||
"""
|
||||
import datetime, json, os, re, sys, http.server
|
||||
import copy, datetime, json, os, random, re, sys, http.server
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room)
|
||||
from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs
|
||||
from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item, player_item
|
||||
import fut_cards
|
||||
import fut_staff
|
||||
from fut_account import ACCOUNT, validate_club # identity + club, single source
|
||||
|
||||
# FUT_PORT exists so a second, THROWAWAY instance can be started without touching the
|
||||
@@ -37,6 +39,16 @@ SID = "OPENFUT-SID-0000000000000001"
|
||||
# Flip to True once you want to exercise the create-club path instead.
|
||||
NEW_USER = False
|
||||
|
||||
# An owned pack is consumed persistently when opened, but FIFA's reveal controller
|
||||
# still returns to the My Packs group after all items are assigned/sold. Keep the
|
||||
# just-opened catalogue record visible until the next hub request so that group is
|
||||
# not deleted underneath a live UI controller.
|
||||
_OPENED_PACK_GRACE = []
|
||||
|
||||
|
||||
def visible_unopened_packs():
|
||||
return STORE.unopened_packs() + list(_OPENED_PACK_GRACE)
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -285,10 +297,16 @@ def user_info():
|
||||
"actives": (current_squad().get("actives") or [])[:5],
|
||||
})
|
||||
# ---- the two side-effecting members, off by default (see _UI above) --------
|
||||
if _UI in ("packs", "full"):
|
||||
# Ownership counters must reflect persistent inventory only. The catalogue
|
||||
# grace row prevents StoreFront from deleting a group beneath its live reveal
|
||||
# controller, but the pack was already consumed and must not remain in FIFA's
|
||||
# cached unopened-pack count.
|
||||
unopened_count = len(STORE.unopened_packs())
|
||||
if unopened_count or _UI in ("packs", "full"):
|
||||
# unopenedPacks(0x35e): after parsing preOrderPacks(0x24b)+recoveredPacks
|
||||
# (0x27b) the deser calls singleton->vtbl[0x4e0](preOrder + recovered).
|
||||
info["unopenedPacks"] = {"preOrderPacks": 0, "recoveredPacks": 0}
|
||||
info["unopenedPacks"] = {"preOrderPacks": 0,
|
||||
"recoveredPacks": unopened_count}
|
||||
if _UI in ("roster", "full"):
|
||||
# squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it
|
||||
# fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen).
|
||||
@@ -936,13 +954,30 @@ def quick_sell_route(h):
|
||||
except Exception:
|
||||
body = {}
|
||||
ids = [it.get("id") for it in (body.get("itemData") or []) if isinstance(it, dict)]
|
||||
# Live FIFA 17 bulk serializer FUN_180126f40 emits the singular atom
|
||||
# `itemId` containing an array of int64 handles. Keep itemIds as a tolerant
|
||||
# alias for old replay fixtures, but never rely on it for the retail client.
|
||||
if not ids and isinstance(body.get("itemId"), list):
|
||||
ids = body["itemId"]
|
||||
if not ids and isinstance(body.get("itemIds"), list):
|
||||
ids = body["itemIds"]
|
||||
ids = [int(i) for i in ids if isinstance(i, int) and i > 0]
|
||||
sellable_before = {it.get("id") for it in STORE.purchased() + STORE.items()}
|
||||
sold, coins = STORE.quick_sell(ids)
|
||||
if sold:
|
||||
log(" QUICKSELL: sold %d card(s) for %d coins (total %d)"
|
||||
% (sold, coins, STORE.coins()))
|
||||
return 200, {}
|
||||
else:
|
||||
log(" QUICKSELL: no requested ids were found; balance unchanged at %d"
|
||||
% STORE.coins())
|
||||
# FutDiscardCardServerResponse. `totalCredits` is the absolute post-sale
|
||||
# wallet balance, not the sale delta. Only echo accounted-for IDs; duplicate
|
||||
# or stale request handles must not be removed from the client model twice.
|
||||
sold_ids = list(dict.fromkeys(iid for iid in ids if iid in sellable_before))
|
||||
return 200, {
|
||||
"items": [{"id": iid} for iid in sold_ids],
|
||||
"totalCredits": STORE.coins(),
|
||||
}
|
||||
|
||||
|
||||
def _move_ack(req, moved):
|
||||
@@ -1116,6 +1151,19 @@ ROUTES = [
|
||||
# is composed by appending a suffix, so it is invisible to the request-template
|
||||
# table, and the generic /squad route below was swallowing it. MUST precede it.
|
||||
(re.compile(G + r"/squad/mode/draft/state"), lambda m, h: draft_state_route(h)),
|
||||
# Live-composed Draft URL, likewise invisible in the static request templates.
|
||||
# It must precede generic /squad or squad_route answers {"id":0}, leaving the
|
||||
# formation carousel empty even though FORMATION_DRAFT was accepted.
|
||||
(re.compile(G + r"/squad/mode/\d+/draft/choices/formation"),
|
||||
lambda m, h: draft_formation_choices_route(h)),
|
||||
(re.compile(G + r"/squad/mode/\d+/draft/choices/captain"),
|
||||
lambda m, h: draft_captain_choices_route(h)),
|
||||
(re.compile(G + r"/squad/mode/\d+/draft/choices/player"),
|
||||
lambda m, h: draft_player_choices_route(h)),
|
||||
(re.compile(G + r"/squad/mode/\d+/draft/choices/manager"),
|
||||
lambda m, h: draft_manager_choices_route(h)),
|
||||
(re.compile(G + r"/squad/mode/\d+/draft/choose"),
|
||||
lambda m, h: draft_choose_route(h)),
|
||||
(re.compile(G + r"/purchase/mode/\d+/draft"), lambda m, h: draft_purchase_route(h)),
|
||||
(re.compile(G + r"/squad"), lambda m, h: squad_route(h)),
|
||||
(re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)),
|
||||
@@ -1286,6 +1334,10 @@ def hub_data():
|
||||
showed it) yet the TRANSFER LIST tile read '0 items / Selling 0' -- the tile reads
|
||||
hub.tradePile, not /tradePile/counts (which the tile never re-polls). All active
|
||||
listings are 'selling'; none are 'sold'. count == selling == number of listings."""
|
||||
if _OPENED_PACK_GRACE:
|
||||
log(" STORE: retiring %d opened-pack grace entry at hub"
|
||||
% len(_OPENED_PACK_GRACE))
|
||||
_OPENED_PACK_GRACE.clear()
|
||||
if not HUBDATA:
|
||||
return {}
|
||||
players = len([i for i in STORE.items() if _is_player(i)])
|
||||
@@ -2522,25 +2574,214 @@ def sbc_tag_route(h):
|
||||
# Draft cannot be entered at all today. FUT_DRAFT_STATE=0 restores the old routing if
|
||||
# this turns out to be wrong.
|
||||
DRAFT_STATE = os.environ.get("FUT_DRAFT_STATE", "1") == "1"
|
||||
# Modes that successfully passed the entry-purchase response in this server process.
|
||||
# Keep this volatile until the complete draft lifecycle (including abandon/rewards)
|
||||
# is implemented; persisting a half-built draft would make recovery harder.
|
||||
_DRAFT_SESSIONS = {}
|
||||
|
||||
|
||||
def _draft_squad(session):
|
||||
"""A Draft-owned squad model, separate from STORE's regular active squad."""
|
||||
squad = copy.deepcopy(SQUAD)
|
||||
squad.update({
|
||||
"id": 0,
|
||||
"personaId": ACCOUNT.persona_id,
|
||||
"squadName": "My Draft",
|
||||
"formation": session.get("formation", "f442"),
|
||||
"squadType": "DRAFT_SQUAD",
|
||||
"chemistry": 0,
|
||||
"starRating": 0,
|
||||
"captain": 0,
|
||||
"manager": ([{
|
||||
"id": session["manager"].get("id", 0),
|
||||
"itemData": copy.deepcopy(session["manager"]),
|
||||
"dream": False,
|
||||
}] if session.get("manager") else []),
|
||||
})
|
||||
# SQUAD is currently the empty, schema-proven seed, but explicitly stripping
|
||||
# itemData prevents a future seed-mode change from leaking the regular XI here.
|
||||
selected = session.get("selected", {})
|
||||
squad["players"] = []
|
||||
for index in range(23):
|
||||
player = {"index": index, "kitNumber": 0}
|
||||
if index in selected:
|
||||
player["itemData"] = copy.deepcopy(selected[index])
|
||||
squad["players"].append(player)
|
||||
captain_slot = session.get("captain_slot")
|
||||
if captain_slot in selected:
|
||||
squad["captain"] = selected[captain_slot].get("id", 0)
|
||||
return squad
|
||||
|
||||
|
||||
def draft_state_route(h):
|
||||
if not DRAFT_STATE:
|
||||
return squad_route(h)
|
||||
m = re.search(r"[?&]mode=([^&]+)", h.path)
|
||||
mode = m.group(1) if m else ""
|
||||
session = _DRAFT_SESSIONS.get(mode)
|
||||
purchased = session is not None
|
||||
return 200, [{
|
||||
"squadState": "INVALID", # 0x2d5 STRING enum
|
||||
# Atom-table-backed enum consumed by FUN_180147070. After a successful
|
||||
# entry purchase FIFA's next legitimate stage is formation selection.
|
||||
"squadState": session.get("stage", "FORMATION_DRAFT") if purchased else "INVALID",
|
||||
"stateParam1": "INVALID", # STRING
|
||||
"stateParam2": "0", # STRING (the int getter also accepts it)
|
||||
"gamesWonCurrentMatch": 0, # INT
|
||||
"roundsInfo": [], # array of the 7-scalar element; empty is safe
|
||||
**({"squad": _draft_squad(session)} if purchased else {}),
|
||||
# entranceCriteria: OMITTED. Shape known, not needed, skip-safe.
|
||||
}]
|
||||
|
||||
|
||||
def draft_formation_choices_route(h):
|
||||
"""Return the first Draft round: a formation carousel.
|
||||
|
||||
FutGetDraftChoicesServerResponse (FUN_18014f2d0) consumes an object root with
|
||||
choices[] records. Formation records use only index + formation; itemData is
|
||||
reserved for later player/manager rounds.
|
||||
"""
|
||||
log(" DRAFT: serving formation choices")
|
||||
return 200, {
|
||||
"positionid": 0,
|
||||
"tier": 1,
|
||||
"choices": [
|
||||
{"index": 0, "formation": "f442"},
|
||||
{"index": 1, "formation": "f433"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def draft_captain_choices_route(h):
|
||||
"""Offer five known-good player cards for the captain round."""
|
||||
candidates = [player for player in fut_cards.POOL if player[1] >= 84]
|
||||
cards = [player_item(800000000 + index, player, special=random.random() < 0.20)
|
||||
for index, player in enumerate(random.sample(candidates, 5))]
|
||||
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
|
||||
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
|
||||
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "CAPTAIN_DRAFT"})
|
||||
session["pending_choices"] = cards
|
||||
log(" DRAFT: serving %d captain choices" % len(cards))
|
||||
return 200, {
|
||||
"positionid": 0,
|
||||
"tier": 1,
|
||||
"choices": [
|
||||
{"index": index, "itemData": card}
|
||||
for index, card in enumerate(cards)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def draft_player_choices_route(h):
|
||||
"""Offer a player round for the slot requested by the Draft UI."""
|
||||
try:
|
||||
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
|
||||
except Exception:
|
||||
body = {}
|
||||
position_id = int(body.get("positionId", body.get("positionid", 0)))
|
||||
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
|
||||
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
|
||||
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "PLAYER_DRAFT"})
|
||||
selected_assets = {
|
||||
card.get("assetId") for card in session.get("selected", {}).values()
|
||||
}
|
||||
|
||||
|
||||
def draft_manager_choices_route(h):
|
||||
"""Offer five verified FIFA 17 managercards for the final Draft round."""
|
||||
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
|
||||
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
|
||||
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "MANAGER_DRAFT"})
|
||||
manager_ids = random.sample(fut_staff.STARTER_MANAGERS, 5)
|
||||
cards = [fut_staff.manager_item(800200000 + index, carddbid)
|
||||
for index, carddbid in enumerate(manager_ids)]
|
||||
session["stage"] = "MANAGER_DRAFT"
|
||||
session["pending_choices"] = cards
|
||||
session["pending_position"] = 23
|
||||
log(" DRAFT: serving %d manager choices" % len(cards))
|
||||
return 200, {
|
||||
"positionid": 23,
|
||||
"tier": 1,
|
||||
"choices": [
|
||||
{"index": index, "itemData": card}
|
||||
for index, card in enumerate(cards)
|
||||
],
|
||||
}
|
||||
# Prefer the requested slot's broad position family. If FIFA sends only a
|
||||
# numeric squad slot (as observed), the client still enforces chemistry/fit;
|
||||
# offering varied high-quality players is safer than inventing a slot map for
|
||||
# every formation. Five unique assets are guaranteed per carousel.
|
||||
candidates = [player for player in fut_cards.POOL
|
||||
if player[1] >= 75 and player[0] not in selected_assets]
|
||||
picks = random.sample(candidates, 5)
|
||||
draft_seq = session.get("draft_item_seq", 0)
|
||||
cards = [player_item(800100000 + draft_seq + index, player,
|
||||
special=random.random() < 0.12)
|
||||
for index, player in enumerate(picks)]
|
||||
session["draft_item_seq"] = draft_seq + len(cards)
|
||||
session["pending_choices"] = cards
|
||||
session["pending_position"] = position_id
|
||||
log(" DRAFT: serving %d player choices for slot %d"
|
||||
% (len(cards), position_id))
|
||||
return 200, {
|
||||
"positionid": position_id,
|
||||
"tier": 1,
|
||||
"choices": [
|
||||
{"index": index, "itemData": card}
|
||||
for index, card in enumerate(cards)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def draft_choose_route(h):
|
||||
"""Acknowledge a pick and advance the volatile Draft state machine."""
|
||||
try:
|
||||
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
|
||||
except Exception:
|
||||
body = {}
|
||||
m = re.search(r"/squad/mode/(\d+)/draft/choose", h.path)
|
||||
mode_id = int(m.group(1)) if m else 0
|
||||
mode = "SINGLE_PLAYER" if mode_id == 1 else "ONLINE"
|
||||
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "FORMATION_DRAFT"})
|
||||
if session.get("stage") == "FORMATION_DRAFT":
|
||||
formations = ("f442", "f433")
|
||||
choice = body.get("choiceIndex", 0)
|
||||
session["formation"] = formations[choice] if choice in range(len(formations)) else "f442"
|
||||
session["stage"] = "CAPTAIN_DRAFT"
|
||||
log(" DRAFT: chose formation %s; advancing to captain"
|
||||
% session["formation"])
|
||||
elif session.get("stage") in ("CAPTAIN_DRAFT", "PLAYER_DRAFT", "MANAGER_DRAFT"):
|
||||
choice_index = int(body.get("choiceIndex", 0))
|
||||
position_id = int(body.get("positionId", session.get("pending_position", 0)))
|
||||
choices = session.get("pending_choices", [])
|
||||
if 0 <= choice_index < len(choices):
|
||||
session.setdefault("selected", {})[position_id] = copy.deepcopy(
|
||||
choices[choice_index]
|
||||
)
|
||||
if session.get("stage") == "CAPTAIN_DRAFT":
|
||||
session["captain_slot"] = position_id
|
||||
session["stage"] = "PLAYER_DRAFT"
|
||||
log(" DRAFT: chose captain for slot %d; advancing to players"
|
||||
% position_id)
|
||||
elif session.get("stage") == "MANAGER_DRAFT":
|
||||
session["manager"] = copy.deepcopy(choices[choice_index])
|
||||
session["stage"] = "COMPLETED_DRAFT"
|
||||
log(" DRAFT: chose manager; draft squad is complete")
|
||||
else:
|
||||
log(" DRAFT: chose player for slot %d" % position_id)
|
||||
else:
|
||||
log(" DRAFT: rejected out-of-range choice %d at slot %d"
|
||||
% (choice_index, position_id))
|
||||
else:
|
||||
log(" DRAFT: acknowledged pick at stage %s body=%s"
|
||||
% (session.get("stage"), json.dumps(body)))
|
||||
# FutPickDraftChoiceServerResponse uses the generic no-field response parser.
|
||||
return 200, {}
|
||||
|
||||
|
||||
# ---- Draft entry purchase ----------------------------------------------------
|
||||
# POST ut/%s/purchase/mode/{price}/draft body {"currency":"COINS","usePreOrder":0}
|
||||
# -> FutPurchaseDraftModeServerResponse. "Buys" entry into draft mode and returns
|
||||
# the fresh draft session summary.
|
||||
# POST ut/%s/purchase/mode/{mode}/draft body {"currency":"COINS","usePreOrder":0}
|
||||
# -> FutPurchaseDraftModeServerResponse. The path component is the draft mode
|
||||
# (1 for SINGLE_PLAYER), not the entry price.
|
||||
#
|
||||
# LIVE 2026-08-04: this endpoint was UNMAPPED, answered {} by the catch-all, and the
|
||||
# client CRASHED immediately after. Sequence, from the log:
|
||||
@@ -2552,33 +2793,22 @@ def draft_state_route(h):
|
||||
# unimplemented call, which is the outcome a correct fix is supposed to have.
|
||||
#
|
||||
# WHICH ENVELOPE. ENDPOINT_MAP flags a "response-variant ambiguity" here: two
|
||||
# structures reference the class name. Resolved this session, and the doc's note about
|
||||
# the second one is wrong:
|
||||
# structures reference the class name. Live behaviour plus the request vtable resolves
|
||||
# the POST to the second variant:
|
||||
# 0x18014c260 (vtable 0x180224ef8, factory 0x18014c090) 3188 chars, OBJECT root
|
||||
# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and exactly
|
||||
# the seven scalar ints below. THIS IS THE RESPONSE PARSER.
|
||||
# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and seven
|
||||
# scalar ints. This is a distinct response using the same class name.
|
||||
# 0x180150310 (vtable 0x1802262f0, factory 0x180150260) 1836 chars, ARRAY root
|
||||
# (loops until 0xd = END_ARRAY), ZERO skip handlers -- and it is not a
|
||||
# response root at all. It parses ENTRANCE CRITERIA: each element's
|
||||
# name is strcmp'd against the literals "COINS", "POINTS" and
|
||||
# "DRAFT_TOKEN" and stored at +0x28/+0x2c/+0x30. It shares the name
|
||||
# string because it is the fee sub-object, not an alternate envelope.
|
||||
# (loops until 0xd = END_ARRAY), ZERO skip handlers. Each element is
|
||||
# parsed by FUN_180138bd0 as name/funds/finalFunds, then name is compared
|
||||
# with "COINS", "POINTS", and "DRAFT_TOKEN". Request vtable
|
||||
# 0x180226300 selects factory 0x180150260 for the live purchase POST.
|
||||
#
|
||||
# THE CRASH ITSELF DISCRIMINATES, which is worth recording as a technique. An
|
||||
# object-root parser handed {} parses benignly and leaves defaults; an array-root
|
||||
# parser handed {} desyncs and HANGS, which is exactly what draft/state did before it
|
||||
# was fixed. We observed a CRASH, not a hang, so the object-root parser is what ran,
|
||||
# and the failure is downstream of an empty-but-valid parse. That is consistent with
|
||||
# 0x18014c260 and inconsistent with 0x180150310.
|
||||
#
|
||||
# All seven members are scalar ints and the skip handler is present, so unknown keys
|
||||
# are inert and there is no freeze surface here.
|
||||
#
|
||||
# NOT DEDUCTED FROM COINS. The client posts {"currency":"COINS"} with the price in the
|
||||
# URL, and the price it sent was 0 because we omit entranceCriteria from draft/state,
|
||||
# so there is no fee to charge yet. Charging a guessed amount would be inventing an
|
||||
# economy rule; when entranceCriteria is served the price becomes real and this is the
|
||||
# place to take it.
|
||||
# LIVE 2026-08-07: returning the seven-int object made FIFA consume the POST (HTTP
|
||||
# 200), issue no follow-up request, and spin at high CPU. That is the array parser's
|
||||
# exact EOF-loop signature. The response below therefore uses the required array root.
|
||||
# No coins are deducted yet: the emulator has not served a verified entrance price,
|
||||
# and the path's mode id must not be mistaken for a price.
|
||||
#
|
||||
# DEFAULT ON for the same reason as FUT_DRAFT_STATE: the current behaviour is a
|
||||
# confirmed crash, so there is no working state being protected.
|
||||
@@ -2593,18 +2823,18 @@ def draft_purchase_route(h):
|
||||
# calling .group() on the first argument. Doing that raised AttributeError,
|
||||
# which killed the connection outright -- strictly worse than the {} it replaced.
|
||||
m = re.search(r"/purchase/mode/(\d+)/draft", h.path)
|
||||
price = int(m.group(1)) if m else 0
|
||||
log(" DRAFT: purchase entry, price=%d (not deducted -- no entranceCriteria "
|
||||
"served yet, so the client posted its own price)" % price)
|
||||
return 200, {
|
||||
"championEventId": 0,
|
||||
"expectedTierLevel": 1,
|
||||
"gamesPlayed": 0,
|
||||
"gamesRemaining": 4, # a draft run is 4 rounds
|
||||
"rank": 0,
|
||||
"score": 0,
|
||||
"tierLevel": 1,
|
||||
}
|
||||
mode = int(m.group(1)) if m else 0
|
||||
mode_name = "SINGLE_PLAYER" if mode == 1 else "ONLINE"
|
||||
_DRAFT_SESSIONS[mode_name] = {"stage": "FORMATION_DRAFT", "formation": "f442"}
|
||||
coins = STORE.coins()
|
||||
points = STORE.profile().get("points", 0)
|
||||
log(" DRAFT: purchase entry, mode=%d; returning array-root currency result "
|
||||
"(entry fee not deducted until entrance criteria are verified)" % mode)
|
||||
return 200, [
|
||||
{"name": "COINS", "funds": coins, "finalFunds": coins},
|
||||
{"name": "POINTS", "funds": points, "finalFunds": points},
|
||||
{"name": "DRAFT_TOKEN", "funds": 0, "finalFunds": 0},
|
||||
]
|
||||
|
||||
|
||||
def champion_route(h):
|
||||
@@ -2866,7 +3096,7 @@ def _probe_final_funds(p):
|
||||
return p["price"]
|
||||
|
||||
|
||||
def _pack_body(p, idx):
|
||||
def _pack_body(p, idx, owned=False):
|
||||
"""One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30).
|
||||
|
||||
THIS IS THE ORIGINAL, KNOWN-GOOD BODY -- restored 2026-08-04 after my "field
|
||||
@@ -2922,10 +3152,20 @@ def _pack_body(p, idx):
|
||||
"goldQuantity": p["count"] if gold else 0,
|
||||
"rareQuantity": p["count"] if gold else 0,
|
||||
"itemQuantity": p["count"],
|
||||
"unopened": False,
|
||||
},
|
||||
# This is a top-level BOOL in the 0x158-byte pack record. Nesting it in
|
||||
# packContentInfo (the old code) is skip-safe but completely inert.
|
||||
"unopened": bool(owned),
|
||||
}
|
||||
if STORE_DISPLAYGROUP:
|
||||
if owned:
|
||||
# Reward packs are opened with usePreOrder=1 and have no purchase path.
|
||||
# Leaving zero-value coin/mtx objects attached makes the My Packs tile
|
||||
# format an unavailable payment label as the literal "undefined".
|
||||
body.pop("currencies", None)
|
||||
body.pop("extPrice", None)
|
||||
if owned:
|
||||
body["displayGroup"] = {"value": "mypacks", "priority": idx}
|
||||
elif STORE_DISPLAYGROUP:
|
||||
# THE "unknown" FIX. displayGroup(0xd9) is parsed INLINE as a FLAT OBJECT --
|
||||
# it is NOT recursive, the case-0xd9 body never re-enters 0x18013af30, so the
|
||||
# recursion the old notes assumed does not exist and there was never anything
|
||||
@@ -2955,7 +3195,19 @@ def _pack_body(p, idx):
|
||||
# than a caption, the store goes from ugly-but-working to unusable. Default OFF
|
||||
# for exactly that reason, and the live test buys a pack to prove the buy path
|
||||
# still works.
|
||||
body["displayGroup"] = {"value": p["name"]}
|
||||
# FIFA 17's StoreFront does not treat this value as an arbitrary caption.
|
||||
# It resolves exactly six hard-coded category tokens: mypacks, points,
|
||||
# bronze, silver, gold and special (FUN_180014580/FUN_180014df0). Pack
|
||||
# titles here create unsupported pseudo-categories and make GOTO_STORE_MYPACK
|
||||
# initially land on the all-groups screen. Keep ordinary packs in the
|
||||
# client's canonical categories; `description` remains the per-pack title.
|
||||
if p.get("specialChance", 0.0) >= 1.0:
|
||||
category = "special"
|
||||
elif gold:
|
||||
category = "gold"
|
||||
else:
|
||||
category = "bronze"
|
||||
body["displayGroup"] = {"value": category}
|
||||
# FUT_STORE_GROUPID. The risk flagged above ACTUALLY HAPPENED, live 2026-08-05:
|
||||
# sending displayGroup did switch the store to a grouped render path, all three
|
||||
# packs collapsed into ONE group, and drilling into any of the three group tiles
|
||||
@@ -2997,7 +3249,32 @@ def store_catalog(h):
|
||||
consumed -- an infinite loop inside FUN_1801c7f10, whose body contains the spin PC
|
||||
0x1801c7f1a that was observed live. Not a mystery freeze; a traced one.
|
||||
"""
|
||||
packs = [_pack_body(p, idx) for idx, p in enumerate(PACK_CATALOG, start=1)]
|
||||
normal = [p for p in PACK_CATALOG if not p.get("ownedOnly")]
|
||||
packs = [_pack_body(p, idx) for idx, p in enumerate(normal, start=1)]
|
||||
owned_ids = visible_unopened_packs()
|
||||
for idx, pack_id in enumerate(owned_ids, start=1):
|
||||
owned = pack_by_id(pack_id)
|
||||
if owned:
|
||||
packs.append(_pack_body(owned, idx, owned=True))
|
||||
if not owned_ids:
|
||||
# GOTO_STORE_MYPACK resolves the hard-coded `mypacks` group before it
|
||||
# renders rows. If the group is absent FIFA falls back to Bronze and
|
||||
# shows the empty-category dialog over the wrong tab. Retain an inactive
|
||||
# zero-item sentinel so the destination resolves, while state != active
|
||||
# keeps it out of the visible row list. Its id is deliberately absent
|
||||
# from PACK_CATALOG, so both purchase/open handlers reject it as well.
|
||||
sentinel = {
|
||||
"id": 65534,
|
||||
"name": "",
|
||||
"price": 0,
|
||||
"count": 0,
|
||||
"gold": True,
|
||||
"specialChance": 0.0,
|
||||
}
|
||||
empty = _pack_body(sentinel, 1, owned=True)
|
||||
empty["state"] = "inactive"
|
||||
empty["unopened"] = False
|
||||
packs.append(empty)
|
||||
return 200, {"purchase": packs, "timestamp": 1596326400}
|
||||
|
||||
|
||||
@@ -3015,10 +3292,11 @@ def store_buy(h):
|
||||
if body.get("state") == "TRANSACTIONCANCEL" or not isinstance(pid, int):
|
||||
return 200, {} # not a confirmed buy
|
||||
pack = pack_by_id(pid)
|
||||
if not pack:
|
||||
if not pack or pack.get("ownedOnly"):
|
||||
return 200, {}
|
||||
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
|
||||
pack.get("tiers"))
|
||||
pack.get("tiers"), pack.get("specialChance", 0.0),
|
||||
pack.get("playersOnly", False))
|
||||
if items is None:
|
||||
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
|
||||
log(" STORE: opened pack %s -> %d items, coins=%d" % (pack["name"], len(items), STORE.coins()))
|
||||
@@ -3047,8 +3325,14 @@ def purchased_items(h):
|
||||
pack = pack_by_id(pid) if isinstance(pid, int) else None
|
||||
if pack is None:
|
||||
return 200, {"itemData": STORE.last_pack()}
|
||||
if pack.get("ownedOnly") and not STORE.consume_unopened_pack(pid):
|
||||
log(" STORE: rejected unopened pack %s; no owned instance" % pid)
|
||||
return 200, {"itemData": STORE.last_pack()}
|
||||
if pack.get("ownedOnly"):
|
||||
_OPENED_PACK_GRACE.append(pid)
|
||||
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
|
||||
pack.get("tiers"))
|
||||
pack.get("tiers"), pack.get("specialChance", 0.0),
|
||||
pack.get("playersOnly", False))
|
||||
if items is None:
|
||||
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
|
||||
log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d"
|
||||
@@ -3072,13 +3356,20 @@ def credits_route(h):
|
||||
# The FUT hub coin counter binds to currencies[].funds (deser 0x180122c50,
|
||||
# atom "currencies" 0xc5), NOT a "credits" key -- wf_76fcf89b.
|
||||
c = STORE.coins()
|
||||
return 200, {
|
||||
body = {
|
||||
"credits": c,
|
||||
"currencies": [
|
||||
{"name": "coins", "funds": c, "finalFunds": c},
|
||||
{"name": "points", "funds": 0, "finalFunds": 0},
|
||||
],
|
||||
}
|
||||
# Do not count _OPENED_PACK_GRACE here: it is a UI-lifetime catalogue shim,
|
||||
# not an owned pack. Reporting it would leave the My Packs badge stuck at 1.
|
||||
unopened_count = len(STORE.unopened_packs())
|
||||
if unopened_count:
|
||||
body["unopenedPacks"] = {"preOrderPacks": 0,
|
||||
"recoveredPacks": unopened_count}
|
||||
return 200, body
|
||||
|
||||
|
||||
# ---- TRANSFER MARKET / AUCTION HOUSE (ENDPOINT_MAP market §) ----------------
|
||||
|
||||
Reference in New Issue
Block a user