fifa17-python: commit working FUT backend deployment (client/server split)
Freeze the running offline FUT backend into version control as fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a fresh checkout: * OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is required for remote mode (compose and entrypoint fail without it) * docker-compose.yml reproducing the frozen baseline container exactly (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart) * .env.example / .env for site config - the LAN IP is never hardcoded in source * tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10, verified byte-identical to the running container at freeze time * client_arm.sh (the 105 client-side arming counterpart) * Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying * docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record, restore instructions and rebuild-equivalence procedure Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored. The live container is untouched pending the .105 launcher audit.
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The MY CLUB stat vocabulary, censused from CardsDLL, and the body per mode.
|
||||
|
||||
HANDOVER MODULE -- nothing here is wired in. `utas_server.club_stats_route` is
|
||||
owned by the integrate agent; this file is the spec in executable form. Import it
|
||||
and call `stats_body()` / `staff_bonus_body()`, or lift the tables.
|
||||
|
||||
=============================================================================
|
||||
1. THE VOCABULARY IS COMPLETE, AND IT IS AN ATOM TABLE, NOT A STRING TABLE
|
||||
=============================================================================
|
||||
`FUN_18012fd40` (1732 chars, decompiled and read END TO END -- every arm below is
|
||||
transcribed from it, none elided) is the whole map. It does NOT compare strings:
|
||||
|
||||
iVar1 = FUN_180180d00(<the 0x30-byte `type` buffer>); // atom lookup
|
||||
switch (iVar1) { ... } // 40 arms
|
||||
return 0; // default
|
||||
|
||||
So the accepted vocabulary is exactly 40 ATOM IDS, and their spellings are the
|
||||
atom names in docs/fut_atoms.tsv -- which is why a census is possible at all and
|
||||
why "a filtered scan" is not needed: the function IS the census. Anything else
|
||||
(including the real atoms `consumablesContract` 0xa6, `consumablesTraining` 0xa7,
|
||||
`consumablesFitness` 0xa8 and the lowercase `leaguelogos` 0x18d, all of which
|
||||
exist in the atom table and all of which are ABSENT from the switch) returns 0
|
||||
and lands in bucket key 0, which no reader ever looks up. Unknown strings are
|
||||
therefore inert, not fatal.
|
||||
|
||||
Coverage statement, per the standing rule about absences: the claim "these 40 and
|
||||
no others" is a claim about a 1732-char function that was decompiled in full and
|
||||
whose default arm is `return 0`. It is not an inference from a grep.
|
||||
|
||||
=============================================================================
|
||||
2. STORAGE, AND WHY EVERY BODY MUST BE COMPLETE
|
||||
=============================================================================
|
||||
Deserializer 0x180130150 (7870 chars, read in full) writes
|
||||
`store[contextValue][statId] = typeValue` where store = CardsDb + 0x1F8B0.
|
||||
|
||||
* contextId (0xb6) is ONLY a guard: `if (contextId == 1 || contextId-5 < 5)`
|
||||
-> contextValue is forced to 0. contextId 3 is used below purely because it
|
||||
is outside that range and so preserves contextValue.
|
||||
* the storage key is contextValue ALONE. Nation 14, league 14 and team 14 share
|
||||
one bucket. One kind of context per response, never two.
|
||||
* element-local variables are cleared ONCE before the array loop, never inside
|
||||
it. Omit a key in element N and it silently inherits element N-1's value.
|
||||
EMIT ALL FOUR KEYS IN EVERY ELEMENT.
|
||||
* `type` is copied with FUN_180008120(buf, s, 0x30) -- a 48-byte buffer. The
|
||||
longest name we use is 40 chars. Fits.
|
||||
|
||||
THE WIPE IS REAL AND IT IS NOT IN THE DESERIALIZER. That is why one investigator
|
||||
read the deserializer, found no clear, and reported "no wipe". The clear is in the
|
||||
RESPONSE FACTORY, `FUN_18012f6d0`:
|
||||
|
||||
store = CardsDb->vt[0x7f0]();
|
||||
FUN_180116240(store+0x30, *(store+0x48)); // destroy the whole outer tree
|
||||
store+0x38 = store+0x40 = store+0x38; // head = root = sentinel
|
||||
store+0x48 = 0; store+0x50 = 0; store+0x58 = 0;
|
||||
... then allocate "RS4:FutStickerBookStats2ServerResponse"
|
||||
|
||||
So EVERY Stats2 response erases the entire map before parsing. The map only ever
|
||||
holds ONE mode's rows. `FUN_18012f680` is a second, standalone clear of the same
|
||||
tree (session teardown).
|
||||
|
||||
AND THE ORDER IS DECIDED, MEASURED IN /tmp/utas_server.log: every MY CLUB entry
|
||||
is `year` then `consumables`, in that order, 45 and 42 times this session. The
|
||||
panels are therefore ALWAYS reading whatever we returned on `consumables`. A
|
||||
consumables body that carries only consumable rows would blank the players,
|
||||
staff, kits and badges rows of the very same tab strip. Hence: one union body,
|
||||
served on every tab-strip mode.
|
||||
|
||||
(`FUN_18012fa90` also caches: if store+0x78/0x7c/0x80 already equal the requested
|
||||
mode/arg1/arg2, no HTTP request is made at all. Re-entering the SAME screen twice
|
||||
in a row is served from the map that is already there.)
|
||||
|
||||
=============================================================================
|
||||
3. WHO READS WHAT. Five consumers, and that is all five.
|
||||
=============================================================================
|
||||
Census method: byte-scan of .text for `call [reg+0x7f8]` / `[reg+0x800]` over all
|
||||
16 registers (with and without REX). Exactly five functions contain such a call:
|
||||
|
||||
FUN_180043b90 the club-stats data provider; switch on store+0x78 (the MODE)
|
||||
FUN_180094ce0 the MY CLUB eight-row summary panel
|
||||
FUN_180095360 the MY CLUB CONSUMABLES tab (7 rows + NUM_COLLECTED)
|
||||
FUN_180096670 the MY CLUB tab builder (staff tab, consumables tab, tiles)
|
||||
FUN_180097c70 the MY CLUB tile-detail panel
|
||||
|
||||
Call graph: FUN_180095660 -> FUN_180096670 -> {FUN_180095360, FUN_180097c70 ->
|
||||
FUN_180094ce0}. FUN_180043b90 has no in-image caller (it is registered).
|
||||
|
||||
CRUCIAL, AND IT CORRECTS THE RECORD: the four MY CLUB panels do NOT switch on the
|
||||
mode. They read the store unconditionally. Only FUN_180043b90 switches. So the
|
||||
mode decides which of ITS cases runs, but the MY CLUB screen renders from
|
||||
whatever the last response left behind, regardless of mode. This is why the union
|
||||
body works and why "the client never asks for /club/stats/club" is survivable.
|
||||
|
||||
=============================================================================
|
||||
4. THE CORRECTION THAT MATTERS MOST THIS ROUND
|
||||
=============================================================================
|
||||
REBUILD_RESEARCH S19 states: "Case 6 reads ids 0x3d CONTRACTS, 0x3e TRAINING and
|
||||
0x40 FITNESS, which are exactly the three ids the type-string map cannot produce.
|
||||
The consumables view is unsettable from this endpoint by construction."
|
||||
|
||||
THAT IS WRONG, and it is the reason the consumables tab is empty. 0x3d/0x3e/0x40
|
||||
are read by case 5 (newcards), not case 6. Case 6 (consumables) reads:
|
||||
|
||||
0x43 0x46 0x42 0x44 0x41 0x4b 0x4c 0x45 0x47 0x48 0x49 0x4d 0x4a 0x3c
|
||||
|
||||
FOURTEEN ids, and EVERY ONE OF THEM IS IN THE TYPE MAP. The consumables panel is
|
||||
fully settable from /club/stats/consumables. The same fourteen (minus 0x48) drive
|
||||
the MY CLUB consumables tab FUN_180095360. We have simply never sent one of them:
|
||||
the body we serve on `consumables` today is the PLAYER stat set.
|
||||
|
||||
Three ids in the vocabulary are read by nobody: 0x29 kitsHome and 0x2a kitsAway
|
||||
are read only by case 5, 0x2f leagueLogos only by case 5. Three ids are read but
|
||||
CANNOT be set: 0x3d, 0x3e, 0x40 (no atom maps to them) -- case 5 only.
|
||||
|
||||
=============================================================================
|
||||
5. THE STAFF BONUS ENDPOINT IS A SECOND, DISJOINT VOCABULARY
|
||||
=============================================================================
|
||||
GET club/stats/staff is FutStaffBonus, deserializer 0x18012b730 (2243 chars, read
|
||||
in full), shape {"bonus":[{"type":str,"value":int}]}. It does NOT touch the Stats2
|
||||
map (so it cannot wipe it), and it does NOT go through FUN_18012fd40. It calls
|
||||
|
||||
store = CardsDb->vt[0x938]() // == CardsDb + 0x5AF0, a flat struct
|
||||
FUN_18012b370(store, typeString, byteValue)
|
||||
|
||||
and FUN_18012b370 is a 22-arm atom switch writing ONE BYTE each at store+0x30 ..
|
||||
store+0x45. Value path: INT getter 0x1801c79d0 -> FUN_1800d7b50, which clamps to
|
||||
0..255 and returns 0 for anything <= 0. `type` goes into a 0x20 buffer; the
|
||||
longest name in the vocabulary is 14 chars.
|
||||
|
||||
Those 22 bytes are the PERCENTAGES on the MY CLUB -> STAFF tab (FUN_180096670
|
||||
case 8, customData 0x14): every row is published with LEFT_PERCENT/RIGHT_PERCENT
|
||||
set to 1. The five COUNTS on the same tab come from the Stats2 store instead
|
||||
(ids 0xb..0xf), so the staff tab needs BOTH endpoints answered.
|
||||
|
||||
=============================================================================
|
||||
6. WHAT IS INFERRED RATHER THAN PROVEN
|
||||
=============================================================================
|
||||
FUN_180094ce0 iterates a vector at model+0x140 (stride 0x40): dword 0 is a
|
||||
category code, dword +8 is the contextValue it looks up. Codes 1..7 and 9 do the
|
||||
six per-context reads (0x28, 0x2d, 4, 3, 2, 5); code 8 reads stadia globally, 10
|
||||
balls, 0x10 the six trophy ids, 0x12 the five staff ids. That +8 value is
|
||||
INFERRED to be a nation id: FUN_180043b90 case 2 performs the identical six reads
|
||||
on rows whose id it fetches as "NATION_ID", and FUN_180097c70 (this function's
|
||||
caller) does the same. The vector's producer was not located, so this is a strong
|
||||
structural inference, not a proof. See LIVE_TESTS at the bottom.
|
||||
"""
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# THE VOCABULARY. statId -> the JSON `type` string (== the atom name).
|
||||
# Transcribed arm by arm from FUN_18012fd40. 40 entries, complete.
|
||||
# --------------------------------------------------------------------------
|
||||
VOCAB = {
|
||||
0x01: "players", # atom 0x238
|
||||
0x02: "playersBronze", # atom 0x239
|
||||
0x03: "playersSilver", # atom 0x23b
|
||||
0x04: "playersGold", # atom 0x23a
|
||||
0x05: "rarePlayers", # atom 0x272
|
||||
0x0A: "staff", # atom 0x2dc
|
||||
0x0B: "staffManager", # atom 0x2dd
|
||||
0x0C: "staffHeadCoach", # atom 0x2de
|
||||
0x0D: "staffGKCoach", # atom 0x2e0 <- NOTE 0x2e0, not 0x2df
|
||||
0x0E: "staffPhysio", # atom 0x2e1
|
||||
0x0F: "staffFitnessCoach", # atom 0x2df <- the pair is transposed
|
||||
0x14: "stadia", # atom 0x2d7
|
||||
0x1E: "balls", # atom 0x04f
|
||||
0x28: "kits", # atom 0x17c
|
||||
0x29: "kitsHome", # atom 0x17d
|
||||
0x2A: "kitsAway", # atom 0x17e
|
||||
0x2D: "badges", # atom 0x04b
|
||||
0x2E: "badgeDBid", # atom 0x04a
|
||||
0x2F: "leagueLogos", # atom 0x18e (NOT 0x18d `leaguelogos`)
|
||||
0x32: "trophies", # atom 0x340
|
||||
0x33: "trophiesOffline", # atom 0x343
|
||||
0x34: "trophiesOnline", # atom 0x344
|
||||
0x35: "trophiesFeaturedOffline", # atom 0x341
|
||||
0x36: "trophiesFeaturedOnline", # atom 0x342
|
||||
0x37: "trophiesSeasonOffline", # atom 0x345
|
||||
0x38: "trophiesSeasonOnline", # atom 0x346
|
||||
0x3C: "consumables", # atom 0x0a5
|
||||
0x41: "consumablesHealing", # atom 0x0af
|
||||
0x42: "consumablesContractPlayer", # atom 0x0a9
|
||||
0x43: "consumablesTrainingPlayer", # atom 0x0b3
|
||||
0x44: "consumablesFitnessPlayer", # atom 0x0ab
|
||||
0x45: "consumablesPosition", # atom 0x0b2
|
||||
0x46: "consumablesTrainingGk", # atom 0x0b5
|
||||
0x47: "consumablesContractManager", # atom 0x0aa
|
||||
0x48: "consumablesFormationManager", # atom 0x0ad
|
||||
0x49: "consumablesTrainingManager", # atom 0x0b4
|
||||
0x4A: "consumablesFitnessTeam", # atom 0x0ac
|
||||
0x4B: "consumablesTrainingPlayerPlayStyle", # atom 0x0b0
|
||||
0x4C: "consumablesTrainingGkPlayStyle", # atom 0x0b1
|
||||
0x4D: "consumablesTrainingManagerLeagueModifier", # atom 0x0ae
|
||||
}
|
||||
|
||||
# Read by a consumer but produced by NO atom -- unsettable from this endpoint.
|
||||
UNSETTABLE = {0x3D: "CONTRACTS (case 5)", 0x3E: "TRAINING (case 5)",
|
||||
0x40: "FITNESS (case 5)"}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# WHO READS WHICH ID. (id, reader, on-screen row)
|
||||
# --------------------------------------------------------------------------
|
||||
# FUN_180043b90 case 1 "club" global 1,0x1e,0x28,0x14,0x0a,0x32
|
||||
# FUN_180043b90 case 2 "year" global 0x1e,0x14,0xb,0xc,0xe,0xd,0xf,
|
||||
# 0x33,0x34,0x35,0x36,0x37,0x38
|
||||
# + per NATION_ID 2,3,4 (PLAYERS = their sum),5,
|
||||
# 0x28,0x2d
|
||||
# FUN_180043b90 case 3 "country/id" per LEAGUE_ID 2,3,4,5,0x28,0x2d
|
||||
# FUN_180043b90 case 4 "league/id" per TEAM_ID 1,0x28,0x2e
|
||||
# FUN_180043b90 case 5 "newcards" global 1,0x0a,0x14,0x1e,0x28,0x2f,0xb,
|
||||
# 0xc,0xf,0xd,0xe,0x2d,0x29,0x2a,
|
||||
# 0x3c,[0x3d,0x3e,0x40],0x41
|
||||
# FUN_180043b90 case 6 "consumables" global 0x43,0x46,0x42,0x44,0x41,0x4b,
|
||||
# 0x4c,0x45,0x47,0x48,0x49,0x4d,
|
||||
# 0x4a,0x3c
|
||||
# FUN_180094ce0 summary per tile id 0x28,0x2d,4,3,2,5 ; global 0x14,0x1e,
|
||||
# 0x33..0x38, 0xb..0xf
|
||||
# FUN_180095360 consumables tab global 0x46+0x43, 0x47+0x42, 0x4a+0x44, 0x41,
|
||||
# 0x4c+0x4b, 0x4d, 0x49+0x45, 0x3c
|
||||
# FUN_180096670 staff tab global 0xb,0xc,0xf,0xd,0xe (+ the bonus bytes)
|
||||
# FUN_180097c70 tile detail global 0x14,0x33..0x38,0xb..0xf,0x1e ;
|
||||
# per id 2,3,4,0x28,0x2d,5
|
||||
|
||||
MODE_READS = {
|
||||
"club": {"global": (0x01, 0x1E, 0x28, 0x14, 0x0A, 0x32), "context": None},
|
||||
"year": {"global": (0x1E, 0x14, 0x0B, 0x0C, 0x0E, 0x0D, 0x0F,
|
||||
0x33, 0x34, 0x35, 0x36, 0x37, 0x38),
|
||||
"context": ("nation", (0x02, 0x03, 0x04, 0x05, 0x28, 0x2D))},
|
||||
"country": {"global": (), "context": ("leagueId", (0x02, 0x03, 0x04, 0x05,
|
||||
0x28, 0x2D))},
|
||||
"league": {"global": (), "context": ("teamid", (0x01, 0x28, 0x2E))},
|
||||
"newcards": {"global": (0x01, 0x0A, 0x14, 0x1E, 0x28, 0x2F, 0x0B, 0x0C,
|
||||
0x0F, 0x0D, 0x0E, 0x2D, 0x29, 0x2A, 0x3C, 0x41),
|
||||
"context": None},
|
||||
"consumables": {"global": (0x43, 0x46, 0x42, 0x44, 0x41, 0x4B, 0x4C, 0x45,
|
||||
0x47, 0x48, 0x49, 0x4D, 0x4A, 0x3C), "context": None},
|
||||
}
|
||||
|
||||
# The tab-strip modes: all four render the SAME MY CLUB screen, whose panels read
|
||||
# the store unconditionally. They get the identical union body.
|
||||
TAB_STRIP_MODES = ("", "year", "consumables", "club", "newcards")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# THE STAFF-BONUS VOCABULARY. atom name -> (store byte offset, screen row)
|
||||
# Transcribed arm by arm from FUN_18012b370 (22 arms), rows from FUN_180096670
|
||||
# case 8. Group codes are the staff-group table at 0x180203310, stride 0x18.
|
||||
# --------------------------------------------------------------------------
|
||||
STAFF_BONUS = {
|
||||
# manager group (code 2, count id 0x0b)
|
||||
"contract": (0x30, "FUT_CONTRACTS"),
|
||||
"managerTalk": (0x31, None), # parsed, no reader found in case 8
|
||||
# fitness-coach group (code 4, count id 0x0f)
|
||||
"fitness": (0x32, "FUT_FITNESS"),
|
||||
# physio group (code 5, count id 0x0e)
|
||||
"physioHead": (0x33, "FUT_MC_HEAD"),
|
||||
"physioShoudler": (0x34, "FUT_MC_UPPERBODY"), # sic, EA's spelling
|
||||
"physioArm": (0x35, "FUT_MC_ARM"),
|
||||
"physioBack": (0x36, "FUT_MC_BACK"),
|
||||
"physioHip": (0x37, "FUT_MC_KNEE"),
|
||||
"physioLeg": (0x38, "FUT_MC_LEG"),
|
||||
"physioFoot": (0x39, "FUT_MC_FOOT"),
|
||||
# GK-coach group (code 10, count id 0x0d)
|
||||
"gkDiving": (0x3A, "FUT_MC_DIVING"),
|
||||
"gkHandling": (0x3B, "FUT_MC_HANDLING"),
|
||||
"gkKicking": (0x3C, "FUT_MC_KICKING"),
|
||||
"gkReflexes": (0x3D, "FUT_MC_REFLEXES"),
|
||||
"gkOneOnOne": (0x3E, "FUT_MC_ACCELERATION"), # label/name disagree; EA's
|
||||
"gkPositioning": (0x3F, "FUT_MC_POSITIONING"),
|
||||
# head-coach group (code 3, count id 0x0c)
|
||||
"pace": (0x40, "FUT_MC_PACE"),
|
||||
"shooting": (0x41, "FUT_MC_SHOOTING"),
|
||||
"passing": (0x42, "FUT_MC_PASSING"),
|
||||
"dribbling": (0x43, "FUT_MC_DRIBBLING"),
|
||||
"defending": (0x44, "FUT_MC_DEFENDING"),
|
||||
"heading": (0x45, "FUT_MC_HEADING"),
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# consumable card `kind` (fut_consumables.SUBTYPES) -> stat id.
|
||||
# --------------------------------------------------------------------------
|
||||
CONSUMABLE_KIND_STAT = {
|
||||
"player_contract": 0x42,
|
||||
"manager_contract": 0x47,
|
||||
"healing": 0x41,
|
||||
"player_fitness": 0x44,
|
||||
"squad_fitness": 0x4A,
|
||||
"gk_training": 0x46,
|
||||
"player_training": 0x43,
|
||||
"position_mod": 0x45,
|
||||
"player_playstyle": 0x4B,
|
||||
"gk_playstyle": 0x4C,
|
||||
"manager_league": 0x4D,
|
||||
"manager_formation_mod": 0x48,
|
||||
"formation_mod": 0x48,
|
||||
# DEAD_ZONE subtypes are never shipped and are counted nowhere.
|
||||
}
|
||||
|
||||
# cardsubtypeid -> staff stat id (the merge's own families, see CARD_SYSTEM.md).
|
||||
STAFF_SUBTYPE_STAT = {4: 0x0B, 5: 0x0C, 6: 0x0D, 7: 0x0E, 8: 0x0F}
|
||||
|
||||
PLAYER_SUBTYPES = (0, 1, 2, 3)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# THE TWO UI GROUP TABLES, for reference. Both are (code, label, extra) triples
|
||||
# at stride 0x18, and both are indexed by the switch in FUN_180096670.
|
||||
#
|
||||
# consumables tab, table at 0x180203260, switch case 0xb:
|
||||
# code 0x00 FUT_MYCLUB_CONSUMABLES_TRAINING_EARNED "training"
|
||||
# code 0x01 FUT_MYCLUB_CONSUMABLES_CONTRACT_EARNED "contracts"
|
||||
# code 0x04 FUT_MYCLUB_CONSUMABLES_FITNESS_EARNED "fitness"
|
||||
# code 0x03 FUT_MYCLUB_CONSUMABLES_HEALING_EARNED "healing"
|
||||
# code 0x17 FUT_MYCLUB_CONSUMABLES_PLAYSTYLE_EARNED "playStyle"
|
||||
# code 0x18 FUT_MYCLUB_CONSUMABLES_MANAGER_LEAGUE_EARNED "managerLeagueModifier"
|
||||
# code 0x11 FUT_MYCLUB_CONSUMABLES_TACTIC_TRAINING_EARNED "position"
|
||||
#
|
||||
# staff tab, table at 0x180203310, switch case 8:
|
||||
# code 0x02 FUT_MYCLUB_MANAGERS count 0x0b bonus row FUT_CONTRACTS
|
||||
# code 0x03 FUT_MYCLUB_HEADCOACHES count 0x0c 6 attribute bonus rows
|
||||
# code 0x04 FUT_MYCLUB_FITNESS count 0x0f bonus row FUT_FITNESS
|
||||
# code 0x0a FUT_MYCLUB_GKCOACHES count 0x0d 6 GK bonus rows
|
||||
# code 0x05 FUT_MYCLUB_PHYSIO count 0x0e 7 body-part bonus rows
|
||||
#
|
||||
# THOSE GROUP NAMES ARE NOT ?type= VALUES. The club query taxonomy is a separate
|
||||
# 30-arm atom switch, FUN_18012ec50, and it reads:
|
||||
# 0 any, 1 player, 2 manager, 3 headcoach, 4 fitnesscoach, 5 physio,
|
||||
# 6 development, 7 custom, 8 unlocks, 9 gkcoach, 10 staff, 11 badge, 12 kit,
|
||||
# 13 stadium, 14 ball, 15 equippables, 16 leaguelogos, 17 offlinetrophy,
|
||||
# 18 onlinetrophy, 19 featuredofflinetrophy, 20 featuredonlinetrophy,
|
||||
# 21 allofflinetrophy, 22 allonlinetrophy, 23 healing, 24 contract,
|
||||
# 25 training, 26 misc, 27 playerdefender, 28 playermidfielder,
|
||||
# 29 playerforward.
|
||||
# So last round's type=contract / training / healing / development arms were
|
||||
# CORRECTLY NAMED. The empty consumables tab is not a naming bug on that route.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _row(ctx_id, ctx_val, stat_id, value):
|
||||
"""One stat element. All four keys, always -- see the note about element-local
|
||||
variables not being reset between elements."""
|
||||
return {"contextId": int(ctx_id), "contextValue": int(ctx_val),
|
||||
"type": VOCAB[stat_id], "typeValue": int(value)}
|
||||
|
||||
|
||||
def global_counts(items, staff_counts=None):
|
||||
"""{statId: value} for the global bucket, from the items the club holds.
|
||||
|
||||
`items` is the club item list (utas_server STORE.items() shape).
|
||||
`staff_counts` optionally overrides the staff tally with the synthetic
|
||||
overlay's counts, keyed by cardsubtypeid 4..8.
|
||||
"""
|
||||
try:
|
||||
import fut_consumables
|
||||
except Exception:
|
||||
fut_consumables = None
|
||||
|
||||
players = [i for i in items if i.get("cardsubtypeid", 0) in PLAYER_SUBTYPES]
|
||||
rating = lambda i: i.get("rating") or 0
|
||||
c = {
|
||||
0x01: len(players),
|
||||
0x04: len([i for i in players if rating(i) >= 75]),
|
||||
0x03: len([i for i in players if 65 <= rating(i) < 75]),
|
||||
0x02: len([i for i in players if 0 < rating(i) < 65]),
|
||||
0x05: len([i for i in players if i.get("rareflag")]),
|
||||
}
|
||||
|
||||
# staff, per family
|
||||
staff = dict(staff_counts) if staff_counts else {}
|
||||
if not staff:
|
||||
for i in items:
|
||||
st = i.get("cardsubtypeid", 0)
|
||||
if st in STAFF_SUBTYPE_STAT:
|
||||
staff[st] = staff.get(st, 0) + 1
|
||||
for st, sid in STAFF_SUBTYPE_STAT.items():
|
||||
c[sid] = staff.get(st, 0)
|
||||
c[0x0A] = sum(c[s] for s in STAFF_SUBTYPE_STAT.values())
|
||||
|
||||
# consumables, per family
|
||||
for sid in (0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,
|
||||
0x4A, 0x4B, 0x4C, 0x4D):
|
||||
c[sid] = 0
|
||||
total_cons = 0
|
||||
if fut_consumables is not None:
|
||||
for i in items:
|
||||
rec = fut_consumables.BY_SUBTYPE.get(i.get("cardsubtypeid", 0))
|
||||
if rec is None:
|
||||
continue
|
||||
total_cons += 1
|
||||
sid = CONSUMABLE_KIND_STAT.get(rec["kind"])
|
||||
if sid:
|
||||
c[sid] = c.get(sid, 0) + 1
|
||||
c[0x3C] = total_cons
|
||||
|
||||
# club items. Honest zeros unless the club really holds them; cardtype 9 has
|
||||
# no merge arm, so we cannot classify these from the item record and the club
|
||||
# holds none today. Every one of these is READ by some panel, so it must be
|
||||
# present or the panel keeps the previous screen's number.
|
||||
for sid in (0x14, 0x1E, 0x28, 0x29, 0x2A, 0x2D, 0x2E, 0x2F,
|
||||
0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38):
|
||||
c.setdefault(sid, 0)
|
||||
return c
|
||||
|
||||
|
||||
def context_rows(items, kind):
|
||||
"""Per-context rows for one screen. `kind` is "", "country" or "league"."""
|
||||
field = {"": "nation", "country": "leagueId", "league": "teamid"}.get(kind)
|
||||
if not field:
|
||||
return [], 0
|
||||
players = [i for i in items if i.get("cardsubtypeid", 0) in PLAYER_SUBTYPES]
|
||||
rating = lambda i: i.get("rating") or 0
|
||||
ctxs = sorted({i.get(field) for i in players if i.get(field) is not None})
|
||||
rows = []
|
||||
for ctx in ctxs:
|
||||
sel = [i for i in players if i.get(field) == ctx]
|
||||
if field == "teamid":
|
||||
# case 4 reads 1, 0x28, 0x2e. FUN_180043b90 publishes 0x2e raw as
|
||||
# BADGES_AVAILABLE; the tab builder FUN_180096670 publishes the same
|
||||
# id as `(uint)(iVar6 != 0)` -- a HAS-A-BADGE boolean. Any non-zero
|
||||
# therefore reads as 1 on one screen and as itself on the other.
|
||||
vals = [(0x01, len(sel)), (0x28, 0), (0x2E, 0)]
|
||||
else:
|
||||
# cases 2 and 3 COMPUTE players as gold+silver+bronze and never read
|
||||
# id 1. The tier counts are mandatory, not decoration.
|
||||
vals = [(0x04, len([i for i in sel if rating(i) >= 75])),
|
||||
(0x03, len([i for i in sel if 65 <= rating(i) < 75])),
|
||||
(0x02, len([i for i in sel if 0 < rating(i) < 65])),
|
||||
(0x05, len([i for i in sel if i.get("rareflag")])),
|
||||
(0x28, 0), (0x2D, 0)]
|
||||
rows += [_row(3, ctx, sid, v) for sid, v in vals]
|
||||
return rows, len(ctxs)
|
||||
|
||||
|
||||
def stats_body(mode, items, staff_counts=None):
|
||||
"""The FutStickerBookStats2 body for GET ut/%s/club/stats/<mode>.
|
||||
|
||||
`mode` is the URL tail: "", "year", "consumables", "club", "newcards",
|
||||
"country/<id>", "league/<id>". The id in the URL says WHICH SCREEN, never
|
||||
which bucket: country/<n> renders a list of LEAGUES and league/<n> a list of
|
||||
TEAMS, and the reader looks each row up by that row's own id.
|
||||
"""
|
||||
parts = (mode or "").split("/")
|
||||
head = parts[0]
|
||||
glob = global_counts(items, staff_counts)
|
||||
stats = [_row(1, 0, sid, val) for sid, val in sorted(glob.items())]
|
||||
if len(parts) >= 2 and parts[1].isdigit() and head in ("country", "league"):
|
||||
ctx, _n = context_rows(items, head)
|
||||
else:
|
||||
ctx, _n = context_rows(items, "")
|
||||
return {"stat": stats + ctx}
|
||||
|
||||
|
||||
def staff_bonus_body(bonuses):
|
||||
"""The FutStaffBonus body for GET ut/%s/club/stats/staff.
|
||||
|
||||
`bonuses` is {atom name: 0..255}. Names not in STAFF_BONUS are dropped rather
|
||||
than sent: an unknown name is inert (FUN_18012b370 falls through) but sending
|
||||
one proves nothing and widens the surface. An empty dict yields {"bonus":[]},
|
||||
which is a different thing from today's {} -- see the live test.
|
||||
"""
|
||||
out = []
|
||||
for name, val in bonuses.items():
|
||||
if name not in STAFF_BONUS:
|
||||
continue
|
||||
v = int(val)
|
||||
out.append({"type": name, "value": max(0, min(255, v))})
|
||||
return {"bonus": out}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# LIVE TESTS (a human fires these; nothing here mutates a save)
|
||||
# --------------------------------------------------------------------------
|
||||
# T1 CONSUMABLES TAB. Serve the union body on every tab-strip mode, then open
|
||||
# MY CLUB -> CONSUMABLES.
|
||||
# positive: the seven rows read TRAINING 42, CONTRACT 13, FITNESS 6,
|
||||
# HEALING 21, PLAYSTYLE 24, MANAGER LEAGUE 0, TACTIC TRAINING 20,
|
||||
# and the header count reads 126 (with the 126-item shelf armed).
|
||||
# negative: all seven still 0 -> the tab is not reading the Stats2 store at
|
||||
# all and FUN_180095360 is not the renderer. That is interpretable
|
||||
# and it kills the whole approach, which is why it is worth firing.
|
||||
#
|
||||
# T2 NATION KEYING (settles the one inference in this file). With the union
|
||||
# body live, read FUT_MYCLUB_PLAYERS_EMPLOYED on the MY CLUB summary.
|
||||
# positive: 205 (the sum over all 29 nation buckets).
|
||||
# negative: 0 while the ENGLAND -> Premier League row still reads 17 -> the
|
||||
# tile vector at model+0x140 is NOT keyed by nation id, and the
|
||||
# producer of that vector has to be found. Also interpretable.
|
||||
#
|
||||
# T3 STAFF BONUS. Answer club/stats/staff with staff_bonus_body({"pace": 7,
|
||||
# "contract": 3}) and open MY CLUB -> STAFF.
|
||||
# positive: the head-coach group shows PACE 7% and the manager group shows
|
||||
# CONTRACTS 3%.
|
||||
# negative: both read 0% -> the bytes at CardsDb+0x5AF0+0x40/+0x30 are not
|
||||
# what the tab renders, and the 22-name table is wrong about its
|
||||
# consumer (it is not wrong about the parser).
|
||||
# Two distinct values on two distinct groups on purpose: one number could be
|
||||
# a coincidence, two in the right places cannot.
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, here)
|
||||
prof = json.load(open(os.path.join(here, "fifa17_profile.json")))
|
||||
items = prof["items"]
|
||||
print("club: %d items" % len(items))
|
||||
for m in ("year", "consumables", "country/14", "league/13"):
|
||||
b = stats_body(m, items)
|
||||
g = [r for r in b["stat"] if r["contextId"] == 1]
|
||||
c = [r for r in b["stat"] if r["contextId"] == 3]
|
||||
print(" %-12s %3d rows (%d global, %d context)"
|
||||
% (m, len(b["stat"]), len(g), len(c)))
|
||||
print("\nglobal bucket, non-zero rows:")
|
||||
for r in stats_body("year", items)["stat"]:
|
||||
if r["contextId"] == 1 and r["typeValue"]:
|
||||
print(" %-42s %d" % (r["type"], r["typeValue"]))
|
||||
Reference in New Issue
Block a user