fifa17-recon: RETRACT the FutMoveCard "no skip handler" claim; stage the ack shape
RETRACTION. This repo claimed, in REBUILD_RESEARCH S14c and in utas_server's
item_route comment, that FutMoveCard 0x180128600 "HAS NO SKIP HANDLER
(FUN_180135ff0 appears zero times, unique among FUT deserializers)" and "parses
only itemData -> dreamSquads". Every part of that is false. Full decompile:
FUN_180135ff0 call sites : 2 (offsets 5006, 6080)
atoms parsed : 7 active dreamSquads id itemData pile reason success
Cause: the decompile was written out as src[:4000] and then searched. The function
is 6193 chars, so BOTH skip-handler call sites and four of the seven atoms lay past
the cut. An absence was reported from a truncated listing -- the same failure mode
as the Memory.getBytes bytearray scan that silently returned zero hits. Never
conclude an absence without asserting the searched region covers the function.
Cost: the false claim implied "any extra key desyncs this parser", which sent the
investigation after client-side state for seven attempts, and the derived premise
"the deciding factor is client-side state, not the wire" was wrong too.
VERIFIED SHAPE. PUT ut/%s/item is not an ack endpoint; it returns per-item VERDICT
records:
id(0x15c) INT 0x1801c79d0 -> record+0x00
pile(0x226) STRING 0x1801c7aa0 -> enum 0x180142650 (club=7 purchased=6 trade=5)
success(0x2fa) BOOL 0x1801c7620 -> record+0x0c
reason(0x279) STRING -> "Destination Full" = 0xf
dreamSquads(0xe9) INT array; else -> FUN_180135ff0 (skip)
The completion handler raises EVENT_CARDS_MOVE_CARD_FAILURE when the record vector
is EMPTY or record+0x0c != 1, and success is initialised to '\0' per element. So
every body ever returned reported the move as FAILED, {} included. Quick sell
survives an identical {} because its callbacks read only the transport code and
ignore the body -- that is the whole asymmetry, and it was on the wire after all.
STAGED, NOT DEFAULTED. FUT_MOVE_BODY=ack emits the correct shape; the default stays
`empty` because sufficiency is untested. One launch settles it.
The ack is answered BEFORE the `if moved:` gate: under FUT_PACK_AUTOCLUB=1 the
cards are already in the club when the reveal asks to move them, so move_items()
returns nothing and a moved-derived body would be zero-record -- failing in exactly
the configuration ack exists to fix. Caught in review before it ran. success is
asserted only for ids that were moved now or are already in the club; anything else
gets an honest success:false rather than an invented verdict.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
@@ -640,20 +640,29 @@ PACK_AUTOCLUB = os.environ.get("FUT_PACK_AUTOCLUB", "1") == "1"
|
||||
|
||||
# FUT_MOVE_BODY -- what PUT ut/%s/item answers. Made switchable so the shape can be
|
||||
# bisected in one relaunch each instead of a code edit per attempt.
|
||||
# empty (default) -> {} full -> echo the moved card objects
|
||||
# dreamsquads -> {"itemData":[{"dreamSquads":[]} x N]}
|
||||
# ack -> {"itemData":[{"id":N,"pile":"club","success":true}, ...]}
|
||||
# the CORRECT shape per the deserializer. UNTESTED LIVE.
|
||||
# empty (default) -> {} KNOWN-BROKEN, see below
|
||||
# full -> echo the moved card objects (known-broken)
|
||||
# dreamsquads -> {"itemData":[{"dreamSquads":[]} x N]} (known-broken)
|
||||
#
|
||||
# WHY `empty` IS NOW THE DEFAULT (live 2026-08-04, after six failed attempts):
|
||||
# Quick Sell All hits the SIBLING endpoint POST ut/delete/%s/item, which was
|
||||
# UNMAPPED and therefore answered with a bare {} -- and it WORKED: no error, session
|
||||
# intact. Meanwhile every crafted body on PUT ut/%s/item was fatal. So a bare {} is
|
||||
# demonstrably acceptable to this screen, and the inherited claim that "returning []
|
||||
# makes FIFA think the move failed -> kicks to main menu" is unproven and probably
|
||||
# another misdiagnosis in the same lineage as the chemistry one.
|
||||
# Also disproven this round: the netwatch recorded ZERO non-loopback connections, so
|
||||
# "error connecting to FIFA 17 Ultimate Team" is FIFA's generic FUT-session failure
|
||||
# text, not a real network failure -- and the 146 missing FUT_RS4_URL_<CALL> keys
|
||||
# (now served, and genuinely missing) were not the cause either.
|
||||
# `empty` REMAINS THE DEFAULT ONLY BECAUSE `ack` HAS NOT BEEN LIVE-TESTED.
|
||||
# It is not a good default: 0x180128600 builds per-item VERDICT records and the
|
||||
# completion handler raises EVENT_CARDS_MOVE_CARD_FAILURE when the record vector is
|
||||
# empty, so {} fails unconditionally. Every rung above except `ack` is now known to
|
||||
# report the move as failed. Flip the default to `ack` the moment one launch
|
||||
# confirms it.
|
||||
#
|
||||
# The earlier note here argued the opposite -- that a bare {} was "demonstrably
|
||||
# acceptable" because Quick Sell survives one. That inference was wrong: quick
|
||||
# sell's callbacks check only the transport code and never read the body, so its
|
||||
# tolerance says nothing about this endpoint. Retained as a caution: a sibling
|
||||
# endpoint accepting a body is not evidence that this one will.
|
||||
#
|
||||
# Still true and still useful from that round: the netwatch recorded ZERO
|
||||
# non-loopback connections during a failure, so "error connecting to FIFA 17
|
||||
# Ultimate Team" is FIFA's generic FUT-session failure text and must not be read as
|
||||
# a network event.
|
||||
MOVE_BODY = os.environ.get("FUT_MOVE_BODY", "empty")
|
||||
|
||||
# FUT_STORE_GROUPS: send displayGroup(0xd9) in the pack catalogue. DEFAULT OFF --
|
||||
@@ -691,6 +700,44 @@ def quick_sell_route(h):
|
||||
return 200, {}
|
||||
|
||||
|
||||
def _move_ack(req, moved):
|
||||
"""FutMoveCard per-item verdict records (deser 0x180128600).
|
||||
|
||||
Built from the REQUEST, not from `moved`: under FUT_PACK_AUTOCLUB the pack's
|
||||
cards are already in the club before the client asks to move them, so
|
||||
STORE.move_items() legitimately returns nothing and a `moved`-derived body
|
||||
would be empty -- which is the one shape guaranteed to raise
|
||||
EVENT_CARDS_MOVE_CARD_FAILURE.
|
||||
|
||||
success is asserted ONLY for ids we can actually account for: either this call
|
||||
moved them, or they are already sitting in the club. Anything else gets
|
||||
success:false, which is the truthful verdict -- claiming success for an id the
|
||||
store has never seen would desync the client's model against ours, and that is
|
||||
a worse failure than an honest per-item false.
|
||||
|
||||
`reason`(0x279) is omitted deliberately. The only string known to map to a
|
||||
specific code is "Destination Full" (0xf) and that is not what happened here;
|
||||
the key is skip-safe, so sending nothing is better than sending a wrong reason.
|
||||
"""
|
||||
club_ids = {it.get("id") for it in STORE.items()}
|
||||
moved_ids = {it.get("id") for it in moved}
|
||||
out = []
|
||||
for r in req:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
iid = r.get("id")
|
||||
ok = iid in moved_ids or iid in club_ids
|
||||
out.append({
|
||||
"id": iid, # INT getter -- send a number
|
||||
"pile": r.get("pile", "club"), # STRING -> enum 0x180142650
|
||||
"success": bool(ok), # BOOL, record+0x0c, must be 1
|
||||
})
|
||||
if out and not all(x["success"] for x in out):
|
||||
log(" ITEM: %d/%d verdicts are success=false (ids not accounted for)"
|
||||
% (sum(1 for x in out if not x["success"]), len(out)))
|
||||
return out
|
||||
|
||||
|
||||
def item_route(h):
|
||||
# PUT ut/game/fifa17/item = FutMoveCard (move item to a pile, e.g. the reveal
|
||||
# screen's "keep/assign" -> {"itemData":[{"id":..,"pile":"club","swap":0,
|
||||
@@ -706,27 +753,55 @@ def item_route(h):
|
||||
req = body.get("itemData")
|
||||
if isinstance(req, list):
|
||||
moved = STORE.move_items(req)
|
||||
# ack is answered BEFORE the `if moved:` gate on purpose. Under
|
||||
# FUT_PACK_AUTOCLUB=1 (the default) a pack's cards are already in the
|
||||
# club by the time the reveal screen asks to move them, so move_items()
|
||||
# legitimately returns nothing -- and gating the ack on `moved` would
|
||||
# emit a ZERO-RECORD itemData, which is precisely the shape that raises
|
||||
# EVENT_CARDS_MOVE_CARD_FAILURE. The verdict has to come from the
|
||||
# REQUEST. (Caught in review before it ever ran; the earlier placement
|
||||
# would have made ack mode fail in exactly the configuration it is meant
|
||||
# to fix, and the bug would have looked like "ack does not work".)
|
||||
if MOVE_BODY == "ack":
|
||||
if moved:
|
||||
log(" ITEM: moved %d item(s) to pile(s)" % len(moved))
|
||||
return 200, {"itemData": _move_ack(req, moved)}
|
||||
if moved:
|
||||
log(" ITEM: moved %d item(s) to pile(s)" % len(moved))
|
||||
# ROOT CAUSE of the "can't send cards to club -> kicked to the main
|
||||
# menu" logout, found 2026-08-04 by decompiling the deserializer:
|
||||
# RETRACTED 2026-08-04. This block previously claimed:
|
||||
# "FutMoveCard 0x180128600 HAS NO SKIP HANDLER ... calls it ZERO
|
||||
# times ... parses exactly two atoms, itemData and dreamSquads"
|
||||
# That was WRONG and it sent the investigation after client-side
|
||||
# state for seven attempts. Cause of the error: the decompile was
|
||||
# truncated to 4000 chars before being searched, and the function is
|
||||
# 6193 chars -- BOTH FUN_180135ff0 call sites (offsets 5006 and 6080)
|
||||
# and four of the seven atoms lie past the cut. Never conclude an
|
||||
# absence from a truncated decompile.
|
||||
#
|
||||
# FutMoveCard 0x180128600 HAS NO SKIP HANDLER. Every other FUT
|
||||
# deserializer routes an unrecognised key to FUN_180135ff0 (the
|
||||
# value-SKIP handler); this one calls it ZERO times. It parses
|
||||
# exactly two atoms -- itemData(0x16b) as an array, and inside each
|
||||
# element dreamSquads(0xe9) as an int array -- and an unknown key
|
||||
# leaves its VALUE unconsumed, so the next loop iteration reads that
|
||||
# value as a key and the reader desyncs.
|
||||
# VERIFIED SHAPE (full decompile, getter read from the first call
|
||||
# after each atom compare):
|
||||
# itemData(0x16b) array of PER-ITEM VERDICT RECORDS, 0x18 bytes each
|
||||
# id(0x15c) INT 0x1801c79d0 -> record+0x00
|
||||
# pile(0x226) STRING 0x1801c7aa0 -> enum via 0x180142650
|
||||
# club=7 purchased=6 trade=5
|
||||
# success(0x2fa) BOOL 0x1801c7620 -> record+0x0c
|
||||
# reason(0x279) STRING 0x1801c7aa0 -> "Destination Full" = 0xf
|
||||
# dreamSquads(0xe9) INT array
|
||||
# anything else -> FUN_180135ff0 (the skip handler, twice)
|
||||
#
|
||||
# We were echoing the FULL card object: ~20 keys each, including a
|
||||
# nested attributeList. Every one of them is unknown to this parser.
|
||||
# That also explains the two earlier misdiagnoses -- ANY extra key
|
||||
# breaks it, so `chemistry` looked causal when it was added, and
|
||||
# removing it changed nothing because 20 other keys remained.
|
||||
# This is NOT an ack endpoint. The completion handler raises
|
||||
# EVENT_CARDS_MOVE_CARD_FAILURE when the record vector is EMPTY or
|
||||
# when record+0x0c != 1, and `success` is initialised to '\0' at the
|
||||
# top of every element loop. So every body we have ever returned --
|
||||
# full cards, +chemistry, dreamsquads-only, and `empty` ({}, which
|
||||
# produces zero records and fails the first guard outright) -- has
|
||||
# reported the move as FAILED. Quick sell survives an identical {}
|
||||
# because its callbacks check only the transport code and ignore the
|
||||
# body: that is the whole asymmetry, and it was on the wire after all.
|
||||
#
|
||||
# Shape selected by FUT_MOVE_BODY (see above) so it can be bisected
|
||||
# live without a code edit.
|
||||
# `ack` is the corrected shape. NOT the default yet: it is untested
|
||||
# live, and the necessary condition being identified is not proof of
|
||||
# sufficiency. One launch with FUT_MOVE_BODY=ack settles it.
|
||||
if MOVE_BODY == "full":
|
||||
return 200, {"itemData": moved}
|
||||
if MOVE_BODY == "dreamsquads":
|
||||
|
||||
Reference in New Issue
Block a user