fifa17-recon: the consumables panel asks 41 times a session and we answer with players
Round 3, 10 agents. The headline is measured, not inferred: GET club/stats/consumables
is requested 41 times per session by the real client (ProtoHttp), and _club_stat_set()
answers it with the PLAYER stat set. The panel reads 14 consumables* names that we
have never sent, so it is told '205 players' when it asked how many contracts the club
owns, and it has nothing to show.
That also explains why last round's 126-item consumable shelf was never requested. It
serves type=contract|training|healing|development and an UNTYPED /club with no
team=/league=, and all 9 of the client's untyped requests this session carry team=. The
only +126 item(s) line in the whole log came from one of our own probes.
Vocabulary recovered: the 14 consumables* rows plus badgeDBid 0x2e, kitsHome 0x29,
kitsAway 0x2a, leagueLogos 0x2f, trophiesSeasonOnline 0x38.
Other measured surfaces the client asks for and we fob off: GET /settings 11x answered
with an empty config array (a 40-flag feature gate, the biggest untouched lever in the
project), leaderboards/options 5x with {}, user/accountinfo 4x with {}.
club/stats/staff is a DIFFERENT class (FutStaffBonus); the staff counts come from the
Stats2 store, which is why the staff screen worked while we answered {}.
Refuted: ENDPOINT_MAP's claim that objectives have no route. FUN_180151610 builds
<base>/objective/%d/reward and FUN_180147780 builds .../complete.
New modules only. utas_server.py is deliberately untouched: whether to wire the counts
depends on a free observation the human can make on the client that is already running,
and spending a restart before that is what this round exists to avoid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
# OpenFUT / FIFA 17 — live-test script for the CONSUMABLES round
|
||||
|
||||
Written 2026-08-05. Live server pid 91168 (started 10:16), FIFA 17 running (pid 91310). Nothing from this round has been landed yet: `utas_server.py` is clean in git, `_club_stat_set()` still emits 21 global rows and none of them is a `consumables*` name.
|
||||
|
||||
---
|
||||
|
||||
## 1. What shipped, per screen, with the exact env flag
|
||||
|
||||
Env actually set on the live process right now (`/proc/91168/environ`): `FUT_CONSUMABLES=all`, `FUT_COACHES=all`, `FUT_MANAGERS=1`. Everything else is at its code default.
|
||||
|
||||
### Working and seen on screen (MEASURED)
|
||||
|
||||
| Screen | Exact flag | Default | Client KNOWN to request it? |
|
||||
|---|---|---|---|
|
||||
| Club player list, 17,563-player pool, real names/positions/nations | none, always on (`club_route`) | on | YES. `GET /club?type=player` 61x this session, paged `start=0..190&count=11` |
|
||||
| Nation and league drill-downs (ENGLAND then Premier League = 17) | `FUT_CLUBSTATS=1` | on | YES. `club/stats/year` 45x, `country/<id>` 9x, `league/<id>` 6x |
|
||||
| Managers, 34 staff cards, zero DB Error, flags + "LaLiga Santander" / "ENG 1" | `FUT_MANAGERS=1` | off | YES. `type=manager` 8x |
|
||||
| Head coach family | `FUT_COACHES=all` | off | YES, once. `type=headcoach` 1x, newly confirmed on the wire this morning |
|
||||
| MY CLUB hub counter (clubPlayers 205) | `FUT_HUBDATA=1` | on | YES. `/hub` 32x |
|
||||
| Squad, Send to Club, store packs | `FUT_MASSINFO=full`, `FUT_USERINFO=roster`, `FUT_STORE_DISPLAYGROUP=1` | on | YES |
|
||||
|
||||
### Shipped but the client has never asked (INFERRED that it ever will)
|
||||
|
||||
| Arm | Exact flag | Status |
|
||||
|---|---|---|
|
||||
| GK coach / physio / fitness coach club arms | `FUT_COACHES=1` or `all` | Served. Never requested by the client. TODO/CONFIRM they are reachable |
|
||||
| Consumable ITEM shelf, 126 items, 69 subtypes | `FUT_CONSUMABLES=all` | Served on `type=contract\|training\|healing\|development`, and on an untyped `/club` with no `team=`/`league=`. **Measured: the game has never received a single consumable.** All 9 of the client's untyped `/club` requests carry `team=`, so even the `all` arm has never fired for the game. The only `+126 item(s)` line in the whole log came from a Python-urllib probe |
|
||||
| Draft state / purchase | `FUT_DRAFT_STATE=1`, `FUT_DRAFT_PURCHASE=1` | Never requested |
|
||||
| Market | `FUT_MARKET=sample` | Only `/tradePile` 4x, answered correct-and-empty |
|
||||
|
||||
### Requested by the client and answered with a placeholder (MEASURED, this is the bug class)
|
||||
|
||||
| Request | Count this session | What we answer | Why |
|
||||
|---|---|---|---|
|
||||
| `GET club/stats/consumables` | **41x from ProtoHttp** | the 195-row PLAYER stat set (players 205, gold 189, ...) | `_club_stat_set()` sends none of the 14 `consumables*` names the panel reads. All 16 `CARDS_NO_*` rows must read 0 |
|
||||
| `GET club/stats/staff` | 9x | `{}` | `FutStaffBonus`, different class. The five staff COUNTS come from the Stats2 store, which is why the staff screen worked anyway. What `{}` costs is only the percentages |
|
||||
| `GET /settings` | 11x | `{"configs": []}` | 40-flag feature gate, never populated |
|
||||
| `GET leaderboards/options` | 5x | `{}` | `FUT_MODES` unset |
|
||||
| `GET user/accountinfo` | 4x | `{}` | `FUT_ACCOUNTINFO` unset. First request of a cold boot, before `POST /ut/auth` |
|
||||
|
||||
### To be landed before step 5 of the script (not yet in the tree)
|
||||
|
||||
- **(A)** the 14 `consumables*` rows plus 5 more vocabulary names (`trophiesSeasonOnline` 0x38, `badgeDBid` 0x2e, `kitsHome` 0x29, `kitsAway` 0x2a, `leagueLogos` 0x2f) appended unconditionally to `_club_stat_set()`, **with a `_consumable_overlay_counts()` hook that counts the SHELF, not `STORE.items()`**. Behind its own kill flag (suggest `FUT_CONSUM_STATS`, default on).
|
||||
- **(B)** two defensive routes above the generic `/club` at `utas_server.py:958`: `/club/consumables` and `/club/loan`, both returning `{}`.
|
||||
- **(C)** optional: `club/stats/staff` body behind `FUT_STAFF_BONUS=1`.
|
||||
|
||||
---
|
||||
|
||||
## 2. The test script
|
||||
|
||||
Budget: **one utas restart, one FIFA launch.** Steps 1 to 3 cost nothing and use the client that is already running. Step 4 is a terminal gate. Steps 6 onward are the single in-game pass.
|
||||
|
||||
### Step 1. Baseline the CONSUMABLES tab on the client that is running right now
|
||||
**Set:** nothing. **Do:** MY CLUB, select CONSUMABLES. Write down two things: (i) was the tab present and selectable, or greyed/absent; (ii) the seven category numbers and the header count.
|
||||
|
||||
- **Positive:** tab opens, all seven read 0. That turns the diagnosis from "inferred from the binary" into "the screen says zero while we send no counters", and it is the whole justification for spending the restart.
|
||||
- **Negative:** tab absent or greyed and no request fires. Then the counters are not the blocker, the gate is elsewhere (`/settings` is the standing suspect), and **do not spend the restart on (A) this round**. If instead the tab shows NON-ZERO numbers, the diagnosis is wrong outright: something other than the Stats2 store feeds it. Stop and re-plan either way.
|
||||
|
||||
### Step 2. The tab sweep, same running client
|
||||
**Set:** nothing. **Do:** from the FUT hub attempt each destination in turn and for each record BOTH facts, present/greyed/absent AND what appeared: Squad Building Challenges, Objectives / Manager Tasks, FUT Draft, Transfer Market (search, Transfer Targets, Transfer List), Seasons, Tournaments, Leaderboards, Team of the Week, Loans, MY CLUB > NEW CARDS, MY CLUB > CLUB summary, and Club Customisation (Badge, Kits, Stadium, Ball). Then:
|
||||
|
||||
```
|
||||
grep -n 'ProtoHttp' -B3 /tmp/utas_server.log | tail -200
|
||||
```
|
||||
|
||||
- **Positive:** any request path we have never seen (`/sbs*`, `/draft/mode*`, `/objective/*`, `/loan/*`, `?type=badge|kit|stadium|ball`). That hands us the real base plus suffix off the wire, which the static census cannot give: we have the suffixes but not which base each composes onto.
|
||||
- **Negative:** interpretable ONLY because fact (i) was recorded. Absent or greyed and silent means a FRONT-END GATE, which promotes the `/settings` work to the top of next round and demotes all the sbs/draft route work. Present, clickable and still silent means the screen is entirely client-side and no server route will ever help it. Without fact (i) the silence is ambiguous and this step is worthless.
|
||||
|
||||
### Step 3. Squad-side consumable path, same running client
|
||||
**Set:** nothing. **Do:** open the squad, pick any player, look for "Apply Consumable" / the consumables context action. Try to reach a consumable list from there. Re-grep as in step 2.
|
||||
|
||||
- **Positive:** any `consumables` request line from ProtoHttp. `FUN_180042c40` (the `FUT_SQUAD_CONSUMABLES_DP` reader) is the second candidate consumer of the item container, and if the drill-down lives here rather than on MY CLUB it changes what step 7 means.
|
||||
- **Negative:** the action is absent or produces no request. Combined with step 7 that narrows the item-list trigger to neither known UI path, which is itself the answer we need before anyone builds a `/consumables/<segment>` body.
|
||||
|
||||
### Step 4. Terminal pre-flight, before the restart. This is a gate, not a test.
|
||||
Run all four. Any failure means do not restart yet.
|
||||
|
||||
1. `python3 tools/check_club_stat_vocab.py` against the OLD server. Expect the known baseline failure (the 14 missing rows on each mode). This proves the checker is wired to the right port.
|
||||
2. Confirm the counts come from the shelf and not the store:
|
||||
```
|
||||
python3 -c "import fut_consumables as fc, fut_club_stats as cs; \
|
||||
print({hex(k):v for k,v in cs.global_counts(fc.starter_consumables(fc.CONSUMABLE_ID_BASE)).items() if k>=0x3c})"
|
||||
```
|
||||
Must print `0x3c 126, 0x41 21, 0x42 7, 0x43 21, 0x44 3, 0x45 20, 0x46 21, 0x47 6, 0x48 0, 0x49 0, 0x4a 3, 0x4b 19, 0x4c 5, 0x4d 0`. **If the integrator wired `STORE.items()` instead, you get fourteen zeros, which is byte-identical on screen to step 6's decisive negative, and the round draws the wrong conclusion from a wiring bug.** The profile holds 205 items, every one `cardsubtypeid 0`.
|
||||
3. Confirm the two defensive routes are ABOVE line 958 and that they do not shadow `/club`. Curl the old server: `GET /ut/game/fifa17/club?type=player` must still return 205 items.
|
||||
4. `md5sum fifa17_profile.json` and keep the value. The overlay is GET-time and must not touch the save.
|
||||
|
||||
### Step 5. The restart and the launch
|
||||
Stop pid 91168 and restart from `fifa17-recon/tools` with the family flags UNCHANGED so nothing that works today moves:
|
||||
|
||||
```
|
||||
FUT_CONSUMABLES=all FUT_COACHES=all FUT_MANAGERS=1 [FUT_STAFF_BONUS=1] python3 -u utas_server.py
|
||||
```
|
||||
|
||||
Then `python3 tools/check_club_stat_vocab.py` again: must print 0 failed. Then **relaunch FIFA 17.** The relaunch is mandatory, not hygiene: `FUN_18012fa90` skips the HTTP fetch entirely when the store's mode/arg1/arg2 already match the pending request, so a warm client re-entering the tab replays the OLD numbers and you get a false negative.
|
||||
|
||||
Do not change any other flag in this restart. One variable per restart.
|
||||
|
||||
### Step 6. THE HEADLINE. MY CLUB > CONSUMABLES
|
||||
**Do:** open it, read the seven category rows and the header.
|
||||
|
||||
- **Positive:** TRAINING 42, CONTRACT 13, FITNESS 6, HEALING 21, PLAYSTYLE 24, MANAGER LEAGUE 0, TACTIC TRAINING 20, header 126. These are computed from the live shelf, not guessed, so a PARTIAL match is the valuable outcome: it localises the error to one kind-to-statId row instead of to the whole hypothesis. (MANAGER LEAGUE 0 and the formation/manager-training rows are structurally 0 because the shelf holds none of those families. That is correct, not a miss.)
|
||||
- **Negative:** all seven still 0. Interpretable only because you ran the vocab checker first and because `tools/probe_club_stats.py` can read the store live. Run it against the running process with the tab open: if it shows `0x42 = 7` in bucket 0 with mode tag 6, the store was written correctly and the panel is not fed by `FUN_180043b90` case 6 / `FUN_180095360` / `FUN_180096670` case 0xb. All three candidate providers read the same ids from the same `+0x800` store, so seven zeros kills all three at once and redirects the hunt to the Scaleform side. **Residual gap, state it honestly:** the probe cannot see the Flash layer, so "published to the data provider but not rendered" stays indistinguishable from "rendered as zero".
|
||||
|
||||
### Step 7. THE DRILL-DOWN GATE. Same screen, select Contracts then Healing
|
||||
**Do not** change the server. Grep on the REQUEST line, not on `UNMAPPED`:
|
||||
|
||||
```
|
||||
grep -n 'consumabl' /tmp/utas_server.log | grep -v 'club/stats\|CLUBSTATS\|(consumables='
|
||||
```
|
||||
|
||||
- **Positive:** any `GET /ut/.../consumables/<segment>` whose following lines carry `User-Agent: ProtoHttp`. There are ZERO such lines in 9,900+ log lines, so one is decisive, and it hands us the base prefix that `FUN_1801308c0` does not prove (it proves only the suffix `/consumables/%s`).
|
||||
- **Negative:** no such request. That proves the category strip is not what invokes `FUN_180048780`, and the item list is reached from some other UI path, most likely the squad apply-consumable flow, which redirects next round to the squad screen. Useful either way.
|
||||
- **Why the grep changed:** the original spec keyed this test on the `!! UNMAPPED PATH` log line. Measured on the live server: `/ut/game/fifa17/consumables/contracts` does log UNMAPPED, but `/ut/game/fifa17/club/consumables/contract` does NOT, and today it returns 331 items and 140,662 bytes of player cards. If the client composes onto `ut/%s/club`, which is exactly the base the reports call the strong inference, the UNMAPPED line never appears and the test returns a guaranteed false negative. Step (B)'s defensive route replaces those 140 KB with `{}` but still logs no UNMAPPED line. Key on the request line.
|
||||
|
||||
### Step 8. STAFF percentages. Only if `FUT_STAFF_BONUS=1` shipped
|
||||
Server serves `{"bonus":[{"type":"pace","value":7},{"type":"contract","value":3}]}`. **Do:** MY CLUB > STAFF.
|
||||
|
||||
- **Positive:** head-coach group shows PACE 7% and manager group shows CONTRACTS 3%. Two names, two different groups, two different values cannot be coincidence; one number could be.
|
||||
- **Negative:** both read 0%. That means either the 22-name table is wrong about its CONSUMER, or `club/stats/staff` is not deserialized by `FutStaffBonusServerResponse` at all (the parser-to-class binding is confirmed at vtable 0x180221610 slot +0x08; the **URL-to-class binding is TODO/CONFIRM**). Both branches mean this route cannot set percentages, so the step stays interpretable, but do not write it up as "the consumer table is wrong".
|
||||
- **Built-in control:** the five staff counts on the same tab come from the Stats2 store, not from this body. They must still read manager 10, head coach 6, GK coach 6, physio 6, fitness coach 6.
|
||||
|
||||
### Step 9. REGRESSION SWEEP. Mandatory. Do not close the session without it.
|
||||
- MY CLUB players: 205, gold 189, silver 8, bronze 8, rare 205.
|
||||
- ENGLAND then Premier League: still 17.
|
||||
- Managers and coaches: still 34 staff cards, zero "DB Error", Luis Enrique 88 still draws the Spain flag and "LaLiga Santander", Conte / Wenger / Klopp / Koeman / Pardew still draw flags and "ENG 1".
|
||||
- Store: packs still list and still deal named cards.
|
||||
- `md5sum fifa17_profile.json` against step 4's value if you opened nothing that writes.
|
||||
- **Any change here is a regression, not a finding.** Go straight to section 4.
|
||||
|
||||
### Step 10. OPTIONAL, only if the integrator served non-zero `kits`/`badges`/`stadia`/`balls`
|
||||
Repeat the four Club Customisation screens from step 2 and grep for `type=badge|kit|stadium|ball|equippables` or `state=active`.
|
||||
|
||||
- **Positive:** we get the exact query the client issues, including whether `state=` rides along, and the club-item family becomes shippable against a real request.
|
||||
- **Negative:** only interpretable BECAUSE the counts were non-zero. With the zeros we serve today a count-gated screen that never asks looks identical to a screen that is not UTAS-fed, which is exactly the failure mode this whole round exists to fix. If the counts were not changed, skip this step entirely.
|
||||
|
||||
---
|
||||
|
||||
## 3. Still unsolved, ranked by value over cost
|
||||
|
||||
1. **Does a non-zero count actually gate the item fetch?** The 1:1 taxonomy match (16 `CARDS_NO_*` rows partition into 8 URL segments) is the only reason to believe it. `FUN_180048780` reads no stat store at all, and the UI layer that invokes it is not in `cardsdll.dll`. Cost: step 7, already in the script.
|
||||
2. **Which base does `/consumables/%s` compose onto?** Only the suffix is proven. Measured, the two candidates behave completely differently on our server, so this is not cosmetic. Cost: step 7's positive answers it for free.
|
||||
3. **`GET /settings`, the 40-flag gate.** Answered `{"configs": []}` on all 11 client requests, and the C++ constructor defaults were never read, so we do not know whether any flag is in a blocking state. Highest untouched lever, but it needs its own restart, a positive control (ship `maximumTradePileSize` 100 and read the transfer-list capacity, so a null result can be told apart from "the array never reached the consumer"), and it must NOT flip `storeEnabled` / `tradingEnabled` / `coinEnabled` on the first pass: `IS_STORE_ENABLED` and `IS_TRADING_ENABLED` are literal UI state keys in .rdata and the store screen is live-proven working. Next round, gated on step 2's sweep.
|
||||
4. **`FutStaffBonus` URL-to-class binding.** Step 8 settles half of it.
|
||||
5. **Why the `FUT_CONSUMABLES=all` arm has never fired for the game.** The overlay serves only on an untyped `/club` with no drill-down, and all 9 of the client's untyped requests carry `team=`. Whether the client ever issues an untyped, undrilled `/club` is unknown. Cheap to settle from the log next session.
|
||||
6. **sbs / draft / objectives / loans.** Six sbs suffixes, ten draft suffixes and two objective builders exist in the binary and none is routed; `docs/ENDPOINT_MAP.md`'s "objectives have no route" claim is refuted (`FUN_180151610` builds `<base>/objective/%d/reward`, `FUN_180147780` builds `.../complete`). The base template is unresolved for all of them. Step 2 converts the static census into a demand curve for free. Ship nothing here until a real request is observed.
|
||||
7. **Club items.** Never once requested, renderer unlocated, and the subtype assignment for `{30, 31, 145, 146, 147, 148, 149, 150}` is assigned nowhere in the 149 dumped tables. Identity path (teamid to `FUN_180119bd0` to localized name) is inferred, never traced. Budget: four menu clicks, nothing more.
|
||||
8. **`0x49 consumablesTrainingManager`** has no family on the shelf, and `0x48` / `0x4d` are structurally 0. If a consumable family is missing from `build_consumables.py`, 0x49 is where it goes. Not worth chasing until step 6 proves the channel works.
|
||||
9. **`0x3d` / `0x3e` / `0x40`** (newcards CONTRACTS/TRAINING/FITNESS) can never be set from the wire: no atom produces them. Dead. Do not chase.
|
||||
10. **`/hub` answered with two keys on 32 requests per session.** Dispatches through C++ reflection with no atom ladder, so there is nothing to enumerate. Confidence anything is wrong: low.
|
||||
|
||||
---
|
||||
|
||||
## 4. What could break, and the instant fallback
|
||||
|
||||
**The shared-route risk, named explicitly.** `_club_stat_set()` and `club_stats_route()` are the SAME code path that serves the live-proven screens: the 205/189/8/8 player tiers, the 29 nation buckets, and the ENGLAND to Premier League 17. Change (A) adds rows to that shared function. `club_route` itself is NOT touched by (A), but change (B) inserts two routes directly above it.
|
||||
|
||||
| Failure | Symptom | Instant fallback |
|
||||
|---|---|---|
|
||||
| Exception inside the new overlay hook (bad import, KeyError in the kind-to-statId bucket) | `club_stats_route` 500s, so the player counts AND the nation/league drill-downs all die at once | `FUT_CONSUM_STATS=0` and restart. Blunt alternative `FUT_CLUBSTATS=0` also works but reverts club/stats to `{}` and loses the 17 |
|
||||
| Defensive route regex too loose, shadows `/club` | Player, manager and coach club lists all go empty in one stroke, and the hub counter drops to 0 | Delete the two route lines and restart. Caught earlier by pre-flight check 3 (`?type=player` must return 205) |
|
||||
| Rows prepended instead of appended | `CLUBSTATS: ... global players=<wrong>` in the log, which is the human's fastest sanity read | Append after the existing rows. `fut_club_stats.stats_body` sorts by stat id so id 1 stays row 0 |
|
||||
| A `consumables*` name misspelt | Silent: unknown atom resolves to stat id 0 and lands in a bucket nothing reads, which looks exactly like "the hypothesis is refuted" | `tools/check_club_stat_vocab.py` before the launch. Also: never send `consumablesContract` (0xa6), `consumablesTraining` (0xa7), `consumablesFitness` (0xa8) or lowercase `leaguelogos` (0x18d). Real atoms, no arm in `FUN_18012fd40` |
|
||||
| Counts wired to `STORE.items()` | Fourteen zeros, indistinguishable from step 6's decisive negative | Pre-flight check 2. Do not launch until it prints 126/21/7/21/3/20/21/6/0/0/3/19/5/0 |
|
||||
| Staff bonus body wrong or the URL is not that class | Staff percentages garbage, or the staff tab misbehaves | Unset `FUT_STAFF_BONUS` and restart. The five staff counts come from a different store; if they break too, that is a larger signal and worth recording |
|
||||
| Client freezes at boot | Only new response content is 19 extra stat rows plus the optional staff body. All four stat keys are proven scalars, so a scalar-where-object busy-loop at `0x1801c7f1a` is unlikely but not impossible | Kill FIFA, unset `FUT_STAFF_BONUS` and `FUT_CONSUM_STATS`, restart, relaunch |
|
||||
| Save mutation | `fifa17_profile.json` changes | The overlay is GET-time and writes nothing. Verify with the md5 from pre-flight check 4. Do not restart the server mid-match or mid-pack-opening: those paths write the save |
|
||||
|
||||
**Two things NOT to ship this round.** The `/consumables/<segment>` item body: never requested, gate it on step 7 exactly as its own report instructs. And any club-item body: never requested, no renderer, unresolved subtypes.
|
||||
|
||||
**One correction not to land:** the proposed edit to the comment at `utas_server.py:1060`. It says 0x3d/0x3e/0x40 can never be SET from the wire, which is correct and is not a claim about 0x3c/0x41. Churning an accurate comment costs trust in the file.
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""READ-ONLY checker for the club/stats vocabulary change.
|
||||
|
||||
Nothing here mutates a save, opens a pack, or writes an item. It issues GETs
|
||||
only, and every GET it issues is one the client already issues by itself.
|
||||
|
||||
Run it BEFORE the integrator lands the consumables rows (it will report the
|
||||
14 rows missing, which is the current, wrong state) and AFTER (it should report
|
||||
all clear). It is deliberately NOT part of test_fut_contract.py /
|
||||
test_card_families.py -- those two have fixed baselines (439 / 414) that must
|
||||
not move.
|
||||
|
||||
python3 check_club_stat_vocab.py [--host 127.0.0.1:8099]
|
||||
|
||||
WHAT IT CHECKS, and why each check exists
|
||||
-----------------------------------------
|
||||
1. SPELLING. The 14 consumables* type strings and all 40 vocabulary names are
|
||||
asserted against docs/fut_atoms.tsv. An unrecognised `type` string is not an
|
||||
error on the client: FUN_18012fd40 returns stat id 0 and the row lands in a
|
||||
bucket nothing reads. A typo therefore fails SILENTLY to zero and looks
|
||||
exactly like "the hypothesis was wrong". This is the highest-risk detail in
|
||||
the whole change, so it is checked first and off the atom table, not off a
|
||||
transcription.
|
||||
2. THE THREE DEAD ATOMS. consumablesContract 0xa6 / consumablesTraining 0xa7 /
|
||||
consumablesFitness 0xa8 are real atom names with NO arm in FUN_18012fd40.
|
||||
Sending them proves nothing and lands in bucket 0 under stat id 0. Assert we
|
||||
never send them.
|
||||
3. PURELY ADDITIVE. Every global `type` the server sends today must still be
|
||||
sent, with the same value, after the change. The player/manager/coach panels
|
||||
are live-proven and ride on those rows.
|
||||
4. SHAPE. All four keys in every element (element-local vars are not reset
|
||||
between elements, so a missing key silently inherits the previous element's
|
||||
value), every value an int, contextId 1 for the global bucket.
|
||||
5. THE COUNTS ARE REACHABLE AT ALL. If FUT_CONSUMABLES is armed, the 14 rows
|
||||
must sum to the size of the shelf the server would actually serve. All
|
||||
fourteen at zero while the shelf is armed is the specific failure this whole
|
||||
round is trying to avoid: the club STORE holds no consumables (the shelf is a
|
||||
GET-time overlay), so counting the store alone yields fourteen zeros and an
|
||||
uninterpretable live test.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DOCS = os.path.join(os.path.dirname(HERE), "docs")
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
# stat id -> atom id, for the fourteen rows the CONSUMABLES panel (FUN_180043b90
|
||||
# case 6) reads out of bucket 0. Both columns are cross-checked below: the name
|
||||
# against docs/fut_atoms.tsv, the stat id against fut_club_stats.VOCAB.
|
||||
CONSUMABLE_ATOMS = {
|
||||
0x3C: 0xA5, 0x41: 0xAF, 0x42: 0xA9, 0x43: 0xB3, 0x44: 0xAB,
|
||||
0x45: 0xB2, 0x46: 0xB5, 0x47: 0xAA, 0x48: 0xAD, 0x49: 0xB4,
|
||||
0x4A: 0xAC, 0x4B: 0xB0, 0x4C: 0xB1, 0x4D: 0xAE,
|
||||
}
|
||||
|
||||
# Real atom names with no arm in FUN_18012fd40 -> stat id 0 -> dropped.
|
||||
DEAD_NAMES = ("consumablesContract", "consumablesTraining", "consumablesFitness")
|
||||
|
||||
PASS, FAIL = [], []
|
||||
|
||||
|
||||
def ok(msg):
|
||||
PASS.append(msg)
|
||||
|
||||
|
||||
def bad(msg):
|
||||
FAIL.append(msg)
|
||||
|
||||
|
||||
def load_atoms():
|
||||
path = os.path.join(DOCS, "fut_atoms.tsv")
|
||||
atoms = {}
|
||||
with open(path, encoding="utf8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
p = line.rstrip("\n").split("\t")
|
||||
if len(p) >= 3:
|
||||
atoms[p[2]] = int(p[1], 16)
|
||||
return atoms
|
||||
|
||||
|
||||
def get(base, path):
|
||||
url = "http://%s/ut/game/fifa17%s" % (base, path)
|
||||
with urllib.request.urlopen(url, timeout=20) as r:
|
||||
return json.loads(r.read().decode() or "{}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", default="127.0.0.1:8099")
|
||||
args = ap.parse_args()
|
||||
|
||||
import fut_club_stats as fcs
|
||||
|
||||
# ---- 1 / 2 spelling, off the atom table ------------------------------
|
||||
atoms = load_atoms()
|
||||
for sid, atom in sorted(CONSUMABLE_ATOMS.items()):
|
||||
name = fcs.VOCAB.get(sid)
|
||||
if name is None:
|
||||
bad("VOCAB has no name for stat id 0x%02x" % sid)
|
||||
elif atoms.get(name) != atom:
|
||||
bad("0x%02x %r: atom %s, expected %s"
|
||||
% (sid, name, hex(atoms.get(name) or 0), hex(atom)))
|
||||
else:
|
||||
ok("0x%02x %-42s atom %s" % (sid, name, hex(atom)))
|
||||
unknown = [n for n in fcs.VOCAB.values() if n not in atoms]
|
||||
if unknown:
|
||||
bad("VOCAB names absent from fut_atoms.tsv: %s" % unknown)
|
||||
else:
|
||||
ok("all %d vocabulary names resolve in fut_atoms.tsv" % len(fcs.VOCAB))
|
||||
if "leaguelogos" in fcs.VOCAB.values():
|
||||
bad("lowercase leaguelogos (0x18d) is NOT in the map; use leagueLogos (0x18e)")
|
||||
else:
|
||||
ok("leagueLogos capitalisation correct")
|
||||
|
||||
# ---- live bodies ------------------------------------------------------
|
||||
try:
|
||||
bodies = {m: get(args.host, "/club/stats/" + m)
|
||||
for m in ("year", "consumables", "country/14", "league/13")}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print("cannot reach the server at %s: %s" % (args.host, exc))
|
||||
return 2
|
||||
|
||||
for mode, body in bodies.items():
|
||||
rows = body.get("stat", [])
|
||||
if not rows:
|
||||
bad("%s: empty stat body" % mode)
|
||||
continue
|
||||
glob = [r for r in rows if r.get("contextId") == 1]
|
||||
|
||||
# ---- 4 shape ----------------------------------------------------
|
||||
wrong_keys = [r for r in rows
|
||||
if set(r) != {"contextId", "contextValue", "type", "typeValue"}]
|
||||
wrong_type = [r for r in rows
|
||||
if not isinstance(r.get("typeValue"), int)
|
||||
or not isinstance(r.get("contextValue"), int)]
|
||||
if wrong_keys:
|
||||
bad("%s: %d elements do not carry all four keys" % (mode, len(wrong_keys)))
|
||||
elif wrong_type:
|
||||
bad("%s: %d elements carry a non-int value" % (mode, len(wrong_type)))
|
||||
else:
|
||||
ok("%-12s %3d rows, all four keys, all ints" % (mode, len(rows)))
|
||||
|
||||
if rows[0].get("type") != "players":
|
||||
bad("%s: first row is %r, not players -- club_stats_route logs "
|
||||
"stats[0]['typeValue'] as the player count" % (mode, rows[0].get("type")))
|
||||
|
||||
# ---- 2 dead atoms ------------------------------------------------
|
||||
sent = {r["type"] for r in glob}
|
||||
for n in DEAD_NAMES:
|
||||
if n in sent:
|
||||
bad("%s: sends dead atom %s (no arm in FUN_18012fd40)" % (mode, n))
|
||||
|
||||
# ---- the fourteen -------------------------------------------------
|
||||
want = {fcs.VOCAB[s] for s in CONSUMABLE_ATOMS}
|
||||
miss = sorted(want - sent)
|
||||
if miss:
|
||||
bad("%s: %d of the 14 consumables rows MISSING: %s"
|
||||
% (mode, len(miss), ", ".join(miss)))
|
||||
else:
|
||||
ok("%-12s carries all 14 consumables rows" % mode)
|
||||
|
||||
# ---- 3 purely additive ----------------------------------------------
|
||||
ref = {r["type"]: r["typeValue"]
|
||||
for r in bodies["year"]["stat"] if r.get("contextId") == 1}
|
||||
for mode in ("consumables", "country/14", "league/13"):
|
||||
cur = {r["type"]: r["typeValue"]
|
||||
for r in bodies[mode]["stat"] if r.get("contextId") == 1}
|
||||
drift = {k: (v, cur.get(k)) for k, v in ref.items() if cur.get(k) != v}
|
||||
if drift:
|
||||
bad("%s: global rows disagree with year: %s" % (mode, drift))
|
||||
if not any("disagree with year" in f for f in FAIL):
|
||||
ok("the global bucket is identical across all four modes")
|
||||
|
||||
for k in ("players", "playersGold", "playersSilver", "playersBronze",
|
||||
"rarePlayers", "staff"):
|
||||
if k not in ref:
|
||||
bad("live-proven row %r is no longer being sent" % k)
|
||||
|
||||
# ---- 5 the counts are reachable at all -------------------------------
|
||||
total = ref.get("consumables")
|
||||
if total is None:
|
||||
ok("(consumables total not sent yet -- pre-change state)")
|
||||
else:
|
||||
leaves = sum(ref.get(fcs.VOCAB[s], 0)
|
||||
for s in CONSUMABLE_ATOMS if s != 0x3C)
|
||||
if total != leaves:
|
||||
bad("consumables total %d != sum of the 13 leaves %d" % (total, leaves))
|
||||
else:
|
||||
ok("consumables total %d == sum of the leaves" % total)
|
||||
try:
|
||||
import fut_consumables as fc
|
||||
shelf = len(fc.starter_consumables(fc.CONSUMABLE_ID_BASE))
|
||||
except Exception: # noqa: BLE001
|
||||
shelf = None
|
||||
if shelf and total == 0:
|
||||
bad("all 14 rows are ZERO while fut_consumables would serve %d items. "
|
||||
"The shelf is a GET-time OVERLAY and is NOT in the club STORE, so "
|
||||
"counting STORE.items() alone yields fourteen zeros -- see "
|
||||
"_staff_overlay_counts() for the pattern the staff rows already use."
|
||||
% shelf)
|
||||
elif shelf and total != shelf:
|
||||
bad("consumables total %d != shelf size %d" % (total, shelf))
|
||||
elif shelf:
|
||||
ok("consumables total matches the %d-item shelf" % shelf)
|
||||
|
||||
print("\n".join(" ok " + m for m in PASS))
|
||||
if FAIL:
|
||||
print("\n".join(" FAIL " + m for m in FAIL))
|
||||
print("\n%d ok, %d failed" % (len(PASS), len(FAIL)))
|
||||
return 1 if FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"]))
|
||||
@@ -0,0 +1,27 @@
|
||||
# Census query: (a) how the client consumes /settings configs (feature-flag gate),
|
||||
# (b) the closed set of club/stats/%s kinds, (c) which URL suffix templates have
|
||||
# live xrefs and from where.
|
||||
import re
|
||||
|
||||
def find_str(s):
|
||||
hits = find_all(s.encode() + b"\x00", blocks=(".rdata", ".data", ".text"))
|
||||
return hits
|
||||
|
||||
TARGETS = [
|
||||
"/stats/%s", "/stats/%s/%d", "/stats/staff", "/consumables/%s", "/counts",
|
||||
"configs", "storeEnabled", "tradingEnabled", "enableSquadBuildingSetsFeature",
|
||||
"enableObjectives", "enableDraftMode", "friendlySeasonsEnabled",
|
||||
"/squadBuildingSets", "/sets", "/objective/", "/totw", "/loan/players",
|
||||
"/tutorialpopups", "/storymode/progress",
|
||||
]
|
||||
|
||||
for t in TARGETS:
|
||||
hits = find_str(t)
|
||||
print("=== %-34s %d string hit(s)" % (repr(t), len(hits)))
|
||||
for h in hits[:4]:
|
||||
xs = xrefs_to(h)
|
||||
print(" @%#x xrefs=%d" % (h, len(xs)))
|
||||
for (fr, ty, fn, en) in xs[:8]:
|
||||
print(" %#x %-12s %s @%#x" % (fr, ty, fn, en))
|
||||
print()
|
||||
print("#" * 70)
|
||||
@@ -0,0 +1,13 @@
|
||||
# Decompile the URL builders + the settings config consumer.
|
||||
TARGETS = {
|
||||
"FUN_18012f4f0 club /stats/%s builder": 0x18012f4f0,
|
||||
"FUN_1801308c0 /consumables/%s builder": 0x1801308c0,
|
||||
"FutGetSettings deser 0x18013c6d0": 0x18013c6d0,
|
||||
"0x18012b083 /stats/staff caller": 0x18012b083,
|
||||
}
|
||||
for name, a in TARGETS.items():
|
||||
c = dec(a)
|
||||
print("=" * 78)
|
||||
print("### %s (len %d)" % (name, len(c)))
|
||||
print(c[:14000])
|
||||
print()
|
||||
@@ -0,0 +1,23 @@
|
||||
import re
|
||||
c = dec(0x18013c6d0)
|
||||
open("/tmp/gf_unserved_settings.c", "w").write(c)
|
||||
print("settings deser len", len(c))
|
||||
cases = re.findall(r"case (0x[0-9a-f]+):|if \(iVar2 == (0x[0-9a-f]+)\)", c)
|
||||
ids = sorted({int(a or b, 16) for a, b in cases})
|
||||
print("switch atoms (%d):" % len(ids), " ".join(hex(i) for i in ids))
|
||||
|
||||
for a, nm in ((0x18012b083, "/stats/staff caller"),
|
||||
(0x180163583, "/counts caller"),
|
||||
(0x18016fa33, "/squadBuildingSets caller"),
|
||||
(0x18017a980, "/sets caller"),
|
||||
(0x180151610, "/objective/ caller A"),
|
||||
(0x180147780, "/objective/ caller B"),
|
||||
(0x18016ef23, "/totw caller"),
|
||||
(0x18014dda3, "/loan/players caller"),
|
||||
(0x18016d813, "/tutorialpopups caller"),
|
||||
(0x18016f5f3, "/storymode/progress caller")):
|
||||
d = dec(a)
|
||||
open("/tmp/gf_unserved_%x.c" % a, "w").write(d)
|
||||
print("\n" + "=" * 70)
|
||||
print("### %s @%#x (len %d)" % (nm, a, len(d)))
|
||||
print(d[:3500])
|
||||
@@ -0,0 +1,25 @@
|
||||
import re
|
||||
# 1. consumables panel provider: which type ids does mode 6 read?
|
||||
c = dec(0x180043b90)
|
||||
open("/tmp/gf_prov.c", "w").write(c)
|
||||
print("### FUN_180043b90 provider len", len(c))
|
||||
i = c.find("case 6")
|
||||
print("--- case-6 region ---")
|
||||
print(c[i-200:i+3000] if i > 0 else "case 6 NOT FOUND; switch text:\n" + "\n".join(
|
||||
l for l in c.splitlines() if "case" in l or "switch" in l))
|
||||
|
||||
# 2. who calls the /consumables/%s builder, /sets, /squadBuildingSets
|
||||
for nm, a in (("/consumables/%s builder FUN_1801308c0", 0x1801308c0),
|
||||
("/sets builder FUN_18017a980", 0x18017a980)):
|
||||
print("\n### callers of %s" % nm)
|
||||
for (fr, ty, fn, en) in xrefs_to(a):
|
||||
print(" %#x %-12s %s @%#x" % (fr, ty, fn, en))
|
||||
|
||||
# 3. the base URL used with those builders: decompile one caller each
|
||||
print("\n### FUN_1801308c0 caller decompile")
|
||||
xs = xrefs_to(0x1801308c0)
|
||||
for (fr, ty, fn, en) in xs[:2]:
|
||||
if en:
|
||||
d = dec(en)
|
||||
print("--- %s @%#x len %d ---" % (fn, en, len(d)))
|
||||
print(d[:5000])
|
||||
@@ -0,0 +1,40 @@
|
||||
# Pin the BASE template each suffix builder composes onto, by walking the vtable the
|
||||
# builder sits in and reading the sibling slot that returns the base string.
|
||||
BASES = ["ut/%s/sbs", "ut/%s/draft/mode", "ut/%s/club", "ut/%s/item", "ut/%s",
|
||||
"ut/%s/champion", "ut/%s/leaderboards", "ut/%s/season", "ut/%s/tournament",
|
||||
"ut/%s/auctionhouse", "ut/%s/trade", "ut/%s/tradePile", "ut/%s/marketdata",
|
||||
"ut/%s/purchased", "ut/%s/user", "ut/%s/squad", "ut/%s/squad/mode"]
|
||||
for b in BASES:
|
||||
hits = find_all(b.encode() + b"\x00", blocks=(".rdata", ".data", ".text"))
|
||||
print("=== %-22s %s" % (b, [hex(h) for h in hits]))
|
||||
for h in hits:
|
||||
for (fr, ty, fn, en) in xrefs_to(h):
|
||||
print(" ref %#x %-10s %s @%#x" % (fr, ty, fn, en))
|
||||
|
||||
# Builders whose base we still need. Print the vtable that holds each.
|
||||
BUILDERS = {"objective/reward FUN_180151610": 0x180151610,
|
||||
"objective/complete FUN_180147780": 0x180147780,
|
||||
"sets FUN_18017a980": 0x18017a980,
|
||||
"consumables FUN_1801308c0": 0x1801308c0,
|
||||
"clubstats FUN_18012f4f0": 0x18012f4f0}
|
||||
for nm, a in BUILDERS.items():
|
||||
print("\n### vtable slots holding %s" % nm)
|
||||
for h in find_all(a.to_bytes(8, "little"), blocks=(".rdata", ".data")):
|
||||
print(" vt entry @%#x" % h)
|
||||
for k in range(-6, 7):
|
||||
try:
|
||||
q = qword(h + k * 8)
|
||||
except Exception:
|
||||
continue
|
||||
tag = ""
|
||||
if 0x180000000 <= q < 0x181000000:
|
||||
try:
|
||||
s = rd_str(q, 60)
|
||||
if s and all(32 <= ord(c) < 127 for c in s) and len(s) > 2:
|
||||
tag = " STR %r" % s
|
||||
except Exception:
|
||||
pass
|
||||
f = fm.getFunctionAt(addr(q))
|
||||
if f is not None and not tag:
|
||||
tag = " FN %s" % f.getName()
|
||||
print(" [%+2d] %#018x%s" % (k, q, tag))
|
||||
@@ -0,0 +1,67 @@
|
||||
# VERIFICATION pass for the "unserved screens" census. Re-derives, independently:
|
||||
# 1. FUN_180043b90 (claimed club-stats panel provider) -- FULL decompile, length,
|
||||
# brace balance, every switch arm, and case-6's row list.
|
||||
# 2. FUN_180094ce0 (the LIVE-PROVEN eight-row staff panel) -- to check that both
|
||||
# panels obtain their data object the same way, which is the only thing that
|
||||
# licenses "the JSON we send reaches those tiles".
|
||||
# 3. FUN_18012fd40 -- atom -> type-id map, specifically the consumable arms.
|
||||
# 4. FUN_18013c6d0 -- settings deser: length, brace balance, distinct atoms,
|
||||
# which getter reads `value`, and whether 0x100 appears.
|
||||
# 5. FUN_18012f4f0 -- the club/stats URL builder's closed mode set.
|
||||
import re
|
||||
|
||||
def report(name, a):
|
||||
c = dec(a)
|
||||
bal = c.count("{") - c.count("}")
|
||||
print("=== %s @%#x len=%d braces_balanced=%s ends=%r" %
|
||||
(name, a, len(c), bal == 0, c.rstrip()[-40:]))
|
||||
return c
|
||||
|
||||
print("#" * 72)
|
||||
print("# 1. FUN_180043b90")
|
||||
c = report("FUN_180043b90", 0x180043b90)
|
||||
open("/tmp/ver_43b90.c", "w").write(c)
|
||||
cases = re.findall(r"^\s*(case \w+|default):", c, re.M)
|
||||
print("switch arms seen:", cases)
|
||||
# every string literal passed as the row name, in order, with the typeid before it
|
||||
rows = re.findall(r'0x800\)\)\(\w+,([0-9a-fx]+)\);|"(CARDS_NO_[A-Z_]+)",(\w+)\)', c)
|
||||
print("--- case 6 region ---")
|
||||
i = c.find("case 6:")
|
||||
j = c.find("case 7:", i)
|
||||
if j == -1:
|
||||
j = len(c)
|
||||
seg = c[i:j] if i != -1 else "(no case 6)"
|
||||
print("case6 segment len", len(seg))
|
||||
for m in re.finditer(r'0x800\)\)\((\w+),([0-9a-fx]+)\)|"(CARDS_NO_[A-Z_]+)"', seg):
|
||||
print(" ", m.group(0))
|
||||
# where does the data object come from?
|
||||
print("--- data-object acquisition (first 30 lines) ---")
|
||||
print("\n".join(c.splitlines()[:32]))
|
||||
|
||||
print("#" * 72)
|
||||
print("# 2. FUN_180094ce0 (live-proven staff panel, for comparison)")
|
||||
c2 = report("FUN_180094ce0", 0x180094ce0)
|
||||
open("/tmp/ver_94ce0.c", "w").write(c2)
|
||||
print("\n".join(c2.splitlines()[:34]))
|
||||
|
||||
print("#" * 72)
|
||||
print("# 3. FUN_18012fd40 atom -> typeid")
|
||||
c3 = report("FUN_18012fd40", 0x18012fd40)
|
||||
open("/tmp/ver_12fd40.c", "w").write(c3)
|
||||
print(c3)
|
||||
|
||||
print("#" * 72)
|
||||
print("# 4. FUN_18013c6d0 settings deser")
|
||||
c4 = report("FUN_18013c6d0", 0x18013c6d0)
|
||||
open("/tmp/ver_13c6d0.c", "w").write(c4)
|
||||
atoms = sorted(set(int(x, 16) for x in re.findall(r"case 0x([0-9a-f]+):", c4)))
|
||||
print("distinct case atoms: %d" % len(atoms), [hex(a) for a in atoms])
|
||||
print("0x100 present:", 0x100 in atoms)
|
||||
print("getter callsites:", sorted(set(re.findall(r"FUN_1801c7[0-9a-f]{3}", c4))))
|
||||
for pat in ("0xa2", "0x354", "0x377"):
|
||||
print(" %s occurrences: %d" % (pat, c4.count(pat)))
|
||||
|
||||
print("#" * 72)
|
||||
print("# 5. FUN_18012f4f0 club/stats URL builder")
|
||||
c5 = report("FUN_18012f4f0", 0x18012f4f0)
|
||||
print(c5)
|
||||
@@ -0,0 +1,41 @@
|
||||
# VERIFICATION pass 2: the objective URL builders (RANK 9), the /consumables/%s
|
||||
# builder (RANK 10), and the xref counts behind the sbs/draft suffix claims.
|
||||
import re
|
||||
|
||||
def show(name, a):
|
||||
c = dec(a)
|
||||
bal = c.count("{") - c.count("}")
|
||||
print("=== %s @%#x len=%d balanced=%s" % (name, a, len(c), bal == 0))
|
||||
print(c)
|
||||
return c
|
||||
|
||||
for nm, a in (("FUN_180151610", 0x180151610), ("FUN_180147780", 0x180147780),
|
||||
("FUN_1801308c0", 0x1801308c0)):
|
||||
show(nm, a)
|
||||
|
||||
print("#" * 72)
|
||||
print("# xrefs to each suffix string")
|
||||
for s in ["/sets", "/sets/tag", "/setId/%d/challenges", "/squadBuildingSets",
|
||||
"/challenge/%d", "/challenge/%d/squad", "/consumables/%s", "/objective/",
|
||||
"/loan/players", "/stats/staff", "/choices/player", "/%d/draft/choose",
|
||||
"ut/%s/sbs", "ut/%s/draft/mode"]:
|
||||
hits = find_all(s.encode() + b"\x00", blocks=(".rdata", ".data", ".text"))
|
||||
for h in hits:
|
||||
xs = xrefs_to(h)
|
||||
txt = ", ".join("%s@%#x" % (fn, en) for (_, _, fn, en) in xs)
|
||||
print(" %-24s @%#x xrefs=%d %s" % (s, h, len(xs), txt))
|
||||
|
||||
print("#" * 72)
|
||||
print("# .rdata neighbourhood of the two objective vtables (identity check)")
|
||||
for vt in (0x1801fc080, 0x180206090):
|
||||
print("-- vtable %#x" % vt)
|
||||
for i in range(8):
|
||||
try:
|
||||
q = qword(vt + i * 8)
|
||||
except Exception:
|
||||
break
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
|
||||
print(" +%02x %#018x %s" % (i * 8, q, f.getName() if f else ""))
|
||||
# ascii that follows
|
||||
b = read_bytes(vt + 64, 160)
|
||||
print(" trailing bytes:", re.findall(rb"[ -~]{4,}", b))
|
||||
@@ -0,0 +1,31 @@
|
||||
# VERIFICATION pass 3: resolve the address discrepancies in the suffix-string
|
||||
# census and get the FROM address / ref type of each single-xref claim.
|
||||
addrs = [0x1802262c0, 0x18022dd08, 0x180226700, 0x1802264e0, 0x18022e6d4,
|
||||
0x180226ec0, 0x18022e908, 0x180227300, 0x1802270e0, 0x18022bd88,
|
||||
0x180221728, 0x180225840, 0x18021e508, 0x18021e820, 0x180222320,
|
||||
0x1802252c8, 0x1802254c0, 0x180225638]
|
||||
print("== what string actually lives at each address ==")
|
||||
for a in addrs:
|
||||
try:
|
||||
print(" %#x %r" % (a, rd_str(a, 60)))
|
||||
except Exception as e:
|
||||
print(" %#x ERR %s" % (a, e))
|
||||
|
||||
print()
|
||||
print("== from-address + reftype for the 'single xref' strings ==")
|
||||
for s in ["/squadBuildingSets", "/loan/players", "/stats/staff", "ut/%s/sbs",
|
||||
"ut/%s/draft/mode", "/sets", "/setId/%d/challenges", "/challenge/%d",
|
||||
"/challenge/%d/squad", "/sets/tag", "/consumables/%s"]:
|
||||
for h in find_all(s.encode() + b"\x00", blocks=(".rdata", ".data", ".text")):
|
||||
for (fr, ty, fn, en) in xrefs_to(h):
|
||||
blk = mem.getBlock(addr(fr))
|
||||
print(" %-22s str@%#x from %#x [%s] %s fn=%s" %
|
||||
(s, h, fr, blk.getName() if blk else "?", ty, fn))
|
||||
|
||||
print()
|
||||
print("== vtable block around 0x1801f5968 (RANK 10 adjacency claim) ==")
|
||||
for off in range(-5, 4):
|
||||
a = 0x1801f5968 + off * 8
|
||||
q = qword(a)
|
||||
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
|
||||
print(" %#x %#018x %s" % (a, q, f.getName() if f else ""))
|
||||
Reference in New Issue
Block a user