Compare commits
10 Commits
9348b83374
...
a3fd51692f
| Author | SHA1 | Date | |
|---|---|---|---|
| a3fd51692f | |||
| e578443d73 | |||
| e3092ca0f9 | |||
| 21a81ad63c | |||
| 3f3d5704a7 | |||
| 89da7b7609 | |||
| 1605e6effd | |||
| afdbb364ca | |||
| d0dbfa99c0 | |||
| 897259c8fb |
@@ -215,7 +215,11 @@ Path template `%s = "game/fifa17"`. Methods inferred from struct verb + endpoint
|
|||||||
### Freeze-risk summary (type fidelity is mandatory)
|
### Freeze-risk summary (type fidelity is mandatory)
|
||||||
- `auctionInfo` → **array** (never object/scalar).
|
- `auctionInfo` → **array** (never object/scalar).
|
||||||
- `itemData` inside each record → **object** (the card; reuse `item_def`).
|
- `itemData` inside each record → **object** (the card; reuse `item_def`).
|
||||||
- `duplicateItemIdList` → **array**.
|
- `duplicateItemIdList` → **array of objects** (element deser `0x180138e10`: `itemId` 0x16d,
|
||||||
|
`duplicateItemId` 0xeb, `itemLoans` 0x16f, `duplicateItemLoans` 0xed). Not an int list.
|
||||||
|
`[]` is safe; a list of bare ints is a freeze. Control that this is not a misread:
|
||||||
|
`dreamSquads` 0xe9 in FutMoveCard genuinely IS a bare int array, parsed by a
|
||||||
|
`while (tok != 0xd)` loop calling the int getter with no inner object loop.
|
||||||
- `bidState`, `tradeState`, `sellerName` → **strings**.
|
- `bidState`, `tradeState`, `sellerName` → **strings**.
|
||||||
- `credits`, `total`, `count`, `*Price`, `*Bid`, `expires`, `tradeId` → **numbers**.
|
- `credits`, `total`, `count`, `*Price`, `*Bid`, `expires`, `tradeId` → **numbers**.
|
||||||
- `watched` → **bool**.
|
- `watched` → **bool**.
|
||||||
@@ -966,7 +970,11 @@ Notes:
|
|||||||
{ "itemData": [ /* the single updated card item */ ] }
|
{ "itemData": [ /* the single updated card item */ ] }
|
||||||
|
|
||||||
// 8 DiscardCard — DELETE ut/delete/game/fifa17/item
|
// 8 DiscardCard — DELETE ut/delete/game/fifa17/item
|
||||||
{ "items": [ 123456789 ], "totalCredits": 15000, "id": 123456789 }
|
// CORRECTED 2026-08-05: `items` is an array of OBJECTS and there is no top-level `id`.
|
||||||
|
// The previous shape, { "items": [ 123456789 ], ..., "id": 123456789 }, was wrong twice
|
||||||
|
// over, and feeding a bare int where the element parser expects an object is a tokenizer
|
||||||
|
// desync, i.e. a hard freeze at 0x1801c7f1a, not a soft failure.
|
||||||
|
{ "items": [ { "id": 123456789 } ], "totalCredits": 15000 }
|
||||||
|
|
||||||
// 9 DiscardCardByRes — DELETE ut/delete/game/fifa17/item
|
// 9 DiscardCardByRes — DELETE ut/delete/game/fifa17/item
|
||||||
{ "totalCredits": 15000 }
|
{ "totalCredits": 15000 }
|
||||||
@@ -1042,7 +1050,7 @@ desyncs the SAX reader → tokenizer freeze at `0x1801c7f1a`.
|
|||||||
| `displayGroup` | 0xd9 | **ARRAY** | nested (freeze-risk) |
|
| `displayGroup` | 0xd9 | **ARRAY** | nested (freeze-risk) |
|
||||||
| `displayGroupAssetId` | 0xda | INT | `[rbp-0x80]` |
|
| `displayGroupAssetId` | 0xda | INT | `[rbp-0x80]` |
|
||||||
| `displayGroupUseDefaultImage` | 0xdb | BOOL | |
|
| `displayGroupUseDefaultImage` | 0xdb | BOOL | |
|
||||||
| `currencies` | 0xc5 | **ARRAY** | coin price: `[{name,funds,finalFunds}]` (freeze-risk) |
|
| `currencies` | 0xc5 | **ARRAY** | coin price: `[{name,funds,finalFunds}]` (freeze-risk). **`finalFunds` is the number the tile RENDERS. CONFIRMED LIVE 2026-08-05** by serving `funds=15000, finalFunds=4321` on one pack and reading `4,321` off the store tile. `funds` is not displayed. |
|
||||||
| `extPrice` | 0x119 | **OBJECT** | → `finalPrice`(0x125,obj `0x180139070`) + `originalPrice`(0x205,obj `0x18013aae0`); inner uses `amount`(0x1b)/`currency`(0xc4) (freeze-risk) |
|
| `extPrice` | 0x119 | **OBJECT** | → `finalPrice`(0x125,obj `0x180139070`) + `originalPrice`(0x205,obj `0x18013aae0`); inner uses `amount`(0x1b)/`currency`(0xc4) (freeze-risk) |
|
||||||
| `packContentInfo` | 0x20c | **OBJECT** | → `bronzeQuantity`(0x63), `silverQuantity`(0x2c6), `goldQuantity`(0x149), `rareQuantity`(0x273), `itemQuantity`(0x170), `start`(0x2e3), `unopened`(0x35d,bool) (freeze-risk) |
|
| `packContentInfo` | 0x20c | **OBJECT** | → `bronzeQuantity`(0x63), `silverQuantity`(0x2c6), `goldQuantity`(0x149), `rareQuantity`(0x273), `itemQuantity`(0x170), `start`(0x2e3), `unopened`(0x35d,bool) (freeze-risk) |
|
||||||
| `sortPriority` | 0x2cb | INT | |
|
| `sortPriority` | 0x2cb | INT | |
|
||||||
@@ -1092,7 +1100,7 @@ desyncs the SAX reader → tokenizer freeze at `0x1801c7f1a`.
|
|||||||
| `itemList` | 0x16e | **ARRAY** of items (element deser `0x18013fe00`) | freeze-risk |
|
| `itemList` | 0x16e | **ARRAY** of items (element deser `0x18013fe00`) | freeze-risk |
|
||||||
| `numberItems` | 0x1dd | INT | `[rsi+0x28]` |
|
| `numberItems` | 0x1dd | INT | `[rsi+0x28]` |
|
||||||
| `purchasedPackId` | 0x264 | INT | `[rsi+0x70]` |
|
| `purchasedPackId` | 0x264 | INT | `[rsi+0x70]` |
|
||||||
| `duplicateItemIdList` | 0xec | **ARRAY** (int list) | freeze-risk |
|
| `duplicateItemIdList` | 0xec | **ARRAY of OBJECTS** (element deser `0x180138e10`) | freeze-risk |
|
||||||
|
|
||||||
- **Status: already handled — VERIFIED byte-exact** against `store_buy()`.
|
- **Status: already handled — VERIFIED byte-exact** against `store_buy()`.
|
||||||
- **Minimal known-good**:
|
- **Minimal known-good**:
|
||||||
@@ -1243,12 +1251,83 @@ reader → infinite spin at `0x1801c7f1a` (the hub freeze).
|
|||||||
- **Handled:** `utas_server.massinfo()` → `{userInfo, squad, settings, userData}`;
|
- **Handled:** `utas_server.massinfo()` → `{userInfo, squad, settings, userData}`;
|
||||||
`FUT_MASSINFO=full|squad|userinfo|settings|empty` bisects it one member per relaunch.
|
`FUT_MASSINFO=full|squad|userinfo|settings|empty` bisects it one member per relaunch.
|
||||||
|
|
||||||
### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED
|
### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED (schema) / the 42 flags are RECOVERED, UNTESTED
|
||||||
- **Deser:** `0x18013c6d0`
|
- **Deser:** `0x18013c6d0` (1982 bytes, 12061-char decompile, read end to end)
|
||||||
- **HTTP:** `GET ut/%s/settings`
|
- **HTTP:** `GET ut/%s/settings`, and the `settings` (0x2bf) member of `userMassInfo`
|
||||||
|
(both callers of the deser: `0x18014e590` and `0x180174630`)
|
||||||
- **Fields:** single wrapper key `configs` (0xa2) → array of config entries
|
- **Fields:** single wrapper key `configs` (0xa2) → array of config entries
|
||||||
`{ type (0x354), value (0x377) }`.
|
`{ type (0x354), value (0x377) }`. The key ladder really does hold nothing else.
|
||||||
- **Handled:** `utas_server.SETTINGS = {"configs": []}`. Min JSON: `{"configs":[]}`.
|
|
||||||
|
**The mechanism the key ladder hides.** A flag is not a JSON key. When an element
|
||||||
|
closes, the client feeds the STRING VALUE of `type` back through the atom hasher
|
||||||
|
(`FUN_180180d00`) and switches on the result, 42 arms wide:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"configs": [{"type": "friendlySeasonsEnabled", "value": 1}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
So the flag vocabulary is the same atom table everything else uses, and the client
|
||||||
|
hashes our string itself — a flag cannot be misnamed silently, it simply falls
|
||||||
|
through to the default arm and is ignored.
|
||||||
|
|
||||||
|
- **`value` is type-forgiving.** Its getter `0x1801c79d0` accepts int (token 2),
|
||||||
|
float (3), bool (4) and string (5, via `sscanf "%I64d"`), coercing all four to
|
||||||
|
int64. `1`, `"1"` and `true` are equivalent. This is one of the few scalar
|
||||||
|
getters in the API with NO desync risk on scalars. An object or array is still
|
||||||
|
a freeze.
|
||||||
|
- **The applier demands exactly 1.** `FUN_18011dc50` is the only writer of the
|
||||||
|
gate bytes and every line is `gate_byte = (field == 1)`. Not truthiness. `2`,
|
||||||
|
`-1` and `"yes"` all read as OFF.
|
||||||
|
|
||||||
|
**Flags that publish a UI gate key.** `FUN_18006cc60` publishes IS_* state keys by
|
||||||
|
reading single bytes inside `FutDataManagerImpl` (service id `0xed84b11`, ctor
|
||||||
|
`0x18010cdc0`). Those bytes are written ONLY by the applier, and the ctor never
|
||||||
|
touches them (whole 16620-char ctor scanned):
|
||||||
|
|
||||||
|
| flag `type` | field | gate byte | UI key |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tradingEnabled` | `[10]` | `0x1fd2e` | `IS_TRADING_ENABLED` |
|
||||||
|
| `storeEnabled` / `_JP` | `[0xb]` / `[0xc]` | `0x1fd2f` / `0x1fd30` | `IS_STORE_ENABLED` (accessor `0x18011c600` picks `_JP` when region == 4) |
|
||||||
|
| `friendlySeasonsEnabled` | `[0x16]` | `0x1fd3a` | `IS_FRIENDLY_SEASON_ENABLED` |
|
||||||
|
| `tournamentQuitEnabled` | `[0x20]` | `0x1fd3b` | `IS_TOURNAMENT_QUIT_ENABLED` |
|
||||||
|
| `processingStateEnabled` | `[0x21]` | `0x1fd3c` | `IS_PROCESSING_STATE_ENABLED` |
|
||||||
|
| `enableDraftMode` | `[0x17]` | `0x1fd3d` | `IS_DRAFT_MODE_ENABLED` |
|
||||||
|
| `enableOfflineDraftMode` = `enableSinglePlayerDraftMode` | `[0x18]` | `0x1fd3e` | (shared arm, one field) |
|
||||||
|
| `storyModeRewardEnabled` | `[0x1f]` | `0x1fd3f` | `IS_STORY_MODE_REWARD_ENABLED` |
|
||||||
|
| `returningUserRewardsScreenEnabled` | `[0x19]` | `0x1fd40` | `IS_RETURNING_USER_REWARDS_SCREEN_ENABLED` |
|
||||||
|
|
||||||
|
**Why this is the standing suspect for Seasons and Draft.** Both refuse while
|
||||||
|
making zero requests to any of the four servers, which no response shape can
|
||||||
|
explain. A UI key evaluated from a byte that nothing ever wrote does explain it.
|
||||||
|
The store is the control: `IS_STORE_ENABLED` reads the same kind of byte and its
|
||||||
|
screen works, because `storeEnabled` and friends are already shipped through the
|
||||||
|
**Blaze** client-config store (`FUT_RS4_CONFIG` in `blaze_responder_v3b.py`) —
|
||||||
|
and that list contains no seasons, draft or tournament flag. Same mechanism, one
|
||||||
|
population, one blank.
|
||||||
|
|
||||||
|
This is a hypothesis with a mechanism, not a confirmed cause. It predicts that
|
||||||
|
sending the flags opens the screens; if they still refuse, the gate is upstream
|
||||||
|
of the UI key and the whole settings line is dead.
|
||||||
|
|
||||||
|
**Two arms that are not simple assignments:**
|
||||||
|
- `enableObjectives` (0xfd) and `enableObjectivesAsManagerTasks` (0xfe) share an
|
||||||
|
arm that can only ever CLEAR `[0x1c]`: `if (value == 0) field = 0`. Sending 1
|
||||||
|
is a no-op. Objectives cannot be turned ON here, only off.
|
||||||
|
- `clientKeepAliveResetTimeoutSec` (0x86, vtable +0x68) and `getOperationTimeoutSec`
|
||||||
|
(0x13d, +0x58) do not store a field; they call a timer object with `value * 1000`.
|
||||||
|
Sending a small number shortens client timeouts. Leave them alone.
|
||||||
|
|
||||||
|
**`maximumTradePileSize` (0x1c0) is the positive control.** It lands in `[0]` and
|
||||||
|
is passed to `FUN_18011f380`, and transfer-list capacity is visible in game. It
|
||||||
|
distinguishes "the flag did not help" from "the configs array never reached the
|
||||||
|
consumer at all", which no boolean flag can do on its own.
|
||||||
|
|
||||||
|
**Not in the switch:** `enableSquadBuildingSetsFeature` (0x100) is a real atom but
|
||||||
|
has NO arm here, so SBC is gated somewhere else. Scanned the full decompile;
|
||||||
|
this absence is asserted over the whole function, not a slice.
|
||||||
|
|
||||||
|
- **Handled:** `utas_server.SETTINGS`, `FUT_SETTINGS` (default `gates`).
|
||||||
|
`off` restores the historical `{"configs": []}`.
|
||||||
|
|
||||||
### FutGetHubDataServerResponse — CONFIDENCE: LOW (full schema) / HIGH (served {} works) — GAP
|
### FutGetHubDataServerResponse — CONFIDENCE: LOW (full schema) / HIGH (served {} works) — GAP
|
||||||
- **Wrapper:** `0x1801736ad` → inner `0x180173a50` / `0x180173b10` / `0x180173c00`.
|
- **Wrapper:** `0x1801736ad` → inner `0x180173a50` / `0x180173b10` / `0x180173c00`.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
|||||||
|
# The /settings feature gate - live-test script
|
||||||
|
|
||||||
|
> **CORRECTION, 2026-08-05 evening. Section 1 of this document is FALSE and the
|
||||||
|
> test in section 3 should not be run as written.**
|
||||||
|
>
|
||||||
|
> Section 1 claims `IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` "have
|
||||||
|
> never been set to true by anything, on any run". They are measured as **1**, on
|
||||||
|
> two separate launches, while `/settings` was answering `{"configs": []}`:
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> disp 0x1fd3a (friendlySeasonsEnabled) value = 1
|
||||||
|
> disp 0x1fd3d (enableDraftMode) value = 1
|
||||||
|
> disp 0x1fd45 (packOpeningAnimationEnabled) value = 1
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Reproduce with `tools/gate_byte_probe.py` (needs the client at the FUT hub, since
|
||||||
|
> CardsDLL loads only then): it resolves the pid by comm,
|
||||||
|
> re-derives the CardsDLL slide from `/proc/<pid>/maps`, proves it against the FNV
|
||||||
|
> prologue at `0x180180d00` read from disk, walks the model singleton at
|
||||||
|
> `DAT_1802e6398`, and decodes each displacement out of its accessor stub
|
||||||
|
> (`0f b6 81 <disp32>`) rather than assuming it.
|
||||||
|
>
|
||||||
|
> **Where the reasoning went wrong.** The finding that `FUN_18011dc50` is the only
|
||||||
|
> writer and that the `FutDataManagerImpl` constructor never touches those bytes was
|
||||||
|
> correct. The inference drawn from it was not. The applier runs whether or not the
|
||||||
|
> configs array has content, and the settings struct it is handed defaults these
|
||||||
|
> fields to 1, so the bytes were being written all along. "Nothing populates the
|
||||||
|
> array" was treated as "nothing writes the byte". Those are different claims and
|
||||||
|
> only the first one was established.
|
||||||
|
>
|
||||||
|
> Seasons therefore does not refuse because its gate byte is false. Its gate byte is
|
||||||
|
> true. The mechanism is still unknown and needs a fresh diagnosis. Everything below
|
||||||
|
> the correction is kept as the record of a wrong turn, not as a plan.
|
||||||
|
|
||||||
|
Written 2026-08-05, after reversing `FutGetSettingsServerResponse` end to end.
|
||||||
|
Nothing here has been in front of the game yet. The code default is `off`, which
|
||||||
|
serves the exact historical `{"configs": []}`, so the tree is currently at the
|
||||||
|
proven baseline and this test is opt-in.
|
||||||
|
|
||||||
|
Full schema, atom ids, gate bytes and accessor addresses are in `ENDPOINT_MAP.md`
|
||||||
|
under `FutGetSettingsServerResponse`. This file is only the experiment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The claim being tested
|
||||||
|
|
||||||
|
`GET /settings` is requested 11 times a session and has always been answered with
|
||||||
|
an empty array. The array is not decoration:
|
||||||
|
|
||||||
|
- Each element is `{"type": "<name>", "value": <scalar>}`. The client hashes the
|
||||||
|
**string value** of `type` through the atom hasher and switches on it, 42 arms
|
||||||
|
wide, so a flag is a row rather than a key.
|
||||||
|
- `FUN_18011dc50` is the **only** writer of the `IS_*` UI gate bytes inside
|
||||||
|
`FutDataManagerImpl`, and every line of it is `byte = (field == 1)`.
|
||||||
|
- The `FutDataManagerImpl` constructor never touches those bytes. The whole
|
||||||
|
16620-char decompile was scanned for the block; it is absent.
|
||||||
|
|
||||||
|
So `IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` have never been set
|
||||||
|
to true by anything, on any run, in the whole history of this project.
|
||||||
|
|
||||||
|
That is a mechanism for the standing bug in which **Seasons refuses while making
|
||||||
|
zero requests to any of the four servers.** No response shape could ever explain
|
||||||
|
that. A UI key evaluated from a byte nobody wrote does.
|
||||||
|
|
||||||
|
**The store is the control that makes this readable.** `IS_STORE_ENABLED` is the
|
||||||
|
same kind of byte read the same way, and the store screen works. It works because
|
||||||
|
`storeEnabled` and its siblings already reach the client through the **Blaze**
|
||||||
|
client-config store (`FUT_RS4_CONFIG`). That list contains no seasons flag, no
|
||||||
|
draft flag, no tournament flag. Same mechanism, one populated, one blank.
|
||||||
|
|
||||||
|
This is a hypothesis with a mechanism, not a demonstrated cause.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Pre-flight, from the terminal, costs nothing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd fifa17-recon/tools
|
||||||
|
FUT_SETTINGS=gates python3 check_settings_flags.py # expect: 14 rows, PASS
|
||||||
|
python3 check_settings_flags.py # expect: mode=off, PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
The checker asserts every shipped flag name against **both** the atom table and
|
||||||
|
the recovered switch arms. Both are needed: `enableSquadBuildingSetsFeature` is a
|
||||||
|
genuine atom with no arm in this switch, so the atom table alone would wave
|
||||||
|
through a flag that does nothing. A misnamed flag is silently inert and looks
|
||||||
|
exactly like a failed fix, which is the failure mode this guards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The run
|
||||||
|
|
||||||
|
Budget: **one launch.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd fifa17-recon/tools
|
||||||
|
FUT_SETTINGS=gates ./openfut-fut.sh start
|
||||||
|
~/Desktop/launch-fifa17.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, in order, and write down what each one does:
|
||||||
|
|
||||||
|
1. **Store.** Open it. This is the control and it goes first, because if
|
||||||
|
populating the array broke the store then the applier demonstrably ran and
|
||||||
|
everything after this reads differently.
|
||||||
|
2. **Transfer list capacity.** Transfers → Transfer List. Read the capacity
|
||||||
|
number. We send `maximumTradePileSize = 77`, a number FUT would never choose
|
||||||
|
on its own.
|
||||||
|
3. **Seasons.** Single-player Seasons, the exact path that has been refusing.
|
||||||
|
4. **FUT Draft.** Both the offline and online entries.
|
||||||
|
5. **Tournaments**, for `tournamentQuitEnabled`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Reading the result
|
||||||
|
|
||||||
|
The control in step 2 is what makes a negative result informative, so read it
|
||||||
|
before concluding anything about steps 3 to 5.
|
||||||
|
|
||||||
|
| Store (1) | Capacity (2) | Seasons (3) | Reading |
|
||||||
|
|---|---|---|---|
|
||||||
|
| works | **77** | opens | Confirmed. The gate was the empty array. Make `gates` the default and move to the `/match` shape, which has been blocked behind this. |
|
||||||
|
| works | **77** | still refuses | The array reached the consumer and the flag was applied, so the gate is **upstream of the UI key**. The settings line is then dead for Seasons and the next move is a live probe of the refusal path, not more response work. This is a real result, not a null one. |
|
||||||
|
| works | not 77 | still refuses | The array never reached the consumer at all. Everything above is untested rather than refuted. Suspect the massinfo `settings` member (the deser's other caller) is what the client actually reads, and check which of the two paths fires in `/tmp/utas.log`. |
|
||||||
|
| **breaks** | any | any | The applier ran and re-asserting the store flags did not hold them. Fall back to `FUT_SETTINGS=keep`, which sends only the already-working flags plus the control. If `keep` also breaks the store, populating the array is harmful in itself and the whole approach is wrong. |
|
||||||
|
|
||||||
|
`keep` exists precisely so that "populating the array at all" and "the new gates"
|
||||||
|
can be separated without guessing, and it costs one restart to use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. What would make this whole plan wrong
|
||||||
|
|
||||||
|
**The gate might not be a UI key at all.** Seasons could be refusing on an
|
||||||
|
entitlement, a persona attribute, or a Blaze session property evaluated inside
|
||||||
|
the Denuvo-packed executable, in which case no `/settings` body reaches it. The
|
||||||
|
step-2 control is what tells these apart: it distinguishes "the flag did not
|
||||||
|
help" from "the array was never consumed", and no boolean flag can do that alone.
|
||||||
|
|
||||||
|
**The store control could be weaker than it looks.** The argument assumes
|
||||||
|
`IS_STORE_ENABLED` currently comes from the Blaze store rather than from a
|
||||||
|
default. If it turns out the store screen does not read that key at all, then it
|
||||||
|
is not a control for anything and the reasoning in §1 loses its anchor.
|
||||||
|
|
||||||
|
**Draft has a second known suspect.** `GET ut/%s/squad/mode/draft/state` is still
|
||||||
|
answered by the generic `/squad` handler with a full active-squad object, which
|
||||||
|
is a textbook type-desync candidate. If Draft still fails while Seasons opens,
|
||||||
|
that route is the next thing to look at, not the flag.
|
||||||
|
|
||||||
|
**A negative result here is worth having.** The settings array has been the
|
||||||
|
standing suspect for the greyed-out entry points for two rounds without anyone
|
||||||
|
sending a single flag. Ruling it out costs one launch and removes it from the
|
||||||
|
backlog permanently.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futmem"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "futmem"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Read-only live-memory inspector for the FIFA 17 process (preservation / reverse-engineering tooling)"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
# An EMPTY [workspace] table makes this crate its own workspace root.
|
||||||
|
# Without it, cargo walks up the directory tree, finds
|
||||||
|
# /home/alex/Documents/OpenFUT/Cargo.toml, sees that futmem is not in its
|
||||||
|
# `members` list, and refuses to build. That parent manifest is untracked and
|
||||||
|
# must not be edited, so we opt out from this side instead.
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# memchr is the ONLY dependency, and it earns its place.
|
||||||
|
# A `find` sweep covers roughly 3 GB of resident memory. The naive
|
||||||
|
# `windows(n).position(...)` search runs at a few hundred MB/s; memchr's
|
||||||
|
# memmem uses SIMD (AVX2 on this box) and runs an order of magnitude faster,
|
||||||
|
# which turns a multi-minute sweep into a few seconds.
|
||||||
|
# Everything else (argument parsing for four subcommands, /proc/<pid>/maps
|
||||||
|
# parsing, hex dumping) is a few dozen lines of std and does not justify
|
||||||
|
# pulling in clap or a proc-maps crate.
|
||||||
|
memchr = "2"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# futmem
|
||||||
|
|
||||||
|
A small, read-only live-memory inspector for FIFA 17, built for the OpenFUT
|
||||||
|
preservation project.
|
||||||
|
|
||||||
|
`FIFA17.exe` is Denuvo-packed: its `.text` and `.rdata` exist in plaintext only
|
||||||
|
inside the running process. Anything the packed executable owns can be reached
|
||||||
|
only through live memory. `CardsDLL_Win64_retail.dll`, which holds nearly all the
|
||||||
|
FUT logic, is unpacked but is loaded at a different address on every launch.
|
||||||
|
`futmem` answers both problems: it finds the process, tells you where everything
|
||||||
|
is loaded, and lets you search and dump it without touching a byte.
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo build --release
|
||||||
|
./target/release/futmem maps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Read only by construction
|
||||||
|
|
||||||
|
A live game session may be running while this tool is used, and corrupting it
|
||||||
|
costs the user their session. The read-only property is therefore structural
|
||||||
|
rather than a matter of discipline:
|
||||||
|
|
||||||
|
* `/proc/<pid>/mem` is opened with `File::open`, i.e. `O_RDONLY`. The identifier
|
||||||
|
`OpenOptions` does not appear anywhere in this crate.
|
||||||
|
* `ProcMem` exposes `&self` read methods only. It hands out no `&mut File` and no
|
||||||
|
raw file descriptor, so no caller outside `mem.rs` can upgrade the handle.
|
||||||
|
* Nothing here calls `ptrace`, sends a signal, or stops the target.
|
||||||
|
|
||||||
|
There is no code path in this crate that can write to another process. Even if
|
||||||
|
one were added by mistake, the kernel would reject the write on an `O_RDONLY`
|
||||||
|
descriptor. Keep it that way.
|
||||||
|
|
||||||
|
## Subcommands
|
||||||
|
|
||||||
|
```
|
||||||
|
futmem maps [--pid N]
|
||||||
|
futmem find <pattern> [--pid N] [--ascii|--utf16|--hex] [--module NAME] [--max N]
|
||||||
|
futmem strings [--pid N] [--min 6] [--range START-END] [--module NAME] [--utf16]
|
||||||
|
[--grep SUBSTR] [--max N]
|
||||||
|
futmem read <va> <len> [--pid N]
|
||||||
|
```
|
||||||
|
|
||||||
|
With no `--pid`, the target is resolved by scanning `/proc/*/comm` for exactly
|
||||||
|
`FIFA17.exe`. This matters: several processes in the Proton/umu tree carry
|
||||||
|
"fifa17" in their command line, including a convincing
|
||||||
|
`umu.exe /mnt/games/FIFA 17/_fifa17.exe` decoy, so a `pgrep -f` match is not good
|
||||||
|
enough. Only `comm` is authoritative.
|
||||||
|
|
||||||
|
Addresses may be written `0x140000000` or `140000000`; bare values are read as
|
||||||
|
hex, which is how this project writes them. Lengths accept `0x100`, `256`, `16k`,
|
||||||
|
`2m`.
|
||||||
|
|
||||||
|
## What `maps` gives you that `cat /proc/pid/maps` does not
|
||||||
|
|
||||||
|
### The relocation slide, computed for you
|
||||||
|
|
||||||
|
Every address in the project's Ghidra database is based at `0x180000000`. The
|
||||||
|
live module is somewhere else. `maps` prints the conversion directly:
|
||||||
|
|
||||||
|
```
|
||||||
|
CardsDLL_Win64_retail.dll PRESENT base 0x6ffffc140000 size 0x31d000 static 0x180000000 slide +0x6ffe7c140000
|
||||||
|
|
||||||
|
CardsDLL address conversion: live_va = static_va + 0x6ffe7c140000
|
||||||
|
```
|
||||||
|
|
||||||
|
It derives this by reading `ImageBase` from the *on-disk* PE (where the module
|
||||||
|
wanted to load) and subtracting it from the live load address. The live header
|
||||||
|
cannot be used for this, because Wine rewrites its `ImageBase` field to the
|
||||||
|
actual load address.
|
||||||
|
|
||||||
|
**Module bases move on every launch.** Never cache the slide across a restart.
|
||||||
|
|
||||||
|
### The Wine mapping gotcha, made visible
|
||||||
|
|
||||||
|
Wine keeps only a PE's 4 KiB header file-backed and copies every section into
|
||||||
|
anonymous memory. So this returns exactly one line:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ grep CardsDLL /proc/4048/maps
|
||||||
|
6ffffc140000-6ffffc141000 r--p 00000000 00:37 2941670 /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll
|
||||||
|
```
|
||||||
|
|
||||||
|
It is easy to misread that as "the module is barely mapped". A module table built
|
||||||
|
naively from path grouping reports CardsDLL as a 4 KiB module; it is really
|
||||||
|
`0x31d000` bytes. `futmem` reads `SizeOfImage` from the live PE header instead
|
||||||
|
and flags the discrepancy:
|
||||||
|
|
||||||
|
```
|
||||||
|
6ffffc140000 6ffffc45d000 3.11 MiB 1 CardsDLL_Win64_retail.dll [maps shows only 4.00 KiB; sections are anonymous]
|
||||||
|
```
|
||||||
|
|
||||||
|
This also drives address attribution. A hit inside CardsDLL's `.rdata` lands in
|
||||||
|
an anonymous region as far as the maps are concerned, so `find` checks module
|
||||||
|
image spans *before* the region list and reports
|
||||||
|
`CardsDLL_Win64_retail.dll+0x22c618` rather than `anon`.
|
||||||
|
|
||||||
|
Only genuine PE images claim a range. `/dev/nvidia0` is mapped at many scattered
|
||||||
|
addresses, and letting its min..max span count as an "image" mis-attributed
|
||||||
|
gigabytes of unrelated anonymous memory to it. Non-PE mappings own only their
|
||||||
|
exact regions.
|
||||||
|
|
||||||
|
### Honest degradation
|
||||||
|
|
||||||
|
If the game has not loaded FUT yet, the difference is visible at a glance rather
|
||||||
|
than showing as an empty table:
|
||||||
|
|
||||||
|
```
|
||||||
|
KEY MODULES
|
||||||
|
FIFA17.exe PRESENT base 0x140000000 ...
|
||||||
|
CardsDLL_Win64_retail.dll ABSENT not in this process's maps (the game has not loaded it yet)
|
||||||
|
```
|
||||||
|
|
||||||
|
An explicit `--pid` that does not point at the game is called out too, so a
|
||||||
|
wrong-target mistake cannot pass unnoticed:
|
||||||
|
|
||||||
|
```
|
||||||
|
pid 26072 (comm "bash"), 39 mapped regions <-- NOT FIFA17.exe; this is not the game process
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
### pread, not seek + read
|
||||||
|
|
||||||
|
`FileExt::read_at` is `pread(2)`: the offset is an argument rather than a mutable
|
||||||
|
cursor on the file. A `&ProcMem` can therefore be shared across threads later
|
||||||
|
without a mutex and without one thread's seek corrupting another's read, and a
|
||||||
|
whole class of "forgot to seek" bugs disappears.
|
||||||
|
|
||||||
|
### Partial sweeps are normal, and are reported
|
||||||
|
|
||||||
|
Many regions marked readable in `/proc/<pid>/maps` are not actually readable:
|
||||||
|
guard pages, Wine's special mappings, and pages Denuvo has not faulted in all
|
||||||
|
return `EIO`. A failed read is skipped and counted, never fatal, and every sweep
|
||||||
|
prints its counts:
|
||||||
|
|
||||||
|
```
|
||||||
|
1 hits; scanned 3552 regions (3.73 GiB), skipped 0 unreadable regions, 3 holes stepped over
|
||||||
|
```
|
||||||
|
|
||||||
|
That line is there so a zero-hit result is never mistaken for proof of absence.
|
||||||
|
When `find` returns nothing it says so explicitly.
|
||||||
|
|
||||||
|
### Chunked reads and the `pattern_len - 1` overlap
|
||||||
|
|
||||||
|
The target has roughly 3 GB resident, so regions are walked in 4 MiB chunks. The
|
||||||
|
classic bug in hand-rolled scanners is that a pattern straddling a chunk boundary
|
||||||
|
is never found: the tail of chunk N holds its first bytes and the head of chunk
|
||||||
|
N+1 holds the rest, and neither buffer contains the whole thing.
|
||||||
|
|
||||||
|
Consecutive chunks therefore overlap by exactly `pattern_len - 1` bytes. That
|
||||||
|
number is neither too small nor too large. Let a chunk cover `[0, n)` and the
|
||||||
|
pattern have length `P`. A match starting at index `s` occupies `s ..= s + P - 1`,
|
||||||
|
so the last match wholly inside the chunk starts at `s = n - P`. Advancing by
|
||||||
|
`n - (P - 1)` starts the next chunk at `n - P + 1`, so:
|
||||||
|
|
||||||
|
* nothing is missed: every straddling match starts at `s >= n - P + 1`, inside
|
||||||
|
the next chunk;
|
||||||
|
* nothing is double-reported: the overlap begins at `n - P + 1`, strictly past
|
||||||
|
`n - P`, the last index that can host a complete match in this chunk. The
|
||||||
|
windows of reportable match *starts* are disjoint even though the byte windows
|
||||||
|
overlap.
|
||||||
|
|
||||||
|
Overlapping by `P` would report every boundary-straddling match twice;
|
||||||
|
overlapping by `P - 2` would miss one alignment.
|
||||||
|
|
||||||
|
This is verified against the live process rather than merely asserted. Region
|
||||||
|
`0x144ed3000` is swept in 4 MiB chunks, so its first boundary falls at
|
||||||
|
`0x1452d3000`. A 16-byte pattern placed 8 bytes before it straddles the boundary,
|
||||||
|
and is found exactly once:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ futmem read 0x1452d2ff8 16
|
||||||
|
0001452d2ff8 a9 48 01 90 90 90 90 90 90 99 51 48 8d 0d 0c 74 |.H........QH...t|
|
||||||
|
|
||||||
|
$ futmem find --hex a948019090909090909951488d0d0c74 --module fifa17
|
||||||
|
0x0001452d2ff8 FIFA17.exe+0x52d2ff8
|
||||||
|
1 hits
|
||||||
|
```
|
||||||
|
|
||||||
|
One hit, not zero and not two.
|
||||||
|
|
||||||
|
String extraction uses a different mechanism for the same reason: it sweeps with
|
||||||
|
zero overlap and carries an unfinished run across contiguous chunks, so a string
|
||||||
|
spanning a boundary is still emitted whole. UTF-16 additionally carries a
|
||||||
|
dangling low byte when a chunk ends mid-pair.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
`memchr` is the only dependency. Its `memmem` uses SIMD and runs roughly an order
|
||||||
|
of magnitude faster than `windows(n).position(...)` over multiple gigabytes,
|
||||||
|
which is the difference between a several-minute sweep and a few seconds.
|
||||||
|
Everything else (argument parsing for four subcommands, maps parsing, PE header
|
||||||
|
parsing, hex dumping) is a few dozen lines of `std` and does not justify pulling
|
||||||
|
in `clap`.
|
||||||
|
|
||||||
|
### Standalone workspace
|
||||||
|
|
||||||
|
`Cargo.toml` carries an empty `[workspace]` table. Without it, cargo walks up the
|
||||||
|
directory tree, finds the untracked workspace manifest at the repo root, sees that
|
||||||
|
`futmem` is not in its `members` list, and refuses to build. Opting out from this
|
||||||
|
side avoids editing that manifest.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
Measured against pid 4048 with the game sitting at the main menu, release build,
|
||||||
|
best and worst of three runs each. These are wall clock, and they are dominated
|
||||||
|
by the `pread` syscalls rather than by the search itself.
|
||||||
|
|
||||||
|
| Sweep | Scope | Wall clock |
|
||||||
|
|---|---|---|
|
||||||
|
| `strings --min 8 --grep pack` | 3.20 GiB, all anon private | 6.3 to 6.8 s |
|
||||||
|
| `find --ascii` (global) | 3.73 GiB, all readable | 5.3 to 7.0 s |
|
||||||
|
| `find --ascii --module cardsdll` | 3.11 MiB | 0.05 s |
|
||||||
|
| `maps` | n/a | 0.05 s |
|
||||||
|
|
||||||
|
Scoping with `--module` is over a hundred times cheaper and should be the default
|
||||||
|
habit when the target is known to live in CardsDLL. A global sweep costs about
|
||||||
|
six seconds, which is cheap enough to use freely but not in a tight loop.
|
||||||
|
|
||||||
|
## Worked example
|
||||||
|
|
||||||
|
```
|
||||||
|
$ futmem find --ascii 'RS4:FutSquadSave' --module cardsdll
|
||||||
|
scanning CardsDLL_Win64_retail.dll image span 0x6ffffc140000-0x6ffffc45d000 (3.11 MiB)
|
||||||
|
from /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll
|
||||||
|
pattern 16 bytes, 7 candidate regions (3.11 MiB)
|
||||||
|
|
||||||
|
0x6ffffc36c618 CardsDLL_Win64_retail.dll+0x22c618
|
||||||
|
6ffffc36c618 52 53 34 3a 46 75 74 53 71 75 61 64 53 61 76 65 |RS4:FutSquadSave|
|
||||||
|
6ffffc36c628 53 65 72 76 65 72 52 65 73 70 6f 6e 73 65 00 00 |ServerResponse..|
|
||||||
|
6ffffc36c638 5b 00 00 00 2c 25 64 00 5d 00 00 00 00 00 00 00 |[...,%d.].......|
|
||||||
|
6ffffc36c648 63 61 70 74 61 69 6e 00 22 05 93 19 01 00 00 00 |captain.".......|
|
||||||
|
|
||||||
|
1 hits; scanned 7 regions (3.11 MiB), skipped 0 unreadable regions, 0 holes stepped over
|
||||||
|
```
|
||||||
|
|
||||||
|
The `+0x22c618` offset converts straight back to the Ghidra address
|
||||||
|
`0x18022c618`. Note that the literal is `RS4:FutSquadSaveServerResponse`, not
|
||||||
|
`RS4:FutSquadSave` with a trailing NUL; read such patterns from the PE rather
|
||||||
|
than assuming them.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This tool is client-side instrumentation. It establishes nothing about the UTAS
|
||||||
|
wire protocol and nothing a server emulator must reimplement. Its value is as the
|
||||||
|
addressing base that lets other work read server-authoritative logic out of
|
||||||
|
CardsDLL. Do not let addresses produced by this tool leak into a protocol
|
||||||
|
document as if they were protocol.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! A deliberately tiny argument parser.
|
||||||
|
//!
|
||||||
|
//! Four subcommands do not justify a `clap` dependency and its build time. The
|
||||||
|
//! only subtlety is that some long options take a value (`--pid 165925`) and
|
||||||
|
//! some are bare booleans (`--utf16`). A parser cannot tell those apart from
|
||||||
|
//! the token stream alone, so each subcommand declares which of its options
|
||||||
|
//! take a value and we look the name up in that list.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct Args {
|
||||||
|
opts: HashMap<String, Option<String>>,
|
||||||
|
pub positional: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ArgError(pub String);
|
||||||
|
|
||||||
|
impl std::fmt::Display for ArgError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Args {
|
||||||
|
/// `value_flags` lists the long option names that consume the following
|
||||||
|
/// token as their value. Everything else beginning with `--` is a boolean.
|
||||||
|
/// `--name=value` is always accepted regardless of the list.
|
||||||
|
pub fn parse<I: Iterator<Item = String>>(
|
||||||
|
argv: I,
|
||||||
|
value_flags: &[&str],
|
||||||
|
) -> Result<Args, ArgError> {
|
||||||
|
let mut opts: HashMap<String, Option<String>> = HashMap::new();
|
||||||
|
let mut positional = Vec::new();
|
||||||
|
let mut it = argv.peekable();
|
||||||
|
|
||||||
|
while let Some(tok) = it.next() {
|
||||||
|
if let Some(rest) = tok.strip_prefix("--") {
|
||||||
|
if rest.is_empty() {
|
||||||
|
// A bare `--` ends option parsing; the rest is positional.
|
||||||
|
positional.extend(it.by_ref());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some((name, value)) = rest.split_once('=') {
|
||||||
|
opts.insert(name.to_string(), Some(value.to_string()));
|
||||||
|
} else if value_flags.contains(&rest) {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| ArgError(format!("--{rest} needs a value")))?;
|
||||||
|
opts.insert(rest.to_string(), Some(value));
|
||||||
|
} else {
|
||||||
|
opts.insert(rest.to_string(), None);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
positional.push(tok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Args { opts, positional })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has(&self, name: &str) -> bool {
|
||||||
|
self.opts.contains_key(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self, name: &str) -> Option<&str> {
|
||||||
|
self.opts.get(name).and_then(|v| v.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_value<T: std::str::FromStr>(&self, name: &str) -> Result<Option<T>, ArgError> {
|
||||||
|
match self.value(name) {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(raw) => raw
|
||||||
|
.parse::<T>()
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|_| ArgError(format!("could not parse --{name} value {raw:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject typos instead of silently ignoring them. `futmem find --acii foo`
|
||||||
|
/// should not quietly scan for nothing.
|
||||||
|
pub fn reject_unknown(&self, known: &[&str]) -> Result<(), ArgError> {
|
||||||
|
for name in self.opts.keys() {
|
||||||
|
if !known.contains(&name.as_str()) {
|
||||||
|
return Err(ArgError(format!("unknown option --{name}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `0x1234`, `1234` (hex assumed when the `0x` prefix is present,
|
||||||
|
/// decimal otherwise) into a virtual address.
|
||||||
|
pub fn parse_addr(raw: &str) -> Result<u64, ArgError> {
|
||||||
|
let cleaned = raw.replace('_', "");
|
||||||
|
let parsed = match cleaned
|
||||||
|
.strip_prefix("0x")
|
||||||
|
.or_else(|| cleaned.strip_prefix("0X"))
|
||||||
|
{
|
||||||
|
Some(hex) => u64::from_str_radix(hex, 16),
|
||||||
|
// Bare addresses in this project are always written in hex
|
||||||
|
// (`6ffffc140000`), so try hex first and fall back to decimal only for
|
||||||
|
// values that are unambiguous.
|
||||||
|
None => u64::from_str_radix(&cleaned, 16).or_else(|_| cleaned.parse::<u64>()),
|
||||||
|
};
|
||||||
|
parsed.map_err(|_| ArgError(format!("bad address {raw:?}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a length: `4096`, `0x1000`, `16k`, `2m`.
|
||||||
|
pub fn parse_len(raw: &str) -> Result<u64, ArgError> {
|
||||||
|
let lower = raw.to_ascii_lowercase();
|
||||||
|
let (body, mult) = match lower.strip_suffix('k') {
|
||||||
|
Some(b) => (b, 1024u64),
|
||||||
|
None => match lower.strip_suffix('m') {
|
||||||
|
Some(b) => (b, 1024 * 1024),
|
||||||
|
None => (lower.as_str(), 1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let n = match body.strip_prefix("0x") {
|
||||||
|
Some(hex) => u64::from_str_radix(hex, 16),
|
||||||
|
None => body.parse::<u64>(),
|
||||||
|
}
|
||||||
|
.map_err(|_| ArgError(format!("bad length {raw:?}")))?;
|
||||||
|
Ok(n * mult)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//! Hex + ASCII rendering, shared by `read` and by `find`'s context blocks.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
fn ascii_gutter(row: &[u8]) -> String {
|
||||||
|
row.iter()
|
||||||
|
.map(|&b| {
|
||||||
|
if (0x20..=0x7e).contains(&b) {
|
||||||
|
b as char
|
||||||
|
} else {
|
||||||
|
'.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classic 16-bytes-per-line dump with absolute addresses in the left column.
|
||||||
|
pub fn hexdump(out: &mut impl Write, base: u64, data: &[u8], indent: &str) -> io::Result<()> {
|
||||||
|
for (i, row) in data.chunks(16).enumerate() {
|
||||||
|
let addr = base + (i * 16) as u64;
|
||||||
|
let mut hex = String::with_capacity(50);
|
||||||
|
for (j, b) in row.iter().enumerate() {
|
||||||
|
if j == 8 {
|
||||||
|
hex.push(' ');
|
||||||
|
}
|
||||||
|
// Writing into a String is infallible.
|
||||||
|
let _ = write!(hex, "{b:02x} ");
|
||||||
|
}
|
||||||
|
writeln!(out, "{indent}{addr:012x} {hex:<50}|{}|", ascii_gutter(row))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//! Turning `/proc/<pid>/maps` lines into a usable module table, and turning an
|
||||||
|
//! address back into `module+offset`.
|
||||||
|
//!
|
||||||
|
//! # The Wine mapping gotcha this module exists to work around
|
||||||
|
//!
|
||||||
|
//! Under Wine, only a PE's 4 KiB header stays file-backed. Wine copies every
|
||||||
|
//! section into ANONYMOUS memory. So `grep CardsDLL /proc/<pid>/maps` returns
|
||||||
|
//! exactly one line, 4 KiB long, and a module table built naively from path
|
||||||
|
//! grouping will report CardsDLL as a 4 KiB module. It is really 0x31d000 bytes.
|
||||||
|
//! An agent who trusts the maps extent concludes the module is "barely mapped"
|
||||||
|
//! and gives up, or computes a wrong module size and mis-attributes every hit.
|
||||||
|
//!
|
||||||
|
//! The fix: read `SizeOfImage` out of the live PE header at the module base.
|
||||||
|
//! That field is authoritative for the module's real extent, and the header is
|
||||||
|
//! the one part of the image that is reliably readable.
|
||||||
|
//!
|
||||||
|
//! # Deriving the slide automatically
|
||||||
|
//!
|
||||||
|
//! Wine rewrites the `ImageBase` field of the *live* header to the actual load
|
||||||
|
//! address, so the live header cannot tell us where the module wanted to load.
|
||||||
|
//! The on-disk file still can, and the maps line gives us its path. Reading the
|
||||||
|
//! on-disk `ImageBase` and subtracting gives the relocation slide:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! slide = live_base - disk_image_base
|
||||||
|
//! live_va = static_va + slide
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! For CardsDLL that is `0x6ffffc140000 - 0x180000000 = 0x6ffe7c140000`, the
|
||||||
|
//! number every Ghidra-derived address in this project has to be adjusted by.
|
||||||
|
//! Printing it removes the most error-prone manual step in the workflow.
|
||||||
|
|
||||||
|
use crate::maps::Region;
|
||||||
|
use crate::mem::ProcMem;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Module {
|
||||||
|
/// Bare file name, e.g. `CardsDLL_Win64_retail.dll`.
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
/// Lowest mapped address carrying this path. For a PE this is the header.
|
||||||
|
pub base: u64,
|
||||||
|
/// Highest address still carrying this path in the maps. Badly understates
|
||||||
|
/// the truth under Wine; see the module docs.
|
||||||
|
pub maps_end: u64,
|
||||||
|
/// Number of separate maps lines mentioning this path.
|
||||||
|
pub region_count: usize,
|
||||||
|
/// `SizeOfImage` from the live PE header, the real extent.
|
||||||
|
pub size_of_image: Option<u64>,
|
||||||
|
/// `ImageBase` from the on-disk file: where the module was linked to load.
|
||||||
|
pub disk_image_base: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module {
|
||||||
|
/// Best available end address: PE-derived when we have it, maps otherwise.
|
||||||
|
pub fn end(&self) -> u64 {
|
||||||
|
match self.size_of_image {
|
||||||
|
Some(size) => self.base + size,
|
||||||
|
None => self.maps_end,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The relocation slide: add this to a static (Ghidra) VA to get a live VA.
|
||||||
|
pub fn slide(&self) -> Option<i128> {
|
||||||
|
self.disk_image_base
|
||||||
|
.map(|disk| self.base as i128 - disk as i128)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is this actually a PE image, as opposed to a device node, font or `.nls`
|
||||||
|
/// data file that merely happens to be mapped?
|
||||||
|
pub fn is_pe(&self) -> bool {
|
||||||
|
self.size_of_image.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only PE images claim an address range.
|
||||||
|
///
|
||||||
|
/// Without the `is_pe` guard this mis-attributes badly. `/dev/nvidia0` is
|
||||||
|
/// mapped at many scattered addresses, so its min..max span covers gigabytes
|
||||||
|
/// of unrelated anonymous memory, and every hit in there would be reported
|
||||||
|
/// as `nvidia0+0x...`. A non-PE mapping only ever owns the exact regions
|
||||||
|
/// listed for it in the maps, which `describe` handles as a fallback.
|
||||||
|
pub fn contains(&self, va: u64) -> bool {
|
||||||
|
self.is_pe() && va >= self.base && va < self.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Little-endian scalar helpers. Returning `Option` keeps a truncated or
|
||||||
|
/// malformed header from panicking the whole run.
|
||||||
|
fn u16_at(buf: &[u8], off: usize) -> Option<u16> {
|
||||||
|
buf.get(off..off + 2)
|
||||||
|
.map(|s| u16::from_le_bytes([s[0], s[1]]))
|
||||||
|
}
|
||||||
|
fn u32_at(buf: &[u8], off: usize) -> Option<u32> {
|
||||||
|
buf.get(off..off + 4)
|
||||||
|
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||||
|
}
|
||||||
|
fn u64_at(buf: &[u8], off: usize) -> Option<u64> {
|
||||||
|
buf.get(off..off + 8)
|
||||||
|
.map(|s| u64::from_le_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `SizeOfImage` and `ImageBase` from a PE header blob.
|
||||||
|
///
|
||||||
|
/// Layout: `e_lfanew` at 0x3c points at the `PE\0\0` signature; the 20-byte
|
||||||
|
/// COFF header follows; the optional header starts at signature+24. Within the
|
||||||
|
/// optional header `SizeOfImage` sits at 0x38 for both PE32 and PE32+ (the
|
||||||
|
/// layouts diverge only between 0x18 and 0x20). `ImageBase` is 8 bytes at 0x18
|
||||||
|
/// for PE32+ and 4 bytes at 0x1c for PE32.
|
||||||
|
fn parse_pe(buf: &[u8]) -> Option<(u64, u64)> {
|
||||||
|
if buf.get(0..2)? != b"MZ" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let nt = u32_at(buf, 0x3c)? as usize;
|
||||||
|
if buf.get(nt..nt + 4)? != b"PE\0\0" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let opt = nt + 24;
|
||||||
|
let magic = u16_at(buf, opt)?;
|
||||||
|
let size_of_image = u32_at(buf, opt + 0x38)? as u64;
|
||||||
|
let image_base = match magic {
|
||||||
|
0x20b => u64_at(buf, opt + 0x18)?, // PE32+
|
||||||
|
0x10b => u32_at(buf, opt + 0x1c)? as u64, // PE32
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((size_of_image, image_base))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pe_from_disk(path: &str) -> Option<(u64, u64)> {
|
||||||
|
// 4 KiB is more than enough for MZ + PE + optional header on any real image.
|
||||||
|
let data = fs::read(path).ok()?;
|
||||||
|
parse_pe(&data[..data.len().min(4096)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the module table. Modules are returned sorted by base address.
|
||||||
|
pub fn modules(regions: &[Region], mem: &ProcMem) -> Vec<Module> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
let mut by_path: HashMap<&str, (u64, u64, usize)> = HashMap::new();
|
||||||
|
|
||||||
|
for r in regions {
|
||||||
|
let Some(path) = r.path.as_deref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if r.pseudo() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry = by_path.entry(path).or_insert((u64::MAX, 0, 0));
|
||||||
|
entry.0 = entry.0.min(r.start);
|
||||||
|
entry.1 = entry.1.max(r.end);
|
||||||
|
entry.2 += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out: Vec<Module> = by_path
|
||||||
|
.into_iter()
|
||||||
|
.map(|(path, (base, maps_end, region_count))| {
|
||||||
|
// The live header gives the true extent; the on-disk header gives
|
||||||
|
// the link-time base, which is what the slide is measured against.
|
||||||
|
let live = mem.read_partial(base, 4096);
|
||||||
|
let live_pe = parse_pe(&live);
|
||||||
|
let disk_pe = pe_from_disk(path);
|
||||||
|
Module {
|
||||||
|
name: path.rsplit('/').next().unwrap_or(path).to_string(),
|
||||||
|
path: path.to_string(),
|
||||||
|
base,
|
||||||
|
maps_end,
|
||||||
|
region_count,
|
||||||
|
size_of_image: live_pe.map(|(s, _)| s).or(disk_pe.map(|(s, _)| s)),
|
||||||
|
disk_image_base: disk_pe.map(|(_, b)| b),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
out.sort_by_key(|m| m.base);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case-insensitive lookup by name substring, e.g. `cardsdll`.
|
||||||
|
pub fn find_module<'a>(mods: &'a [Module], needle: &str) -> Option<&'a Module> {
|
||||||
|
let needle = needle.to_ascii_lowercase();
|
||||||
|
mods.iter()
|
||||||
|
.find(|m| m.name.to_ascii_lowercase().contains(&needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Describe an address as `module+0xoff`, falling back to the region kind.
|
||||||
|
///
|
||||||
|
/// Checking module image spans BEFORE the region list is essential here: a hit
|
||||||
|
/// inside CardsDLL's `.rdata` lands in an anonymous region as far as the maps
|
||||||
|
/// are concerned, and would otherwise be reported as `anon`, throwing away the
|
||||||
|
/// single most useful piece of context.
|
||||||
|
pub fn describe(va: u64, mods: &[Module], regions: &[Region]) -> String {
|
||||||
|
if let Some(m) = mods.iter().find(|m| m.contains(va)) {
|
||||||
|
return format!("{}+{:#x}", m.name, va - m.base);
|
||||||
|
}
|
||||||
|
match regions.iter().find(|r| va >= r.start && va < r.end) {
|
||||||
|
Some(r) => match r.path.as_deref() {
|
||||||
|
Some(p) => format!("{}+{:#x}", p.rsplit('/').next().unwrap_or(p), va - r.start),
|
||||||
|
None => format!("anon:{:#x}({})", r.start, r.perms),
|
||||||
|
},
|
||||||
|
None => "unmapped".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
//! # futmem: a read-only live-memory inspector for FIFA 17
|
||||||
|
//!
|
||||||
|
//! Preservation and interoperability tooling for the OpenFUT project. FIFA 17's
|
||||||
|
//! `FIFA17.exe` is Denuvo-packed, so its `.text` and `.rdata` exist in plaintext
|
||||||
|
//! only inside the running process. Anything the packed executable owns can be
|
||||||
|
//! reached only through live memory. This tool is how you reach it.
|
||||||
|
//!
|
||||||
|
//! ## READ ONLY BY CONSTRUCTION
|
||||||
|
//!
|
||||||
|
//! A live game session may be running while this tool is used, and corrupting it
|
||||||
|
//! costs the user their session. The read-only property is therefore structural
|
||||||
|
//! rather than a matter of discipline:
|
||||||
|
//!
|
||||||
|
//! * `/proc/<pid>/mem` is opened with `File::open`, i.e. `O_RDONLY`. The string
|
||||||
|
//! `OpenOptions` does not appear anywhere in this crate.
|
||||||
|
//! * `ProcMem` exposes `&self` read methods only, hands out no `&mut File` and
|
||||||
|
//! no raw descriptor, so no caller can upgrade the handle to a writable one.
|
||||||
|
//! * Nothing here calls `ptrace`, sends a signal, or stops the target.
|
||||||
|
//!
|
||||||
|
//! There is no code path in this crate that can write to another process. Even
|
||||||
|
//! if one were added by mistake, the kernel would reject the write on an
|
||||||
|
//! `O_RDONLY` descriptor.
|
||||||
|
//!
|
||||||
|
//! ## Design notes
|
||||||
|
//!
|
||||||
|
//! * **pread, not seek+read.** `FileExt::read_at` takes the offset as an
|
||||||
|
//! argument instead of mutating a shared file cursor, so a `&ProcMem` can be
|
||||||
|
//! shared across threads later without a mutex, and a whole class of "forgot
|
||||||
|
//! to seek" bugs disappears. See `mem.rs`.
|
||||||
|
//! * **Partial sweeps are normal.** Many regions marked readable in
|
||||||
|
//! `/proc/<pid>/maps` are not actually readable: guard pages, Wine's special
|
||||||
|
//! mappings, and pages Denuvo has not faulted in all return `EIO`. A failed
|
||||||
|
//! read is skipped and counted, never fatal, and the counts are printed so a
|
||||||
|
//! zero-hit result is never mistaken for proof of absence. See `scan.rs`.
|
||||||
|
//! * **Chunked reads with a `pattern_len - 1` overlap.** The target has roughly
|
||||||
|
//! 3 GB resident, so regions are walked in 4 MiB chunks. Consecutive chunks
|
||||||
|
//! overlap by exactly `pattern_len - 1` bytes so a pattern straddling a
|
||||||
|
//! boundary is still found, and not double-reported. `scan.rs` carries the
|
||||||
|
//! proof that this specific overlap is the correct one; it is the classic
|
||||||
|
//! off-by-one in scanners of this kind.
|
||||||
|
//! * **Minimal dependencies.** `memchr` is the only one, and it earns its place
|
||||||
|
//! on a multi-gigabyte sweep. Four subcommands do not justify `clap`.
|
||||||
|
//!
|
||||||
|
//! ## The Wine mapping gotcha
|
||||||
|
//!
|
||||||
|
//! Wine keeps only a PE's 4 KiB header file-backed and copies the sections into
|
||||||
|
//! anonymous memory. `grep CardsDLL /proc/<pid>/maps` therefore returns exactly
|
||||||
|
//! one 4 KiB line. A module table built naively from the maps reports CardsDLL as
|
||||||
|
//! a 4 KiB module when it is really 0x31d000 bytes. `futmem maps` reads
|
||||||
|
//! `SizeOfImage` from the live PE header instead, and derives the relocation
|
||||||
|
//! slide by comparing the live load address against the on-disk `ImageBase`, so
|
||||||
|
//! the number needed to convert Ghidra addresses to live ones is printed rather
|
||||||
|
//! than recomputed by hand.
|
||||||
|
|
||||||
|
mod cli;
|
||||||
|
mod dump;
|
||||||
|
mod image;
|
||||||
|
mod maps;
|
||||||
|
mod mem;
|
||||||
|
mod scan;
|
||||||
|
|
||||||
|
use cli::{parse_addr, parse_len, ArgError, Args};
|
||||||
|
use maps::{human, Region};
|
||||||
|
use mem::ProcMem;
|
||||||
|
use std::io::{self, BufWriter, Write};
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
const COMM: &str = "FIFA17.exe";
|
||||||
|
/// Modules this project always wants to know the status of.
|
||||||
|
const KEY_MODULES: [&str; 3] = [
|
||||||
|
"FIFA17.exe",
|
||||||
|
"CardsDLL_Win64_retail.dll",
|
||||||
|
"powdll_Win64_retail.dll",
|
||||||
|
];
|
||||||
|
|
||||||
|
const USAGE: &str = "\
|
||||||
|
futmem: read-only live-memory inspector for FIFA 17 (OpenFUT preservation tooling)
|
||||||
|
|
||||||
|
USAGE
|
||||||
|
futmem maps [--pid N]
|
||||||
|
futmem find <pattern> [--pid N] [--ascii|--utf16|--hex] [--module NAME] [--max N]
|
||||||
|
futmem strings [--pid N] [--min 6] [--range START-END] [--module NAME] [--utf16]
|
||||||
|
[--grep SUBSTR] [--max N]
|
||||||
|
futmem read <va> <len> [--pid N]
|
||||||
|
|
||||||
|
COMMON
|
||||||
|
--pid N Target pid. Omitted, futmem resolves the process whose
|
||||||
|
/proc/<pid>/comm is exactly \"FIFA17.exe\". Decoy processes in the
|
||||||
|
Proton tree match a pgrep -f on \"fifa17\", so comm is the authority.
|
||||||
|
|
||||||
|
find
|
||||||
|
--ascii Pattern is ASCII text. This is the default.
|
||||||
|
--utf16 Widen the ASCII pattern to UTF-16LE, how Windows stores most UI
|
||||||
|
strings.
|
||||||
|
--hex Pattern is a hex byte string, e.g. 4883ec284885c9. Spaces ignored.
|
||||||
|
--module NAME Restrict the scan to a module's image span, matched case
|
||||||
|
insensitively on a substring of the file name, e.g. --module cardsdll.
|
||||||
|
--max N Stop after N hits.
|
||||||
|
|
||||||
|
strings
|
||||||
|
--min N Minimum run length. Default 6.
|
||||||
|
--range A-B Scan exactly this address range, e.g. --range 0x1450f3000-0x14b1a3000.
|
||||||
|
--module NAME Scan a module's image span.
|
||||||
|
--utf16 Extract UTF-16LE strings instead of ASCII.
|
||||||
|
--grep S Only print strings containing S, matched case insensitively.
|
||||||
|
--max N Stop after N strings.
|
||||||
|
With none of --range or --module, the default scope is every anonymous private
|
||||||
|
region, which is where a packed executable's decrypted data lives.
|
||||||
|
|
||||||
|
Addresses may be written 0x140000000 or 140000000; bare values are read as hex.
|
||||||
|
Lengths accept 0x100, 256, 16k, 2m.
|
||||||
|
|
||||||
|
All operations are strictly read-only. See the crate docs for the guarantee.
|
||||||
|
";
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let Some(sub) = argv.first().cloned() else {
|
||||||
|
print!("{USAGE}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
};
|
||||||
|
let rest = argv.into_iter().skip(1);
|
||||||
|
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let mut out = BufWriter::new(stdout.lock());
|
||||||
|
|
||||||
|
let result = match sub.as_str() {
|
||||||
|
"maps" => cmd_maps(&mut out, rest),
|
||||||
|
"find" => cmd_find(&mut out, rest),
|
||||||
|
"strings" => cmd_strings(&mut out, rest),
|
||||||
|
"read" => cmd_read(&mut out, rest),
|
||||||
|
"-h" | "--help" | "help" => {
|
||||||
|
print!("{USAGE}");
|
||||||
|
return ExitCode::SUCCESS;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
eprintln!("futmem: unknown subcommand {other:?}\n");
|
||||||
|
eprint!("{USAGE}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flushing separately so a broken pipe (futmem strings | head) is not
|
||||||
|
// reported as a failure.
|
||||||
|
let flushed = out.flush();
|
||||||
|
match (result, flushed) {
|
||||||
|
(Err(e), _) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
|
||||||
|
(_, Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
|
||||||
|
(Err(e), _) => {
|
||||||
|
eprintln!("futmem: {e}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
(Ok(()), Err(e)) => {
|
||||||
|
eprintln!("futmem: {e}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
(Ok(()), Ok(())) => ExitCode::SUCCESS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arg_err(e: ArgError) -> io::Error {
|
||||||
|
new_invalid(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the target pid from `--pid` or by scanning `/proc/*/comm`.
|
||||||
|
fn resolve_pid(args: &Args) -> io::Result<i32> {
|
||||||
|
match args.parse_value::<i32>("pid").map_err(arg_err)? {
|
||||||
|
Some(pid) => Ok(pid),
|
||||||
|
None => maps::find_pid(COMM),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- maps
|
||||||
|
|
||||||
|
fn cmd_maps<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
|
||||||
|
let args = Args::parse(argv, &["pid"]).map_err(arg_err)?;
|
||||||
|
args.reject_unknown(&["pid"]).map_err(arg_err)?;
|
||||||
|
let pid = resolve_pid(&args)?;
|
||||||
|
let regions = maps::read_maps(pid)?;
|
||||||
|
let mem = ProcMem::open(pid)?;
|
||||||
|
let mods = image::modules(®ions, &mem);
|
||||||
|
|
||||||
|
// Report the comm we actually found, not the one we hoped for: an explicit
|
||||||
|
// --pid may point anywhere, and silently labelling it "FIFA17.exe" would
|
||||||
|
// make a wrong-target mistake invisible.
|
||||||
|
let comm = maps::read_comm(pid);
|
||||||
|
let warn = if comm == COMM {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" <-- NOT {COMM}; this is not the game process")
|
||||||
|
};
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"pid {pid} (comm {comm:?}), {} mapped regions{warn}",
|
||||||
|
regions.len()
|
||||||
|
)?;
|
||||||
|
writeln!(out)?;
|
||||||
|
|
||||||
|
// -- key modules first, so "is FUT loaded yet?" is answerable at a glance.
|
||||||
|
writeln!(out, "KEY MODULES")?;
|
||||||
|
for want in KEY_MODULES {
|
||||||
|
match image::find_module(&mods, want) {
|
||||||
|
Some(m) => {
|
||||||
|
let slide = match m.slide() {
|
||||||
|
Some(s) if s >= 0 => format!("slide +{:#x}", s),
|
||||||
|
Some(s) => format!("slide -{:#x}", -s),
|
||||||
|
None => "slide unknown".to_string(),
|
||||||
|
};
|
||||||
|
let static_base = m
|
||||||
|
.disk_image_base
|
||||||
|
.map(|b| format!("static {b:#x}"))
|
||||||
|
.unwrap_or_else(|| "static ?".to_string());
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {:<28} PRESENT base {:#x} size {:#x} {static_base} {slide}",
|
||||||
|
m.name,
|
||||||
|
m.base,
|
||||||
|
m.size_of_image.unwrap_or(m.maps_end - m.base),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
None => writeln!(
|
||||||
|
out,
|
||||||
|
" {want:<28} ABSENT not in this process's maps (the game has not loaded it yet)"
|
||||||
|
)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(m) = image::find_module(&mods, "CardsDLL") {
|
||||||
|
if let Some(slide) = m.slide() {
|
||||||
|
writeln!(out)?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" CardsDLL address conversion: live_va = static_va + {slide:#x}"
|
||||||
|
)?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" (Ghidra static base {:#x} -> live base {:#x}. Valid for pid {pid} only; \
|
||||||
|
module bases move on every launch.)",
|
||||||
|
m.disk_image_base.unwrap_or(0),
|
||||||
|
m.base
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeln!(out)?;
|
||||||
|
|
||||||
|
// -- full module table
|
||||||
|
writeln!(out, "MODULES (file-backed, grouped by path)")?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {:<14} {:<14} {:<12} {:>5} name",
|
||||||
|
"base", "end (PE)", "size", "regs"
|
||||||
|
)?;
|
||||||
|
for m in &mods {
|
||||||
|
let note = if !m.is_pe() {
|
||||||
|
// A device node, .nls table or font, not a loadable image. Its
|
||||||
|
// min..max span is meaningless, so say so rather than imply an extent.
|
||||||
|
" [non-PE mapping; span is min..max of scattered regions]".to_string()
|
||||||
|
} else if m.maps_end - m.base < m.end() - m.base {
|
||||||
|
// The Wine gotcha, made visible instead of silently misleading.
|
||||||
|
format!(
|
||||||
|
" [maps shows only {}; sections are anonymous]",
|
||||||
|
human(m.maps_end - m.base)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {:<14x} {:<14x} {:<12} {:>5} {}{}",
|
||||||
|
m.base,
|
||||||
|
m.end(),
|
||||||
|
human(m.end() - m.base),
|
||||||
|
m.region_count,
|
||||||
|
m.name,
|
||||||
|
note
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
writeln!(out)?;
|
||||||
|
|
||||||
|
// -- writable + executable regions: where packers put decrypted code.
|
||||||
|
let wx: Vec<&Region> = regions
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.writable() && r.executable())
|
||||||
|
.collect();
|
||||||
|
let wx_total: u64 = wx.iter().map(|r| r.size()).sum();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"WRITABLE + EXECUTABLE REGIONS ({} regions, {})",
|
||||||
|
wx.len(),
|
||||||
|
human(wx_total)
|
||||||
|
)?;
|
||||||
|
// Wine emits hundreds of 4 KiB per-thread stubs that are pure noise.
|
||||||
|
let mut small_wx = 0usize;
|
||||||
|
for r in &wx {
|
||||||
|
if r.size() <= 64 * 1024 {
|
||||||
|
small_wx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {:012x}-{:012x} {} {:>10} {}",
|
||||||
|
r.start,
|
||||||
|
r.end,
|
||||||
|
r.perms,
|
||||||
|
human(r.size()),
|
||||||
|
describe_region(r, &mods)
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
if small_wx > 0 {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" (+{small_wx} regions of 64 KiB or less, Wine per-thread stubs, omitted)"
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
writeln!(out)?;
|
||||||
|
|
||||||
|
// -- large anonymous private regions
|
||||||
|
let mut anon: Vec<&Region> = regions
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.anonymous() && r.private() && r.readable() && r.size() > 1024 * 1024)
|
||||||
|
.collect();
|
||||||
|
anon.sort_by_key(|r| std::cmp::Reverse(r.size()));
|
||||||
|
let anon_total: u64 = anon.iter().map(|r| r.size()).sum();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"ANONYMOUS PRIVATE REGIONS OVER 1 MB ({} regions, {})",
|
||||||
|
anon.len(),
|
||||||
|
human(anon_total)
|
||||||
|
)?;
|
||||||
|
for r in &anon {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" {:012x}-{:012x} {} {:>10} {}",
|
||||||
|
r.start,
|
||||||
|
r.end,
|
||||||
|
r.perms,
|
||||||
|
human(r.size()),
|
||||||
|
describe_region(r, &mods)
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Label a region with the module whose image span contains it, if any.
|
||||||
|
fn describe_region(r: &Region, mods: &[image::Module]) -> String {
|
||||||
|
if let Some(p) = r.path.as_deref() {
|
||||||
|
// The file offset matters for a packed executable: it says which part of
|
||||||
|
// the on-disk image this mapping still corresponds to.
|
||||||
|
let name = p.rsplit('/').next().unwrap_or(p);
|
||||||
|
return format!("{name} @fileoff {:#x}", r.offset);
|
||||||
|
}
|
||||||
|
match mods.iter().find(|m| m.contains(r.start)) {
|
||||||
|
Some(m) => format!("anon, inside {} image", m.name),
|
||||||
|
None => "anon".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- find
|
||||||
|
|
||||||
|
fn cmd_find<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
|
||||||
|
let known = ["pid", "ascii", "utf16", "hex", "module", "max"];
|
||||||
|
let args = Args::parse(argv, &["pid", "module", "max"]).map_err(arg_err)?;
|
||||||
|
args.reject_unknown(&known).map_err(arg_err)?;
|
||||||
|
|
||||||
|
let Some(raw) = args.positional.first() else {
|
||||||
|
return Err(new_invalid(ArgError("find needs a pattern".into())));
|
||||||
|
};
|
||||||
|
|
||||||
|
let pattern: Vec<u8> = if args.has("hex") {
|
||||||
|
parse_hex(raw).map_err(arg_err)?
|
||||||
|
} else if args.has("utf16") {
|
||||||
|
// Widen ASCII to UTF-16LE: each byte followed by a zero high byte.
|
||||||
|
raw.bytes().flat_map(|b| [b, 0]).collect()
|
||||||
|
} else {
|
||||||
|
raw.as_bytes().to_vec()
|
||||||
|
};
|
||||||
|
let max = args.parse_value::<usize>("max").map_err(arg_err)?;
|
||||||
|
|
||||||
|
let pid = resolve_pid(&args)?;
|
||||||
|
let regions = maps::read_maps(pid)?;
|
||||||
|
let mem = ProcMem::open(pid)?;
|
||||||
|
let mods = image::modules(®ions, &mem);
|
||||||
|
|
||||||
|
let module = match args.value("module") {
|
||||||
|
Some(name) => match image::find_module(&mods, name) {
|
||||||
|
Some(m) => Some(m.clone()),
|
||||||
|
None => {
|
||||||
|
return Err(new_invalid(ArgError(format!(
|
||||||
|
"no module matching {name:?} in pid {pid}; run `futmem maps` to list them"
|
||||||
|
))))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(m) = &module {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"scanning {} image span {:#x}-{:#x} ({})\n from {}",
|
||||||
|
m.name,
|
||||||
|
m.base,
|
||||||
|
m.end(),
|
||||||
|
human(m.end() - m.base),
|
||||||
|
m.path
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targets = scan::scan_targets(®ions, module.as_ref(), false);
|
||||||
|
let target_bytes: u64 = targets.iter().map(|r| r.size()).sum();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"pattern {} bytes, {} candidate regions ({})",
|
||||||
|
pattern.len(),
|
||||||
|
targets.len(),
|
||||||
|
human(target_bytes)
|
||||||
|
)?;
|
||||||
|
writeln!(out)?;
|
||||||
|
|
||||||
|
let mut hits: Vec<u64> = Vec::new();
|
||||||
|
let stats = scan::find_pattern(&mem, &targets, &pattern, max, |va| hits.push(va));
|
||||||
|
|
||||||
|
for va in &hits {
|
||||||
|
let loc = image::describe(*va, &mods, ®ions);
|
||||||
|
writeln!(out, "{va:#014x} {loc}")?;
|
||||||
|
let ctx = mem.read_partial(*va, 64);
|
||||||
|
if !ctx.is_empty() {
|
||||||
|
dump::hexdump(out, *va, &ctx, " ")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeln!(out)?;
|
||||||
|
writeln!(out, "{} hits; {}", hits.len(), stats.summary())?;
|
||||||
|
if hits.is_empty() {
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"note: {} regions were unreadable, so an empty result is NOT proof of absence.",
|
||||||
|
stats.regions_skipped
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_hex(raw: &str) -> Result<Vec<u8>, ArgError> {
|
||||||
|
let cleaned: String = raw
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_whitespace() && *c != ':' && *c != ',')
|
||||||
|
.collect();
|
||||||
|
let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned);
|
||||||
|
if !cleaned.len().is_multiple_of(2) {
|
||||||
|
return Err(ArgError(format!(
|
||||||
|
"hex pattern has an odd number of digits ({})",
|
||||||
|
cleaned.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
(0..cleaned.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| {
|
||||||
|
u8::from_str_radix(&cleaned[i..i + 2], 16)
|
||||||
|
.map_err(|_| ArgError(format!("bad hex byte {:?}", &cleaned[i..i + 2])))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- strings
|
||||||
|
|
||||||
|
fn cmd_strings<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
|
||||||
|
let known = ["pid", "min", "range", "module", "utf16", "grep", "max"];
|
||||||
|
let args =
|
||||||
|
Args::parse(argv, &["pid", "min", "range", "module", "grep", "max"]).map_err(arg_err)?;
|
||||||
|
args.reject_unknown(&known).map_err(arg_err)?;
|
||||||
|
|
||||||
|
let min = args
|
||||||
|
.parse_value::<usize>("min")
|
||||||
|
.map_err(arg_err)?
|
||||||
|
.unwrap_or(6);
|
||||||
|
let max = args.parse_value::<usize>("max").map_err(arg_err)?;
|
||||||
|
let grep = args.value("grep");
|
||||||
|
let utf16 = args.has("utf16");
|
||||||
|
|
||||||
|
let pid = resolve_pid(&args)?;
|
||||||
|
let regions = maps::read_maps(pid)?;
|
||||||
|
let mem = ProcMem::open(pid)?;
|
||||||
|
let mods = image::modules(®ions, &mem);
|
||||||
|
|
||||||
|
let targets: Vec<Region> = if let Some(range) = args.value("range") {
|
||||||
|
let (a, b) = range
|
||||||
|
.split_once('-')
|
||||||
|
.ok_or_else(|| new_invalid(ArgError("--range wants START-END".into())))?;
|
||||||
|
let start = parse_addr(a).map_err(arg_err)?;
|
||||||
|
let end = parse_addr(b).map_err(arg_err)?;
|
||||||
|
if end <= start {
|
||||||
|
return Err(new_invalid(ArgError(format!(
|
||||||
|
"--range end {end:#x} is not above start {start:#x}"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
writeln!(out, "scanning {start:#x}-{end:#x} ({})", human(end - start))?;
|
||||||
|
vec![Region {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
perms: "r--p".to_string(),
|
||||||
|
offset: 0,
|
||||||
|
path: None,
|
||||||
|
}]
|
||||||
|
} else if let Some(name) = args.value("module") {
|
||||||
|
let m = image::find_module(&mods, name).ok_or_else(|| {
|
||||||
|
new_invalid(ArgError(format!(
|
||||||
|
"no module matching {name:?} in pid {pid}"
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"scanning {} image span {:#x}-{:#x} ({})\n from {}",
|
||||||
|
m.name,
|
||||||
|
m.base,
|
||||||
|
m.end(),
|
||||||
|
human(m.end() - m.base),
|
||||||
|
m.path
|
||||||
|
)?;
|
||||||
|
scan::scan_targets(®ions, Some(m), false)
|
||||||
|
} else {
|
||||||
|
// Default scope: anonymous private memory, where a packed executable's
|
||||||
|
// decrypted data lives.
|
||||||
|
let t = scan::scan_targets(®ions, None, true);
|
||||||
|
let bytes: u64 = t.iter().map(|r| r.size()).sum();
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
"scanning {} anonymous private regions ({})",
|
||||||
|
t.len(),
|
||||||
|
human(bytes)
|
||||||
|
)?;
|
||||||
|
t
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut count = 0usize;
|
||||||
|
let stats = scan::find_strings(&mem, &targets, utf16, min, grep, max, |va, s| {
|
||||||
|
count += 1;
|
||||||
|
// Ignoring the write error here keeps the closure simple; a broken pipe
|
||||||
|
// is caught when the buffer is flushed in main.
|
||||||
|
let _ = writeln!(out, "{va:#014x} {}", s);
|
||||||
|
});
|
||||||
|
|
||||||
|
writeln!(out)?;
|
||||||
|
writeln!(out, "{count} strings; {}", stats.summary())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- read
|
||||||
|
|
||||||
|
fn cmd_read<W: Write>(out: &mut W, argv: impl Iterator<Item = String>) -> io::Result<()> {
|
||||||
|
let args = Args::parse(argv, &["pid"]).map_err(arg_err)?;
|
||||||
|
args.reject_unknown(&["pid"]).map_err(arg_err)?;
|
||||||
|
if args.positional.len() < 2 {
|
||||||
|
return Err(new_invalid(ArgError("read needs <va> and <len>".into())));
|
||||||
|
}
|
||||||
|
let va = parse_addr(&args.positional[0]).map_err(arg_err)?;
|
||||||
|
let len = parse_len(&args.positional[1]).map_err(arg_err)?;
|
||||||
|
if len == 0 || len > 64 * 1024 * 1024 {
|
||||||
|
return Err(new_invalid(ArgError(format!(
|
||||||
|
"length {len} out of range (1 .. 64 MiB)"
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pid = resolve_pid(&args)?;
|
||||||
|
let regions = maps::read_maps(pid)?;
|
||||||
|
let mem = ProcMem::open(pid)?;
|
||||||
|
let mods = image::modules(®ions, &mem);
|
||||||
|
|
||||||
|
writeln!(out, "{va:#x} {}", image::describe(va, &mods, ®ions))?;
|
||||||
|
let data = mem.read_exact(va, len as usize)?;
|
||||||
|
dump::hexdump(out, va, &data, "")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_invalid(e: ArgError) -> io::Error {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidInput, e.0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
//! Parsing `/proc/<pid>/maps` and finding the FIFA 17 process.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Region {
|
||||||
|
pub start: u64,
|
||||||
|
pub end: u64,
|
||||||
|
/// The raw four permission characters, e.g. `rwxp` or `r--s`.
|
||||||
|
pub perms: String,
|
||||||
|
/// File offset this mapping starts at, meaningless for anonymous regions.
|
||||||
|
pub offset: u64,
|
||||||
|
/// `None` for anonymous mappings.
|
||||||
|
pub path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Region {
|
||||||
|
pub fn size(&self) -> u64 {
|
||||||
|
self.end - self.start
|
||||||
|
}
|
||||||
|
pub fn readable(&self) -> bool {
|
||||||
|
self.perms.as_bytes().first() == Some(&b'r')
|
||||||
|
}
|
||||||
|
pub fn writable(&self) -> bool {
|
||||||
|
self.perms.as_bytes().get(1) == Some(&b'w')
|
||||||
|
}
|
||||||
|
pub fn executable(&self) -> bool {
|
||||||
|
self.perms.as_bytes().get(2) == Some(&b'x')
|
||||||
|
}
|
||||||
|
pub fn private(&self) -> bool {
|
||||||
|
self.perms.as_bytes().get(3) == Some(&b'p')
|
||||||
|
}
|
||||||
|
pub fn anonymous(&self) -> bool {
|
||||||
|
self.path.is_none()
|
||||||
|
}
|
||||||
|
/// Pseudo-files the kernel exposes. Reading `[vvar]` through
|
||||||
|
/// `/proc/pid/mem` fails, and `[vsyscall]` is not interesting here.
|
||||||
|
pub fn pseudo(&self) -> bool {
|
||||||
|
matches!(self.path.as_deref(), Some(p) if p.starts_with('['))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_maps(pid: i32) -> io::Result<Vec<Region>> {
|
||||||
|
let text = fs::read_to_string(format!("/proc/{pid}/maps")).map_err(|e| {
|
||||||
|
let hint = if fs::metadata(format!("/proc/{pid}")).is_err() {
|
||||||
|
format!("no process with pid {pid}")
|
||||||
|
} else {
|
||||||
|
format!("pid {pid} exists but its maps are unreadable (different user?)")
|
||||||
|
};
|
||||||
|
io::Error::new(e.kind(), format!("reading /proc/{pid}/maps: {hint}"))
|
||||||
|
})?;
|
||||||
|
Ok(text.lines().filter_map(parse_line).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The target's `comm`, so output can name what was actually inspected rather
|
||||||
|
/// than assuming an explicit `--pid` pointed at the game.
|
||||||
|
pub fn read_comm(pid: i32) -> String {
|
||||||
|
fs::read_to_string(format!("/proc/{pid}/comm"))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|_| "?".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the next whitespace-delimited field starting at `cursor`, advancing it.
|
||||||
|
fn next_field<'a>(line: &'a str, cursor: &mut usize) -> Option<&'a str> {
|
||||||
|
let bytes = line.as_bytes();
|
||||||
|
while *cursor < bytes.len() && bytes[*cursor].is_ascii_whitespace() {
|
||||||
|
*cursor += 1;
|
||||||
|
}
|
||||||
|
let start = *cursor;
|
||||||
|
while *cursor < bytes.len() && !bytes[*cursor].is_ascii_whitespace() {
|
||||||
|
*cursor += 1;
|
||||||
|
}
|
||||||
|
if start == *cursor {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(&line[start..*cursor])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_line(line: &str) -> Option<Region> {
|
||||||
|
// Format: `start-end perms offset dev inode path`
|
||||||
|
//
|
||||||
|
// The path may contain spaces (`/mnt/games/FIFA 17/FIFA17.exe`) and may
|
||||||
|
// carry a ` (deleted)` suffix, so we consume exactly five leading fields by
|
||||||
|
// position and take the untouched remainder as the path.
|
||||||
|
//
|
||||||
|
// Doing this with `line.find(inode)` to locate the split point is a trap:
|
||||||
|
// the inode of an anonymous mapping is "0", and `find("0")` happily matches
|
||||||
|
// a zero digit inside the address range at the very start of the line. That
|
||||||
|
// silently turns half the address into a path. Hence the explicit cursor.
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
let range = next_field(line, &mut cursor)?;
|
||||||
|
let perms = next_field(line, &mut cursor)?;
|
||||||
|
let offset = next_field(line, &mut cursor)?;
|
||||||
|
let _dev = next_field(line, &mut cursor)?;
|
||||||
|
let _inode = next_field(line, &mut cursor)?;
|
||||||
|
|
||||||
|
let (start, end) = range.split_once('-')?;
|
||||||
|
let start = u64::from_str_radix(start, 16).ok()?;
|
||||||
|
let end = u64::from_str_radix(end, 16).ok()?;
|
||||||
|
|
||||||
|
let tail = line[cursor..].trim();
|
||||||
|
let path = if tail.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(tail.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(Region {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
perms: perms.to_string(),
|
||||||
|
offset: u64::from_str_radix(offset, 16).ok()?,
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the FIFA 17 process.
|
||||||
|
///
|
||||||
|
/// `comm` is the authority, NOT `cmdline`. Under Proton there are a dozen
|
||||||
|
/// helper processes (bash, umu-run, srt-bwrap, pv-adverb, proton, umu.exe)
|
||||||
|
/// whose command lines mention fifa17, and at least one of them
|
||||||
|
/// (`umu.exe /mnt/games/FIFA 17/_fifa17.exe`) is a convincing decoy. Only the
|
||||||
|
/// real game has `comm == "FIFA17.exe"`. Its `/proc/<pid>/exe` points at
|
||||||
|
/// wine64-preloader, which is expected and is not a reason to doubt the match.
|
||||||
|
pub fn find_pid(comm_name: &str) -> io::Result<i32> {
|
||||||
|
let mut hits = Vec::new();
|
||||||
|
for entry in fs::read_dir("/proc")? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
let Ok(pid) = name.parse::<i32>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) {
|
||||||
|
if comm.trim() == comm_name {
|
||||||
|
hits.push(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match hits.len() {
|
||||||
|
0 => Err(io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
format!("no process with comm == {comm_name:?}; is the game running? pass --pid to override"),
|
||||||
|
)),
|
||||||
|
1 => Ok(hits[0]),
|
||||||
|
_ => Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!("{} processes have comm == {comm_name:?}: {hits:?}; pass --pid to disambiguate", hits.len()),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn human(bytes: u64) -> String {
|
||||||
|
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||||
|
let mut value = bytes as f64;
|
||||||
|
let mut unit = 0;
|
||||||
|
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
||||||
|
value /= 1024.0;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
if unit == 0 {
|
||||||
|
format!("{bytes} B")
|
||||||
|
} else {
|
||||||
|
format!("{value:.2} {}", UNITS[unit])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//! Read-only access to another process's address space.
|
||||||
|
//!
|
||||||
|
//! # The safety property this module exists to guarantee
|
||||||
|
//!
|
||||||
|
//! A live FIFA 17 session may be running while this tool is used. Corrupting it
|
||||||
|
//! costs the user their progress and their patience. So the guarantee here is
|
||||||
|
//! structural, not a matter of being careful:
|
||||||
|
//!
|
||||||
|
//! * `/proc/<pid>/mem` is opened with [`File::open`], which is `O_RDONLY`.
|
||||||
|
//! There is no [`std::fs::OpenOptions`] anywhere in this crate.
|
||||||
|
//! * [`ProcMem`] exposes `&self` read methods only. It hands out no `&mut File`
|
||||||
|
//! and no raw fd, so no caller outside this module can upgrade the handle.
|
||||||
|
//! * Nothing in the crate calls `ptrace`, sends a signal, or writes to any
|
||||||
|
//! path under `/proc`.
|
||||||
|
//!
|
||||||
|
//! Even if a caller tried to write, the kernel would reject it on an `O_RDONLY`
|
||||||
|
//! descriptor. The type system and the open mode agree, which is the point.
|
||||||
|
//!
|
||||||
|
//! # Why pread and not seek + read
|
||||||
|
//!
|
||||||
|
//! [`FileExt::read_at`] is `pread(2)`: it takes the offset as an argument
|
||||||
|
//! instead of mutating a shared file cursor. That means a `&ProcMem` can be
|
||||||
|
//! shared across threads later without a mutex and without one thread's seek
|
||||||
|
//! corrupting another's read. It also removes a whole class of "forgot to seek"
|
||||||
|
//! bugs. There is never a reason to prefer seek+read here.
|
||||||
|
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io;
|
||||||
|
use std::os::unix::fs::FileExt;
|
||||||
|
|
||||||
|
/// The page size we assume when stepping over an unreadable hole. Every x86-64
|
||||||
|
/// mapping is a multiple of this, so it is a safe granularity for recovery.
|
||||||
|
pub const PAGE: u64 = 4096;
|
||||||
|
|
||||||
|
/// A read-only handle on a process's memory.
|
||||||
|
pub struct ProcMem {
|
||||||
|
file: File,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a single chunk read produced.
|
||||||
|
pub enum ChunkRead {
|
||||||
|
/// `n` bytes landed in the buffer. May be shorter than requested when the
|
||||||
|
/// read ran into an unmapped hole partway through.
|
||||||
|
Got(usize),
|
||||||
|
/// Nothing readable at this address at all.
|
||||||
|
Hole,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcMem {
|
||||||
|
/// Open the target read-only. See the module docs for why this is
|
||||||
|
/// `File::open` and must stay that way.
|
||||||
|
pub fn open(pid: i32) -> io::Result<Self> {
|
||||||
|
let file = File::open(format!("/proc/{pid}/mem")).map_err(|e| {
|
||||||
|
io::Error::new(
|
||||||
|
e.kind(),
|
||||||
|
format!("opening /proc/{pid}/mem: {e} (same-user or CAP_SYS_PTRACE required)"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Self { file })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort read. Never fatal: a hole reports [`ChunkRead::Hole`] rather
|
||||||
|
/// than propagating an error, because in a 3 GB sweep unreadable regions are
|
||||||
|
/// the normal case, not an exceptional one.
|
||||||
|
///
|
||||||
|
/// Guard pages, Wine's special mappings and pages Denuvo has not faulted in
|
||||||
|
/// are all marked readable in `/proc/<pid>/maps` yet return `EIO` here. The
|
||||||
|
/// caller counts these and reports the total so the user knows the sweep was
|
||||||
|
/// partial.
|
||||||
|
pub fn read_chunk(&self, va: u64, buf: &mut [u8]) -> ChunkRead {
|
||||||
|
match self.file.read_at(buf, va) {
|
||||||
|
Ok(0) | Err(_) => ChunkRead::Hole,
|
||||||
|
Ok(n) => ChunkRead::Got(n),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict read for cases where a short read is genuinely an error, such as
|
||||||
|
/// an explicit `futmem read <va> <len>` the user asked for by hand.
|
||||||
|
pub fn read_exact(&self, va: u64, len: usize) -> io::Result<Vec<u8>> {
|
||||||
|
let mut buf = vec![0u8; len];
|
||||||
|
self.file.read_exact_at(&mut buf, va).map_err(|e| {
|
||||||
|
io::Error::new(
|
||||||
|
e.kind(),
|
||||||
|
format!("reading {len} bytes at {va:#x}: {e} (address may be unmapped)"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read up to `len` bytes, returning however many were actually available.
|
||||||
|
/// Used for printing context around a hit that sits near the end of a region.
|
||||||
|
pub fn read_partial(&self, va: u64, len: usize) -> Vec<u8> {
|
||||||
|
let mut buf = vec![0u8; len];
|
||||||
|
match self.file.read_at(&mut buf, va) {
|
||||||
|
Ok(n) => {
|
||||||
|
buf.truncate(n);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
//! Chunked sweeping of a remote address space, plus the two things we sweep
|
||||||
|
//! for: byte patterns and printable strings.
|
||||||
|
//!
|
||||||
|
//! # Why chunking, and the off-by-one that ruins scanners
|
||||||
|
//!
|
||||||
|
//! The target has roughly 3 GB resident. Reading a region in one allocation is
|
||||||
|
//! wasteful and can fail outright, so regions are walked in 4 MiB chunks.
|
||||||
|
//!
|
||||||
|
//! The classic bug in every hand-rolled scanner is that a pattern straddling a
|
||||||
|
//! chunk boundary is never found: the tail of chunk N holds the first few bytes
|
||||||
|
//! and the head of chunk N+1 holds the rest, and neither buffer contains the
|
||||||
|
//! whole thing. The fix is to overlap consecutive chunks by `pattern_len - 1`
|
||||||
|
//! bytes.
|
||||||
|
//!
|
||||||
|
//! That specific overlap is exactly right, and it is worth showing why it is
|
||||||
|
//! neither too small nor too large. Let a chunk cover `[0, n)` and the pattern
|
||||||
|
//! have length `P`. A match starting at index `s` occupies `s ..= s + P - 1`, so
|
||||||
|
//! the last match fully inside the chunk starts at `s = n - P`. Any match
|
||||||
|
//! starting at `s > n - P` runs off the end and must be caught by the next
|
||||||
|
//! chunk, so the next chunk has to begin at or before `n - P + 1`. Advancing by
|
||||||
|
//! `n - (P - 1)` starts it at precisely `n - P + 1`:
|
||||||
|
//!
|
||||||
|
//! * Nothing is missed: every straddling match starts at `s >= n - P + 1`,
|
||||||
|
//! which is inside the next chunk.
|
||||||
|
//! * Nothing is double-reported: the first index of the overlap is
|
||||||
|
//! `n - P + 1`, which is strictly greater than `n - P`, the last index that
|
||||||
|
//! can host a complete match in this chunk. The two windows of *reportable*
|
||||||
|
//! match starts are disjoint even though the byte windows overlap.
|
||||||
|
//!
|
||||||
|
//! Overlapping by `P` instead would report every boundary-straddling match
|
||||||
|
//! twice; overlapping by `P - 2` would miss one alignment. Hence `P - 1`.
|
||||||
|
//!
|
||||||
|
//! # Holes
|
||||||
|
//!
|
||||||
|
//! A region marked readable in `/proc/<pid>/maps` is frequently not readable in
|
||||||
|
//! practice: guard pages, Wine's special mappings, and pages Denuvo has not
|
||||||
|
//! faulted in all return `EIO`. These are counted and stepped over a page at a
|
||||||
|
//! time, never propagated as errors, because in a sweep this size they are
|
||||||
|
//! routine. The counts are reported so the user knows the sweep was partial and
|
||||||
|
//! does not read a zero-hit result as proof of absence.
|
||||||
|
|
||||||
|
use crate::image::Module;
|
||||||
|
use crate::maps::Region;
|
||||||
|
use crate::mem::{ChunkRead, ProcMem, PAGE};
|
||||||
|
|
||||||
|
pub const CHUNK: usize = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct SweepStats {
|
||||||
|
pub regions_scanned: usize,
|
||||||
|
/// Regions from which not a single byte could be read.
|
||||||
|
pub regions_skipped: usize,
|
||||||
|
/// Individual chunk reads that hit an unreadable hole.
|
||||||
|
pub holes: usize,
|
||||||
|
pub bytes_read: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SweepStats {
|
||||||
|
pub fn summary(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"scanned {} regions ({}), skipped {} unreadable regions, {} holes stepped over",
|
||||||
|
self.regions_scanned,
|
||||||
|
crate::maps::human(self.bytes_read),
|
||||||
|
self.regions_skipped,
|
||||||
|
self.holes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn align_up(va: u64, align: u64) -> u64 {
|
||||||
|
va.div_ceil(align) * align
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk one region in chunks, invoking `f(chunk_va, bytes, contiguous)`.
|
||||||
|
///
|
||||||
|
/// `contiguous` is true when this chunk's data continues directly from the
|
||||||
|
/// previous callback with no gap, which string extraction needs in order to
|
||||||
|
/// join a run that spans a boundary. `overlap` is `pattern_len - 1` for pattern
|
||||||
|
/// search and 0 for stateful scanners that track continuity themselves.
|
||||||
|
///
|
||||||
|
/// Returns early (`false`) if `f` signals it has seen enough.
|
||||||
|
fn sweep_region<F>(
|
||||||
|
mem: &ProcMem,
|
||||||
|
region: &Region,
|
||||||
|
overlap: usize,
|
||||||
|
buf: &mut [u8],
|
||||||
|
stats: &mut SweepStats,
|
||||||
|
f: &mut F,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
F: FnMut(u64, &[u8], bool) -> bool,
|
||||||
|
{
|
||||||
|
let mut pos = region.start;
|
||||||
|
let mut contiguous = false;
|
||||||
|
let mut read_anything = false;
|
||||||
|
|
||||||
|
while pos < region.end {
|
||||||
|
let want = (buf.len() as u64).min(region.end - pos) as usize;
|
||||||
|
match mem.read_chunk(pos, &mut buf[..want]) {
|
||||||
|
ChunkRead::Hole => {
|
||||||
|
stats.holes += 1;
|
||||||
|
contiguous = false;
|
||||||
|
// Step to the next page; the current one is unreadable.
|
||||||
|
pos = align_up(pos + 1, PAGE);
|
||||||
|
}
|
||||||
|
ChunkRead::Got(n) => {
|
||||||
|
read_anything = true;
|
||||||
|
stats.bytes_read += n as u64;
|
||||||
|
if !f(pos, &buf[..n], contiguous) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if pos + n as u64 >= region.end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if n < want {
|
||||||
|
// Short read: an unmapped hole begins at pos + n. No pattern
|
||||||
|
// can span a hole, so no overlap is needed here; resume on
|
||||||
|
// the next page boundary.
|
||||||
|
contiguous = false;
|
||||||
|
pos = align_up(pos + n as u64 + 1, PAGE);
|
||||||
|
} else {
|
||||||
|
if n <= overlap {
|
||||||
|
break; // cannot make forward progress
|
||||||
|
}
|
||||||
|
contiguous = true;
|
||||||
|
pos += (n - overlap) as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if read_anything {
|
||||||
|
stats.regions_scanned += 1;
|
||||||
|
} else {
|
||||||
|
stats.regions_skipped += 1;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which regions a sweep should touch.
|
||||||
|
pub fn scan_targets(regions: &[Region], module: Option<&Module>, anon_only: bool) -> Vec<Region> {
|
||||||
|
regions
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.readable() && !r.pseudo())
|
||||||
|
.filter(|r| !anon_only || r.anonymous())
|
||||||
|
.filter_map(|r| match module {
|
||||||
|
None => Some(r.clone()),
|
||||||
|
// Clip the region to the module's image span rather than dropping
|
||||||
|
// it: under Wine a module's sections live in large anonymous
|
||||||
|
// regions that may extend past the image.
|
||||||
|
Some(m) => {
|
||||||
|
let start = r.start.max(m.base);
|
||||||
|
let end = r.end.min(m.end());
|
||||||
|
if start < end {
|
||||||
|
let mut clipped = (*r).clone();
|
||||||
|
clipped.start = start;
|
||||||
|
clipped.end = end;
|
||||||
|
Some(clipped)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search every target region for `pattern`. Calls `hit(va)` per match.
|
||||||
|
pub fn find_pattern<F>(
|
||||||
|
mem: &ProcMem,
|
||||||
|
targets: &[Region],
|
||||||
|
pattern: &[u8],
|
||||||
|
max: Option<usize>,
|
||||||
|
mut hit: F,
|
||||||
|
) -> SweepStats
|
||||||
|
where
|
||||||
|
F: FnMut(u64),
|
||||||
|
{
|
||||||
|
let mut stats = SweepStats::default();
|
||||||
|
if pattern.is_empty() {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
let finder = memchr::memmem::Finder::new(pattern);
|
||||||
|
let overlap = pattern.len() - 1;
|
||||||
|
// The buffer must comfortably exceed the overlap or progress stalls.
|
||||||
|
let mut buf = vec![0u8; CHUNK.max(pattern.len() * 4)];
|
||||||
|
let mut found = 0usize;
|
||||||
|
|
||||||
|
for region in targets {
|
||||||
|
let keep_going = sweep_region(
|
||||||
|
mem,
|
||||||
|
region,
|
||||||
|
overlap,
|
||||||
|
&mut buf,
|
||||||
|
&mut stats,
|
||||||
|
&mut |base, data, _contiguous| {
|
||||||
|
for off in finder.find_iter(data) {
|
||||||
|
hit(base + off as u64);
|
||||||
|
found += 1;
|
||||||
|
if max.is_some_and(|m| found >= m) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if !keep_going {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
|
||||||
|
fn printable(b: u8) -> bool {
|
||||||
|
(0x20..=0x7e).contains(&b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts printable runs, carrying an unfinished run across contiguous chunks
|
||||||
|
/// so a string straddling a boundary is still emitted whole.
|
||||||
|
struct StringScanner {
|
||||||
|
utf16: bool,
|
||||||
|
min: usize,
|
||||||
|
run: Vec<u8>,
|
||||||
|
run_start: u64,
|
||||||
|
open: bool,
|
||||||
|
/// UTF-16 only: a low byte at the very end of a chunk whose high byte will
|
||||||
|
/// arrive in the next one.
|
||||||
|
carry: Option<(u64, u8)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StringScanner {
|
||||||
|
fn new(utf16: bool, min: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
utf16,
|
||||||
|
min,
|
||||||
|
run: Vec::with_capacity(256),
|
||||||
|
run_start: 0,
|
||||||
|
open: false,
|
||||||
|
carry: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush<F: FnMut(u64, &str)>(&mut self, emit: &mut F) {
|
||||||
|
if self.open && self.run.len() >= self.min {
|
||||||
|
// Runs are printable ASCII by construction, so this cannot fail.
|
||||||
|
if let Ok(s) = std::str::from_utf8(&self.run) {
|
||||||
|
emit(self.run_start, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.run.clear();
|
||||||
|
self.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push<F: FnMut(u64, &str)>(&mut self, va: u64, b: u8, emit: &mut F) {
|
||||||
|
if !self.open {
|
||||||
|
self.open = true;
|
||||||
|
self.run_start = va;
|
||||||
|
}
|
||||||
|
self.run.push(b);
|
||||||
|
// Guard against a pathological all-printable megabyte eating memory.
|
||||||
|
if self.run.len() >= 4096 {
|
||||||
|
self.flush(emit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed<F: FnMut(u64, &str)>(
|
||||||
|
&mut self,
|
||||||
|
base: u64,
|
||||||
|
data: &[u8],
|
||||||
|
contiguous: bool,
|
||||||
|
emit: &mut F,
|
||||||
|
) {
|
||||||
|
if !contiguous {
|
||||||
|
self.flush(emit);
|
||||||
|
self.carry = None;
|
||||||
|
}
|
||||||
|
if self.utf16 {
|
||||||
|
self.feed_utf16(base, data, emit);
|
||||||
|
} else {
|
||||||
|
for (i, &b) in data.iter().enumerate() {
|
||||||
|
if printable(b) {
|
||||||
|
self.push(base + i as u64, b, emit);
|
||||||
|
} else {
|
||||||
|
self.flush(emit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed_utf16<F: FnMut(u64, &str)>(&mut self, base: u64, data: &[u8], emit: &mut F) {
|
||||||
|
let mut i = 0usize;
|
||||||
|
// A pair split across the chunk boundary: complete it if the high byte
|
||||||
|
// is the expected 0x00, otherwise the run ends here.
|
||||||
|
if let Some((addr, lo)) = self.carry.take() {
|
||||||
|
if data.first() == Some(&0) && printable(lo) {
|
||||||
|
self.push(addr, lo, emit);
|
||||||
|
i = 1;
|
||||||
|
} else {
|
||||||
|
self.flush(emit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while i + 1 < data.len() {
|
||||||
|
let (lo, hi) = (data[i], data[i + 1]);
|
||||||
|
if hi == 0 && printable(lo) {
|
||||||
|
self.push(base + i as u64, lo, emit);
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
self.flush(emit);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i < data.len() {
|
||||||
|
self.carry = Some((base + i as u64, data[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract strings from every target region. Calls `emit(va, text)`.
|
||||||
|
pub fn find_strings<F>(
|
||||||
|
mem: &ProcMem,
|
||||||
|
targets: &[Region],
|
||||||
|
utf16: bool,
|
||||||
|
min: usize,
|
||||||
|
grep: Option<&str>,
|
||||||
|
max: Option<usize>,
|
||||||
|
mut emit: F,
|
||||||
|
) -> SweepStats
|
||||||
|
where
|
||||||
|
F: FnMut(u64, &str),
|
||||||
|
{
|
||||||
|
let mut stats = SweepStats::default();
|
||||||
|
let mut buf = vec![0u8; CHUNK];
|
||||||
|
let grep_lower = grep.map(|g| g.to_ascii_lowercase());
|
||||||
|
let mut count = 0usize;
|
||||||
|
|
||||||
|
for region in targets {
|
||||||
|
let mut scanner = StringScanner::new(utf16, min);
|
||||||
|
let mut stop = false;
|
||||||
|
// overlap 0: the scanner tracks continuity itself via `contiguous`.
|
||||||
|
let keep_going = sweep_region(
|
||||||
|
mem,
|
||||||
|
region,
|
||||||
|
0,
|
||||||
|
&mut buf,
|
||||||
|
&mut stats,
|
||||||
|
&mut |base, data, contiguous| {
|
||||||
|
scanner.feed(base, data, contiguous, &mut |va, s| {
|
||||||
|
let matches = match &grep_lower {
|
||||||
|
Some(g) => s.to_ascii_lowercase().contains(g.as_str()),
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if matches {
|
||||||
|
emit(va, s);
|
||||||
|
count += 1;
|
||||||
|
if max.is_some_and(|m| count >= m) {
|
||||||
|
stop = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
!stop
|
||||||
|
},
|
||||||
|
);
|
||||||
|
scanner.flush(&mut |va, s| {
|
||||||
|
let matches = match &grep_lower {
|
||||||
|
Some(g) => s.to_ascii_lowercase().contains(g.as_str()),
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if matches {
|
||||||
|
emit(va, s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !keep_going || stop {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
gvenv/
|
||||||
@@ -669,6 +669,52 @@ FUT_RS4_CONFIG = (
|
|||||||
"IS_FIFAPOINT_PURCHASABLE", "IS_EASTORE_SERVICE_READY",
|
"IS_FIFAPOINT_PURCHASABLE", "IS_EASTORE_SERVICE_READY",
|
||||||
"COINS_PURCHASE_ENABLED", "POINTS_PURCHASE_ENABLED", "MONEY_PURCHASE_ENABLED",
|
"COINS_PURCHASE_ENABLED", "POINTS_PURCHASE_ENABLED", "MONEY_PURCHASE_ENABLED",
|
||||||
)]
|
)]
|
||||||
|
# FUT_TRADING: the transfer-market equivalent of the store block above.
|
||||||
|
#
|
||||||
|
# WHY THIS IS HERE AND NOT IN /settings. "Place on Transfer List" and "List on
|
||||||
|
# Transfer Market" are greyed out because the TO_TRADE_PILE predicate
|
||||||
|
# FUN_1801a7260 needs a service gate at vtable+0x270, which is
|
||||||
|
# `movzx eax, byte [rcx+0x1fd2e]; ret`. That byte is the tradingEnabled gate and it
|
||||||
|
# reads 0.
|
||||||
|
#
|
||||||
|
# Sending tradingEnabled through /settings does NOT move it, PROVEN live 2026-08-06:
|
||||||
|
# the arm is right (case 0x336 writes param_2[10]) and the applier is right
|
||||||
|
# (0x1fd2e = param_2[10] == 1), but the applier has NO caller Ghidra can see and is
|
||||||
|
# not reachable from the settings deserializer. The decisive measurement: we served
|
||||||
|
# maximumTradePileSize=77 and NO int gate field carries 77 (+0x1fd14=0, +0x1fd4c=0,
|
||||||
|
# +0x1fd54=480). Every gate byte is a constructor default. That also explains
|
||||||
|
# storeEnabled reading 1: a default, never our value.
|
||||||
|
#
|
||||||
|
# REFUTED 2026-08-06, KEPT ONLY AS A RECORD. THIS DOES NOT WORK. Do not turn it on
|
||||||
|
# expecting an effect, and do not reason from it.
|
||||||
|
#
|
||||||
|
# The reasoning above was wrong in two places and the flag is inert:
|
||||||
|
#
|
||||||
|
# 1. IS_TRADING_ENABLED IS AN OUTPUT NAME, NOT AN INPUT. FUN_18006cc60 is a
|
||||||
|
# PUBLISHER: at 0x18006ccc6 it does `call [rax+0x270]` (which reads gate byte
|
||||||
|
# 0x1fd2e), then `lea rdx,[IS_TRADING_ENABLED]` and hands the value OUT under
|
||||||
|
# that name. The only rip-relative reference to the literal 0x1801fc118 in the
|
||||||
|
# whole of .text is that lea. There is no comparison against it anywhere, so a
|
||||||
|
# client-config key of that name cannot be read as an input by anything. The same
|
||||||
|
# is true of the IS_* store keys above, which means the store block may also be
|
||||||
|
# inert and its apparent success was never actually attributed.
|
||||||
|
# 2. The gate byte was briefly measured as 1 and that was over-claimed as a success.
|
||||||
|
# On a fresh session it reads 0, and a thorough re-measurement read 0 on the very
|
||||||
|
# pid where it had read 1. Either the first read was transient or something clears
|
||||||
|
# it after login. The only writer of 0x1fd2e is FUN_18011dc50 at 0x18011dc91.
|
||||||
|
#
|
||||||
|
# What IS now known, and supersedes the "/settings is dead" claim in the note above:
|
||||||
|
# FUN_18011dc50 is NOT unreachable. It is a VIRTUAL method at model vtable slot
|
||||||
|
# +0x988 (absolute pointer at 0x18021cc28), which is why a direct-call search found
|
||||||
|
# no callers. The real chain is
|
||||||
|
# settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
|
||||||
|
# -> completion callback FUN_180173e00 -> vt+0x988 / vt+0x998 -> gate bytes
|
||||||
|
# and FUN_180173e00 bails before applying anything unless the int at response+0x1c
|
||||||
|
# is zero. Which atom writes +0x1c is UNKNOWN and is the thing worth chasing.
|
||||||
|
#
|
||||||
|
# Default OFF and it should stay off.
|
||||||
|
+ ([(k, "1") for k in ("tradingEnabled", "IS_TRADING_ENABLED")]
|
||||||
|
if os.environ.get("FUT_TRADING") else [])
|
||||||
# NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any
|
# NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any
|
||||||
# response -- proven inert (wf_96b6c0c5): they are JSON field names that route
|
# response -- proven inert (wf_96b6c0c5): they are JSON field names that route
|
||||||
# to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md.
|
# to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md.
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Offline check: every /settings flag we ship is one the client actually switches on.
|
||||||
|
|
||||||
|
A flag name is not validated by anything at runtime. The client hashes the string
|
||||||
|
we send and switches on the result, so a typo, a renamed field or a flag that
|
||||||
|
simply has no arm in the switch is INERT and looks exactly like "the fix did not
|
||||||
|
work". This asserts each shipped name against two independent sources:
|
||||||
|
|
||||||
|
1. docs/fut_atoms.tsv -- the recovered atom table (the name must hash to an id)
|
||||||
|
2. the switch arms recovered from 0x18013c6d0 (the id must have an arm)
|
||||||
|
|
||||||
|
Source 2 is the one that matters: enableSquadBuildingSetsFeature is a perfectly
|
||||||
|
real atom with NO arm, so source 1 alone would have passed it.
|
||||||
|
|
||||||
|
Run before shipping any settings change. No server needed.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
|
||||||
|
# The 42 atoms with an arm in FUN_18013c6d0, recovered 2026-08-05 by
|
||||||
|
# tools/ghidra_queries/q_settings_flags.py + q_settings_types.py (full decompile,
|
||||||
|
# both halves of the switch, coverage asserted by char count).
|
||||||
|
SWITCH_ARMS = {
|
||||||
|
0x18: "allowGracePeriodForSquadBuildingSets",
|
||||||
|
0x19: "allowUntradeableForSquadBuildingSets",
|
||||||
|
0x6D: "cardPackStoreEnabled", 0x6E: "cardPackStoreEnabled_JP",
|
||||||
|
0x80: "checkServerDbVersion", 0x86: "clientKeepAliveResetTimeoutSec",
|
||||||
|
0x8C: "clubCreateThreshold", 0x98: "coinEnabled", 0x99: "coinEnabled_JP",
|
||||||
|
0xA3: "constrainGracePeriod", 0xBB: "couchPlayEnabled",
|
||||||
|
0xF9: "enableDraftMode", 0xFA: "enableOfflineDraftMode",
|
||||||
|
0xFB: "enableLiveMessaging", 0xFC: "enableLoyaltyBonusForConceptPlayers",
|
||||||
|
0xFD: "enableObjectives", 0xFE: "enableObjectivesAsManagerTasks",
|
||||||
|
0xFF: "enableSinglePlayerDraftMode", 0x118: "extendGameSessionTimerSec",
|
||||||
|
0x11F: "fifaPointsEnabled", 0x120: "fifaPointsEnabled_JP",
|
||||||
|
0x133: "friendlySeasonsEnabled", 0x13D: "getOperationTimeoutSec",
|
||||||
|
0x16C: "itemDbVersion", 0x1C0: "maximumTradePileSize",
|
||||||
|
0x1CD: "mtxEnabled", 0x1CE: "mtxEnabled_JP",
|
||||||
|
0x1DE: "numEndMatchRetriesAllowed", 0x20E: "packOpeningAnimationEnabled",
|
||||||
|
0x242: "pointsPackStoreEnabled", 0x257: "processingStateEnabled",
|
||||||
|
0x28A: "returningUserRewardsScreenEnabled",
|
||||||
|
0x2D0: "squadBuildingSetsGracePeriodMinutes",
|
||||||
|
0x2F1: "storeEnabled", 0x2F2: "storeEnabled_JP",
|
||||||
|
0x2F3: "storyModeRewardEnabled",
|
||||||
|
0x2F5: "championsScheduleViewPeriodInMinutes",
|
||||||
|
0x30F: "enableFloatPointSquadRating",
|
||||||
|
0x310: "enableLegacyYearInfoInItemResourceId",
|
||||||
|
0x320: "tokenRedemptionEnabled", 0x32D: "tournamentQuitEnabled",
|
||||||
|
0x336: "tradingEnabled",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Arms that do NOT simply store a value. Shipping these has side effects.
|
||||||
|
SPECIAL = {
|
||||||
|
"enableObjectives": "shared arm can only CLEAR the field; 1 is a no-op, 0 disables",
|
||||||
|
"enableObjectivesAsManagerTasks": "same shared arm as enableObjectives",
|
||||||
|
"clientKeepAliveResetTimeoutSec": "reprograms a client timer with value*1000",
|
||||||
|
"getOperationTimeoutSec": "reprograms a client timer with value*1000",
|
||||||
|
"checkServerDbVersion": "makes the client go read a server_db_version config",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
fails = []
|
||||||
|
|
||||||
|
atoms = {}
|
||||||
|
with open(os.path.join(HERE, "..", "docs", "fut_atoms.tsv")) as fh:
|
||||||
|
for line in fh:
|
||||||
|
p = line.rstrip("\n").split("\t")
|
||||||
|
if len(p) >= 3:
|
||||||
|
try:
|
||||||
|
atoms[p[2]] = int(p[1], 16)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Cross-check the recovered table against the atom table both ways.
|
||||||
|
by_name = {v: k for k, v in SWITCH_ARMS.items()}
|
||||||
|
for name, aid in by_name.items():
|
||||||
|
if name not in atoms:
|
||||||
|
fails.append("switch arm %s (%#x) is not in fut_atoms.tsv" % (name, aid))
|
||||||
|
elif atoms[name] != aid:
|
||||||
|
fails.append("%s: switch says %#x, atom table says %#x"
|
||||||
|
% (name, aid, atoms[name]))
|
||||||
|
|
||||||
|
import utas_server as u
|
||||||
|
|
||||||
|
body = u.SETTINGS
|
||||||
|
if not isinstance(body, dict) or list(body) != ["configs"]:
|
||||||
|
fails.append("body must be exactly {'configs': [...]}, got %r" % (body,))
|
||||||
|
return report(fails)
|
||||||
|
rows = body["configs"]
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
fails.append("configs must be a LIST (a scalar here desyncs the parser)")
|
||||||
|
return report(fails)
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for r in rows:
|
||||||
|
if not isinstance(r, dict) or set(r) != {"type", "value"}:
|
||||||
|
fails.append("row must be exactly {type, value}: %r" % (r,))
|
||||||
|
continue
|
||||||
|
t, v = r["type"], r["value"]
|
||||||
|
# value: any scalar is safe (getter 0x1801c79d0 coerces int/float/bool/str),
|
||||||
|
# but the applier tests `== 1`, so a bool True would work and a string "1"
|
||||||
|
# would work -- ints keep it unambiguous. An object or array FREEZES.
|
||||||
|
if isinstance(v, (dict, list)):
|
||||||
|
fails.append("%s: value is %s -- an object/array here FREEZES the client"
|
||||||
|
% (t, type(v).__name__))
|
||||||
|
if not isinstance(t, str):
|
||||||
|
fails.append("type must be a string, got %r" % (t,))
|
||||||
|
continue
|
||||||
|
if t in seen:
|
||||||
|
fails.append("%s sent twice; last one wins, so this is at best confusing" % t)
|
||||||
|
seen.add(t)
|
||||||
|
if t not in by_name:
|
||||||
|
hint = " (it IS an atom, but has no arm in the switch)" if t in atoms else ""
|
||||||
|
fails.append("%s has no arm in 0x18013c6d0 -- INERT%s" % (t, hint))
|
||||||
|
elif t in SPECIAL:
|
||||||
|
print(" NOTE %-34s %s" % (t, SPECIAL[t]))
|
||||||
|
|
||||||
|
gates = {"friendlySeasonsEnabled", "enableDraftMode", "tournamentQuitEnabled"}
|
||||||
|
# Read the mode off the server module, never re-declare the default here: a
|
||||||
|
# checker with its own copy of a default tests the copy, not the server.
|
||||||
|
mode = u._SETTINGS_MODE
|
||||||
|
if mode == "gates":
|
||||||
|
for g in sorted(gates - seen):
|
||||||
|
fails.append("mode 'gates' but %s is missing" % g)
|
||||||
|
for t in sorted(seen & gates):
|
||||||
|
row = next(r for r in rows if r["type"] == t)
|
||||||
|
if row["value"] != 1:
|
||||||
|
fails.append("%s = %r; the applier tests `== 1`, nothing else opens "
|
||||||
|
"the gate" % (t, row["value"]))
|
||||||
|
|
||||||
|
print(" mode=%s, %d rows, %d distinct flags, all with a live switch arm"
|
||||||
|
% (mode, len(rows), len(seen)))
|
||||||
|
return report(fails)
|
||||||
|
|
||||||
|
|
||||||
|
def report(fails):
|
||||||
|
if fails:
|
||||||
|
print("\nFAIL (%d)" % len(fails))
|
||||||
|
for f in fails:
|
||||||
|
print(" - %s" % f)
|
||||||
|
return 1
|
||||||
|
print("PASS")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -47,16 +47,30 @@ _DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "
|
|||||||
CLUBITEM_ID_BASE = 960000000 # distinct from save 1e8, sweep 9e8, consumables 9.4e8
|
CLUBITEM_ID_BASE = 960000000 # distinct from save 1e8, sweep 9e8, consumables 9.4e8
|
||||||
|
|
||||||
# (table, art id, stat id, stat name, UNVERIFIED cardsubtypeid)
|
# (table, art id, stat id, stat name, UNVERIFIED cardsubtypeid)
|
||||||
|
# CORRECTED 2026-08-06. Every previous subtype was inside the 0x91..0x96 block, which
|
||||||
|
# is TROPHIES: FUN_180108c00 computes subtype = tournamentType + 0x91, and FUN_1800fed90
|
||||||
|
# is the only function in the binary whose case set is exactly {0x91..0x96}. So all five
|
||||||
|
# families were pointed at the trophy range.
|
||||||
|
#
|
||||||
|
# Kits, stadia and badges are NOT cardtype 9. FUN_1800d8330 has
|
||||||
|
# `case 9: case 10: case 0xb: return 7`, and cardtype 7 DOES have a resolver: manager
|
||||||
|
# vtable +0x498 = FUN_180119bd0, reached from FUN_1800f6c40 when item+0x4c == 7, called
|
||||||
|
# with (subtype, teamid, assetId). That matters for testing: CARD_SYSTEM.md said a wrong
|
||||||
|
# club-item id "cannot announce itself", and for these three that is false. A wrong
|
||||||
|
# teamid produces a visibly wrong TeamName_Abbr15_ caption, which is why kits go first.
|
||||||
FAMILIES = [
|
FAMILIES = [
|
||||||
("balls", "fcc_balls.json", 37, 0x1E, "balls", 149),
|
("balls", "fcc_balls.json", 37, 0x1E, "balls", 30),
|
||||||
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 148),
|
("stadia", "fcc_stadium.json", 36, 0x14, "stadia", 10),
|
||||||
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 145),
|
("badges", "fcc_badgecards.json", 39, 0x2E, "badgeDBid", 11),
|
||||||
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 146),
|
("kits", "fcc_kitcards.json", 35, 0x28, "kits", 9),
|
||||||
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 150),
|
("leaguelogos", "fcc_leaguelogos.json", 40, 0x2F, "leagueLogos", 31),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Every cardsubtypeid known to reach cardtype 9. Used by probe_shelf().
|
# Candidate set for probe_shelf(). The old set {30,31,145..150} could NOT have answered
|
||||||
CARDTYPE9_SUBTYPES = (30, 31, 145, 146, 147, 148, 149, 150)
|
# the question for kits, stadia or badges, because 9, 10 and 11 were not in it: the
|
||||||
|
# probe route the docs preferred would have spent a launch and returned nothing for
|
||||||
|
# three of the five families.
|
||||||
|
CARDTYPE9_SUBTYPES = (9, 10, 11, 30, 31)
|
||||||
|
|
||||||
# How many of each family the starter club owns. Small on purpose: the point is to
|
# How many of each family the starter club owns. Small on purpose: the point is to
|
||||||
# make the counter non-zero so the client asks, not to hand anyone a collection.
|
# make the counter non-zero so the client asks, not to hand anyone a collection.
|
||||||
@@ -71,22 +85,35 @@ def _rows(fname):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _item(item_id, carddbid, cardassetid, subtype, extra=None):
|
def _item(item_id, carddbid, cardassetid, subtype, teamid=None, extra=None):
|
||||||
"""One club item. Deliberately narrow: no rating, no position, no attributes,
|
"""One club item. Deliberately narrow: no rating, no position, no attributes,
|
||||||
no nation, no league, no team. A club item has none of those, and sending a
|
no nation, no league. A club item has none of those, and sending a field the
|
||||||
field the family does not have is how a wrong shape gets accepted and does
|
family does not have is how a wrong shape gets accepted and does nothing."""
|
||||||
nothing."""
|
|
||||||
it = {
|
it = {
|
||||||
"id": item_id,
|
"id": item_id,
|
||||||
"resourceId": carddbid,
|
"resourceId": carddbid,
|
||||||
"assetId": carddbid,
|
"assetId": carddbid,
|
||||||
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
|
"cardassetid": cardassetid, # THE ART ID, never a copy of resourceId
|
||||||
"cardsubtypeid": subtype,
|
"cardsubtypeid": subtype,
|
||||||
"itemType": "club", # UNOBSERVED on the wire; see module docstring
|
|
||||||
"itemState": "free",
|
"itemState": "free",
|
||||||
"owners": 1,
|
"owners": 1,
|
||||||
"untradeable": False,
|
"untradeable": False,
|
||||||
}
|
}
|
||||||
|
# KIT (9) and BADGE (11) display as <caption> + TeamName_Abbr15_<teamid>, so
|
||||||
|
# without teamid the name comes out as the caption alone. STADIUM (10) reads
|
||||||
|
# StadiumName_<assetId>, which resourceId already supplies, so it needs nothing.
|
||||||
|
# teamid is atom 0x306, read with the INT primitive FUN_1801c79d0 and stored at
|
||||||
|
# record +0x94: an established scalar field, not a new shape.
|
||||||
|
#
|
||||||
|
# BE HONEST ABOUT THE 2026-08-05 CRASH: teamid was one of the three extras in the
|
||||||
|
# response that crashed the client, and it was never bisected. `value` is the
|
||||||
|
# established suspect, because it is an OBJECT member elsewhere and a scalar where
|
||||||
|
# an object is expected is the 0x1801c7f1a busy loop, and that response also
|
||||||
|
# carried 30 items across FIVE wrong subtypes at once. This adds teamid ALONE, to
|
||||||
|
# ONE family, with the subtypes now corrected. That is the narrow test the crash
|
||||||
|
# denied us, and it is why families are served one at a time.
|
||||||
|
if teamid is not None and subtype in (9, 11):
|
||||||
|
it["teamid"] = teamid
|
||||||
if extra:
|
if extra:
|
||||||
it.update(extra)
|
it.update(extra)
|
||||||
return it
|
return it
|
||||||
@@ -119,7 +146,13 @@ def shelf(next_id=CLUBITEM_ID_BASE, families=None):
|
|||||||
# at 0x1801c7f1a, which reads exactly like "the game is taking its time"
|
# at 0x1801c7f1a, which reads exactly like "the game is taking its time"
|
||||||
# and then dies. Omission is safe; an unestablished field is not. None of
|
# and then dies. Omission is safe; an unestablished field is not. None of
|
||||||
# the three was needed to draw a card.
|
# the three was needed to draw a card.
|
||||||
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype))
|
# teamid is passed but _item only APPLIES it to kits (9) and badges (11),
|
||||||
|
# which are the two families whose caption is <name> + TeamName_Abbr15_
|
||||||
|
# <teamid>. It is the one field from the fcc row being reintroduced after
|
||||||
|
# the 2026-08-05 crash, deliberately alone and deliberately narrow: see
|
||||||
|
# the note in _item(). value and leagueid stay omitted.
|
||||||
|
picked.append(_item(nid, cid, r.get("cardassetid", art), subtype,
|
||||||
|
teamid=r.get("teamid")))
|
||||||
nid += 1
|
nid += 1
|
||||||
out[name] = picked
|
out[name] = picked
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -19,6 +19,112 @@ from fut_account import ACCOUNT # single source of truth for identity/c
|
|||||||
|
|
||||||
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
|
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
|
||||||
|
|
||||||
|
# ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------
|
||||||
|
#
|
||||||
|
# quick_sell() used to pay an invented rating tier (600/300/150/50). That number
|
||||||
|
# was wrong for every card. The real table is `fcc_discardcoins` in the client's
|
||||||
|
# own game DB, 141 rows keyed (cardtype, level, rare) -> price, recovered from the
|
||||||
|
# running client 2026-08-05 and verified against 22 live club items, 22/22 exact.
|
||||||
|
#
|
||||||
|
# The client computes the DISPLAYED value itself with the same table whenever our
|
||||||
|
# `discardValue` (atom 0xd7) is 0 or absent: FUN_18013fe00 stores our value at item
|
||||||
|
# +0x38, and the guard at 0x180141025 (`cmp dword [rbp+0x198],0` / `ja`) skips the
|
||||||
|
# local computation when it is non-zero. So today the client shows the real value
|
||||||
|
# while the server pays a made-up one, and the two disagree on every card. This
|
||||||
|
# makes the paid value agree with the shown value.
|
||||||
|
#
|
||||||
|
# value = round_half_up(rating * price / 100)
|
||||||
|
# level = 3 if rating >= 75, 2 if 65..74, else 1 (0x180141e8a..0x180141ea3;
|
||||||
|
# derived from rating, NOT a wire field)
|
||||||
|
# cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and
|
||||||
|
# checked across every subtype 0..599 with zero disagreements
|
||||||
|
#
|
||||||
|
# ZERO WIRE CHANGE. Nothing new is sent; only the coin figure the server credits
|
||||||
|
# changes. Default off per the house rule, but this is the one patch worth
|
||||||
|
# defaulting on after a single verification.
|
||||||
|
# See docs/plan-2026-08-05-store-subsystem.md section 3.6.
|
||||||
|
DISCARD_TABLE = os.environ.get("FUT_DISCARD_TABLE", "0") == "1"
|
||||||
|
|
||||||
|
_DP = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _dp(ct, rares, p1, p2, p3):
|
||||||
|
for r in rares:
|
||||||
|
_DP[(ct, 1, r)] = p1
|
||||||
|
_DP[(ct, 2, r)] = p2
|
||||||
|
_DP[(ct, 3, r)] = p3
|
||||||
|
|
||||||
|
|
||||||
|
_dp(1, [0], 30, 150, 400)
|
||||||
|
_dp(1, [1], 75, 350, 800)
|
||||||
|
_dp(1, [7], 1500, 5000, 9000)
|
||||||
|
_dp(1, [2, 3, 10, 13] + list(range(17, 32)), 2000, 7000, 12200)
|
||||||
|
_dp(1, [4, 8, 9], 6000, 10000, 18000)
|
||||||
|
_dp(1, [11], 10000, 15000, 24000)
|
||||||
|
_dp(1, [5, 6], 20000, 40000, 80000)
|
||||||
|
_dp(1, [12], 120000, 120000, 120000)
|
||||||
|
_dp(2, [0], 20, 70, 110)
|
||||||
|
_dp(2, [1], 25, 120, 320)
|
||||||
|
for _ct in (3, 4, 5, 10):
|
||||||
|
_dp(_ct, [0], 10, 55, 110)
|
||||||
|
_dp(_ct, [1], 50, 100, 300)
|
||||||
|
for _ct in (6, 7, 8, 9):
|
||||||
|
_dp(_ct, [0], 5, 20, 40)
|
||||||
|
_dp(_ct, [1], 20, 50, 70)
|
||||||
|
|
||||||
|
|
||||||
|
def _cardtype(sub):
|
||||||
|
"""FUN_1800d8330. 0 means no table row, which the client renders as value 0."""
|
||||||
|
if sub is None:
|
||||||
|
return 0
|
||||||
|
if 0 <= sub <= 3:
|
||||||
|
return 1
|
||||||
|
if sub == 4:
|
||||||
|
return 2
|
||||||
|
if sub == 5:
|
||||||
|
return 3
|
||||||
|
if sub == 6:
|
||||||
|
return 10
|
||||||
|
if sub == 7:
|
||||||
|
return 5
|
||||||
|
if sub == 8:
|
||||||
|
return 4
|
||||||
|
if 9 <= sub <= 11:
|
||||||
|
return 7
|
||||||
|
if sub in (30, 31, 231, 232, 233, 236) or 145 <= sub <= 150:
|
||||||
|
return 9
|
||||||
|
if (51 <= sub <= 136) or (201 <= sub <= 220) or (250 <= sub <= 273) \
|
||||||
|
or (300 <= sub <= 341):
|
||||||
|
return 6
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def discard_value(item):
|
||||||
|
"""round_half_up(rating * price / 100), price from fcc_discardcoins.
|
||||||
|
|
||||||
|
Returns None when the formula does not apply, so callers fall back instead of
|
||||||
|
paying nothing. THE UNRATED-CARD CASE IS NOT COVERED BY THE RECOVERED FORMULA:
|
||||||
|
it was verified 22/22 against club items, all of which were rated players, and
|
||||||
|
`rating * price / 100` collapses to 0 for a staff card carrying no rating. Found
|
||||||
|
by running the whole save through it, where exactly one item (a staff card,
|
||||||
|
cardsubtypeid 8, rating None) came back 0 while the old tier paid 50. Paying 0 for
|
||||||
|
a card the previous code paid for is a regression, so unrated cards fall back.
|
||||||
|
What FUT really pays for staff and consumables is UNKNOWN and worth recovering;
|
||||||
|
the likely answer is the unscaled table price, but that is a guess and is not
|
||||||
|
shipped as one.
|
||||||
|
"""
|
||||||
|
r = item.get("rating")
|
||||||
|
if not r:
|
||||||
|
return None
|
||||||
|
ct = _cardtype(item.get("cardsubtypeid"))
|
||||||
|
r = int(r)
|
||||||
|
lvl = 3 if r >= 75 else 2 if r >= 65 else 1
|
||||||
|
price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0)
|
||||||
|
if not price:
|
||||||
|
return None # no table row: the client renders 0, we should not
|
||||||
|
n = r * price
|
||||||
|
return n // 100 + (1 if n % 100 >= 50 else 0)
|
||||||
|
|
||||||
# Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX
|
# Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX
|
||||||
# and UTAS cannot drift apart; prefer ACCOUNT.<field> in new code. These are
|
# and UTAS cannot drift apart; prefer ACCOUNT.<field> in new code. These are
|
||||||
# import-time snapshots and will NOT reflect a later adopt_from_auth().
|
# import-time snapshots and will NOT reflect a later adopt_from_auth().
|
||||||
@@ -62,9 +168,37 @@ ITEM_ID_BASE = 100000000
|
|||||||
_SQUAD_FITNESS_TRAP = 219
|
_SQUAD_FITNESS_TRAP = 219
|
||||||
|
|
||||||
|
|
||||||
|
# FUT_TRADEABLE: send untradeable=false so the client's tradeable byte gets set.
|
||||||
|
#
|
||||||
|
# "Place on Transfer List" and "List on Transfer Market" are greyed out on every card,
|
||||||
|
# and BOTH gates are ours. FUN_1801a7260, the TO_TRADE_PILE predicate published by
|
||||||
|
# FUN_18003e370, returns 1 only if the service gate at vtable+0x270 is non-zero AND
|
||||||
|
# item+0x49 is non-zero. The deserializer stores untradeable INVERTED (case 0x361 does
|
||||||
|
# CONCAT11(cVar6 == '\0', ...)), so untradeable:true writes 0 and kills the flag.
|
||||||
|
#
|
||||||
|
# THIS FLAG ALONE IS NOT ENOUGH, and shipping it alone will look like the finding
|
||||||
|
# failed. The other gate is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the
|
||||||
|
# tradingEnabled gate byte. Measured live 2026-08-06 as 0, while friendlySeasons
|
||||||
|
# (0x1fd3a), draftMode (0x1fd3d) and packOpeningAnimation (0x1fd45) all read 1 in the
|
||||||
|
# same walk. tradingEnabled is the only gate byte yet found that is not already 1, and
|
||||||
|
# it is ALREADY in _SETTINGS_KEEP: it has simply never been sent, because
|
||||||
|
# _SETTINGS_MODE defaults to off. So the run needs FUT_SETTINGS=keep beside this.
|
||||||
|
#
|
||||||
|
# Freeze risk: none beyond what we already send. untradeable is atom 0x361 read by the
|
||||||
|
# BOOL primitive FUN_1801c7620, and we already send the key on every card; only the
|
||||||
|
# value changes. The constructor default for +0x49 is 1 (tradeable), so false moves
|
||||||
|
# the field toward the client's own default rather than away from it.
|
||||||
|
#
|
||||||
|
# Side effects, both permissive rather than restrictive: item+0x49 also feeds
|
||||||
|
# FUN_1800bc580, which counts untradeable squad members and publishes UNTRADABLE_COUNT,
|
||||||
|
# which gates squad submission in FUN_1800bba10 (today that takes the
|
||||||
|
# couldNotSubmitSquad branch).
|
||||||
|
TRADEABLE = os.environ.get("FUT_TRADEABLE", "0") == "1"
|
||||||
|
|
||||||
|
|
||||||
def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00,
|
def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00,
|
||||||
cardsubtypeid=0, rareflag=1):
|
cardsubtypeid=0, rareflag=1):
|
||||||
return {
|
return _with_discard({
|
||||||
"id": item_id,
|
"id": item_id,
|
||||||
"resourceId": (version << 24) | asset,
|
"resourceId": (version << 24) | asset,
|
||||||
"assetId": asset,
|
"assetId": asset,
|
||||||
@@ -82,10 +216,62 @@ def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00
|
|||||||
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
|
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
|
||||||
"itemState": "free",
|
"itemState": "free",
|
||||||
"owners": 1,
|
"owners": 1,
|
||||||
"untradeable": True,
|
"untradeable": not TRADEABLE,
|
||||||
"contract": 7,
|
"contract": 7,
|
||||||
"fitness": 99,
|
"fitness": 99,
|
||||||
}
|
})
|
||||||
|
# discardValue is stamped HERE, inside the single item factory, so every path that
|
||||||
|
# builds an item gets it: pack contents, the starter grant, club reads and market
|
||||||
|
# listings alike. Stamping it at one call site would leave the reveal screen and
|
||||||
|
# the club showing different numbers for the same card.
|
||||||
|
|
||||||
|
|
||||||
|
# FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS
|
||||||
|
# the same number the server pays.
|
||||||
|
#
|
||||||
|
# Measured live 2026-08-06. With FUT_DISCARD_TABLE on, the server correctly paid 600
|
||||||
|
# for a 75-rated rare gold (9,844,900 -> 9,845,500, exact) while the reveal screen
|
||||||
|
# showed "Quick Sell 0", and "Quick Sell all remaining Items" showed 0 too. So the
|
||||||
|
# figure was right and invisible, and the screen contradicted the wallet.
|
||||||
|
#
|
||||||
|
# The cause is the guard the table work reversed. FUN_18013fe00 stores our
|
||||||
|
# discardValue at item +0x38; at 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` skips
|
||||||
|
# the client's own local computation when that value is NON-ZERO. We seed 0, so the
|
||||||
|
# client runs its own fcc_discardcoins lookup, that lookup returns no row for our
|
||||||
|
# cards, the price register stays 0, and it renders 0. WHY its lookup misses is still
|
||||||
|
# UNKNOWN and worth knowing, but it does not have to be answered to fix the display:
|
||||||
|
# sending a non-zero value bypasses the lookup entirely and the client uses ours.
|
||||||
|
#
|
||||||
|
# Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
|
||||||
|
# scalar getter 0x1801c79d0. The freezes on this project have all come from feeding an
|
||||||
|
# object or array where a scalar was expected, never the reverse.
|
||||||
|
#
|
||||||
|
# Requires FUT_DISCARD_TABLE, since without the real table this would put the invented
|
||||||
|
# tier on screen and make a wrong number authoritative-looking rather than merely paid.
|
||||||
|
DISCARD_SEND = os.environ.get("FUT_DISCARD_SEND", "0") == "1" and DISCARD_TABLE
|
||||||
|
|
||||||
|
|
||||||
|
def _with_discard(it):
|
||||||
|
"""Apply the read-path flags to one item.
|
||||||
|
|
||||||
|
Two things, both of which MUST happen on read and not only at creation: the
|
||||||
|
saved profile holds 246 items minted long before either flag existed, and the
|
||||||
|
club route serves them straight out of the save. Stamping only in _item() left
|
||||||
|
the wire carrying untradeable:true with FUT_TRADEABLE=1 set, which was caught by
|
||||||
|
reading the served JSON rather than by unit-testing the factory.
|
||||||
|
|
||||||
|
Callers pass a COPY, so the save is never mutated by a read.
|
||||||
|
"""
|
||||||
|
if DISCARD_SEND:
|
||||||
|
# Omit the key entirely when the formula does not apply, rather than sending
|
||||||
|
# 0: a 0 makes the client fall back to its own lookup, and the tile binds our
|
||||||
|
# value anyway, so 0 renders as 0.
|
||||||
|
v = discard_value(it)
|
||||||
|
if v:
|
||||||
|
it["discardValue"] = v
|
||||||
|
if TRADEABLE:
|
||||||
|
it["untradeable"] = False
|
||||||
|
return it
|
||||||
|
|
||||||
|
|
||||||
def _new_profile():
|
def _new_profile():
|
||||||
@@ -182,7 +368,13 @@ class Store:
|
|||||||
return self.load()["coins"]
|
return self.load()["coins"]
|
||||||
|
|
||||||
def items(self):
|
def items(self):
|
||||||
return self.load()["items"]
|
# Stamp discardValue on READ as well as on creation. _item() only covers cards
|
||||||
|
# minted from now on, and the save already holds 246 items built before the
|
||||||
|
# flag existed; without this the reveal screen would show real values while
|
||||||
|
# the club showed 0 for everything older. Stamped on the way out and NOT
|
||||||
|
# persisted, so the save stays clean and turning the flag off is a true revert.
|
||||||
|
its = self.load()["items"]
|
||||||
|
return [_with_discard(dict(it)) for it in its] if DISCARD_SEND else its
|
||||||
|
|
||||||
def add_items(self, new_items):
|
def add_items(self, new_items):
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
@@ -215,6 +407,15 @@ class Store:
|
|||||||
dv = it.get("discardValue") or 0
|
dv = it.get("discardValue") or 0
|
||||||
if dv:
|
if dv:
|
||||||
return int(dv)
|
return int(dv)
|
||||||
|
if DISCARD_TABLE:
|
||||||
|
# The real table. Matches what the client displays once
|
||||||
|
# FUT_DISCARD_SEND puts the value on the wire.
|
||||||
|
v = discard_value(it)
|
||||||
|
if v is not None:
|
||||||
|
return v
|
||||||
|
# else: unrated card, formula does not apply, fall through
|
||||||
|
# The invented tier. Wrong for every card, kept only as the live-proven
|
||||||
|
# default until FUT_DISCARD_TABLE has been in front of the game once.
|
||||||
r = it.get("rating") or 0
|
r = it.get("rating") or 0
|
||||||
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
|
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
@@ -299,9 +500,14 @@ class Store:
|
|||||||
return moved
|
return moved
|
||||||
|
|
||||||
def purchased(self):
|
def purchased(self):
|
||||||
|
# Stamped on read exactly like items(). Leaving this out was a real defect:
|
||||||
|
# the pending pile is the ONE place a quick-sell value is actually read, so
|
||||||
|
# the club showed real numbers while the reveal screen showed 0 for anything
|
||||||
|
# already sitting in the pile. Found by a verification pass, not by testing.
|
||||||
"""Items still held in the purchased/unassigned pile (returned by
|
"""Items still held in the purchased/unassigned pile (returned by
|
||||||
GET /purchased/items); they move to the club via FutMoveCard (PUT /item)."""
|
GET /purchased/items); they move to the club via FutMoveCard (PUT /item)."""
|
||||||
return self.load().get("purchased", [])
|
pur = self.load().get("purchased", [])
|
||||||
|
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
|
||||||
|
|
||||||
def active_squad(self):
|
def active_squad(self):
|
||||||
sq = self.load()["squads"]
|
sq = self.load()["squads"]
|
||||||
@@ -396,7 +602,9 @@ class Store:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
def last_pack(self):
|
def last_pack(self):
|
||||||
return self.load().get("purchased", [])
|
# Same stamping as purchased(); this is the reveal-screen read path.
|
||||||
|
pur = self.load().get("purchased", [])
|
||||||
|
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read the FutDataManagerImpl UI gate bytes out of the LIVE FIFA 17 client.
|
||||||
|
|
||||||
|
Why this exists: on 2026-08-05 the /settings gate plan concluded that
|
||||||
|
IS_FRIENDLY_SEASON_ENABLED and IS_DRAFT_MODE_ENABLED had never been set true by
|
||||||
|
anything. Measured against the running client, both are 1, and have been all along.
|
||||||
|
The applier FUN_18011dc50 runs whether or not the configs array has content, and the
|
||||||
|
settings struct it is handed defaults these fields to 1. "Nothing populates the array"
|
||||||
|
is not "nothing writes the byte".
|
||||||
|
|
||||||
|
Read-only. Opens /proc/<pid>/mem O_RDONLY and preads. Nothing here can write.
|
||||||
|
|
||||||
|
Nothing is assumed:
|
||||||
|
* the pid is resolved by exact /proc/*/comm match, never hardcoded
|
||||||
|
* the CardsDLL base is read from /proc/<pid>/maps, never cached across launches
|
||||||
|
(Wine copies the sections into anonymous memory, so only the 4 KiB PE header is
|
||||||
|
file-backed and `grep CardsDLL maps` returns exactly ONE line, which is easy to
|
||||||
|
misread as "barely mapped")
|
||||||
|
* the slide is PROVEN against the FNV atom-hash prologue at 0x180180d00, read from
|
||||||
|
the on-disk PE, before any other address is trusted
|
||||||
|
* each gate byte displacement is DECODED from its accessor stub (0f b6 81 <disp32>,
|
||||||
|
movzx eax, byte [rcx+disp32]) rather than taken from a table
|
||||||
|
|
||||||
|
Requires the client to have reached Ultimate Team, since CardsDLL loads only then.
|
||||||
|
Usage: python3 gate_byte_probe.py
|
||||||
|
"""
|
||||||
|
import os, struct, sys
|
||||||
|
pid=None
|
||||||
|
for d in os.listdir('/proc'):
|
||||||
|
if d.isdigit():
|
||||||
|
try:
|
||||||
|
if open('/proc/%s/comm'%d).read().strip()=='FIFA17.exe': pid=int(d); break
|
||||||
|
except Exception: pass
|
||||||
|
assert pid, "not running"
|
||||||
|
print("pid", pid)
|
||||||
|
base=None
|
||||||
|
for ln in open('/proc/%d/maps'%pid):
|
||||||
|
if 'CardsDLL' in ln:
|
||||||
|
base=int(ln.split('-')[0],16); print("cardsdll map line:", ln.strip())
|
||||||
|
assert base
|
||||||
|
slide = base - 0x180000000
|
||||||
|
print("base %#x slide %#x" % (base, slide))
|
||||||
|
fd=os.open('/proc/%d/mem'%pid, os.O_RDONLY)
|
||||||
|
def rd(va,n): return os.pread(fd, n, va)
|
||||||
|
# control: FNV prologue, bytes taken from the on-disk PE
|
||||||
|
pe=open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll','rb').read()
|
||||||
|
# .text rva 0x1000 rawptr 0x400
|
||||||
|
def f(va): return va-0x180000000-0x1000+0x400
|
||||||
|
ctl_disk=pe[f(0x180180d00):f(0x180180d00)+32]
|
||||||
|
ctl_live=rd(0x180180d00+slide,32)
|
||||||
|
print("CONTROL FNV", "MATCH" if ctl_disk==ctl_live else "MISMATCH", ctl_live.hex())
|
||||||
|
# model singleton
|
||||||
|
dat=0x1802e6398+slide
|
||||||
|
obj=struct.unpack('<Q', rd(dat,8))[0]
|
||||||
|
print("DAT_1802e6398 ->", hex(obj))
|
||||||
|
vt=struct.unpack('<Q', rd(obj,8))[0]
|
||||||
|
print("vtable live %#x static %#x" % (vt, vt-slide))
|
||||||
|
for off,name in [(0x2b0,'friendlySeasons'),(0x2c8,'draftMode'),(0x2e0,'packOpeningAnimation')]:
|
||||||
|
slot=struct.unpack('<Q', rd(vt+off,8))[0]
|
||||||
|
stub=rd(slot,8)
|
||||||
|
disp=struct.unpack('<I', stub[3:7])[0] if stub[:3]==b'\x0f\xb6\x81' else None
|
||||||
|
val=rd(obj+disp,1)[0] if disp is not None else None
|
||||||
|
print(" slot +%#x -> %#x stub=%s disp=%s value=%s" % (off, slot-slide, stub.hex(), hex(disp) if disp else None, val))
|
||||||
|
# unopenedPacks total
|
||||||
|
print("model+0x20950 =", struct.unpack('<I', rd(obj+0x20950,4))[0])
|
||||||
|
os.close(fd)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""ADVERSARIAL Q1.
|
||||||
|
|
||||||
|
HYPOTHESIS UNDER ATTACK (dim1 claim 3): "FUN_1800150d0 ... finds-or-creates a group by
|
||||||
|
an exact string compare on displayGroup.value", i.e. wire-record +0x00 holds
|
||||||
|
displayGroup.value.
|
||||||
|
|
||||||
|
WHY IT IS NOT PROVEN: live we serve description == displayGroup.value == the SAME
|
||||||
|
STRING for all three packs ("Bronze Pack"/"Gold Pack"/"Premium Gold"), so the live
|
||||||
|
group caption cannot distinguish displayGroup.value (atom 0xd9->0x377) from
|
||||||
|
description (atom 0xd1). If the key is actually `description`, recommendation #2
|
||||||
|
(serve displayGroup.value="gold") silently does nothing.
|
||||||
|
|
||||||
|
METHOD: decompile the 0x158 wire-record element deserializer 0x18013af30 IN FULL,
|
||||||
|
print len(src), and enumerate the atom dispatch. Explicitly search the raw
|
||||||
|
disassembly of the function for EVERY syntactic dispatch form the brief warns about:
|
||||||
|
== imm, != imm, switch case labels (jump table), and sub/dec ladders.
|
||||||
|
CONTROL: atom 0x20f (packType) is known-present (live pack model +0x38 = "BRONZE"),
|
||||||
|
so whatever form finds packType must also be applied to 0xd1/0xd9/0xda/0x2cb.
|
||||||
|
The control uses the SAME method (raw immediate scan over the same instruction
|
||||||
|
range), not a different one.
|
||||||
|
"""
|
||||||
|
import sys, traceback, re
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q1_out.txt"
|
||||||
|
try:
|
||||||
|
fh = open(OUT, "w")
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a)
|
||||||
|
print(s); fh.write(s + "\n")
|
||||||
|
|
||||||
|
ATOMS = {0x23:"assetId",0xd1:"description",0xd9:"displayGroup",0xda:"displayGroupAssetId",
|
||||||
|
0xdb:"displayGroupUseDefaultImage",0x15c:"id",0x20f:"packType",0x250:"priority",
|
||||||
|
0x2cb:"sortPriority",0x377:"value",0x36a:"useDefaultImage",0x260:"purchase"}
|
||||||
|
|
||||||
|
for target in (0x18013af30,):
|
||||||
|
f = func(target)
|
||||||
|
P("=== FUNCTION %s @ %#x body=%s ===" % (f.getName(), int(f.getEntryPoint().getOffset()), f.getBody()))
|
||||||
|
src = dec(target, 300)
|
||||||
|
P("len(src) =", len(src))
|
||||||
|
P("---- FULL DECOMPILE BEGIN ----")
|
||||||
|
P(src)
|
||||||
|
P("---- FULL DECOMPILE END ----")
|
||||||
|
|
||||||
|
# raw instruction scan of the whole function body for every atom immediate
|
||||||
|
P()
|
||||||
|
P("=== RAW INSTRUCTION SCAN over FUN_18013af30 body: all forms ===")
|
||||||
|
f = func(0x18013af30)
|
||||||
|
body = f.getBody()
|
||||||
|
it = listing.getInstructions(body, True)
|
||||||
|
ins = []
|
||||||
|
while it.hasNext():
|
||||||
|
i = it.next()
|
||||||
|
ins.append((int(i.getAddress().getOffset()), str(i.getMnemonicString()), str(i)))
|
||||||
|
P("instruction count:", len(ins))
|
||||||
|
# collect all immediates appearing anywhere in the text form
|
||||||
|
found = {}
|
||||||
|
for a, mn, txt in ins:
|
||||||
|
for m in re.finditer(r'0x([0-9a-fA-F]+)', txt):
|
||||||
|
v = int(m.group(1), 16)
|
||||||
|
if v in ATOMS:
|
||||||
|
found.setdefault(v, []).append((a, mn, txt))
|
||||||
|
for v in sorted(ATOMS):
|
||||||
|
lst = found.get(v, [])
|
||||||
|
P("atom %#05x %-28s hits=%d" % (v, ATOMS[v], len(lst)))
|
||||||
|
for a, mn, txt in lst:
|
||||||
|
P(" %#x %s" % (a, txt))
|
||||||
|
# dispatch-form census: CMP/SUB/DEC ladders on the atom register
|
||||||
|
P()
|
||||||
|
P("=== dispatch-form census (CMP/SUB/DEC/SWITCH inside the function) ===")
|
||||||
|
forms = {"CMP":0,"SUB":0,"DEC":0,"JMP":0,"SWITCH":0}
|
||||||
|
for a, mn, txt in ins:
|
||||||
|
if mn in forms: forms[mn]+=1
|
||||||
|
if mn == "JMP" and "[" in txt: forms["SWITCH"]+=1
|
||||||
|
P(forms)
|
||||||
|
P("all CMP with a small immediate (candidate atom compares):")
|
||||||
|
for a, mn, txt in ins:
|
||||||
|
if mn in ("CMP","SUB","DEC","ADD") :
|
||||||
|
m = re.search(r'0x([0-9a-fA-F]{1,4})\s*$', txt)
|
||||||
|
if m:
|
||||||
|
v=int(m.group(1),16)
|
||||||
|
if 0x10 <= v <= 0x400:
|
||||||
|
P(" %#x %-8s %s -> imm %#x %s" % (a, mn, txt, v, ATOMS.get(v,"")))
|
||||||
|
fh.close()
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""ADVERSARIAL Q2. Batch.
|
||||||
|
|
||||||
|
Targets under attack:
|
||||||
|
(a) dim1 claim 4: "FUN_1800147f0 ... a miss returns NULL and the caller then
|
||||||
|
dereferences address 0x40, i.e. it would crash" -- ABSENCE OF A NULL CHECK.
|
||||||
|
Method: print the RAW DISASSEMBLY of FUN_1800147f0 from the CALL to
|
||||||
|
FUN_180014420 to the next 40 instructions, so a TEST/JZ is visible if present.
|
||||||
|
Control: the same raw-listing method applied to FUN_180014380's call sites,
|
||||||
|
where the decompiler DOES show a null test, must show TEST/JZ. Same form.
|
||||||
|
(b) dim1 claim 5: "+0x290 is written in exactly TWO places in all of CardsDLL".
|
||||||
|
objdump found 12 dword/qword writes at +0x290 plus one QWORD write at +0x28c
|
||||||
|
that covers it. Resolve the containing function of every one and decide.
|
||||||
|
(c) dim1 claim 9/10: model+0x94 = group ordinal, model+0x1a0 = sortPriority;
|
||||||
|
+0x1a0 pushed to no Flash field. Print FUN_18002c3c0 and FUN_180015d80 in full
|
||||||
|
and print their exact address ranges so the claim can be re-checked in objdump.
|
||||||
|
(d) dim1 claim 3: FUN_1800150d0 / FUN_180012950 / FUN_180014380 full.
|
||||||
|
(e) dim1 claim 7: FUN_180014580 / FUN_180014df0 six literals; enumerate.
|
||||||
|
(f) FUN_180014610 group-tile builder: does tile+0x9c really get the ordinal
|
||||||
|
(CHILD_CATEGORY) and tile+0xac the displayGroupAssetId? Recommendation #1
|
||||||
|
depends entirely on this.
|
||||||
|
"""
|
||||||
|
import sys, traceback, re
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q2_out.txt"
|
||||||
|
try:
|
||||||
|
fh = open(OUT, "w")
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a)
|
||||||
|
print(s); fh.write(s + "\n")
|
||||||
|
|
||||||
|
TARGETS = [0x1800150d0, 0x180012950, 0x180014380, 0x180014420, 0x1800147f0,
|
||||||
|
0x180014610, 0x18002c3c0, 0x180015d80, 0x180014580, 0x180014df0,
|
||||||
|
0x18007e7f0, 0x18007d1a0, 0x18007dab0]
|
||||||
|
P("=== FUNCTION BOUNDS ===")
|
||||||
|
for t in TARGETS:
|
||||||
|
f = func(t)
|
||||||
|
if f is None:
|
||||||
|
P("%#x -> NO FUNCTION" % t); continue
|
||||||
|
P("%#x %-22s min=%#x max=%#x size=%#x" % (t, f.getName(),
|
||||||
|
int(f.getBody().getMinAddress().getOffset()),
|
||||||
|
int(f.getBody().getMaxAddress().getOffset()),
|
||||||
|
int(f.getBody().getNumAddresses())))
|
||||||
|
|
||||||
|
# (b) resolve containing functions of every +0x290 write objdump found
|
||||||
|
P()
|
||||||
|
P("=== (b) containing functions of every raw +0x290 / +0x28c write ===")
|
||||||
|
W = [0x180051da3,0x18007d3ba,0x18007f0c0,0x18008c777,0x18008fd45,0x1800d3564,
|
||||||
|
0x1800d43fc,0x18013454a,0x180189d84,0x18018caa3,0x18018e1ff,0x180191f77,
|
||||||
|
0x18015b885,0x180067eb0,0x180067ebf]
|
||||||
|
for w in W:
|
||||||
|
f = func(w)
|
||||||
|
P(" %#x -> %s @ %#x" % (w, f.getName() if f else "NONE",
|
||||||
|
int(f.getEntryPoint().getOffset()) if f else 0))
|
||||||
|
# is any of those functions in the store-screen vtable?
|
||||||
|
P()
|
||||||
|
P("=== store screen vtable 0x1801ff690 (first 48 slots) ===")
|
||||||
|
ents = set()
|
||||||
|
for off, tgt, nm in vtable(0x1801ff690, 48):
|
||||||
|
P(" +%#04x %#x %s" % (off, tgt, nm))
|
||||||
|
ents.add(tgt)
|
||||||
|
P("vtable also at 0x1801ff6f8 / 0x1801ff610 per the claim; dumping 0x1801ff610:")
|
||||||
|
for off, tgt, nm in vtable(0x1801ff610, 24):
|
||||||
|
P(" +%#04x %#x %s" % (off, tgt, nm))
|
||||||
|
|
||||||
|
# (a) raw disassembly around the FUN_180014420 call inside FUN_1800147f0
|
||||||
|
P()
|
||||||
|
P("=== (a) RAW LISTING of FUN_1800147f0 (whole function) ===")
|
||||||
|
f = func(0x1800147f0)
|
||||||
|
it = listing.getInstructions(f.getBody(), True)
|
||||||
|
n = 0
|
||||||
|
while it.hasNext():
|
||||||
|
i = it.next(); n += 1
|
||||||
|
P(" %#x %s" % (int(i.getAddress().getOffset()), str(i)))
|
||||||
|
P("instruction count:", n)
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=== (a-control) RAW LISTING of FUN_180014610 (whole function) ===")
|
||||||
|
f = func(0x180014610)
|
||||||
|
it = listing.getInstructions(f.getBody(), True)
|
||||||
|
n = 0
|
||||||
|
while it.hasNext():
|
||||||
|
i = it.next(); n += 1
|
||||||
|
P(" %#x %s" % (int(i.getAddress().getOffset()), str(i)))
|
||||||
|
P("instruction count:", n)
|
||||||
|
|
||||||
|
for t in TARGETS:
|
||||||
|
P()
|
||||||
|
f = func(t)
|
||||||
|
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
|
||||||
|
src = dec(t, 300)
|
||||||
|
P("len(src) =", len(src))
|
||||||
|
P(src)
|
||||||
|
P("======== END %#x ========" % t)
|
||||||
|
fh.close()
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
try: fh.close()
|
||||||
|
except Exception: pass
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""ADVERSARIAL Q3.
|
||||||
|
|
||||||
|
Attacking dim1 claim 10: "sortPriority is inert at the UI. It reaches pack+0x1a0 and is
|
||||||
|
pushed to no Flash field ... Both are dead ends for this bug."
|
||||||
|
An objdump scan of the store cluster found 0x1800108cd/0x1800108d3
|
||||||
|
mov eax,[rsi+0x1a0] ; cmp [rbx+0x1a0],eax
|
||||||
|
which is the shape of a SORT COMPARATOR on two 0x1a8 models, and 0x18002cc62
|
||||||
|
mov [rbx+0x1a0],esi
|
||||||
|
inside FUN_18002cc90, which FUN_18002c3c0 tail-calls AFTER setting +0x1a0 = sortPriority.
|
||||||
|
Both were missed by "grep the push list".
|
||||||
|
|
||||||
|
Also decompile:
|
||||||
|
FUN_18002c8b0 -- the per-group filter in FUN_180014610; if it can HIDE a group the
|
||||||
|
tile ordinals the user sees stop matching the group ordinals.
|
||||||
|
FUN_18007e5e0 / FUN_18007df60 -- the six-panel binding (dim1 claim 7).
|
||||||
|
FUN_18007e7f0 cases 0x7551 / 0x753f -- the CATEGORY_ID round trip.
|
||||||
|
callers of FUN_1800147f0.
|
||||||
|
"""
|
||||||
|
import sys, traceback
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q3_out.txt"
|
||||||
|
try:
|
||||||
|
fh = open(OUT, "w")
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a)
|
||||||
|
print(s); fh.write(s + "\n")
|
||||||
|
|
||||||
|
for a in (0x1800108cd, 0x18002cc62, 0x180010b5c, 0x180011c2c):
|
||||||
|
f = func(a)
|
||||||
|
P("%#x -> %s @ %#x size=%#x" % (a, f.getName() if f else "NONE",
|
||||||
|
int(f.getEntryPoint().getOffset()) if f else 0,
|
||||||
|
int(f.getBody().getNumAddresses()) if f else 0))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=== callers of FUN_1800147f0 ===")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x1800147f0):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
P("=== callers of FUN_180014610 ===")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x180014610):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
P("=== callers of FUN_18002c8b0 ===")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x18002c8b0):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
P("=== callers of the comparator's containing function ===")
|
||||||
|
cf = func(0x1800108cd)
|
||||||
|
if cf:
|
||||||
|
for frm, typ, fn, ent in xrefs_to(int(cf.getEntryPoint().getOffset())):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
|
||||||
|
tg = []
|
||||||
|
if cf: tg.append(int(cf.getEntryPoint().getOffset()))
|
||||||
|
tg += [0x18002cc90, 0x18002c8b0, 0x18007e5e0, 0x18007df60, 0x180014b60]
|
||||||
|
for t in tg:
|
||||||
|
f = func(t)
|
||||||
|
P()
|
||||||
|
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
|
||||||
|
src = dec(t, 300)
|
||||||
|
P("len(src) =", len(src))
|
||||||
|
P(src)
|
||||||
|
P("======== END %#x ========" % t)
|
||||||
|
|
||||||
|
# full FUN_18007e7f0 (big) -- print only, it is the CATEGORY_ID round trip
|
||||||
|
P()
|
||||||
|
P("======== DECOMPILE FUN_18007e7f0 (full) ========")
|
||||||
|
src = dec(0x18007e7f0, 600)
|
||||||
|
P("len(src) =", len(src))
|
||||||
|
P(src)
|
||||||
|
P("======== END ========")
|
||||||
|
fh.close()
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
try: fh.close()
|
||||||
|
except Exception: pass
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""ADVERSARIAL Q4. Where is the +0x1a0 (sortPriority) merge sort actually used, and
|
||||||
|
what does the 0x1a8 ctor leave in +0x1a0 / +0x94 for GROUP TILES (FUN_180014610 sets
|
||||||
|
neither)? Also FUN_180012950 and FUN_180014380 in full for the group-key claim."""
|
||||||
|
import sys, traceback
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q4_out.txt"
|
||||||
|
try:
|
||||||
|
fh = open(OUT, "w")
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a)
|
||||||
|
print(s); fh.write(s + "\n")
|
||||||
|
P("=== callers of FUN_180010cd0 (the merge-sort driver over +0x1a0) ===")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x180010cd0):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
P("=== callers of FUN_180010890 ===")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x180010890):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
for t in (0x1800130c0, 0x180012950, 0x180014380, 0x180010cd0):
|
||||||
|
f = func(t)
|
||||||
|
P()
|
||||||
|
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
|
||||||
|
src = dec(t, 300)
|
||||||
|
P("len(src) =", len(src))
|
||||||
|
P(src)
|
||||||
|
P("======== END %#x ========" % t)
|
||||||
|
fh.close()
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
try: fh.close()
|
||||||
|
except Exception: pass
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""ADVERSARIAL Q5. The sortPriority merge sort has exactly one entry point
|
||||||
|
(0x180016f81 -> FUN_180010bc0). Identify its containing function, what list it sorts,
|
||||||
|
and who calls it. Also print FUN_1800130c0 in full to see whether +0x1a0 / +0x94 are
|
||||||
|
initialised at all for group tiles (FUN_180014610 sets neither)."""
|
||||||
|
import sys, traceback
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q5_out.txt"
|
||||||
|
try:
|
||||||
|
fh = open(OUT, "w")
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a)
|
||||||
|
print(s); fh.write(s + "\n")
|
||||||
|
f = func(0x180016f81)
|
||||||
|
P("0x180016f81 is inside %s @ %#x size=%#x" % (f.getName(), int(f.getEntryPoint().getOffset()),
|
||||||
|
int(f.getBody().getNumAddresses())))
|
||||||
|
ent = int(f.getEntryPoint().getOffset())
|
||||||
|
P("=== callers of %s ===" % f.getName())
|
||||||
|
for frm, typ, fn, e in xrefs_to(ent):
|
||||||
|
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, e))
|
||||||
|
for t in (ent, 0x1800130c0):
|
||||||
|
g = func(t)
|
||||||
|
P()
|
||||||
|
P("======== DECOMPILE %s @ %#x ========" % (g.getName(), t))
|
||||||
|
src = dec(t, 300)
|
||||||
|
P("len(src) =", len(src)); P(src)
|
||||||
|
P("======== END %#x ========" % t)
|
||||||
|
fh.close()
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
try: fh.close()
|
||||||
|
except Exception: pass
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""ADVERSARIAL BATCH 1.
|
||||||
|
|
||||||
|
HYPOTHESES UNDER ATTACK (all from another agent, assumed WRONG until reproduced):
|
||||||
|
H1 item+0x49 = (untradeable == false), written by atom 0x361 in FUN_18013fe00.
|
||||||
|
H2 FUN_1801a7260 (TO_TRADE_PILE) requires item+0x49 != 0, and the eight flags are
|
||||||
|
ENABLE flags.
|
||||||
|
H3 FUN_18003e370 publishes 8 names in the order DISCARD, MODIFY, TO_ACTIVE_SQUAD,
|
||||||
|
TO_TRADE_PILE, ... and FUN_1800e2a40 fills those 8 bytes in that order.
|
||||||
|
H4 item+0x54 is the discard LEVEL written at 0x180141e8a..0x180141ea3, not itemType.
|
||||||
|
H5 the itemState table starts at 0x180229cc0 with 12 entries.
|
||||||
|
H6 FUN_180166660 has exactly one caller.
|
||||||
|
H7 FUN_1801a8620 (+0x38) and FUN_1801a8090 (+0x3c) have exactly one xref each.
|
||||||
|
|
||||||
|
CONTROL: for every "exactly one caller" claim I also run the SAME xrefs_to call on a
|
||||||
|
function that is known to have many callers (FUN_180135ff0, the value-SKIP, ~134) and
|
||||||
|
on the FNV hasher 0x180180d00, so a zero/one result cannot be a broken scan.
|
||||||
|
Everything is printed IN FULL; no truncation.
|
||||||
|
"""
|
||||||
|
import traceback, sys
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q1_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a)
|
||||||
|
f.write(s + "\n")
|
||||||
|
|
||||||
|
P("=" * 30, "CONTROL: xrefs machinery works", "=" * 30)
|
||||||
|
for nm, a in (("FUN_180135ff0 value-SKIP", 0x180135FF0),
|
||||||
|
("FUN_180180d00 FNV hasher", 0x180180D00),
|
||||||
|
("FUN_1801c7620 BOOL prim", 0x1801C7620)):
|
||||||
|
xr = xrefs_to(a)
|
||||||
|
ents = sorted(set(e for _, t, _, e in xr if "CALL" in t and e))
|
||||||
|
P("%s: %d refs, %d distinct calling funcs" % (nm, len(xr), len(ents)))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 30, "H7 discard getters", "=" * 30)
|
||||||
|
for nm, a in (("FUN_1801a8620 (+0x38 DISCARD_CREDITS?)", 0x1801A8620),
|
||||||
|
("FUN_1801a8090 (+0x3c CALCULATED?)", 0x1801A8090),
|
||||||
|
("FUN_1801a80c0 (CARD_LEVEL?)", 0x1801A80C0)):
|
||||||
|
P("---", nm)
|
||||||
|
fn = fm.getFunctionAt(addr(a))
|
||||||
|
P(" function at addr:", fn.getName() if fn else None)
|
||||||
|
for frm, t, cf, e in xrefs_to(a):
|
||||||
|
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
P(" BODY:")
|
||||||
|
P(dec(a))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 30, "H6 FUN_180166660 callers", "=" * 30)
|
||||||
|
for frm, t, cf, e in xrefs_to(0x180166660):
|
||||||
|
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
P(dec(0x180166660))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 30, "H5 itemState table walk from 0x180229c00", "=" * 30)
|
||||||
|
a = 0x180229C00
|
||||||
|
for i in range(40):
|
||||||
|
p = qword(a + i * 0x10)
|
||||||
|
q = qword(a + i * 0x10 + 8)
|
||||||
|
s = ""
|
||||||
|
if 0x180000000 <= p < 0x181000000:
|
||||||
|
try:
|
||||||
|
s = rd_str(p, 60)
|
||||||
|
except Exception:
|
||||||
|
s = "?"
|
||||||
|
P(" %#x p=%#018x q=%#018x %r" % (a + i * 0x10, p, q, s))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 30, "H2 TO_TRADE_PILE predicate + siblings", "=" * 30)
|
||||||
|
for a in (0x1801A7260, 0x1801A8940, 0x1801A71C0, 0x1801A7210, 0x1801A7250,
|
||||||
|
0x1801A7180, 0x1801A7320, 0x1801A71E0, 0x1801A8900, 0x1801A89F0):
|
||||||
|
fn = fm.getFunctionAt(addr(a))
|
||||||
|
P("### %#x %s xrefs=%d" % (a, fn.getName() if fn else "NO FUNC", len(xrefs_to(a))))
|
||||||
|
for frm, t, cf, e in xrefs_to(a):
|
||||||
|
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
P(dec(a))
|
||||||
|
P()
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 30, "H3 publisher + filler, FULL", "=" * 30)
|
||||||
|
for a in (0x18003E370, 0x1800E2A40):
|
||||||
|
P("### %#x len-of-decompile follows" % a)
|
||||||
|
d = dec(a)
|
||||||
|
P(" len(src) =", len(d))
|
||||||
|
P(d)
|
||||||
|
P()
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 30, "H4 level write at 0x180141e60..0x180141ec0 raw disasm", "=" * 30)
|
||||||
|
ins = listing.getInstructions(addr(0x180141E40), True)
|
||||||
|
n = 0
|
||||||
|
while ins.hasNext() and n < 60:
|
||||||
|
i = ins.next()
|
||||||
|
if int(i.getAddress().getOffset()) > 0x180141EC0:
|
||||||
|
break
|
||||||
|
P(" %#x %s" % (int(i.getAddress().getOffset()), i))
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""BATCH 10: disassemble the undefined thunk at 0x18011c670 (slot +0x270 of the
|
||||||
|
0xed84b12 service = the second gate on TO_TRADE_PILE)."""
|
||||||
|
import traceback
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q10_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
P("bytes at 0x18011c670:", read_bytes(0x18011C670, 64).hex())
|
||||||
|
it = listing.getInstructions(addr(0x18011C670), True)
|
||||||
|
n = 0
|
||||||
|
while it.hasNext() and n < 40:
|
||||||
|
i = it.next(); a = int(i.getAddress().getOffset())
|
||||||
|
if a > 0x18011C6F0: break
|
||||||
|
P(" %#x %s" % (a, i)); n += 1
|
||||||
|
P()
|
||||||
|
for t in (0x18011C4C0, 0x18011C500):
|
||||||
|
P("### %#x" % t); P(dec(t)); P()
|
||||||
|
f.close(); print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""ADVERSARIAL BATCH 2 -- the ABSENCE claims, re-tested with a DIFFERENT method.
|
||||||
|
|
||||||
|
The other agent tested "+0x49 is compared in exactly two places" and "itemState 5/6
|
||||||
|
are never tested" with a LOAD/COMPARE-PAIR scan keyed on displacement. That method
|
||||||
|
has a structural blind spot: a compare performed on a value RETURNED BY AN ACCESSOR
|
||||||
|
never shows the displacement at the compare site. FUN_1801a8940 is exactly such an
|
||||||
|
accessor for +0x49 and it has a caller (FUN_1800bc580) the agent never opened.
|
||||||
|
|
||||||
|
MY METHOD (different): enumerate EVERY instruction in .text whose textual form
|
||||||
|
contains the displacement, with no filter on opcode class at all -- so ==, !=, switch
|
||||||
|
case labels and sub/dec ladders are all caught at the LOAD, and the containing
|
||||||
|
function is then read. Plus a byte-pattern census of the two-instruction accessor
|
||||||
|
shape 48 8b 4x 18 / <load disp> which finds getters my displacement scan would
|
||||||
|
attribute to the getter rather than to its caller.
|
||||||
|
|
||||||
|
CONTROLS (same syntactic form as the targets -- a raw displacement load):
|
||||||
|
0x38 and 0x3c : known-live fields, must come back non-zero
|
||||||
|
0x4c : the other agent reported 37 pairs, must come back >= 37
|
||||||
|
0xdeadbe : impossible displacement, must come back 0 (proves the scan can
|
||||||
|
return zero for a real absence rather than always finding noise)
|
||||||
|
"""
|
||||||
|
import re, traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q2_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
|
||||||
|
TARGETS = [0x38, 0x3c, 0x48, 0x49, 0x4c, 0x54, 0x58, 0x5c, 0x60, 0x88, 0x90]
|
||||||
|
pats = {d: re.compile(r"\+\s*0x%x\s*\]" % d) for d in TARGETS}
|
||||||
|
impossible = re.compile(r"\+\s*0xdeadbe\s*\]")
|
||||||
|
|
||||||
|
hits = {d: [] for d in TARGETS}
|
||||||
|
imp = []
|
||||||
|
n = 0
|
||||||
|
it = listing.getInstructions(True)
|
||||||
|
while it.hasNext():
|
||||||
|
i = it.next()
|
||||||
|
s = i.toString()
|
||||||
|
n += 1
|
||||||
|
for d, p in pats.items():
|
||||||
|
if p.search(s):
|
||||||
|
hits[d].append((int(i.getAddress().getOffset()), s))
|
||||||
|
if impossible.search(s):
|
||||||
|
imp.append(int(i.getAddress().getOffset()))
|
||||||
|
P("instructions scanned:", n)
|
||||||
|
P("IMPOSSIBLE-DISPLACEMENT CONTROL 0xdeadbe hits:", len(imp), "(must be 0)")
|
||||||
|
P()
|
||||||
|
for d in TARGETS:
|
||||||
|
fns = {}
|
||||||
|
for a, s in hits[d]:
|
||||||
|
fn = fm.getFunctionContaining(addr(a))
|
||||||
|
k = (fn.getName(), int(fn.getEntryPoint().getOffset())) if fn else ("?", 0)
|
||||||
|
fns.setdefault(k, []).append((a, s))
|
||||||
|
P("### displacement +0x%02x : %d instructions in %d functions" % (d, len(hits[d]), len(fns)))
|
||||||
|
if d in (0x49, 0x48):
|
||||||
|
for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]):
|
||||||
|
P(" %s @%#x (%d)" % (nm, e, len(lst)))
|
||||||
|
for a, s in lst:
|
||||||
|
P(" %#x %s" % (a, s))
|
||||||
|
elif d == 0x5c:
|
||||||
|
P(" functions:")
|
||||||
|
for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]):
|
||||||
|
P(" %s @%#x n=%d" % (nm, e, len(lst)))
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 30, "+0x5c FULL instruction list (itemState 5/6 absence retest)", "=" * 30)
|
||||||
|
for a, s in hits[0x5C]:
|
||||||
|
fn = fm.getFunctionContaining(addr(a))
|
||||||
|
P(" %#x %-52s %s" % (a, s, fn.getName() if fn else "?"))
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 30, "ACCESSOR CENSUS: byte pattern 48 8b 4x 18 followed by a load", "=" * 30)
|
||||||
|
seen = {}
|
||||||
|
for reg in (0x41, 0x51, 0x49, 0x59, 0x71, 0x79):
|
||||||
|
pat = bytes([0x48, 0x8B, reg, 0x18])
|
||||||
|
for a in find_all(pat, blocks=(".text",)):
|
||||||
|
try:
|
||||||
|
nxt = read_bytes(a + 4, 8)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
seen.setdefault(a, nxt)
|
||||||
|
P("call-shape candidates:", len(seen))
|
||||||
|
interest = {}
|
||||||
|
for a, nxt in seen.items():
|
||||||
|
disp = None
|
||||||
|
if nxt[0] == 0x8B and (nxt[1] & 0xC0) == 0x40:
|
||||||
|
disp = nxt[2]
|
||||||
|
elif nxt[0] == 0x0F and nxt[1] in (0xB6, 0xB7) and (nxt[2] & 0xC0) == 0x40:
|
||||||
|
disp = nxt[3]
|
||||||
|
elif nxt[0] == 0x83 and (nxt[1] & 0xC0) == 0x40:
|
||||||
|
disp = nxt[2]
|
||||||
|
elif nxt[0] == 0x8A and (nxt[1] & 0xC0) == 0x40:
|
||||||
|
disp = nxt[2]
|
||||||
|
if disp in (0x38, 0x3C, 0x48, 0x49, 0x4C, 0x54, 0x58, 0x5C, 0x60, 0x88, 0x90):
|
||||||
|
fn = fm.getFunctionContaining(addr(a))
|
||||||
|
interest.setdefault(disp, []).append((a, fn.getName() if fn else "?",
|
||||||
|
int(fn.getEntryPoint().getOffset()) if fn else 0))
|
||||||
|
for d in sorted(interest):
|
||||||
|
P("### accessor-shape loads of +0x%02x : %d" % (d, len(interest[d])))
|
||||||
|
for a, nm, e in sorted(interest[d], key=lambda x: x[2]):
|
||||||
|
P(" %#x in %s @%#x" % (a, nm, e))
|
||||||
|
if e:
|
||||||
|
nc = [(fr, t, cf, ce) for fr, t, cf, ce in xrefs_to(e) if "CALL" in t]
|
||||||
|
P(" callers: %d -> %s" % (len(nc), sorted(set(cf for _, _, cf, _ in nc))))
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 30, "THE UNOPENED +0x49 CONSUMER: FUN_1800bc580", "=" * 30)
|
||||||
|
d = dec(0x1800BC580)
|
||||||
|
P("len(src) =", len(d))
|
||||||
|
P(d)
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""ADVERSARIAL BATCH 3.
|
||||||
|
|
||||||
|
My batch-2 displacement census turned up FOUR +0x5c sites the other agent's
|
||||||
|
constant-collecting scan did not report, including MOV dword [RDI+0x5c],0x5 and
|
||||||
|
MOV dword [RDI+0x5c],0x6 in FUN_180147070 -- i.e. the client WRITES forSale and
|
||||||
|
offered. Their claim "forSale(5) and offered(6): NEVER TESTED ANYWHERE" and the
|
||||||
|
action "nothing reads them" are under direct attack here.
|
||||||
|
|
||||||
|
Also under attack:
|
||||||
|
- "no other code path can produce the greyout from wire data": FUN_1800bc580 is a
|
||||||
|
THIRD +0x49 consumer (it counts untradeable squad members). What uses that count?
|
||||||
|
- the FUN_1800e2a40 <-> FUN_18003e370 vtable link the agent flagged as a gap.
|
||||||
|
- the +0x23f playStyle mapper, the 0x226 pile mapper, and the record-offset anchor
|
||||||
|
inside FUN_18013fe00 (printed IN FULL, with len).
|
||||||
|
|
||||||
|
CONTROL for the vtable hunt: I search for the 8-byte pointer to FUN_1800e2a40 AND,
|
||||||
|
in the same pass, for the pointer to FUN_1801a7260 (which the agent reported has NO
|
||||||
|
8-byte pointer, only 4-byte .pdata RVAs) and to FUN_18003e370. A hunt that finds all
|
||||||
|
three or none tells me the search itself is sound.
|
||||||
|
"""
|
||||||
|
import traceback, struct
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q3_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
|
||||||
|
P("=" * 25, "A. itemState WRITERS/READERS the other scan missed", "=" * 25)
|
||||||
|
for a in (0x180147070, 0x1801A6FC0, 0x1800A47B0, 0x18011DC50, 0x1800D73D0):
|
||||||
|
d = dec(a)
|
||||||
|
P("### %#x len=%d xrefs:" % (a, len(d)))
|
||||||
|
for frm, t, cf, e in xrefs_to(a):
|
||||||
|
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
P(d)
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 25, "B. the third +0x49 consumer: who calls FUN_1800bc580", "=" * 25)
|
||||||
|
for frm, t, cf, e in xrefs_to(0x1800BC580):
|
||||||
|
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
P("--- FUN_1801a8890 (the sibling predicate counted into param_2):")
|
||||||
|
P(dec(0x1801A8890))
|
||||||
|
P("--- FUN_1801a80a0:")
|
||||||
|
P(dec(0x1801A80A0))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "C. vtable link FUN_1800e2a40 <- FUN_18003e370 slot 0x40", "=" * 25)
|
||||||
|
for nm, a in (("FUN_1800e2a40", 0x1800E2A40), ("FUN_1801a7260", 0x1801A7260),
|
||||||
|
("FUN_18003e370", 0x18003E370), ("FUN_1800eb850", 0x1800EB850)):
|
||||||
|
pat = struct.pack("<Q", a)
|
||||||
|
hits = find_all(pat, blocks=(".rdata", ".data"))
|
||||||
|
P(" %s ptr8 hits: %s" % (nm, [hex(h) for h in hits]))
|
||||||
|
for h in hits:
|
||||||
|
# walk backwards to find the table start (first qword that is not a .text ptr)
|
||||||
|
start = h
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
v = qword(start - 8)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if not (0x180001000 <= v < 0x1801E5000):
|
||||||
|
break
|
||||||
|
start -= 8
|
||||||
|
P(" table start %#x, slot +%#x" % (start, h - start))
|
||||||
|
for i in range(0, 40):
|
||||||
|
try:
|
||||||
|
v = qword(start + i * 8)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if not (0x180001000 <= v < 0x1801E5000):
|
||||||
|
P(" +%#04x %#x <END>" % (i * 8, v))
|
||||||
|
break
|
||||||
|
fn = fm.getFunctionAt(addr(v))
|
||||||
|
P(" +%#04x %#x %s%s" % (i * 8, v, fn.getName() if fn else "",
|
||||||
|
" <== TARGET" if v == a else ""))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "D. FUN_18013fe00 FULL", "=" * 25)
|
||||||
|
d = dec(0x18013FE00, timeout=600)
|
||||||
|
P("len(src) =", len(d))
|
||||||
|
P(d)
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "E. mappers", "=" * 25)
|
||||||
|
for nm, a in (("playStyle FUN_180136480", 0x180136480),
|
||||||
|
("pile FUN_180142650", 0x180142650),
|
||||||
|
("owners helper FUN_1800d7b50", 0x1800D7B50),
|
||||||
|
("BOUGHT_FOR mapper FUN_1800d7b30", 0x1800D7B30),
|
||||||
|
("family FUN_1800d8330", 0x1800D8330)):
|
||||||
|
P("### " + nm)
|
||||||
|
dd = dec(a)
|
||||||
|
P(" len=%d" % len(dd))
|
||||||
|
P(dd)
|
||||||
|
P()
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""ADVERSARIAL BATCH 4 -- the remaining serve-changing and absence claims.
|
||||||
|
|
||||||
|
- FUN_180141660: is the +0x54 level write really on the COMMON tail, or only on the
|
||||||
|
"DB Error" path? If only on the error path the whole level story changes.
|
||||||
|
- FUN_1801b3640: CMP dword [RAX+0x5c],R15D -- a REGISTER compare the other agent's
|
||||||
|
constant-collecting scan could not evaluate. If R15D can be 5 or 6 their
|
||||||
|
"forSale/offered are never tested" absence claim dies.
|
||||||
|
- FUN_18003e550: the listing panel. Does "List on Transfer Market" have its own
|
||||||
|
enable predicate the eight-flag array does not cover?
|
||||||
|
- FUN_1800eb850: are DISCARD_CREDITS / CALCULATED_DISCARD_CREDITS really the two
|
||||||
|
names, pushed from 0x1801a8620 / 0x1801a8090?
|
||||||
|
- 0x226 pile census, re-tested by xrefs to the mapper FUN_180142650 (a DIFFERENT
|
||||||
|
method from decompiling all 134 skip-callers).
|
||||||
|
- itemState string-writer absence, re-tested by xrefs to every one of the 12 string
|
||||||
|
literals, with the ITEM-TYPE table strings ('player','staff') as a control that
|
||||||
|
has known extra users.
|
||||||
|
- FUN_180008190: resolve the indirect string compare through the global vtable.
|
||||||
|
"""
|
||||||
|
import traceback, struct
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q4_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
|
||||||
|
P("=" * 25, "A. FUN_180141660 -- is the level write a common tail?", "=" * 25)
|
||||||
|
fn = fm.getFunctionAt(addr(0x180141660))
|
||||||
|
body = fn.getBody()
|
||||||
|
P("body:", body, " min %#x max %#x" % (int(body.getMinAddress().getOffset()),
|
||||||
|
int(body.getMaxAddress().getOffset())))
|
||||||
|
# every RET in the function, and every branch target landing at/after 0x180141e77
|
||||||
|
rets, brs = [], []
|
||||||
|
it = listing.getInstructions(body, True)
|
||||||
|
while it.hasNext():
|
||||||
|
i = it.next()
|
||||||
|
m = i.getMnemonicString()
|
||||||
|
a = int(i.getAddress().getOffset())
|
||||||
|
if m == "RET":
|
||||||
|
rets.append(a)
|
||||||
|
if m.startswith("J"):
|
||||||
|
for r in i.getFlows():
|
||||||
|
t = int(r.getOffset())
|
||||||
|
if 0x180141E70 <= t <= 0x180141EB0:
|
||||||
|
brs.append((a, m, t))
|
||||||
|
P("RET sites:", [hex(x) for x in rets])
|
||||||
|
P("branches into the tail 0x180141e70..0x180141eb0:")
|
||||||
|
for a, m, t in brs:
|
||||||
|
P(" %#x %s -> %#x" % (a, m, t))
|
||||||
|
P()
|
||||||
|
P("FUN_180141660 decompile:")
|
||||||
|
d = dec(0x180141660, timeout=600)
|
||||||
|
P("len =", len(d))
|
||||||
|
P(d)
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "B. FUN_1801b3640 -- the register compare on +0x5c", "=" * 25)
|
||||||
|
ins = listing.getInstructions(addr(0x1801B3860), True)
|
||||||
|
n = 0
|
||||||
|
while ins.hasNext() and n < 90:
|
||||||
|
i = ins.next()
|
||||||
|
a = int(i.getAddress().getOffset())
|
||||||
|
if a > 0x1801B38E0:
|
||||||
|
break
|
||||||
|
P(" %#x %s" % (a, i))
|
||||||
|
n += 1
|
||||||
|
P()
|
||||||
|
P("R15 setup search 0x1801b3640..0x1801b3894:")
|
||||||
|
ins = listing.getInstructions(addr(0x1801B3640), True)
|
||||||
|
while ins.hasNext():
|
||||||
|
i = ins.next()
|
||||||
|
a = int(i.getAddress().getOffset())
|
||||||
|
if a > 0x1801B3894:
|
||||||
|
break
|
||||||
|
s = i.toString()
|
||||||
|
if "R15" in s:
|
||||||
|
P(" %#x %s" % (a, s))
|
||||||
|
P()
|
||||||
|
d = dec(0x1801B3640, timeout=600)
|
||||||
|
P("FUN_1801b3640 len =", len(d))
|
||||||
|
P(d)
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "C. FUN_18003e550 listing panel + FUN_1800eb850 discard push", "=" * 25)
|
||||||
|
for a in (0x18003E550, 0x1800EB850):
|
||||||
|
d = dec(a, timeout=600)
|
||||||
|
P("### %#x len=%d" % (a, len(d)))
|
||||||
|
P(d)
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 25, "D. pile mapper xrefs (different method for the 0x226 census)", "=" * 25)
|
||||||
|
for frm, t, cf, e in xrefs_to(0x180142650):
|
||||||
|
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "E. itemState string literals: every xref", "=" * 25)
|
||||||
|
names = ["invalid", "free", "WAITING_FOR_GAME", "inGame", "forSale", "offered",
|
||||||
|
"activeBadge", "activeHomeKit", "activeAwayKit", "activeBall",
|
||||||
|
"activeStadium", "active",
|
||||||
|
"player", "staff"] # last two = CONTROL, known to be used elsewhere
|
||||||
|
for nm in names:
|
||||||
|
hits = find_all(nm.encode() + b"\x00", blocks=(".rdata", ".data"))
|
||||||
|
P("### %-18s literal hits: %s" % (nm, [hex(h) for h in hits]))
|
||||||
|
for h in hits:
|
||||||
|
for frm, t, cf, e in xrefs_to(h):
|
||||||
|
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
|
||||||
|
P()
|
||||||
|
P("=" * 25, "F. FUN_180008190 indirect compare + FUN_180130d10 + FUN_1801c3480", "=" * 25)
|
||||||
|
for a in (0x180008190, 0x180130D10, 0x1801C3480):
|
||||||
|
d = dec(a, timeout=600)
|
||||||
|
P("### %#x len=%d" % (a, len(d)))
|
||||||
|
P(d)
|
||||||
|
P()
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""ADVERSARIAL BATCH 5 -- consequences of the one serve-changing action, and the
|
||||||
|
service gate the other agent left open.
|
||||||
|
|
||||||
|
1. untradeable:false flips item+0x49 to 1 on EVERY card. Besides TO_TRADE_PILE that
|
||||||
|
byte feeds FUN_1800bc580, which counts untradeable members of the 11-slot active
|
||||||
|
squad. Who consumes that count, and does flipping it change anything else?
|
||||||
|
2. FUN_1801a7260's other gate: slot +0x270 of the service FUN_180009c80 resolves.
|
||||||
|
Identify the service vtable and that slot if possible.
|
||||||
|
"""
|
||||||
|
import traceback, struct
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q5_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
|
||||||
|
P("=" * 25, "1. consumers of the untradeable-squad count", "=" * 25)
|
||||||
|
for a in (0x1800BB2A0, 0x1800BBA10):
|
||||||
|
d = dec(a, timeout=600)
|
||||||
|
P("### %#x len=%d" % (a, len(d)))
|
||||||
|
P(d)
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 25, "2. the service locator used by FUN_1801a7260", "=" * 25)
|
||||||
|
for nm, a in (("FUN_1800d7170", 0x1800D7170), ("FUN_180009c80", 0x180009C80),
|
||||||
|
("FUN_180018bd0", 0x180018BD0), ("FUN_180009b60", 0x180009B60)):
|
||||||
|
P("### " + nm)
|
||||||
|
P(dec(a))
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 25, "3. any vtable with >= 0x280 bytes containing plausible slot 0x270", "=" * 25)
|
||||||
|
# find .rdata runs of >= 0x50 consecutive .text pointers; report those long enough
|
||||||
|
for b in mem.getBlocks():
|
||||||
|
if b.getName() != ".rdata" or not b.isInitialized():
|
||||||
|
continue
|
||||||
|
s = int(b.getStart().getOffset())
|
||||||
|
e = int(b.getEnd().getOffset())
|
||||||
|
a = (s + 7) & ~7
|
||||||
|
run_start = None
|
||||||
|
while a + 8 <= e:
|
||||||
|
try:
|
||||||
|
v = qword(a)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
ok = 0x180001000 <= v < 0x1801E5000
|
||||||
|
if ok and run_start is None:
|
||||||
|
run_start = a
|
||||||
|
elif not ok and run_start is not None:
|
||||||
|
ln = a - run_start
|
||||||
|
if ln >= 0x280:
|
||||||
|
P(" vtable-ish run %#x..%#x len %#x slot+0x270 -> %#x %s" %
|
||||||
|
(run_start, a, ln, qword(run_start + 0x270),
|
||||||
|
(lambda fn: fn.getName() if fn else "")(fm.getFunctionAt(addr(qword(run_start + 0x270))))))
|
||||||
|
run_start = None
|
||||||
|
a += 8
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""ADVERSARIAL BATCH 6 -- pin the service behind GUID 0xed84b11/0xed84b12 whose
|
||||||
|
vtable slot +0x270 is the OTHER gate on TO_TRADE_PILE. If that gate is an online /
|
||||||
|
transfer-market-availability check it may block the menu even with untradeable:false,
|
||||||
|
which is the single biggest risk to the headline recommendation.
|
||||||
|
|
||||||
|
METHOD: the class that implements an interface references the same GUID constant when
|
||||||
|
it registers. Scan .text for the 4-byte immediates and report every function.
|
||||||
|
CONTROL: the same scan for 0x10c80b95 (the CardInventory-ish service FUN_18003e370
|
||||||
|
uses) and 0xed80ed8 -- if those come back with registrars and 0xed84b11 does not, the
|
||||||
|
absence is about this GUID and not about the scan.
|
||||||
|
"""
|
||||||
|
import traceback, struct
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q6_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
|
||||||
|
for g in (0xED84B11, 0xED84B12, 0x10C80B95, 0x10C80B96, 0xED80ED8):
|
||||||
|
pat = struct.pack("<I", g)
|
||||||
|
hits = find_all(pat, blocks=(".text", ".rdata", ".data"))
|
||||||
|
fns = {}
|
||||||
|
for h in hits:
|
||||||
|
fn = fm.getFunctionContaining(addr(h))
|
||||||
|
k = fn.getName() if fn else "(data)"
|
||||||
|
fns.setdefault(k, []).append(h)
|
||||||
|
P("### GUID %#x : %d byte hits in %d functions" % (g, len(hits), len(fns)))
|
||||||
|
for k, v in sorted(fns.items()):
|
||||||
|
P(" %-24s %s" % (k, [hex(x) for x in v]))
|
||||||
|
P()
|
||||||
|
|
||||||
|
P("=" * 25, "the registrar bodies", "=" * 25)
|
||||||
|
seen = set()
|
||||||
|
for g in (0xED84B11, 0xED84B12):
|
||||||
|
for h in find_all(struct.pack("<I", g), blocks=(".text",)):
|
||||||
|
fn = fm.getFunctionContaining(addr(h))
|
||||||
|
if fn is None:
|
||||||
|
continue
|
||||||
|
e = int(fn.getEntryPoint().getOffset())
|
||||||
|
if e in seen:
|
||||||
|
continue
|
||||||
|
seen.add(e)
|
||||||
|
P("### %s @%#x" % (fn.getName(), e))
|
||||||
|
P(dec(e))
|
||||||
|
P()
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""ADVERSARIAL BATCH 7 -- finish the two open links.
|
||||||
|
|
||||||
|
(a) 0x10c80b96 appears as data at 0x1800e1662, inside the function at vtable slot
|
||||||
|
+0xa8 of the table 0x180215a80 -- the same table whose slot +0xd0 is
|
||||||
|
FUN_1800e2a40. If that holds it independently proves the FUN_18003e370 ->
|
||||||
|
FUN_1800e2a40 link the other agent could only infer semantically.
|
||||||
|
(b) 0xed84b12 appears as data at 0x180113f52. Whatever class that belongs to is the
|
||||||
|
service FUN_1801a7260 calls slot +0x270 on. Find its vtable and read slot 0x270.
|
||||||
|
"""
|
||||||
|
import traceback, struct
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q7_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
|
||||||
|
def P(*a):
|
||||||
|
f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
|
||||||
|
for a in (0x1800E1662, 0x180113F52):
|
||||||
|
fn = fm.getFunctionContaining(addr(a))
|
||||||
|
P("### data GUID at %#x -> containing function %s @%#x" %
|
||||||
|
(a, fn.getName() if fn else None,
|
||||||
|
int(fn.getEntryPoint().getOffset()) if fn else 0))
|
||||||
|
if fn:
|
||||||
|
e = int(fn.getEntryPoint().getOffset())
|
||||||
|
P(dec(e))
|
||||||
|
hits = find_all(struct.pack("<Q", e), blocks=(".rdata", ".data"))
|
||||||
|
P(" 8-byte pointer to it: %s" % [hex(h) for h in hits])
|
||||||
|
for h in hits:
|
||||||
|
start = h
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
v = qword(start - 8)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if not (0x180001000 <= v < 0x1801E5000):
|
||||||
|
break
|
||||||
|
start -= 8
|
||||||
|
P(" run start %#x, this fn at slot +%#x" % (start, h - start))
|
||||||
|
# find the first non-stub entry -- the secondary vtable base
|
||||||
|
base = start
|
||||||
|
while qword(base) == 0x1801C577A:
|
||||||
|
base += 8
|
||||||
|
P(" first non-stub entry at %#x (offset +%#x from run start)" % (base, base - start))
|
||||||
|
P(" => slot of this fn relative to first non-stub: +%#x" % (h - base))
|
||||||
|
for i in range(0, 90):
|
||||||
|
v = qword(base + i * 8)
|
||||||
|
if not (0x180001000 <= v < 0x1801E5000):
|
||||||
|
break
|
||||||
|
f2 = fm.getFunctionAt(addr(v))
|
||||||
|
mark = ""
|
||||||
|
if i * 8 == 0x270:
|
||||||
|
mark = " <== SLOT 0x270"
|
||||||
|
if i * 8 == 0x40:
|
||||||
|
mark = " <== SLOT 0x40"
|
||||||
|
P(" +%#05x %#x %s%s" % (i * 8, v, f2.getName() if f2 else "", mark))
|
||||||
|
P()
|
||||||
|
|
||||||
|
f.close()
|
||||||
|
print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""BATCH 8: the two GUID-returning stubs are undefined functions. Read them as raw
|
||||||
|
instructions and find the vtable that holds them. CONTROL: both stubs must decode to
|
||||||
|
'mov eax, <guid>; ret' -- if they do not, my reading of them as interface-id getters
|
||||||
|
is wrong and I say so."""
|
||||||
|
import traceback, struct
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q8_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
for lo, hi in ((0x1800E1650, 0x1800E1690), (0x180113F40, 0x180113F80)):
|
||||||
|
P("### raw %#x..%#x" % (lo, hi))
|
||||||
|
P(" bytes:", read_bytes(lo, hi - lo).hex())
|
||||||
|
it = listing.getInstructions(addr(lo), True)
|
||||||
|
while it.hasNext():
|
||||||
|
i = it.next()
|
||||||
|
a = int(i.getAddress().getOffset())
|
||||||
|
if a >= hi: break
|
||||||
|
P(" %#x %s" % (a, i))
|
||||||
|
P()
|
||||||
|
for cand in (0x1800E1660, 0x180113F50, 0x180113F4C, 0x180113F40):
|
||||||
|
hits = find_all(struct.pack("<Q", cand), blocks=(".rdata", ".data"))
|
||||||
|
P("ptr8 to %#x : %s" % (cand, [hex(h) for h in hits]))
|
||||||
|
for h in hits:
|
||||||
|
start = h
|
||||||
|
while True:
|
||||||
|
try: v = qword(start - 8)
|
||||||
|
except Exception: break
|
||||||
|
if not (0x180001000 <= v < 0x1801E5000): break
|
||||||
|
start -= 8
|
||||||
|
base = start
|
||||||
|
while qword(base) == 0x1801C577A: base += 8
|
||||||
|
P(" run %#x, first non-stub %#x, this at +%#x from non-stub" % (start, base, h - base))
|
||||||
|
for i in range(0, 100):
|
||||||
|
v = qword(base + i * 8)
|
||||||
|
if not (0x180001000 <= v < 0x1801E5000): break
|
||||||
|
fn = fm.getFunctionAt(addr(v))
|
||||||
|
if i*8 in (0x40, 0x270, 0x308, 0x290, 0x2b0, 0x148, 0xd0, 0x20):
|
||||||
|
P(" +%#05x %#x %s" % (i*8, v, fn.getName() if fn else ""))
|
||||||
|
P(" table length: %#x" % (i*8))
|
||||||
|
f.close(); print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""BATCH 9. The cast helper is vtable slot +0x18 (FUN_180009c80 calls
|
||||||
|
(*(*svc))[0x18] with the interface GUID). So vtable_base = cast_stub_slot_addr - 0x18.
|
||||||
|
- 0x10c80b96 class: stub ptr at 0x180215b28 -> base 0x180215b10 -> slot +0x40 must be
|
||||||
|
FUN_1800e2a40 if the FUN_18003e370 link is real. (CONTROL for the arithmetic.)
|
||||||
|
- 0xed84b12 class: stub ptr at 0x18021c2b8 -> base 0x18021c2a0 -> slot +0x270 is the
|
||||||
|
other gate on TO_TRADE_PILE.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q9_raw.txt"
|
||||||
|
try:
|
||||||
|
f = open(OUT, "w")
|
||||||
|
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
|
||||||
|
for nm, base, slots in (("iface 0x10c80b96 (CONTROL)", 0x180215B10, (0x18, 0x40)),
|
||||||
|
("iface 0xed84b12", 0x18021C2A0, (0x18, 0x270, 0x290, 0x2b0, 0x308, 0x148))):
|
||||||
|
P("### %s vtable base %#x" % (nm, base))
|
||||||
|
for s in slots:
|
||||||
|
v = qword(base + s)
|
||||||
|
fn = fm.getFunctionAt(addr(v))
|
||||||
|
P(" +%#05x -> %#x %s" % (s, v, fn.getName() if fn else ""))
|
||||||
|
P()
|
||||||
|
for a in (0x1801B1CE0,):
|
||||||
|
pass
|
||||||
|
v = qword(0x18021C2A0 + 0x270)
|
||||||
|
P("=== slot 0x270 body ===")
|
||||||
|
P(dec(v, timeout=300))
|
||||||
|
P("=== xrefs to it ===")
|
||||||
|
for frm, t, cf, e in xrefs_to(v):
|
||||||
|
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
|
||||||
|
f.close(); print("WROTE", OUT)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""ADVERSARIAL BATCH 1.
|
||||||
|
|
||||||
|
HYPOTHESES UNDER TEST (all from another agent, assumed WRONG until reproduced):
|
||||||
|
H1 FUN_1800d8330 maps cardsubtypeid -> cardtype and returns 9 for exactly
|
||||||
|
{0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec}; and returns 7 for 9,10,11.
|
||||||
|
H2 FUN_180119bd0 arms: 9 -> KITS, 10 -> Stadium, 0xb -> Badge, else "".
|
||||||
|
H3 FUN_1801a8640 == *(u32*)(*(u64*)(param_1+0x18)+0x50) i.e. cardsubtypeid.
|
||||||
|
H4 FUN_1800f6c40 calls vtable+0x498 only when item+0x4c == 7, args
|
||||||
|
(item+0x50, item+0x94, item+0x20); and sets IS_KIT_%d when item+0x50==9.
|
||||||
|
H5 FUN_180141660 tail writes item+0x54 = level(rating@+0xb4): 3 if >=0x4b,
|
||||||
|
else 2 - (rating < 0x41). <-- CONTRADICTS the live-map "+0x54 = itemType".
|
||||||
|
|
||||||
|
CONTROL: FUN_1800d8330 must decompile non-empty and its case labels must be
|
||||||
|
recoverable; it is a jump table, which is the form that DEFEATED an earlier scan.
|
||||||
|
Every decompile is written to disk IN FULL with its length printed, so no claim
|
||||||
|
here can rest on a truncated body.
|
||||||
|
|
||||||
|
Output: /tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/
|
||||||
|
"""
|
||||||
|
import traceback, os
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
TARGETS = {
|
||||||
|
"FUN_1800d8330": 0x1800d8330,
|
||||||
|
"FUN_180119bd0": 0x180119bd0,
|
||||||
|
"FUN_1801a8640": 0x1801a8640,
|
||||||
|
"FUN_1800f6c40": 0x1800f6c40,
|
||||||
|
"FUN_180141660": 0x180141660,
|
||||||
|
"FUN_1801a8570": 0x1801a8570,
|
||||||
|
"FUN_1801a8560": 0x1801a8560,
|
||||||
|
"FUN_1801a8800": 0x1801a8800,
|
||||||
|
"FUN_1801a8040": 0x1801a8040,
|
||||||
|
"FUN_180136480": 0x180136480,
|
||||||
|
}
|
||||||
|
for name, a in TARGETS.items():
|
||||||
|
src = dec(a)
|
||||||
|
p = os.path.join(OUT, name + ".c")
|
||||||
|
open(p, "w").write(src)
|
||||||
|
print("WROTE %-16s len=%6d -> %s" % (name, len(src), p))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== small functions printed IN FULL ===")
|
||||||
|
for name in ("FUN_1800d8330", "FUN_1801a8640", "FUN_1801a8570", "FUN_1801a8560",
|
||||||
|
"FUN_1801a8800", "FUN_1801a8040", "FUN_180119bd0"):
|
||||||
|
src = open(os.path.join(OUT, name + ".c")).read()
|
||||||
|
print("\n----------8<---------- %s (len=%d) ----------" % (name, len(src)))
|
||||||
|
print(src)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== CONTROL: case labels of FUN_1800d8330 via the listing ===")
|
||||||
|
f = func(0x1800d8330)
|
||||||
|
print("entry 0x%x body %s" % (int(f.getEntryPoint().getOffset()), f.getBody()))
|
||||||
|
it = listing.getInstructions(f.getBody(), True)
|
||||||
|
n = 0
|
||||||
|
while it.hasNext():
|
||||||
|
ins = it.next()
|
||||||
|
n += 1
|
||||||
|
print("instruction count: %d" % n)
|
||||||
|
# enumerate caseD_ labels inside the body
|
||||||
|
st = prog.getSymbolTable()
|
||||||
|
labs = []
|
||||||
|
rng = f.getBody()
|
||||||
|
for sym in st.getAllSymbols(True):
|
||||||
|
a2 = sym.getAddress()
|
||||||
|
if a2 is not None and rng.contains(a2) and str(sym.getName()).startswith("caseD_"):
|
||||||
|
labs.append((str(sym.getName()), int(a2.getOffset())))
|
||||||
|
print("caseD_ labels in FUN_1800d8330: %d -> %s" % (len(labs), sorted(set(l[0] for l in labs))))
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""ADVERSARIAL BATCH 2.
|
||||||
|
|
||||||
|
MAIN ATTACK: the claim "cardtype 9 has NO resolver at all, so ball and leaguelogo
|
||||||
|
display strings must come off the wire (localizedName + description)". That claim
|
||||||
|
CHANGES WHAT WE SERVE, so it is priority 1.
|
||||||
|
|
||||||
|
Counter-evidence to chase: .rdata at 0x1802041d0 holds 'fcc_leaguelogos' and
|
||||||
|
0x1802041e0 holds 'LeagueName_Abbr_15_%d', sitting immediately beside 'FUT_UC_KITS'
|
||||||
|
(0x180204180) which IS a resolver literal. If some function formats
|
||||||
|
LeagueName_Abbr_15_%d for a league logo, the "must come off the wire" claim is wrong.
|
||||||
|
|
||||||
|
H6 vtable+0x490 = FUN_18011a860 is a GENERIC name resolver taking
|
||||||
|
(cardtype@+0x4c, cardsubtypeid@+0x50, resourceId@+0x18). Does it have a
|
||||||
|
cardtype-9 arm?
|
||||||
|
H7 'fcc_leaguelogos' / 'LeagueName_Abbr_15_%d' are referenced by some function.
|
||||||
|
H8 FUN_18012ee20 has EXACTLY ONE caller (the club URL builder). [absence claim]
|
||||||
|
H9 FUN_1800fed90 is the ONLY function whose switch case set is exactly
|
||||||
|
{0x91..0x96}. [absence claim -- re-tested here by a DIFFERENT method than
|
||||||
|
the original caseD_ symbol enumeration: I enumerate switch tables from the
|
||||||
|
instruction/flow side via getBasicBlocks + scalar operands, AND repeat the
|
||||||
|
symbol method, and compare the two.]
|
||||||
|
H10 FUN_180141660 (the merge) is called on every deserialized item.
|
||||||
|
|
||||||
|
CONTROL for the xref questions: 'FUT_UC_KITS' at 0x180204180 MUST come back with
|
||||||
|
>=1 referencing function (we already know FUN_180119bd0 uses it). If the xref
|
||||||
|
method returns 0 for FUT_UC_KITS the method is broken and every negative is void.
|
||||||
|
Same syntactic form (a .rdata string address referenced by a LEA) as the targets.
|
||||||
|
"""
|
||||||
|
import traceback, os
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=== CONTROL + targets: xrefs to .rdata string addresses ===")
|
||||||
|
STRS = {
|
||||||
|
"FUT_UC_KITS (CONTROL)": 0x180204180,
|
||||||
|
"FUT_UC_BALL": 0x180239120,
|
||||||
|
"fcc_leaguelogos": 0x1802041d0,
|
||||||
|
"LeagueName_Abbr_15_%d": 0x1802041e0,
|
||||||
|
"leagues": 0x1802041b0,
|
||||||
|
"Badge (0x1802041b8)": 0x1802041b8,
|
||||||
|
"countryid": 0x1802041c0,
|
||||||
|
"fcc_myclubs": 0x180204190,
|
||||||
|
"TeamName_Abbr15_%d?": None,
|
||||||
|
}
|
||||||
|
for name, a in STRS.items():
|
||||||
|
if a is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
xs = xrefs_to(a)
|
||||||
|
except Exception as e:
|
||||||
|
print(" %-24s XREF ERROR %s" % (name, e)); continue
|
||||||
|
fns = sorted(set((x[2], x[3]) for x in xs))
|
||||||
|
print(" %-24s 0x%x %d refs, funcs: %s" %
|
||||||
|
(name, a, len(xs), ["%s@0x%x" % (n, e) for n, e in fns]))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== find TeamName_Abbr15_%d and StadiumName_%d addresses then xref ===")
|
||||||
|
for lit in (b"TeamName_Abbr15_%d\x00", b"StadiumName_%d\x00", b"LeagueName_Abbr_15_%d\x00",
|
||||||
|
b"fcc_leaguelogos\x00", b"fcc_balls\x00", b"fcc_stadium\x00",
|
||||||
|
b"fcc_badgecards\x00", b"fcc_kitcards\x00", b"fcc_misccards\x00"):
|
||||||
|
hits = find_all(lit, blocks=(".rdata", ".data", ".text"))
|
||||||
|
print(" %-26s %d hit(s) at %s" % (lit.rstrip(b"\x00").decode(), len(hits),
|
||||||
|
[hex(h) for h in hits]))
|
||||||
|
for h in hits:
|
||||||
|
xs = xrefs_to(h)
|
||||||
|
fns = sorted(set((x[2], x[3]) for x in xs))
|
||||||
|
print(" -> %d refs: %s" % (len(xs), ["%s@0x%x" % (n, e) for n, e in fns]))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== H6: generic resolver FUN_18011a860 (vtable +0x490) FULL ===")
|
||||||
|
src = dec(0x18011a860)
|
||||||
|
open(os.path.join(OUT, "FUN_18011a860.c"), "w").write(src)
|
||||||
|
print("len=%d" % len(src))
|
||||||
|
print(src)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== H8: callers of FUN_18012ee20 (itemState code -> atom) ===")
|
||||||
|
for fa in (0x18012ee20, 0x180141660, 0x180166660, 0x1800fed90):
|
||||||
|
try:
|
||||||
|
cs = callers(fa)
|
||||||
|
except Exception:
|
||||||
|
cs = [(x[0], x[2], x[3]) for x in xrefs_to(fa)]
|
||||||
|
print(" FUN_%x callers: %s" % (fa, cs))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== H9: switch case-set enumeration, TWO methods ===")
|
||||||
|
st = prog.getSymbolTable()
|
||||||
|
# method 1: caseD_ symbols grouped by containing function
|
||||||
|
import collections
|
||||||
|
bysym = collections.defaultdict(set)
|
||||||
|
n = 0
|
||||||
|
for sym in st.getAllSymbols(True):
|
||||||
|
nm = str(sym.getName())
|
||||||
|
if not nm.startswith("caseD_"):
|
||||||
|
continue
|
||||||
|
n += 1
|
||||||
|
a2 = sym.getAddress()
|
||||||
|
f = fm.getFunctionContaining(a2)
|
||||||
|
if f is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
v = int(nm.split("_")[-1], 16)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
bysym[int(f.getEntryPoint().getOffset())].add(v)
|
||||||
|
print(" method1: %d caseD_ symbols over %d functions" % (n, len(bysym)))
|
||||||
|
TARGET = set(range(0x91, 0x97))
|
||||||
|
exact = [hex(k) for k, v in bysym.items() if v == TARGET]
|
||||||
|
superset = [hex(k) for k, v in bysym.items() if TARGET <= v and v != TARGET]
|
||||||
|
overlap = [hex(k) for k, v in bysym.items() if (TARGET & v) and not (TARGET <= v)]
|
||||||
|
print(" functions with case set EXACTLY {0x91..0x96}: %s" % exact)
|
||||||
|
print(" functions whose case set is a SUPERSET: %s" % superset)
|
||||||
|
print(" functions with PARTIAL overlap: %s" % overlap)
|
||||||
|
print(" CONTROL FUN_1800d8330 present in method1? %s -> %s" %
|
||||||
|
(0x1800d8330 in bysym, sorted(hex(x) for x in bysym.get(0x1800d8330, []))))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== H10: callers of the merge FUN_180141660 ===")
|
||||||
|
xs = xrefs_to(0x180141660)
|
||||||
|
print(" %d refs: %s" % (len(xs), sorted(set("%s@0x%x" % (x[2], x[3]) for x in xs))))
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""ADVERSARIAL BATCH 3.
|
||||||
|
|
||||||
|
PRIORITY-1 ATTACK: FUN_180098f20 is the ONLY referencer of both 'fcc_leaguelogos'
|
||||||
|
and 'LeagueName_Abbr_15_%d'. If it resolves a league-logo display name from the DB,
|
||||||
|
then the claim "cardtype 9 has no resolver at all, so ball and leaguelogo need
|
||||||
|
localizedName + description off the wire" is WRONG, and that claim changes what we
|
||||||
|
serve.
|
||||||
|
|
||||||
|
ALSO:
|
||||||
|
H11 FUN_180108c00 deserializes atom 0x32f (tournamentType) and computes
|
||||||
|
subtype = value + 0x91. (the trophy claim)
|
||||||
|
H12 FUN_1801bfac0 arm iVar5 == 0x1e -> FUT_UC_BALL, and the 0x1f arm.
|
||||||
|
H13 DAT_18022315c is the string "rare" (supports low-dword-of-uStack_130 = rareflag)
|
||||||
|
H14 the deser's stack struct -> record copy: which stack slot becomes record+0x58.
|
||||||
|
|
||||||
|
CONTROL for the "who calls X" questions: FUN_180119bd0 must come back with >=1
|
||||||
|
caller (we already proved FUN_1800f6c40 calls it through vtable slot +0x498 --
|
||||||
|
though that is an INDIRECT call, so a direct-xref method may legitimately return 0;
|
||||||
|
that is exactly why the control matters and why a 0 here is NOT an absence).
|
||||||
|
"""
|
||||||
|
import traceback, os
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=== H13: strings at the DAT_ addresses used as DB column names ===")
|
||||||
|
for a in (0x18022315c, 0x1801eeeb0, 0x1802ef590, 0x18021ce7c, 0x18021ce7f, 0x1801e9caf):
|
||||||
|
try:
|
||||||
|
print(" 0x%x -> %r" % (a, rd_str(a, 40)))
|
||||||
|
except Exception as e:
|
||||||
|
print(" 0x%x -> ERR %s" % (a, e))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== PRIORITY 1: FUN_180098f20 FULL (the fcc_leaguelogos referencer) ===")
|
||||||
|
src = dec(0x180098f20)
|
||||||
|
open(os.path.join(OUT, "FUN_180098f20.c"), "w").write(src)
|
||||||
|
print("len=%d" % len(src))
|
||||||
|
print(src)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== who calls FUN_180098f20 ? ===")
|
||||||
|
for fa, label in ((0x180098f20, "leaguelogo resolver"),
|
||||||
|
(0x180119bd0, "CONTROL kit/stadium/badge resolver (indirect-only expected)"),
|
||||||
|
(0x18011a860, "generic resolver +0x490"),
|
||||||
|
(0x180094580, "third FUT_UC_KITS user"),
|
||||||
|
(0x1800991a0, "fcc_myclubs user"),
|
||||||
|
(0x180099490, "leagues/countryid/Badge user")):
|
||||||
|
xs = xrefs_to(fa)
|
||||||
|
print(" 0x%x %-52s %d refs: %s" %
|
||||||
|
(fa, label, len(xs), sorted(set("%s@0x%x" % (x[2], x[3]) for x in xs))))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== H11: FUN_180108c00 FULL (tournamentType -> subtype 0x91+) ===")
|
||||||
|
src = dec(0x180108c00)
|
||||||
|
open(os.path.join(OUT, "FUN_180108c00.c"), "w").write(src)
|
||||||
|
print("len=%d" % len(src))
|
||||||
|
print(src[:9000])
|
||||||
|
if len(src) > 9000:
|
||||||
|
print("... [remainder in FUN_180108c00.c]")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== FUN_1800fed90 FULL (the 0x91..0x96 switch) ===")
|
||||||
|
src = dec(0x1800fed90)
|
||||||
|
open(os.path.join(OUT, "FUN_1800fed90.c"), "w").write(src)
|
||||||
|
print("len=%d" % len(src))
|
||||||
|
print(src)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== re-decompile the item deser MYSELF (do not trust the other agent's copy) ===")
|
||||||
|
src = dec(0x18013fe00, timeout=600)
|
||||||
|
p = os.path.join(OUT, "FUN_18013fe00.c")
|
||||||
|
open(p, "w").write(src)
|
||||||
|
print("len=%d -> %s" % (len(src), p))
|
||||||
|
# print only the lines that matter for H14
|
||||||
|
for i, ln in enumerate(src.splitlines(), 1):
|
||||||
|
if ("uStack_130" in ln or "local_100" in ln or "FUN_180141660" in ln
|
||||||
|
or "local_13c" in ln or "local_138" in ln):
|
||||||
|
print(" %4d: %s" % (i, ln))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== also dump the card-detail builder for the 0x1e / 0x1f arms ===")
|
||||||
|
src = dec(0x1801bfac0, timeout=600)
|
||||||
|
open(os.path.join(OUT, "FUN_1801bfac0.c"), "w").write(src)
|
||||||
|
print("len=%d" % len(src))
|
||||||
|
for i, ln in enumerate(src.splitlines(), 1):
|
||||||
|
if ("0x1e" in ln or "0x1f" in ln or "FUT_UC_BALL" in ln or "FUN_1801a8640" in ln
|
||||||
|
or "Stadium" in ln or "Badge" in ln or "FUT_UC_KITS" in ln
|
||||||
|
or "LeagueName" in ln or "fcc_" in ln):
|
||||||
|
print(" %4d: %s" % (i, ln))
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""ADVERSARIAL VERIFICATION BATCH 1.
|
||||||
|
|
||||||
|
HYPOTHESES UNDER ATTACK (from the D4 report):
|
||||||
|
H-A record+0x54 is card LEVEL derived from rating by an unconditional ladder in
|
||||||
|
the tail of FUN_180141660, NOT itemType.
|
||||||
|
H-B FUN_1801a87f0 is a one-byte read of record+0xb4 and all four OVERALL_RATING
|
||||||
|
publishers call it.
|
||||||
|
H-C playStyle lands at record+0x88, FUN_180136480 accepts only 0xfb..0x111.
|
||||||
|
H-D atom 0x173 itemType never becomes an int.
|
||||||
|
|
||||||
|
CONTROLS.
|
||||||
|
* For every "no such thing" statement I enumerate case labels, `== 0x`, `!= 0x`
|
||||||
|
AND sub/dec ladders, and I state which form the positive control used.
|
||||||
|
* Positive control for the dispatch enumeration: atoms 0x274 (rating) and 0x287
|
||||||
|
(resourceId), both known-present, must be found by the SAME enumerator.
|
||||||
|
* Positive control for the literal-xref method: a literal whose xref count is
|
||||||
|
independently known.
|
||||||
|
Everything is written to files; nothing is truncated.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
|
||||||
|
|
||||||
|
try:
|
||||||
|
import re
|
||||||
|
|
||||||
|
def dump(name, s):
|
||||||
|
p = OUT + name
|
||||||
|
open(p, "w").write(s)
|
||||||
|
print("WROTE %s (%d chars)" % (p, len(s)))
|
||||||
|
|
||||||
|
targets = {
|
||||||
|
"merge_141660": 0x180141660,
|
||||||
|
"deser_13fe00": 0x18013FE00,
|
||||||
|
"playersmerge_135890": 0x180135890,
|
||||||
|
"acc_rating_1a87f0": 0x1801A87F0,
|
||||||
|
"acc_cardlevel_1a80c0": 0x1801A80C0,
|
||||||
|
"acc_playstyle_1a85c0": 0x1801A85C0,
|
||||||
|
"acc_league_1a8550": 0x1801A8550,
|
||||||
|
"acc_attr_1a8450": 0x1801A8450,
|
||||||
|
"acc_dream_1a8830": 0x1801A8830,
|
||||||
|
"acc_assetid_1a8010": 0x1801A8010,
|
||||||
|
"acc_asset2_1a8020": 0x1801A8020,
|
||||||
|
"mapper_playstyle_136480": 0x180136480,
|
||||||
|
"family_d8330": 0x1800D8330,
|
||||||
|
"resid_166ca0": 0x180166CA0,
|
||||||
|
}
|
||||||
|
blob = []
|
||||||
|
src = {}
|
||||||
|
for nm, a in targets.items():
|
||||||
|
f = func(a)
|
||||||
|
s = dec(a, 600)
|
||||||
|
src[nm] = s
|
||||||
|
blob.append("=" * 78)
|
||||||
|
blob.append("### %s @ %#x ghidra_fn=%s entry=%#x len=%d" % (
|
||||||
|
nm, a, f.getName() if f else "NONE",
|
||||||
|
int(f.getEntryPoint().getOffset()) if f else 0, len(s)))
|
||||||
|
blob.append(s)
|
||||||
|
dump("v1_bodies.txt", "\n".join(blob))
|
||||||
|
|
||||||
|
# ---- dispatch-form enumeration over the item deser, ALL FOUR FORMS
|
||||||
|
d = src["deser_13fe00"]
|
||||||
|
print("\n--- deser FUN_18013fe00 len=%d ---" % len(d))
|
||||||
|
cases = sorted(set(int(x, 16) for x in re.findall(r"case\s+0x([0-9a-fA-F]+)", d)))
|
||||||
|
cases += sorted(set(int(x) for x in re.findall(r"case\s+(\d+)", d)))
|
||||||
|
eq = sorted(set(int(x, 16) for x in re.findall(r"==\s*0x([0-9a-fA-F]+)", d)))
|
||||||
|
ne = sorted(set(int(x, 16) for x in re.findall(r"!=\s*0x([0-9a-fA-F]+)", d)))
|
||||||
|
lt = sorted(set(int(x, 16) for x in re.findall(r"<\s*0x([0-9a-fA-F]+)", d)))
|
||||||
|
sub = sorted(set(int(x, 16) for x in re.findall(r"-\s*0x([0-9a-fA-F]+)", d)))
|
||||||
|
print("case labels (%d): %s" % (len(cases), [hex(c) for c in cases]))
|
||||||
|
print("== 0x (%d): %s" % (len(eq), [hex(c) for c in eq]))
|
||||||
|
print("!= 0x (%d): %s" % (len(ne), [hex(c) for c in ne]))
|
||||||
|
print("< 0x (%d): %s" % (len(lt), [hex(c) for c in lt]))
|
||||||
|
print("- 0x ladders (%d): %s" % (len(sub), [hex(c) for c in sub]))
|
||||||
|
for probe, label in [(0x274, "rating CONTROL"), (0x287, "resourceId CONTROL"),
|
||||||
|
(0x173, "itemType"), (0x23F, "playStyle"),
|
||||||
|
(0x172, "itemState"), (0x207, "owners"),
|
||||||
|
(0x361, "untradeable"), (0x1B, "amount"),
|
||||||
|
(0x226, "pile"), (0x6B, "cardassetid"), (0x23, "assetId"),
|
||||||
|
(0x18A, "leagueId"), (0x1D1, "nation"), (0x6C, "cardsubtypeid")]:
|
||||||
|
forms = []
|
||||||
|
if probe in cases:
|
||||||
|
forms.append("case")
|
||||||
|
if probe in eq:
|
||||||
|
forms.append("==")
|
||||||
|
if probe in ne:
|
||||||
|
forms.append("!=")
|
||||||
|
print(" atom %#x %-18s dispatch forms: %s" % (probe, label, forms or "NONE FOUND"))
|
||||||
|
|
||||||
|
# ---- who writes offset 0x54 anywhere in the two functions?
|
||||||
|
print("\n--- textual writes to +0x54 / 0x54 in merge and deser ---")
|
||||||
|
for nm in ("merge_141660", "deser_13fe00", "playersmerge_135890"):
|
||||||
|
for ln_no, ln in enumerate(src[nm].split("\n")):
|
||||||
|
if "0x54" in ln or "0xb4" in ln:
|
||||||
|
print(" %-20s %4d| %s" % (nm, ln_no, ln.strip()))
|
||||||
|
|
||||||
|
# ---- OVERALL_RATING literal: locate it MYSELF, then xref
|
||||||
|
print("\n--- OVERALL_RATING literal census ---")
|
||||||
|
hits = find_all(b"OVERALL_RATING\x00")
|
||||||
|
print("occurrences of 'OVERALL_RATING\\0':", [hex(h) for h in hits])
|
||||||
|
for h in hits:
|
||||||
|
xs = xrefs_to(h)
|
||||||
|
print(" %#x xrefs=%d" % (h, len(xs)))
|
||||||
|
for frm, t, fn, ent in xs:
|
||||||
|
print(" from %#x %s in %s @%#x" % (frm, t, fn, ent))
|
||||||
|
# control: a literal with an obviously different xref profile
|
||||||
|
for lit in (b"CARD_LEVEL\x00", b"PLAY_STYLE\x00", b"LEAGUE_ID\x00",
|
||||||
|
b"ATTRIBUTE_VALUE\x00", b"IS_DREAM_PLAYER\x00", b"ASSET_ID\x00"):
|
||||||
|
hs = find_all(lit)
|
||||||
|
print("\n%s occurrences: %s" % (lit, [hex(x) for x in hs]))
|
||||||
|
for h in hs:
|
||||||
|
xs = xrefs_to(h)
|
||||||
|
print(" %#x xrefs=%d -> %s" % (h, len(xs), sorted(set(x[2] for x in xs))))
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""ADVERSARIAL VERIFICATION BATCH 2.
|
||||||
|
|
||||||
|
Q1 COMPLETENESS GAP the D4 report admitted: are there raw, non-accessor reads of
|
||||||
|
record+0xb4 anywhere in the binary? 0xb4 cannot be encoded as a signed disp8,
|
||||||
|
so EVERY [reg+0xb4] reference must carry the literal disp32 bytes b4 00 00 00.
|
||||||
|
Scanning .text for those four bytes and decoding the containing instruction is
|
||||||
|
therefore an EXHAUSTIVE search, not a sample. Same scan for 0x54 and 0x88.
|
||||||
|
Positive control: the scan must find FUN_1801a87f0 (+0xb4), FUN_180141660's
|
||||||
|
ladder (+0xb4 and +0x54) and FUN_1801a85c0 (+0x88).
|
||||||
|
|
||||||
|
Q2 FUN_18013f4d0 -- the family-6 handler the deser tail calls with (record,
|
||||||
|
resourceId, AMOUNT). If it stores amount in the record, the standing
|
||||||
|
"amount is dropped" verdict is wrong.
|
||||||
|
|
||||||
|
Q3 the +0xe0 mystery: FUN_1801a8540, FUN_1800e5940 (manager publisher),
|
||||||
|
FUN_1800e6e20 (player publisher) in full.
|
||||||
|
|
||||||
|
Q4 FUN_180166660 itemState mapper, FUN_1800d7b50/b30/b10/af0 value readers.
|
||||||
|
|
||||||
|
Q5 who calls FUN_18013fe00 and FUN_180141660 (is the ladder really on every path).
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
|
||||||
|
|
||||||
|
try:
|
||||||
|
def scan_disp(off):
|
||||||
|
pat = bytes([off & 0xFF, (off >> 8) & 0xFF, (off >> 16) & 0xFF, (off >> 24) & 0xFF])
|
||||||
|
hits = find_all(pat, blocks=(".text",))
|
||||||
|
rows = []
|
||||||
|
for h in hits:
|
||||||
|
ins = listing.getInstructionContaining(addr(h))
|
||||||
|
if ins is None:
|
||||||
|
continue
|
||||||
|
a = int(ins.getAddress().getOffset())
|
||||||
|
txt = str(ins)
|
||||||
|
if ("0xb4]" in txt or "0x54]" in txt or "0x88]" in txt or
|
||||||
|
hex(off) in txt.lower()):
|
||||||
|
f = fm.getFunctionContaining(ins.getAddress())
|
||||||
|
rows.append((a, txt, f.getName() if f else "?"))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
for off, label in ((0xB4, "record+0xb4 rating"),
|
||||||
|
(0x54, "record+0x54 disputed"),
|
||||||
|
(0x88, "record+0x88 playStyle")):
|
||||||
|
rows = scan_disp(off)
|
||||||
|
print("\n==== EXHAUSTIVE disp32 scan for [reg+%#x] (%s): %d instructions"
|
||||||
|
% (off, label, len(rows)))
|
||||||
|
seen = {}
|
||||||
|
for a, txt, fn in rows:
|
||||||
|
seen.setdefault(fn, []).append((a, txt))
|
||||||
|
for fn in sorted(seen):
|
||||||
|
print(" %-24s" % fn, ["%#x %s" % (a, t) for a, t in seen[fn]])
|
||||||
|
|
||||||
|
bodies = []
|
||||||
|
for nm, a in (("f_13f4d0_family6", 0x18013F4D0),
|
||||||
|
("acc_1a8540", 0x1801A8540),
|
||||||
|
("acc_1a86b0", 0x1801A86B0),
|
||||||
|
("acc_1a8590_nation", 0x1801A8590),
|
||||||
|
("acc_1a86a0_team", 0x1801A86A0),
|
||||||
|
("pub_mgr_1800e5940", 0x1800E5940),
|
||||||
|
("pub_player_1800e6e20", 0x1800E6E20),
|
||||||
|
("itemstate_166660", 0x180166660),
|
||||||
|
("rd_d7b50", 0x1800D7B50), ("rd_d7b30", 0x1800D7B30),
|
||||||
|
("rd_d7b10", 0x1800D7B10), ("rd_d7af0", 0x1800D7AF0),
|
||||||
|
("stamp_d84e0", 0x1800D84E0)):
|
||||||
|
f = func(a)
|
||||||
|
s = dec(a, 600)
|
||||||
|
bodies.append("=" * 78)
|
||||||
|
bodies.append("### %s @ %#x len=%d" % (nm, a, len(s)))
|
||||||
|
bodies.append(s)
|
||||||
|
open(OUT + "v2_bodies.txt", "w").write("\n".join(bodies))
|
||||||
|
print("\nWROTE v2_bodies.txt")
|
||||||
|
|
||||||
|
print("\n==== callers ====")
|
||||||
|
for nm, a in (("FUN_18013fe00 item deser", 0x18013FE00),
|
||||||
|
("FUN_180141660 merge", 0x180141660),
|
||||||
|
("FUN_180135890 players merge", 0x180135890),
|
||||||
|
("FUN_1801a87f0 rating acc", 0x1801A87F0),
|
||||||
|
("FUN_1801a80c0 cardlevel acc", 0x1801A80C0),
|
||||||
|
("FUN_1801a85c0 playstyle acc", 0x1801A85C0),
|
||||||
|
("FUN_1801a8550 league acc", 0x1801A8550),
|
||||||
|
("FUN_1801a8540", 0x1801A8540)):
|
||||||
|
xs = xrefs_to(a)
|
||||||
|
cs = sorted(set("%s@%#x" % (x[2], x[3]) for x in xs if x[1].startswith("UNCONDITIONAL_CALL") or "CALL" in x[1]))
|
||||||
|
print("%-30s xrefs=%d callers=%s" % (nm, len(xs), cs))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""ADVERSARIAL BATCH 3: exhaustive [reg+disp32] scan, tightened.
|
||||||
|
|
||||||
|
0xb4 / 0x54 / 0x88 / 0xe0 cannot be a signed disp8, so every [reg+off] reference
|
||||||
|
must carry the disp32 bytes literally. The scan is therefore exhaustive over .text.
|
||||||
|
Filter: keep only instructions whose printed operand ends in "+ 0x<off>]", drop LEA
|
||||||
|
and the unwind-stub noise.
|
||||||
|
Positive controls that MUST appear: FUN_1801a87f0 (+0xb4 read),
|
||||||
|
FUN_180141660 (+0xb4 read and +0x54 write), FUN_1801a85c0 (+0x88 read),
|
||||||
|
FUN_1801a80c0 (+0x54 read and write).
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
for off in (0xB4, 0x54, 0x88, 0xE0):
|
||||||
|
pat = bytes([off, 0, 0, 0])
|
||||||
|
hits = find_all(pat, blocks=(".text",))
|
||||||
|
rows = []
|
||||||
|
for h in hits:
|
||||||
|
ins = listing.getInstructionContaining(addr(h))
|
||||||
|
if ins is None:
|
||||||
|
continue
|
||||||
|
txt = str(ins)
|
||||||
|
if ("+ %s]" % hex(off)) not in txt:
|
||||||
|
continue
|
||||||
|
mn = txt.split()[0]
|
||||||
|
if mn in ("LEA", "NOP"):
|
||||||
|
continue
|
||||||
|
f = fm.getFunctionContaining(ins.getAddress())
|
||||||
|
fn = f.getName() if f else "?"
|
||||||
|
if fn.startswith("Unwind") or fn.startswith("_guard"):
|
||||||
|
continue
|
||||||
|
rows.append((int(ins.getAddress().getOffset()), txt, fn))
|
||||||
|
rows = sorted(set(rows))
|
||||||
|
print("\n==== [reg+%#x] exhaustive disp32 scan: %d non-LEA, non-unwind instructions"
|
||||||
|
% (off, len(rows)))
|
||||||
|
byf = {}
|
||||||
|
for a, t, fn in rows:
|
||||||
|
byf.setdefault(fn, []).append((a, t))
|
||||||
|
for fn in sorted(byf):
|
||||||
|
print(" %-26s %s" % (fn, "; ".join("%#x %s" % x for x in byf[fn])))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""ADVERSARIAL BATCH 4.
|
||||||
|
|
||||||
|
CARD SIDE
|
||||||
|
A. FUN_1801aa7f0 and FUN_1800e6410 read [reg+0xb4] as a byte but sit OUTSIDE the
|
||||||
|
accessor range [0x1801a7000,0x1801a9000) the D4 report swept. Do they read an
|
||||||
|
item record? If so the "OVERALL_RATING has exactly four publishers, all through
|
||||||
|
FUN_1801a87f0" completeness argument has a hole.
|
||||||
|
B. FUN_1801356c0 -- the family-2 (manager) merge. Does it clobber +0xdd..+0xfb the
|
||||||
|
way the players merge does? That decides whether leagueId at +0xe0 survives for
|
||||||
|
managers.
|
||||||
|
C. FUN_180134cb0 -- writes +0xfc..+0x101, which FUN_1801a86b0 reads as the
|
||||||
|
per-attribute chemistry delta.
|
||||||
|
D. disp8 scan for [reg+0x54]: 0x54 fits a signed disp8 so the disp32 trick does
|
||||||
|
NOT apply; iterate EVERY instruction in .text instead. Positive control:
|
||||||
|
FUN_180141660 and FUN_1801a80c0 must appear.
|
||||||
|
|
||||||
|
ROUTE SIDE
|
||||||
|
E. FUN_18012ec50 club ?type= switch, FUN_18012f4f0 club/stats switch,
|
||||||
|
FUN_1801308c0 consumables suffix, FUN_18012ddf0 query builder -- full, so the
|
||||||
|
"exactly 30 / exactly 7 / no /stats/team" absences can be re-tested against
|
||||||
|
case labels AND == AND != AND ladders.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
|
||||||
|
|
||||||
|
try:
|
||||||
|
import re
|
||||||
|
bodies = []
|
||||||
|
src = {}
|
||||||
|
for nm, a in (("rating_reader_1aa7f0", 0x1801AA7F0),
|
||||||
|
("rating_reader_e6410", 0x1800E6410),
|
||||||
|
("mgr_merge_1356c0", 0x1801356C0),
|
||||||
|
("chem_134cb0", 0x180134CB0),
|
||||||
|
("clubtype_12ec50", 0x18012EC50),
|
||||||
|
("clubstats_12f4f0", 0x18012F4F0),
|
||||||
|
("consum_1308c0", 0x1801308C0),
|
||||||
|
("clubsearch_12ddf0", 0x18012DDF0)):
|
||||||
|
s = dec(a, 600)
|
||||||
|
src[nm] = s
|
||||||
|
bodies.append("=" * 78)
|
||||||
|
bodies.append("### %s @ %#x len=%d" % (nm, a, len(s)))
|
||||||
|
bodies.append(s)
|
||||||
|
open(OUT + "v4_bodies.txt", "w").write("\n".join(bodies))
|
||||||
|
print("WROTE v4_bodies.txt")
|
||||||
|
|
||||||
|
for nm in ("clubtype_12ec50", "clubstats_12f4f0"):
|
||||||
|
s = src[nm]
|
||||||
|
cases = re.findall(r"case\s+(0x[0-9a-fA-F]+|\d+):", s)
|
||||||
|
eq = re.findall(r"==\s*(0x[0-9a-fA-F]+|\d+)", s)
|
||||||
|
ne = re.findall(r"!=\s*(0x[0-9a-fA-F]+|\d+)", s)
|
||||||
|
sub = re.findall(r"-\s*(0x[0-9a-fA-F]+|\d+)U?\s*<", s)
|
||||||
|
print("\n%s len=%d cases=%d %s\n ==%s !=%s ladders=%s"
|
||||||
|
% (nm, len(s), len(cases), cases, eq, ne, sub))
|
||||||
|
|
||||||
|
# ---- D: exhaustive instruction walk for [reg+0x54]
|
||||||
|
print("\n==== EVERY instruction in .text referencing [reg + 0x54] ====")
|
||||||
|
blk = [b for b in mem.getBlocks() if b.getName() == ".text"][0]
|
||||||
|
it = listing.getInstructions(blk.getStart(), True)
|
||||||
|
n = 0
|
||||||
|
found = []
|
||||||
|
while it.hasNext():
|
||||||
|
ins = it.next()
|
||||||
|
if ins.getAddress().getOffset() > int(blk.getEnd().getOffset()):
|
||||||
|
break
|
||||||
|
n += 1
|
||||||
|
t = str(ins)
|
||||||
|
if "+ 0x54]" in t:
|
||||||
|
f = fm.getFunctionContaining(ins.getAddress())
|
||||||
|
found.append((int(ins.getAddress().getOffset()), t,
|
||||||
|
f.getName() if f else "?"))
|
||||||
|
print("instructions walked: %d ; hits: %d" % (n, len(found)))
|
||||||
|
byf = {}
|
||||||
|
for a, t, fn in found:
|
||||||
|
byf.setdefault(fn, []).append("%#x %s" % (a, t))
|
||||||
|
for fn in sorted(byf):
|
||||||
|
print(" %-26s %s" % (fn, "; ".join(byf[fn])))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Q1 recon: the itemState enum table and the club?type= strings.
|
||||||
|
|
||||||
|
HYPOTHESIS: the itemState enum table at 0x180229d20 (stride 0x10, 10 entries) is
|
||||||
|
referenced by (a) a string->enum mapper in the deserializer and (b) an equip path
|
||||||
|
that WRITES activeBadge/activeHomeKit/... The equip path is the place most likely
|
||||||
|
to switch on cardsubtypeid for cardtype 9.
|
||||||
|
|
||||||
|
CONTROL: the table dump itself. The doc states the ten names; if the dump does not
|
||||||
|
reproduce WAITING_FOR_GAME, inGame, forSale, offered, activeBadge, activeHomeKit,
|
||||||
|
activeAwayKit, activeBall, activeStadium, active in that order, my table read is
|
||||||
|
wrong and every conclusion downstream is void.
|
||||||
|
|
||||||
|
Also: locate the literals for club?type= singular names (stadium/ball/equippables)
|
||||||
|
and the family caption keys, with occurrence counts, so later queries can pick a
|
||||||
|
unique anchor.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=== A: itemState enum table 0x180229d20, stride 0x10, 14 entries ===")
|
||||||
|
T = 0x180229D20
|
||||||
|
for i in range(14):
|
||||||
|
e = T + i * 0x10
|
||||||
|
q0 = qword(e)
|
||||||
|
q1 = qword(e + 8)
|
||||||
|
s = ""
|
||||||
|
if 0x180000000 <= q0 < 0x181000000:
|
||||||
|
try:
|
||||||
|
s = rd_str(q0, 64)
|
||||||
|
except Exception:
|
||||||
|
s = "?"
|
||||||
|
print(" [%2d] %#x: q0=%#018x %-24r q1=%#x" % (i, e, q0, s, q1))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== B: xrefs to the table start and to each row ===")
|
||||||
|
for i in range(12):
|
||||||
|
e = T + i * 0x10
|
||||||
|
xs = xrefs_to(e)
|
||||||
|
if xs:
|
||||||
|
print(" row %d @%#x:" % (i, e))
|
||||||
|
for frm, typ, fn, ent in xs:
|
||||||
|
print(" from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== C: string literals of interest, all occurrences ===")
|
||||||
|
pats = [
|
||||||
|
b"activeBadge", b"activeHomeKit", b"activeAwayKit", b"activeBall",
|
||||||
|
b"activeStadium", b"itemState", b"forSale", b"inGame",
|
||||||
|
b"equippables", b"stadium", b"Stadium", b"ball", b"Ball",
|
||||||
|
b"badge", b"Badge", b"kit", b"Kit", b"clubLogo", b"leagueLogo",
|
||||||
|
b"CLUBLOGO", b"LEAGUELOGO", b"BADGE", b"STADIUM", b"BALL", b"KIT",
|
||||||
|
]
|
||||||
|
for p in pats:
|
||||||
|
hits = find_all(p)
|
||||||
|
print(" %-16r n=%d %s" % (p.decode(), len(hits),
|
||||||
|
" ".join("%#x" % h for h in hits[:12])))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Q10: the fcc_ table vocabulary and the classifier's neighbourhood.
|
||||||
|
|
||||||
|
Q8/Q9 changed the picture: inside the item deserializer, cardtype 9 items whose
|
||||||
|
cardsubtypeid is in [0x91,0x95) take a custom-image path, and a SEPARATE
|
||||||
|
deserializer FUN_180108c00 computes subtype = wireValue + 0x91 and then picks the
|
||||||
|
loc format by range:
|
||||||
|
0x91 <= s < 0x95 -> "TOURNY_LOC_%d"
|
||||||
|
0x95 <= s < 0x97 -> "SEASON_LOC_%d"
|
||||||
|
so 0x91..0x96 look like TROPHIES, not badges/kits/stadia/balls. Also, cardtype 7
|
||||||
|
(subtypes 9,10,11) has an arm that defaults a field to 0x23 = 35, and 35 is the
|
||||||
|
kit cardassetid recorded in tools/fut_clubitems.py.
|
||||||
|
|
||||||
|
This query gathers the vocabulary needed to test that:
|
||||||
|
A. every "fcc_" table name literal in the binary, with the function that queries
|
||||||
|
it -- the merge's per-family table map;
|
||||||
|
B. every literal starting "cardsubtype" / "cardtype" (column names);
|
||||||
|
C. the small helpers around the classifier: FUN_1800d84e0 (called right after it
|
||||||
|
in the deser), FUN_1800d7b30/b50/af0/b10, FUN_1800d7170.
|
||||||
|
|
||||||
|
CONTROL: "fcc_discardcoins" must appear in A, and its query site must be
|
||||||
|
FUN_18013fe00 (line 784 of the Q8 decompile). If it does not, the literal scan is
|
||||||
|
not seeing the same code the decompiler is.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=== A: fcc_ table literals ===")
|
||||||
|
seen = set()
|
||||||
|
for h in find_all(b"fcc_"):
|
||||||
|
s = rd_str(h, 64)
|
||||||
|
if not s or s in seen:
|
||||||
|
continue
|
||||||
|
seen.add(s)
|
||||||
|
xs = xrefs_to(h)
|
||||||
|
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
|
||||||
|
print(" %#x %-28r <- %s" % (h, s, who or "-"))
|
||||||
|
print(" total distinct: %d" % len(seen))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== B: cardtype / cardsubtype column literals ===")
|
||||||
|
for pat in (b"cardtype", b"cardsubtype", b"carddbid", b"cardassetid"):
|
||||||
|
for h in find_all(pat):
|
||||||
|
s = rd_str(h, 64)
|
||||||
|
xs = xrefs_to(h)
|
||||||
|
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
|
||||||
|
print(" %#x %-28r <- %s" % (h, s, who or "-"))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== C: helpers ===")
|
||||||
|
for a in (0x1800D84E0, 0x1800D7B30, 0x1800D7B50, 0x1800D7AF0, 0x1800D7B10):
|
||||||
|
src = dec(a)
|
||||||
|
print("-" * 70)
|
||||||
|
print("FUN_%x len=%d" % (a, len(src)))
|
||||||
|
print(src if len(src) < 2500 else src[:2500] + "\n...[TRUNCATED, len above]")
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Q11: dump the candidate functions to files for local analysis.
|
||||||
|
|
||||||
|
Rationale: the interesting functions are 3k-27k chars each and printing them all to
|
||||||
|
the transcript is wasteful. Write each decompile to
|
||||||
|
/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec/FUN_<addr>.c and print only name+length here.
|
||||||
|
|
||||||
|
Set chosen from Q10:
|
||||||
|
FUN_180098f20 queries fcc_leaguelogos AND uses carddbid + cardassetid
|
||||||
|
FUN_180098560 / FUN_1800989f0 / FUN_180042440 / FUN_180043350 fcc_myclubscategories
|
||||||
|
FUN_1800991a0 fcc_myclubs
|
||||||
|
FUN_180141660 the merge (carddbid)
|
||||||
|
FUN_18011a860 / FUN_1801356c0 / FUN_1801362e0 other carddbid users
|
||||||
|
FUN_18013fe00 the shared item deserializer (full, for local grep)
|
||||||
|
FUN_18011e9d0 the <0x95 callback from Q9
|
||||||
|
FUN_18013af30 the remaining scan hit
|
||||||
|
|
||||||
|
CONTROL: FUN_18013fe00 must come out at 26234 chars, the length Q8 measured. A
|
||||||
|
different length means a different function or a different decompiler setting.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = ("/tmp/claude-1000/-home-alex-Documents-OpenFUT/"
|
||||||
|
"8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec")
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
for a in (0x180098F20, 0x180098560, 0x1800989F0, 0x180042440, 0x180043350,
|
||||||
|
0x1800991A0, 0x180141660, 0x18011A860, 0x1801356C0, 0x1801362E0,
|
||||||
|
0x18013FE00, 0x18011E9D0, 0x18013AF30, 0x180096670, 0x1801017E0):
|
||||||
|
src = dec(a)
|
||||||
|
p = os.path.join(OUT, "FUN_%x.c" % a)
|
||||||
|
with open(p, "w") as f:
|
||||||
|
f.write(src)
|
||||||
|
print(" %-14s len=%d -> %s" % ("FUN_%x" % a, len(src), p))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Q12: the UI group tables around 0x180203260 and every family caption key.
|
||||||
|
|
||||||
|
Known: consumables group table at 0x180203260 (7 rows, stride 0x18, indexed by the
|
||||||
|
switch in FUN_180096670 case 0xb) and staff at 0x180203310 (5 rows, case 8). The
|
||||||
|
club-item claim "there is no equivalent table" is exactly the kind of absence this
|
||||||
|
project keeps getting wrong, so walk the WHOLE region 0x180203100..0x180203700 as
|
||||||
|
stride-0x18 triples and print anything string-shaped, then xref each candidate
|
||||||
|
table start.
|
||||||
|
|
||||||
|
Also print every .rdata literal containing BADGE / STADIUM / BALL / KIT / LOGO /
|
||||||
|
TROPHY (upper case, i.e. loc keys) with its xrefs. Q4 of the brief.
|
||||||
|
|
||||||
|
CONTROL: the consumables table at 0x180203260 must come out as the seven rows
|
||||||
|
already recorded (TRAINING/CONTRACT/FITNESS/HEALING/PLAYSTYLE/MANAGER_LEAGUE/
|
||||||
|
TACTIC_TRAINING with codes 0,1,4,3,0x17,0x18,0x11). If the walk does not reproduce
|
||||||
|
it, the stride/layout assumption is wrong and nothing else in this query counts.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
|
def walk(lo, hi, stride):
|
||||||
|
a = lo
|
||||||
|
while a < hi:
|
||||||
|
cells = []
|
||||||
|
for k in range(0, stride, 8):
|
||||||
|
try:
|
||||||
|
q = qword(a + k)
|
||||||
|
except Exception:
|
||||||
|
q = 0
|
||||||
|
s = ""
|
||||||
|
if 0x180000000 <= q < 0x181000000:
|
||||||
|
try:
|
||||||
|
t = rd_str(q, 80)
|
||||||
|
if t and all(0x20 <= ord(c) < 0x7F for c in t):
|
||||||
|
s = t
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cells.append("%#x%s" % (q, (" %r" % s) if s else ""))
|
||||||
|
print(" %#x %s" % (a, " | ".join(cells)))
|
||||||
|
a += stride
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=== stride-0x18 walk 0x180203200..0x180203460 ===")
|
||||||
|
walk(0x180203200, 0x180203460, 0x18)
|
||||||
|
print()
|
||||||
|
print("=== xrefs to plausible table starts ===")
|
||||||
|
for a in range(0x180203200, 0x180203460, 8):
|
||||||
|
xs = xrefs_to(a)
|
||||||
|
if xs:
|
||||||
|
print(" %#x:" % a)
|
||||||
|
for frm, typ, fn, ent in xs:
|
||||||
|
print(" %#x %s in %s(%#x)" % (frm, typ, fn, ent))
|
||||||
|
print()
|
||||||
|
print("=== upper-case family loc keys ===")
|
||||||
|
seen = set()
|
||||||
|
for pat in (b"BADGE", b"STADIUM", b"BALL", b"KIT", b"LOGO", b"TROPHY"):
|
||||||
|
for h in find_all(pat):
|
||||||
|
# walk back to the start of the C string
|
||||||
|
p = h
|
||||||
|
for _ in range(80):
|
||||||
|
try:
|
||||||
|
if mem.getByte(addr(p - 1)) & 0xFF == 0:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
p -= 1
|
||||||
|
s = rd_str(p, 120)
|
||||||
|
if p in seen or len(s) < 4:
|
||||||
|
continue
|
||||||
|
seen.add(p)
|
||||||
|
xs = xrefs_to(p)
|
||||||
|
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
|
||||||
|
print(" %#x %-52r <- %s" % (p, s, who or "-"))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Q13: every FUT_MYCLUB_ loc key, and the table row that carries it.
|
||||||
|
|
||||||
|
Q12 reproduced the consumables table (control passed) and showed the staff table's
|
||||||
|
middle column IS the cardtype (manager 2, headcoach 3, fitnesscoach 4, gkcoach 0xa,
|
||||||
|
physio 5 -- exactly the merge's switch arms), and a trophies pair:
|
||||||
|
0x180203380 {0x05, 0, FUT_MYCLUB_OFFLINE_TROPHIES_EARNED}
|
||||||
|
0x180203398 {0x15, 1, FUT_MYCLUB_ONLINE_TROPHIES_EARNED}
|
||||||
|
|
||||||
|
If a badges/kits/stadia/balls row exists in the same shape, its middle column is the
|
||||||
|
answer. Enumerate EVERY FUT_MYCLUB_ literal, find the pointer to it in .rdata/.data,
|
||||||
|
and print the 0x18-byte row it sits in for all three possible cell positions, plus
|
||||||
|
the rows either side.
|
||||||
|
|
||||||
|
CONTROL: FUT_MYCLUB_CONSUMABLES_TRAINING_EARNED must resolve to the row
|
||||||
|
{0, ptr, 'training'} at 0x180203260. Any layout guess that cannot reproduce that row
|
||||||
|
is wrong.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
keys = []
|
||||||
|
for h in find_all(b"FUT_MYCLUB_"):
|
||||||
|
s = rd_str(h, 120)
|
||||||
|
keys.append((h, s))
|
||||||
|
keys.sort()
|
||||||
|
print("=== %d FUT_MYCLUB_ literals ===" % len(keys))
|
||||||
|
for h, s in keys:
|
||||||
|
print(" %#x %r" % (h, s))
|
||||||
|
print()
|
||||||
|
print("=== pointer rows ===")
|
||||||
|
for h, s in keys:
|
||||||
|
ptrs = find_all(h.to_bytes(8, "little"), blocks=(".rdata", ".data"))
|
||||||
|
if not ptrs:
|
||||||
|
print(" %-46r no pointer" % s)
|
||||||
|
continue
|
||||||
|
for pa in ptrs:
|
||||||
|
ctx = []
|
||||||
|
for off in (-0x18, -0x10, -8, 0, 8, 0x10, 0x18):
|
||||||
|
try:
|
||||||
|
q = qword(pa + off)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
t = ""
|
||||||
|
if 0x180000000 <= q < 0x181000000:
|
||||||
|
try:
|
||||||
|
u = rd_str(q, 80)
|
||||||
|
if u and all(0x20 <= ord(c) < 0x7F for c in u):
|
||||||
|
t = u
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ctx.append("%+#5x:%#x%s" % (off, q, (" %r" % t) if t else ""))
|
||||||
|
print(" %-46r @%#x" % (s, pa))
|
||||||
|
print(" " + " ".join(ctx))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Q14: the club URL format strings and their builders.
|
||||||
|
|
||||||
|
Q6 found 'type=%s' at 0x180224c7a and 0x180224ff9 with no direct xref, which means
|
||||||
|
each is the TAIL of a longer literal whose start is what the code references. Dump
|
||||||
|
every C string in 0x180224a00..0x180225300 and 0x18021e200..0x18021e800 with xrefs,
|
||||||
|
so the club request builder can be identified and decompiled.
|
||||||
|
|
||||||
|
Also dump 0x180228400..0x18022b200 for the transfermarket/club parameter strings.
|
||||||
|
|
||||||
|
CONTROL: '&cat=%s' at 0x1802285b8 is already known to be referenced by
|
||||||
|
FUN_180162c90; it must show that xref here too.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
|
def dump(lo, hi, tag):
|
||||||
|
print("=== %s %#x..%#x ===" % (tag, lo, hi))
|
||||||
|
p = lo
|
||||||
|
while p < hi:
|
||||||
|
try:
|
||||||
|
b = mem.getByte(addr(p)) & 0xFF
|
||||||
|
except Exception:
|
||||||
|
p += 1
|
||||||
|
continue
|
||||||
|
if 0x20 <= b < 0x7F:
|
||||||
|
s = rd_str(p, 160)
|
||||||
|
if len(s) >= 3:
|
||||||
|
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
|
||||||
|
for _f, _t, fn, ent in xrefs_to(p)}))
|
||||||
|
print(" %#x %-66r %s" % (p, s, who))
|
||||||
|
p += max(1, len(s)) + 1
|
||||||
|
else:
|
||||||
|
p += 1
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
dump(0x180224A00, 0x180225300, "club/url block")
|
||||||
|
dump(0x18021E200, 0x18021E800, "route table")
|
||||||
|
dump(0x180228400, 0x180229000, "params block")
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Q15: find the equip path by the atoms it must name.
|
||||||
|
|
||||||
|
The itemState vocabulary is also in the atom table:
|
||||||
|
0xc activeAwayKit 0xd activeBadge 0xe activeBall 0x10 activeHomeKit
|
||||||
|
0x12 activeStadium 0xa active 0x12e free 0x164 inGame 0x1e5 offered
|
||||||
|
and the club ?type= taxonomy switch FUN_18012ec50 shows how a name reaches the wire:
|
||||||
|
FUN_180180cd0(atom) returns the atom's name string. So whatever chooses which of the
|
||||||
|
five active* states to send must call FUN_180180cd0 with 0xc/0xd/0xe/0x10/0x12, and
|
||||||
|
the choice is made from the item's family. That is the mapping the brief wants.
|
||||||
|
|
||||||
|
Method: enumerate every caller of FUN_180180cd0, decompile each once, and report the
|
||||||
|
call sites whose literal argument is one of the atoms of interest:
|
||||||
|
equip states 0xc 0xd 0xe 0x10 0x12
|
||||||
|
club families 0x49 badge, 0x179 kit, 0x2d8 stadium, 0x4d ball,
|
||||||
|
0x18d leaguelogos, 0x10a equippables, 0x4b badges, 0x4f balls,
|
||||||
|
0x17c kits, 0x18e leagueLogos, 0x2d7 stadia
|
||||||
|
Print the matching lines with context so the surrounding switch is visible.
|
||||||
|
|
||||||
|
CONTROL: FUN_18012ec50 is a known caller and must be reported with its family atoms
|
||||||
|
(0x49, 0x179, 0x2d8, 0x4d, 0x18d, 0x10a). If it is not in the output, the caller
|
||||||
|
enumeration or the literal matching is broken.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
WANT = {0xC: "activeAwayKit", 0xD: "activeBadge", 0xE: "activeBall",
|
||||||
|
0x10: "activeHomeKit", 0x12: "activeStadium", 0xA: "active",
|
||||||
|
0x12E: "free", 0x164: "inGame", 0x1E5: "offered",
|
||||||
|
0x49: "badge", 0x179: "kit", 0x2D8: "stadium", 0x4D: "ball",
|
||||||
|
0x18D: "leaguelogos", 0x10A: "equippables", 0x4B: "badges",
|
||||||
|
0x4F: "balls", 0x17C: "kits", 0x18E: "leagueLogos", 0x2D7: "stadia"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
ents = {}
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x180180CD0):
|
||||||
|
if ent:
|
||||||
|
ents[ent] = fn
|
||||||
|
print("callers of FUN_180180cd0: %d" % len(ents))
|
||||||
|
pat = re.compile(r"FUN_180180cd0\((0x[0-9a-f]+|\d+)\)")
|
||||||
|
nhit = 0
|
||||||
|
for ent, fn in sorted(ents.items()):
|
||||||
|
src = dec(ent)
|
||||||
|
lines = src.splitlines()
|
||||||
|
found = []
|
||||||
|
for i, l in enumerate(lines):
|
||||||
|
for m in pat.finditer(l):
|
||||||
|
v = int(m.group(1), 0)
|
||||||
|
if v in WANT:
|
||||||
|
found.append((i, v))
|
||||||
|
if not found:
|
||||||
|
continue
|
||||||
|
nhit += 1
|
||||||
|
print("=" * 70)
|
||||||
|
print("%s @%#x len=%d atoms=%s" %
|
||||||
|
(fn, ent, len(src),
|
||||||
|
sorted({"%#x=%s" % (v, WANT[v]) for _i, v in found})))
|
||||||
|
shown = set()
|
||||||
|
for i, _v in found:
|
||||||
|
for j in range(max(0, i - 4), min(len(lines), i + 2)):
|
||||||
|
if j in shown:
|
||||||
|
continue
|
||||||
|
shown.add(j)
|
||||||
|
print(" %4d: %s" % (j, lines[j].strip()))
|
||||||
|
print(" ---")
|
||||||
|
print("functions with hits: %d" % nhit)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Q16: the equip path -- callers of the itemState serializer.
|
||||||
|
|
||||||
|
Q15 found FUN_18012ee20: itemState code -> atom name, with
|
||||||
|
1->free 2->inGame 5->0x1f8 6->offered 100->activeBadge 0x65->activeHomeKit
|
||||||
|
0x66->activeAwayKit 0x67->activeBall 0x68->activeStadium 0xff->active
|
||||||
|
Whoever CALLS it with 0x64..0x68 is the equip path, and the code that picks which of
|
||||||
|
those five to pass must know the item's family.
|
||||||
|
|
||||||
|
Dump: every caller of FUN_18012ee20 in full, plus FUN_18012ddf0 (the club URL
|
||||||
|
builder) in full, plus FUN_18012ec50's caller chain context.
|
||||||
|
|
||||||
|
CONTROL: FUN_18012ddf0 must contain the five-way if/else on *(param_1+0x30) that
|
||||||
|
Q15 printed (0xa badge, 0xb kit, 0x15 stadium, 0x16 ball, else equippables). If the
|
||||||
|
full decompile lacks it, this is not the same function.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
ents = {}
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x18012EE20):
|
||||||
|
if ent:
|
||||||
|
ents[ent] = fn
|
||||||
|
print("callers of FUN_18012ee20 (itemState->atom): %d -> %s" %
|
||||||
|
(len(ents), ["%s(%#x)" % (v, k) for k, v in ents.items()]))
|
||||||
|
for ent in sorted(ents):
|
||||||
|
src = dec(ent)
|
||||||
|
print("=" * 78)
|
||||||
|
print("CALLER %s @%#x len=%d" % (ents[ent], ent, len(src)))
|
||||||
|
print(src)
|
||||||
|
print("=" * 78)
|
||||||
|
src = dec(0x18012DDF0)
|
||||||
|
print("CLUB URL BUILDER FUN_18012ddf0 len=%d" % len(src))
|
||||||
|
print(src)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Q17: enumerate EVERY switch case label in the binary, then find the ones that
|
||||||
|
distinguish club subtypes.
|
||||||
|
|
||||||
|
Q5's scalar scan failed its control because jump-table case labels are not
|
||||||
|
instruction immediates. Ghidra, however, names them: it creates symbols of the form
|
||||||
|
switchD_<addr>_caseD_<n> (and caseD_<n>) at each case target. Walking the symbol
|
||||||
|
table therefore enumerates switch dispatch in the one form a scalar scan cannot see.
|
||||||
|
|
||||||
|
Report every function whose case-value set intersects the club-subtype candidates
|
||||||
|
{0x1e,0x1f,9,10,11,0x91..0x96} and print the full case set for each.
|
||||||
|
|
||||||
|
CONTROL: FUN_1800d8330 must appear with case labels including 0x1e, 0x1f, 0x91..0x96,
|
||||||
|
0xe7..0xe9 and 0xec. If it does not, the symbol-based enumeration is broken and no
|
||||||
|
absence claim may be made from it.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
st = prog.getSymbolTable()
|
||||||
|
it = st.getAllSymbols(True)
|
||||||
|
pat = re.compile(r"caseD_([0-9a-fA-F]+)$")
|
||||||
|
per = {}
|
||||||
|
n = 0
|
||||||
|
while it.hasNext():
|
||||||
|
s = it.next()
|
||||||
|
m = pat.search(s.getName())
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
n += 1
|
||||||
|
try:
|
||||||
|
v = int(m.group(1), 16)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
f = fm.getFunctionContaining(s.getAddress())
|
||||||
|
key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0)
|
||||||
|
per.setdefault(key, set()).add(v)
|
||||||
|
print("case labels found: %d in %d functions" % (n, len(per)))
|
||||||
|
|
||||||
|
CAND = {0x1E, 0x1F, 9, 10, 11, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96}
|
||||||
|
print()
|
||||||
|
print("=== functions whose case set meets the club-subtype candidates ===")
|
||||||
|
rows = []
|
||||||
|
for (name, ent), vals in per.items():
|
||||||
|
inter = vals & CAND
|
||||||
|
if len(inter) >= 2:
|
||||||
|
rows.append((len(inter), name, ent, vals))
|
||||||
|
rows.sort(reverse=True)
|
||||||
|
for k, name, ent, vals in rows:
|
||||||
|
print(" %-26s %#x hits=%d cases=%s" %
|
||||||
|
(name, ent, k, sorted("%#x" % v for v in vals)))
|
||||||
|
print()
|
||||||
|
print("=== control: FUN_1800d8330 ===")
|
||||||
|
for (name, ent), vals in per.items():
|
||||||
|
if ent == 0x1800D8330:
|
||||||
|
print(" YES cases=%s" % sorted("%#x" % v for v in vals))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Q18: FUN_1800fed90 -- a switch whose case set is EXACTLY {0x91..0x96}.
|
||||||
|
|
||||||
|
Q17's case-label enumeration (control passed on FUN_1800d8330) found exactly one
|
||||||
|
function whose switch discriminates the six high club subtypes and nothing else:
|
||||||
|
FUN_1800fed90. If 0x91..0x96 are trophies, this is where each one is turned into a
|
||||||
|
concrete thing, and the six arms should be distinguishable.
|
||||||
|
|
||||||
|
Also decompile FUN_1800f4bc0 and FUN_1800f2f70 (case sets 0xa..0x14, i.e. they
|
||||||
|
distinguish 10 and 11, the other two cardtype-7 subtypes) and FUN_1800d8260 /
|
||||||
|
FUN_1800d86c0 / FUN_1800d8b50 (small enum->string helpers next to the classifier).
|
||||||
|
|
||||||
|
CONTROL: FUN_1800d8b50 is called by the club URL builder FUN_18012ddf0 to render a
|
||||||
|
value for query key atom 0x243; it should decompile to a code->string table, which
|
||||||
|
is a known shape. If it comes out as something else, my reading of the URL builder
|
||||||
|
is wrong.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
for a in (0x1800FED90, 0x1800F4BC0, 0x1800F2F70, 0x1800D8260, 0x1800D86C0,
|
||||||
|
0x1800D8B50):
|
||||||
|
src = dec(a)
|
||||||
|
print("=" * 78)
|
||||||
|
print("FUN_%x len=%d" % (a, len(src)))
|
||||||
|
print(src if len(src) < 9000 else src[:9000] + "\n...[cut at 9000, len above]")
|
||||||
|
print("=" * 78)
|
||||||
|
print("=== callers ===")
|
||||||
|
for a in (0x1800FED90, 0x1800F4BC0):
|
||||||
|
print(" callers of %#x:" % a)
|
||||||
|
for frm, typ, fn, ent in xrefs_to(a):
|
||||||
|
print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Q19: every constant the code compares a +0x50 (cardsubtypeid) or +0x4c (cardtype)
|
||||||
|
field against.
|
||||||
|
|
||||||
|
The parsed item record has cardsubtypeid at +0x50 and cardtype at +0x4c. Instead of
|
||||||
|
searching for a constant (which misses jump tables) or for a syntactic form (which
|
||||||
|
misses != and ladders), search for the FIELD ACCESS and then collect every immediate
|
||||||
|
that touches the loaded register within the next 8 instructions, whatever the
|
||||||
|
mnemonic. Both the direct form (CMP dword [reg+0x50], imm) and the load-then-test
|
||||||
|
form (MOV r32,[reg+0x50]; SUB r32,imm; CMP r32,imm) are covered.
|
||||||
|
|
||||||
|
CONTROL: FUN_18011e3c0 is known to do `*(int *)(x + 0x50) - 0x91U < 6`, so it must
|
||||||
|
appear with 0x91 (and 6) attached to a +0x50 access. If the control is absent the
|
||||||
|
scan is broken and nothing may be concluded from what it does not find.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
block = None
|
||||||
|
for b in mem.getBlocks():
|
||||||
|
if b.getName() == ".text":
|
||||||
|
block = b
|
||||||
|
break
|
||||||
|
per = {}
|
||||||
|
it = listing.getInstructions(block.getStart(), True)
|
||||||
|
window = [] # [(reg_name, remaining_instrs)]
|
||||||
|
n = 0
|
||||||
|
while it.hasNext():
|
||||||
|
ins = it.next()
|
||||||
|
n += 1
|
||||||
|
txt = str(ins)
|
||||||
|
# 1) direct: memory operand with disp 0x50/0x4c and an immediate
|
||||||
|
for disp in ("0x50", "0x4c"):
|
||||||
|
if ("+ " + disp + "]") in txt or ("+" + disp + "]") in txt:
|
||||||
|
imms = []
|
||||||
|
for i in range(ins.getNumOperands()):
|
||||||
|
for o in ins.getOpObjects(i):
|
||||||
|
try:
|
||||||
|
imms.append(int(o.getValue()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
f = fm.getFunctionContaining(ins.getAddress())
|
||||||
|
key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0)
|
||||||
|
rec = per.setdefault(key, {"direct": set(), "near": set()})
|
||||||
|
for v in imms:
|
||||||
|
if v not in (0x50, 0x4C) and 0 <= v < 0x1000:
|
||||||
|
rec["direct"].add((disp, v))
|
||||||
|
# start a window: whatever register this instruction defines
|
||||||
|
for r in ins.getResultObjects():
|
||||||
|
window.append([str(r), 8, key, disp])
|
||||||
|
# 2) decay window and attach immediates that touch the tracked register
|
||||||
|
nxt = []
|
||||||
|
for w in window:
|
||||||
|
reg, left, key, disp = w
|
||||||
|
if left <= 0:
|
||||||
|
continue
|
||||||
|
if reg in txt:
|
||||||
|
for i in range(ins.getNumOperands()):
|
||||||
|
for o in ins.getOpObjects(i):
|
||||||
|
try:
|
||||||
|
v = int(o.getValue())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if 0 <= v < 0x1000:
|
||||||
|
per.setdefault(key, {"direct": set(), "near": set()})
|
||||||
|
per[key]["near"].add((disp, v))
|
||||||
|
w[1] = left - 1
|
||||||
|
nxt.append(w)
|
||||||
|
window = nxt[-40:]
|
||||||
|
|
||||||
|
print("instructions scanned: %d" % n)
|
||||||
|
print()
|
||||||
|
CAND = {9, 10, 11, 0x1E, 0x1F, 7, 0x91}
|
||||||
|
print("=== functions whose +0x50 / +0x4c constants meet {9,10,11,0x1e,0x1f,7,0x91} ===")
|
||||||
|
for (name, ent), rec in sorted(per.items()):
|
||||||
|
vals = rec["direct"] | rec["near"]
|
||||||
|
hit = {v for _d, v in vals} & CAND
|
||||||
|
if not hit:
|
||||||
|
continue
|
||||||
|
print(" %-24s %#x hits=%s" % (name, ent, sorted("%#x" % h for h in hit)))
|
||||||
|
print(" direct=%s" % sorted("%s:%#x" % (d, v) for d, v in rec["direct"]))
|
||||||
|
print(" near =%s" % sorted("%s:%#x" % (d, v) for d, v in rec["near"])[:40])
|
||||||
|
print()
|
||||||
|
print("=== control FUN_18011e3c0 ===")
|
||||||
|
for (name, ent), rec in per.items():
|
||||||
|
if ent == 0x18011E3C0:
|
||||||
|
print(" direct=%s" % sorted("%s:%#x" % (d, v) for d, v in rec["direct"]))
|
||||||
|
print(" near =%s" % sorted("%s:%#x" % (d, v) for d, v in rec["near"]))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Q2: two string clusters that look like family-name tables.
|
||||||
|
|
||||||
|
Q1 found 'badge' 0x18022a220, 'kit' 0x18022a228, 'leagueLogo' 0x18022a230,
|
||||||
|
'ball' 0x18022a278 packed together, and a second cluster 'badge' 0x1802303c8,
|
||||||
|
'ball' 0x1802303dc, 'equippables' 0x180230f48, 'leagueLogo' 0x180231608.
|
||||||
|
|
||||||
|
HYPOTHESIS: cluster 1 is the value list of a {name -> code} enum table like the
|
||||||
|
itemState one (stride 0x10: char* then int). Cluster 2 is the club?type= route
|
||||||
|
vocabulary.
|
||||||
|
|
||||||
|
CONTROL: the itemState table itself. My Q1 read started mid-table (row0 =
|
||||||
|
activeBadge with code 0x64, while the doc's list starts at WAITING_FOR_GAME), so
|
||||||
|
this query re-walks BACKWARDS from 0x180229d20 to find the real table start and
|
||||||
|
prints it in full. If the ten documented names do not appear in order, my table
|
||||||
|
walker is wrong.
|
||||||
|
|
||||||
|
Then: for every string in each cluster, find the .rdata qword that points at it
|
||||||
|
(the table row) and print the row's neighbours, plus xrefs.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
|
def dump_strings(lo, hi, label):
|
||||||
|
print("=== strings %s %#x..%#x ===" % (label, lo, hi))
|
||||||
|
p = lo
|
||||||
|
while p < hi:
|
||||||
|
try:
|
||||||
|
b = mem.getByte(addr(p)) & 0xFF
|
||||||
|
except Exception:
|
||||||
|
p += 1
|
||||||
|
continue
|
||||||
|
if 0x20 <= b < 0x7F:
|
||||||
|
s = rd_str(p, 96)
|
||||||
|
if len(s) >= 2:
|
||||||
|
print(" %#x %r" % (p, s))
|
||||||
|
p += max(1, len(s)) + 1
|
||||||
|
else:
|
||||||
|
p += 1
|
||||||
|
|
||||||
|
|
||||||
|
def walk_table(start, n, back=0):
|
||||||
|
print("--- table walk from %#x, %d rows (stride 0x10) ---" % (start, n))
|
||||||
|
for i in range(-back, n):
|
||||||
|
e = start + i * 0x10
|
||||||
|
try:
|
||||||
|
q0, q1 = qword(e), qword(e + 8)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
s = ""
|
||||||
|
if 0x180000000 <= q0 < 0x181000000:
|
||||||
|
try:
|
||||||
|
s = rd_str(q0, 64)
|
||||||
|
except Exception:
|
||||||
|
s = "?"
|
||||||
|
print(" [%3d] %#x ptr=%#x %-26r val=%#x" % (i, e, q0, s, q1))
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
walk_table(0x180229D20, 8, back=14)
|
||||||
|
print()
|
||||||
|
dump_strings(0x18022A200, 0x18022A380, "cluster1")
|
||||||
|
print()
|
||||||
|
dump_strings(0x180230300, 0x180230420, "cluster2a")
|
||||||
|
print()
|
||||||
|
dump_strings(0x180230F00, 0x180230FA0, "cluster2b")
|
||||||
|
print()
|
||||||
|
dump_strings(0x180231380, 0x180231680, "cluster2c")
|
||||||
|
print()
|
||||||
|
print("=== xrefs / pointer-rows for cluster strings ===")
|
||||||
|
for name, a in [("badge", 0x18022A220), ("kit", 0x18022A228),
|
||||||
|
("leagueLogo", 0x18022A230), ("ball", 0x18022A278),
|
||||||
|
("badge2", 0x1802303C8), ("ball2", 0x1802303DC),
|
||||||
|
("equippables", 0x180230F48), ("leagueLogo2", 0x180231608),
|
||||||
|
("itemState", 0x180231490)]:
|
||||||
|
print(" %s @%#x" % (name, a))
|
||||||
|
for frm, typ, fn, ent in xrefs_to(a):
|
||||||
|
print(" xref from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
|
||||||
|
ptrs = find_all(a.to_bytes(8, "little"), blocks=(".rdata", ".data"))
|
||||||
|
for pa in ptrs[:8]:
|
||||||
|
print(" ptr-row @%#x next-q=%#x prev-q=%#x" %
|
||||||
|
(pa, qword(pa + 8), qword(pa - 8)))
|
||||||
|
for frm, typ, fn, ent in xrefs_to(pa):
|
||||||
|
print(" row xref %#x %s in %s(%#x)" % (frm, typ, fn, ent))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Q20: the functions that test cardtype==7 / cardsubtypeid in {9,10,11}.
|
||||||
|
|
||||||
|
Q19 (control passed: it recovered FUN_18011e3c0's 0x91/0x94/0x96 on a +0x50 field)
|
||||||
|
flagged:
|
||||||
|
FUN_1800f6c40 direct [+0x4c]==7 AND [+0x50]==9
|
||||||
|
FUN_180084720 direct [+0x50]==9 and [+0x50]==0xb
|
||||||
|
FUN_180094580 near [+0x50] 9 / 0xb / 3
|
||||||
|
FUN_18015fa80 direct [+0x50]==9
|
||||||
|
FUN_1801362e0 direct [+0x4c] 1 / 2 / 7
|
||||||
|
Decompile each. Whatever these do with subtypes 9/10/11 is the club-family
|
||||||
|
behaviour, and a loc key or asset id in any arm names the family.
|
||||||
|
|
||||||
|
CONTROL: FUN_1801362e0 is one of the merge's arms (called from FUN_180141660 case 2,
|
||||||
|
the manager arm) so it must be a DB lookup on carddbid; if it is not, the +0x4c
|
||||||
|
attribution is on a different struct and these hits are noise.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
for a in (0x1800F6C40, 0x180084720, 0x180094580, 0x18015FA80, 0x1801362E0):
|
||||||
|
src = dec(a)
|
||||||
|
print("=" * 78)
|
||||||
|
print("FUN_%x len=%d" % (a, len(src)))
|
||||||
|
print(src if len(src) < 12000 else src[:12000] + "\n...[cut, len above]")
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Q21: the FUT data-manager vtable slots that name a club item.
|
||||||
|
|
||||||
|
FUN_1800f6c40 (the pack/award tile builder) does:
|
||||||
|
if (item+0x4c == 1) -> ITEM_RARITY / ITEM_LEVEL
|
||||||
|
else if (item+0x50 == 9) -> "IS_KIT_%d" = 1 <-- names subtype 9
|
||||||
|
name = mgr->vt[0x490](out, item+0x4c cardtype, item+0x50 subtype, item+0x18)
|
||||||
|
if (name empty && item+0x4c == 7)
|
||||||
|
name = mgr->vt[0x498](out, item+0x50 subtype, item+0x94 teamid, item+0x20)
|
||||||
|
where mgr = FUN_18011a830(). Slots 0x490 and 0x498 are therefore the club-item name
|
||||||
|
resolvers and must switch on the subtype.
|
||||||
|
|
||||||
|
Resolve the manager's vtable, then decompile slots 0x490, 0x498, 0xa08, 0xa38, 0xa40.
|
||||||
|
|
||||||
|
CONTROL: slot 0xa08 is the one the item deserializer calls to file a parsed item
|
||||||
|
(FUN_18013fe00 line 825), and slot 0xa40 is the lookup FUN_18011e3c0 uses with a
|
||||||
|
resourceId. If the resolved vtable's 0xa08/0xa40 are not functions, the vtable
|
||||||
|
resolution is wrong.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
src = dec(0x18011A830)
|
||||||
|
print("=== FUN_18011a830 (manager accessor) len=%d ===" % len(src))
|
||||||
|
print(src)
|
||||||
|
# find the vtable it installs / the object's class
|
||||||
|
print()
|
||||||
|
print("=== candidate vtables referenced from FUN_18011a830 and its callees ===")
|
||||||
|
f = func(0x18011A830)
|
||||||
|
cands = set()
|
||||||
|
for ad in f.getBody().getAddresses(True):
|
||||||
|
ins = listing.getInstructionAt(ad)
|
||||||
|
if ins is None:
|
||||||
|
continue
|
||||||
|
for r in ins.getReferencesFrom():
|
||||||
|
t = int(r.getToAddress().getOffset())
|
||||||
|
if 0x1801E5000 <= t <= 0x1802891FF:
|
||||||
|
cands.add(t)
|
||||||
|
for t in sorted(cands):
|
||||||
|
try:
|
||||||
|
v0, v1 = qword(t), qword(t + 8)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
print(" %#x -> %#x %#x (%s / %s)" %
|
||||||
|
(t, v0, v1, fname(v0) if 0x180000000 <= v0 < 0x181000000 else "-",
|
||||||
|
fname(v1) if 0x180000000 <= v1 < 0x181000000 else "-"))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Q22: resolve the manager object's vtable through the global DAT_1802e6398.
|
||||||
|
|
||||||
|
FUN_18011a830 just returns DAT_1802e6398, so the vtable is installed wherever that
|
||||||
|
global is written. Find the writers, decompile the smallest, and read the vtable it
|
||||||
|
stores. Then dump slots 0x490 / 0x498 / 0xa08 / 0xa38 / 0xa40.
|
||||||
|
|
||||||
|
CONTROL: the recovered vtable's slot 0xa08 and 0xa40 must both be real functions
|
||||||
|
(the item deserializer calls 0xa08 to file an item; FUN_18011e3c0 calls 0xa40 with a
|
||||||
|
resourceId). If either is not a function, the vtable is wrong.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=== writers/readers of DAT_1802e6398 ===")
|
||||||
|
ents = {}
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x1802E6398):
|
||||||
|
ents.setdefault(ent, []).append((frm, typ, fn))
|
||||||
|
for ent, lst in sorted(ents.items()):
|
||||||
|
print(" %s(%#x) n=%d types=%s" %
|
||||||
|
(lst[0][2], ent, len(lst), sorted({t for _f, t, _n in lst})))
|
||||||
|
# the constructor is a function that WRITES it
|
||||||
|
writers = [e for e, lst in ents.items()
|
||||||
|
if any(t == "WRITE" for _f, t, _n in lst)]
|
||||||
|
print("writers: %s" % ["%#x" % w for w in writers])
|
||||||
|
for w in writers:
|
||||||
|
src = dec(w)
|
||||||
|
print("=" * 70)
|
||||||
|
print("writer FUN_%x len=%d" % (w, len(src)))
|
||||||
|
print(src if len(src) < 6000 else src[:6000] + "\n...[cut]")
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Q23: callers of the manager setter FUN_18011d780 -> the manager's vtable.
|
||||||
|
|
||||||
|
CONTROL: the vtable found must have real functions at slots 0xa08 and 0xa40.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x18011D780):
|
||||||
|
print("caller %s(%#x) via %#x %s" % (fn, ent, frm, typ))
|
||||||
|
ents = {ent for _f, _t, _n, ent in xrefs_to(0x18011D780) if ent}
|
||||||
|
for ent in ents:
|
||||||
|
src = dec(ent)
|
||||||
|
print("=" * 70)
|
||||||
|
print("FUN_%x len=%d" % (ent, len(src)))
|
||||||
|
print(src if len(src) < 7000 else src[:7000] + "\n...[cut]")
|
||||||
|
f = func(ent)
|
||||||
|
cands = set()
|
||||||
|
for ad in f.getBody().getAddresses(True):
|
||||||
|
ins = listing.getInstructionAt(ad)
|
||||||
|
if ins is None:
|
||||||
|
continue
|
||||||
|
for r in ins.getReferencesFrom():
|
||||||
|
t = int(r.getToAddress().getOffset())
|
||||||
|
if 0x1801E5000 <= t <= 0x1802891FF:
|
||||||
|
cands.add(t)
|
||||||
|
print("--- .rdata refs, checked for vtable shape ---")
|
||||||
|
for t in sorted(cands):
|
||||||
|
try:
|
||||||
|
v0, v1 = qword(t), qword(t + 8)
|
||||||
|
s90, s98 = qword(t + 0x490), qword(t + 0x498)
|
||||||
|
a08, a40 = qword(t + 0xA08), qword(t + 0xA40)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
ok = all(fm.getFunctionAt(addr(x)) is not None
|
||||||
|
for x in (v0, v1) if 0x180000000 <= x < 0x181000000)
|
||||||
|
print(" %#x v0=%s v1=%s | +0x490=%s +0x498=%s +0xa08=%s +0xa40=%s%s" %
|
||||||
|
(t, fname(v0), fname(v1), fname(s90), fname(s98),
|
||||||
|
fname(a08), fname(a40), " <== VTABLE?" if ok else ""))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Q24: FUN_180119bd0 -- the cardtype-7 name resolver. This should BE the mapping.
|
||||||
|
|
||||||
|
The manager vtable was read out of the live process (read-only): DAT_1802e6398 ->
|
||||||
|
object -> vtable static 0x18021c2a0, with
|
||||||
|
+0x490 = FUN_18011a860 (cardtype switch 1,2,3,4,5,10 -- no club arm)
|
||||||
|
+0x498 = FUN_180119bd0 (called ONLY when +0x490 returned empty AND cardtype==7,
|
||||||
|
with args (cardsubtypeid, teamid, assetId))
|
||||||
|
+0xa08 = FUN_18011cca0 (file a parsed item)
|
||||||
|
+0xa38 = FUN_180113e40 (register trophy: (tournamentId, subtype, name))
|
||||||
|
+0xa40 = FUN_18011bf40 (lookup by resourceId)
|
||||||
|
|
||||||
|
Decompile 0x498, 0xa38, 0xa40 and 0xa08.
|
||||||
|
|
||||||
|
CONTROL: FUN_18011a860 must be the same function Q11 dumped (12905 chars) with the
|
||||||
|
cardtype switch; that is what makes the 0x498 fallback meaningful.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
for a, tag in [(0x180119BD0, "+0x498 club-item name resolver"),
|
||||||
|
(0x180113E40, "+0xa38 trophy register"),
|
||||||
|
(0x18011BF40, "+0xa40 lookup by resourceId"),
|
||||||
|
(0x18011CCA0, "+0xa08 file parsed item")]:
|
||||||
|
src = dec(a)
|
||||||
|
print("=" * 78)
|
||||||
|
print("%s FUN_%x len=%d" % (tag, a, len(src)))
|
||||||
|
print(src if len(src) < 14000 else src[:14000] + "\n...[cut, len above]")
|
||||||
|
print("=" * 78)
|
||||||
|
print("control: FUN_18011a860 len=%d" % len(dec(0x18011A860)))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Q25: is there a cardtype-9 sibling of FUN_180119bd0 for balls and league logos?
|
||||||
|
|
||||||
|
FUN_180119bd0 settles cardtype 7: 9 -> "FUT_UC_KITS"+TeamName_Abbr15_<teamid>
|
||||||
|
10 -> "Stadium"+StadiumName_<assetId>
|
||||||
|
11 -> "Badge"+TeamName_Abbr15_<teamid>
|
||||||
|
That leaves 0x1e and 0x1f (the only other cardtype-9 subtypes besides trophies
|
||||||
|
0x91..0x96 and misc 0xe7..0xec) for ball and league logo.
|
||||||
|
|
||||||
|
Dump the loc-key string neighbourhood the resolver draws from (0x1801ec700..
|
||||||
|
0x1801ed400 holds 'Stadium'/'Ball' literals) with xrefs, and xref the exact literals
|
||||||
|
"Stadium", "Badge", "FUT_UC_KITS" to find any sibling resolver. A function that
|
||||||
|
references a ball or league-logo caption is the cardtype-9 equivalent.
|
||||||
|
|
||||||
|
CONTROL: the literals "Stadium" and "Badge" must show FUN_180119bd0 as an xref. If
|
||||||
|
they do not, I am looking at different copies of those strings.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=== atoms 0xd1 and 0x19c (the two club-item wire strings) ===")
|
||||||
|
for a in (0xD1, 0x19C):
|
||||||
|
print(" %#x = %d" % (a, a))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== string dump 0x1801ec700..0x1801ed400 ===")
|
||||||
|
p = 0x1801EC700
|
||||||
|
while p < 0x1801ED400:
|
||||||
|
try:
|
||||||
|
b = mem.getByte(addr(p)) & 0xFF
|
||||||
|
except Exception:
|
||||||
|
p += 1
|
||||||
|
continue
|
||||||
|
if 0x20 <= b < 0x7F:
|
||||||
|
s = rd_str(p, 120)
|
||||||
|
if len(s) >= 3:
|
||||||
|
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
|
||||||
|
for _f, _t, fn, ent in xrefs_to(p)}))
|
||||||
|
print(" %#x %-46r %s" % (p, s, who))
|
||||||
|
p += max(1, len(s)) + 1
|
||||||
|
else:
|
||||||
|
p += 1
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== exact-literal xrefs ===")
|
||||||
|
for lit in (b"Stadium\x00", b"Badge\x00", b"FUT_UC_KITS\x00", b"Ball\x00",
|
||||||
|
b"BallName", b"LeagueLogo", b"leaguelogo", b"FUT_UC_"):
|
||||||
|
for h in find_all(lit):
|
||||||
|
s = rd_str(h, 80)
|
||||||
|
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
|
||||||
|
for _f, _t, fn, ent in xrefs_to(h)}))
|
||||||
|
print(" %-14r %#x %-30r <- %s" % (lit.rstrip(b"\x00").decode(), h, s, who or "-"))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Q26: FUN_1801bfac0 -- the one function that names ALL the club families.
|
||||||
|
|
||||||
|
It references 'Stadium', 'Badge', 'FUT_UC_KITS' and 'FUT_UC_BALL' (and the GK
|
||||||
|
attribute captions), and Q10 showed it queries fcc_matches. If it switches on
|
||||||
|
cardsubtypeid it will name the ball subtype, which FUN_180119bd0 (cardtype 7 only)
|
||||||
|
cannot.
|
||||||
|
|
||||||
|
Also dump the string cluster 0x180239000..0x180239180 which holds FUT_UC_BALL,
|
||||||
|
'Stadium' and 'badge' close together, with xrefs.
|
||||||
|
|
||||||
|
CONTROL: FUN_180119bd0 must appear as an xref of 'Stadium' 0x18021ce80 and 'Badge'
|
||||||
|
0x1802041b8 -- it did in Q25, so the literal identification is sound.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
src = dec(0x1801BFAC0)
|
||||||
|
print("=== FUN_1801bfac0 len=%d ===" % len(src))
|
||||||
|
print(src if len(src) < 20000 else src[:20000] + "\n...[cut, len above]")
|
||||||
|
print()
|
||||||
|
print("=== strings 0x180238f80..0x180239200 ===")
|
||||||
|
p = 0x180238F80
|
||||||
|
while p < 0x180239200:
|
||||||
|
try:
|
||||||
|
b = mem.getByte(addr(p)) & 0xFF
|
||||||
|
except Exception:
|
||||||
|
p += 1
|
||||||
|
continue
|
||||||
|
if 0x20 <= b < 0x7F:
|
||||||
|
s = rd_str(p, 120)
|
||||||
|
if len(s) >= 3:
|
||||||
|
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
|
||||||
|
for _f, _t, fn, ent in xrefs_to(p)}))
|
||||||
|
print(" %#x %-40r %s" % (p, s, who))
|
||||||
|
p += max(1, len(s)) + 1
|
||||||
|
else:
|
||||||
|
p += 1
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Q27: dump FUN_1801bfac0 in FULL to disk (it is >20k chars and was cut in Q26).
|
||||||
|
|
||||||
|
It is the card-detail builder and the only function referencing FUT_UC_KITS,
|
||||||
|
'Stadium', 'Badge' AND FUT_UC_BALL, so its club arms should name every family
|
||||||
|
including the ball subtype that FUN_180119bd0 (cardtype 7 only) cannot reach.
|
||||||
|
|
||||||
|
Also dump FUN_180094580 (references FUT_UC_KITS, and Q19 flagged it testing
|
||||||
|
item+0x50 against 9 and 0xb) and FUN_180099490 ('Badge').
|
||||||
|
|
||||||
|
No truncation: written to files, lengths printed here.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = ("/tmp/claude-1000/-home-alex-Documents-OpenFUT/"
|
||||||
|
"8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec")
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
for a in (0x1801BFAC0, 0x180094580, 0x180099490, 0x18015FA80, 0x180102790,
|
||||||
|
0x1800F6C40, 0x180084720):
|
||||||
|
src = dec(a)
|
||||||
|
p = os.path.join(OUT, "FUN_%x.c" % a)
|
||||||
|
with open(p, "w") as f:
|
||||||
|
f.write(src)
|
||||||
|
print(" FUN_%x len=%d" % (a, len(src)))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Q28: verify the accessor that FUN_1801bfac0 switches on IS cardsubtypeid, and
|
||||||
|
read the deserializer arms for the club-item string fields.
|
||||||
|
|
||||||
|
FUN_1801bfac0 does `iVar5 = FUN_1801a8640(local_78)` and then
|
||||||
|
iVar5 == 9 -> FUT_UC_KITS (+ FUT_ThirdKit / KitBioAwayDescription variants)
|
||||||
|
iVar5 == 10 -> "Stadium" + StadiumName_%d + StadiumDetailDesc
|
||||||
|
iVar5 == 0xb-> "Badge" + TeamName_Abbr15_%d + badgeBioDescription
|
||||||
|
iVar5 == 0x1e -> "FUT_UC_BALL"
|
||||||
|
iVar5 == 0x1f -> league-derived id, no generic asset string
|
||||||
|
iVar5 - 0xe7U < 2 / 0xe9 / 0xec -> misc
|
||||||
|
That reading only holds if FUN_1801a8640 returns the item's +0x50 cardsubtypeid.
|
||||||
|
Decompile it and its neighbours FUN_1801a8570 / FUN_1801a8560 / FUN_1801a8020 /
|
||||||
|
FUN_1801a86a0 / FUN_1801a8800 / FUN_1801a87f0 / FUN_1801a8040.
|
||||||
|
|
||||||
|
CONTROL: FUN_1801a86a0 is used in the badge arm as the argument to
|
||||||
|
TeamName_Abbr15_%d, so it must return the +0x94 teamid. If it returns something
|
||||||
|
else, my field-offset map for these accessors is wrong.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
for a in (0x1801A8640, 0x1801A8570, 0x1801A8560, 0x1801A8020, 0x1801A86A0,
|
||||||
|
0x1801A8800, 0x1801A87F0, 0x1801A8040):
|
||||||
|
src = dec(a)
|
||||||
|
print("-" * 70)
|
||||||
|
print("FUN_%x len=%d" % (a, len(src)))
|
||||||
|
print(src)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Q3: walk the whole enum-table block around 0x180229a00..0x180229e00.
|
||||||
|
|
||||||
|
Q2 found row @0x180229b50 = {'badge', 0xa}, 0x180229b60 = {'kit', 0xb},
|
||||||
|
0x180229b70 = {'leagueLogo', 0xc}, and 0x180229c10 = {'ball', 0x16} -- i.e. a
|
||||||
|
{name -> numeric code} table that NAMES THE CLUB FAMILIES. That is exactly the
|
||||||
|
mapping the brief asks for, IF the codes are cardsubtypeids.
|
||||||
|
|
||||||
|
HYPOTHESIS: one of these tables is the cardsubtypeid vocabulary. Codes 0xa/0xb/0xc
|
||||||
|
are NOT in the cardtype-9 subtype set (0x1e,0x1f,0x91..0x96), so either it is a
|
||||||
|
different axis (an "item sub-family" enum) or the mapping is indirect.
|
||||||
|
|
||||||
|
CONTROL: the itemState table at 0x180229cc0 (invalid/free/WAITING_FOR_GAME/...)
|
||||||
|
must reappear intact inside the same walk, with the same codes Q2 printed.
|
||||||
|
|
||||||
|
Dump every 0x10 row from 0x180229800 to 0x180229f00, printing ptr, string, value.
|
||||||
|
Then xref every table start candidate (a row whose predecessor is not a valid
|
||||||
|
string row) to find the lookup function.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
LO, HI = 0x180229800, 0x180229F00
|
||||||
|
rows = []
|
||||||
|
a = LO
|
||||||
|
while a < HI:
|
||||||
|
try:
|
||||||
|
q0, q1 = qword(a), qword(a + 8)
|
||||||
|
except Exception:
|
||||||
|
a += 0x10
|
||||||
|
continue
|
||||||
|
s = None
|
||||||
|
if 0x180000000 <= q0 < 0x181000000:
|
||||||
|
try:
|
||||||
|
t = rd_str(q0, 64)
|
||||||
|
if t and all(0x20 <= ord(c) < 0x7F for c in t):
|
||||||
|
s = t
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
rows.append((a, q0, s, q1))
|
||||||
|
a += 0x10
|
||||||
|
|
||||||
|
print("=== enum row walk %#x..%#x ===" % (LO, HI))
|
||||||
|
prev_ok = False
|
||||||
|
starts = []
|
||||||
|
for (a, q0, s, q1) in rows:
|
||||||
|
mark = ""
|
||||||
|
ok = s is not None
|
||||||
|
if ok and not prev_ok:
|
||||||
|
mark = " <== TABLE START?"
|
||||||
|
starts.append(a)
|
||||||
|
prev_ok = ok
|
||||||
|
print(" %#x ptr=%#018x %-28r val=%#-10x%s" % (a, q0, s or "", q1, mark))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== xrefs to each candidate table start ===")
|
||||||
|
for a in starts:
|
||||||
|
print(" start %#x" % a)
|
||||||
|
for frm, typ, fn, ent in xrefs_to(a):
|
||||||
|
print(" from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Q4: the enum converter helpers and their callers.
|
||||||
|
|
||||||
|
Q3 established two request-side vocabularies:
|
||||||
|
table 0x180229ab0 "subtype filter": any=-1 playerGK=1..physio=9 badge=0xa kit=0xb
|
||||||
|
leagueLogo=0xc playerTraining=0xd GKTraining=0xe position=0xf playStyle=0x10
|
||||||
|
managerLeagueModifier=0x11 contract=0x12 fitness=0x13 healing=0x14
|
||||||
|
stadium=0x15 ball=0x16
|
||||||
|
table 0x180229c30 "type filter": any=-1 player=1 staff=2 clubInfo=3 training=4
|
||||||
|
development=5 stadium=6 ball=7
|
||||||
|
table 0x180229cc0 "itemState": invalid=0 free=1 WAITING_FOR_GAME=2 inGame=2
|
||||||
|
forSale=5 offered=6 activeBadge=0x64 .. activeStadium=0x68 active=0xff
|
||||||
|
|
||||||
|
HYPOTHESIS: the converter functions FUN_180166300 (subtype), FUN_180166340 (type),
|
||||||
|
FUN_180166660 (itemState) are string<->code helpers; their CALLERS are the request
|
||||||
|
builder and the equip path. The equip path must choose 0x64..0x68 from the item, and
|
||||||
|
that choice is the subtype->family mapping we want.
|
||||||
|
|
||||||
|
CONTROL: FUN_1800d8330, decompiled in full here, must reproduce the documented
|
||||||
|
cardtype-9 subtype set {0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec}. If it does not, my
|
||||||
|
project copy is not the analysed one.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
for a, tag in [(0x1800D8330, "CONTROL FUN_1800d8330 cardsubtype->cardtype"),
|
||||||
|
(0x180166300, "subtype-enum helper"),
|
||||||
|
(0x180166340, "type-enum helper"),
|
||||||
|
(0x180166660, "itemState helper A"),
|
||||||
|
(0x1801666F0, "itemState/other helper B"),
|
||||||
|
(0x180166790, "helper C")]:
|
||||||
|
src = dec(a)
|
||||||
|
print("=" * 78)
|
||||||
|
print("%s @%#x len=%d" % (tag, a, len(src)))
|
||||||
|
print(src)
|
||||||
|
print("=" * 78)
|
||||||
|
print("=== callers ===")
|
||||||
|
for a in (0x180166300, 0x180166340, 0x180166660, 0x1801666F0, 0x180166790):
|
||||||
|
print(" callers of %#x:" % a)
|
||||||
|
seen = set()
|
||||||
|
for frm, typ, fn, ent in xrefs_to(a):
|
||||||
|
if ent in seen:
|
||||||
|
continue
|
||||||
|
seen.add(ent)
|
||||||
|
print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Q5: who consumes cardtype 9 / the club subtypes?
|
||||||
|
|
||||||
|
Two prongs.
|
||||||
|
|
||||||
|
A. FUN_180162c90 calls BOTH code->string helpers (type at 0x180162cec, subtype at
|
||||||
|
0x180163071), so it is the request builder for club?type=...&... Decompile it in
|
||||||
|
full: it names the query parameters and shows which enum feeds which parameter.
|
||||||
|
This answers Q3 directly.
|
||||||
|
|
||||||
|
B. THE ABSENCE TRAP GUARD. Rather than grep for "== 0x91", scan EVERY instruction in
|
||||||
|
.text for a scalar operand in the club-subtype set {0x1e,0x1f,0x91..0x96} and
|
||||||
|
group by containing function, regardless of mnemonic (cmp / sub / mov / lea /
|
||||||
|
switch-index arithmetic all count), then report functions that carry three or more
|
||||||
|
DISTINCT members of the set. A dispatch written as a sub-ladder or a jump table
|
||||||
|
still shows up because the constants themselves must exist somewhere -- and if a
|
||||||
|
jump table is used, the case labels live in .rdata, so also scan .rdata/.data for
|
||||||
|
the byte-pattern of a switch index table.
|
||||||
|
|
||||||
|
CONTROL for the scan: FUN_1800d8330 is known to contain all eight of 0x1e,0x1f,
|
||||||
|
0x91..0x96 as switch case labels. If the scan does not list FUN_1800d8330, the scan
|
||||||
|
is broken and any "no other consumer" conclusion is void.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
src = dec(0x180162C90)
|
||||||
|
print("=== A: FUN_180162c90 request builder, len=%d ===" % len(src))
|
||||||
|
print(src)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== B: scalar scan over .text for {0x1e,0x1f,0x91..0x96} ===")
|
||||||
|
TARGET = {0x1E, 0x1F, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96}
|
||||||
|
per = {}
|
||||||
|
block = None
|
||||||
|
for b in mem.getBlocks():
|
||||||
|
if b.getName() == ".text":
|
||||||
|
block = b
|
||||||
|
break
|
||||||
|
it = listing.getInstructions(block.getStart(), True)
|
||||||
|
n = 0
|
||||||
|
while it.hasNext():
|
||||||
|
ins = it.next()
|
||||||
|
if ins.getAddress().getOffset() > block.getEnd().getOffset():
|
||||||
|
break
|
||||||
|
n += 1
|
||||||
|
for i in range(ins.getNumOperands()):
|
||||||
|
for o in ins.getOpObjects(i):
|
||||||
|
try:
|
||||||
|
v = int(o.getValue())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if v in TARGET:
|
||||||
|
f = fm.getFunctionContaining(ins.getAddress())
|
||||||
|
key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0)
|
||||||
|
per.setdefault(key, {}).setdefault(v, []).append(
|
||||||
|
int(ins.getAddress().getOffset()))
|
||||||
|
print(" instructions scanned: %d" % n)
|
||||||
|
rank = sorted(per.items(), key=lambda kv: -len(kv[1]))
|
||||||
|
for (name, ent), d in rank:
|
||||||
|
if len(d) < 3:
|
||||||
|
continue
|
||||||
|
print(" %-28s %#x distinct=%d %s" %
|
||||||
|
(name, ent, len(d), sorted("%#x" % k for k in d)))
|
||||||
|
print(" --- control present? FUN_1800d8330 ---")
|
||||||
|
for (name, ent), d in per.items():
|
||||||
|
if ent == 0x1800D8330:
|
||||||
|
print(" YES: distinct=%d %s" % (len(d), sorted("%#x" % k for k in d)))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Q6: the club query taxonomy switch and the club URL builder.
|
||||||
|
|
||||||
|
Prior work (tools/fut_club_stats.py header) records FUN_18012ec50 as the 30-arm
|
||||||
|
atom switch that turns a club ?type= name into a code, with 11 badge, 12 kit,
|
||||||
|
13 stadium, 14 ball, 15 equippables, 16 leaguelogos.
|
||||||
|
|
||||||
|
HYPOTHESIS: something downstream of that code either (a) adds a &cardsubtype=
|
||||||
|
filter, which would give the mapping outright, or (b) buckets the RESPONSE items
|
||||||
|
by cardsubtypeid for the combined equippables view.
|
||||||
|
|
||||||
|
NOTE ON THE FAILED CONTROL IN Q5: the scalar scan did NOT find FUN_1800d8330,
|
||||||
|
whose 0x1e/0x1f/0x91..0x96 case labels are a jump table, so scalar scanning cannot
|
||||||
|
see switch dispatch here. Every "not found" from that scan is void. This query
|
||||||
|
therefore works from call graph and strings only.
|
||||||
|
|
||||||
|
CONTROL here: FUN_18012ec50 must decompile to a switch whose arms match the 30
|
||||||
|
names already recorded. If it does not, the recorded note (and my starting point)
|
||||||
|
is wrong.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
for a, tag in [(0x18012EC50, "CONTROL club ?type= taxonomy switch"),
|
||||||
|
(0x180096670, "UI group-table switch")]:
|
||||||
|
src = dec(a)
|
||||||
|
print("=" * 78)
|
||||||
|
print("%s @%#x len=%d" % (tag, a, len(src)))
|
||||||
|
print(src)
|
||||||
|
|
||||||
|
print("=" * 78)
|
||||||
|
print("=== callers of FUN_18012ec50 ===")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x18012EC50):
|
||||||
|
print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== url-ish literals ===")
|
||||||
|
for p in [b"/club?", b"/club", b"club?type", b"type=%s", b"&cat=%s",
|
||||||
|
b"&cardsubtype=%d", b"&count=%d", b"/item", b"/purchased"]:
|
||||||
|
hits = find_all(p)
|
||||||
|
print(" %-18r n=%d" % (p.decode(), len(hits)))
|
||||||
|
for h in hits[:14]:
|
||||||
|
s = rd_str(h, 120)
|
||||||
|
fs = []
|
||||||
|
for frm, typ, fn, ent in xrefs_to(h):
|
||||||
|
fs.append("%s(%#x)" % (fn, ent))
|
||||||
|
print(" %#x %-58r <- %s" % (h, s, ",".join(fs) or "-"))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Q7: every caller of the cardtype classifier, and how each treats cardtype 9.
|
||||||
|
|
||||||
|
FUN_1800d8330(cardsubtypeid) -> cardtype. Anything that needs to know "this is a
|
||||||
|
club item" must either call it or test cardsubtypeid directly. Enumerate its
|
||||||
|
callers, decompile each, and print EVERY line mentioning a 9 in any comparison or
|
||||||
|
switch form (case 9:, ==9, !=9, -9, <9, >9), so an arm written as != does not hide.
|
||||||
|
|
||||||
|
Also decompile the shared item deserializer FUN_18013fe00 fully? No: too long for
|
||||||
|
one batch. Instead print its length and the lines around the FUN_1800d8330 call and
|
||||||
|
around the itemState helper call at 0x1801406e3.
|
||||||
|
|
||||||
|
CONTROL: FUN_1800d8330 itself is excluded; the caller list must be non-empty and
|
||||||
|
must include the item deserializer FUN_18013fe00 (which is documented to compute
|
||||||
|
the family into item+0x4c). If FUN_18013fe00 is absent from the caller list, the
|
||||||
|
xref enumeration is broken.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
seen = {}
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x1800D8330):
|
||||||
|
if ent and ent != 0x1800D8330:
|
||||||
|
seen.setdefault(ent, fn)
|
||||||
|
print("=== callers of FUN_1800d8330: %d ===" % len(seen))
|
||||||
|
pat = re.compile(r"(case\s+9\s*:|[=!<>]=\s*9\b|==\s*9\b|!=\s*9\b|\b9\s*[=!<>]|-\s*9\b)")
|
||||||
|
for ent, fn in sorted(seen.items()):
|
||||||
|
src = dec(ent)
|
||||||
|
lines = src.splitlines()
|
||||||
|
hits = [(i, l.strip()) for i, l in enumerate(lines) if pat.search(l)]
|
||||||
|
print("-" * 70)
|
||||||
|
print("%s @%#x len=%d nine-lines=%d" % (fn, ent, len(src), len(hits)))
|
||||||
|
for i, l in hits[:40]:
|
||||||
|
print(" %4d: %s" % (i, l))
|
||||||
|
print("=" * 70)
|
||||||
|
src = dec(0x18013FE00)
|
||||||
|
print("item deser FUN_18013fe00 len=%d" % len(src))
|
||||||
|
for i, l in enumerate(src.splitlines()):
|
||||||
|
if "1800d8330" in l or "180166660" in l or "1801666f0" in l:
|
||||||
|
print(" %4d: %s" % (i, l.strip()))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Q8: the cardtype-9 arms inside the shared item deserializer.
|
||||||
|
|
||||||
|
Q7: FUN_1800d8330 has exactly ONE caller, FUN_18013fe00, and the decompile has
|
||||||
|
`if (local_13c == 9)` at line 729 and `if ((int)local_138 == 9)` at line 754, where
|
||||||
|
local_138 is the cardsubtypeid fed to the classifier and local_13c is the cardtype.
|
||||||
|
|
||||||
|
Print lines 640..900 of that decompile verbatim (no truncation claim will be made
|
||||||
|
from a window: the window is stated as a window). Also print lines 380..470 which
|
||||||
|
contain the itemState conversion calls, and the function tail (last 80 lines),
|
||||||
|
because the brief warns that locals that look dead are often copied out at the tail.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
src = dec(0x18013FE00)
|
||||||
|
lines = src.splitlines()
|
||||||
|
print("total lines=%d chars=%d" % (len(lines), len(src)))
|
||||||
|
for lo, hi, tag in [(380, 470, "itemState region"),
|
||||||
|
(640, 900, "cardtype-9 region"),
|
||||||
|
(len(lines) - 90, len(lines), "tail")]:
|
||||||
|
print("=" * 70)
|
||||||
|
print("--- %s: lines %d..%d ---" % (tag, lo, hi))
|
||||||
|
for i in range(max(0, lo), min(len(lines), hi)):
|
||||||
|
print("%5d: %s" % (i, lines[i]))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Q9: the five functions that carry club-subtype immediates individually.
|
||||||
|
|
||||||
|
Q5's scalar scan cannot see jump-table switches (its control, FUN_1800d8330, was
|
||||||
|
missed), but everything it DID find is a real immediate in an instruction. Those
|
||||||
|
functions are:
|
||||||
|
FUN_1801017e0 0x1e 0x91 0x92 0x93 0x94
|
||||||
|
FUN_180108c00 0x1e 0x91 0x94 0x96
|
||||||
|
FUN_18011e3c0 0x91 0x94 0x96
|
||||||
|
FUN_18011e9d0 0x91 0x94 0x96
|
||||||
|
FUN_18013af30 0x1e 0x1f 0x94
|
||||||
|
FUN_180067d00 0x94 0x95 0x96
|
||||||
|
Plus the range test found in Q8 inside the item deserializer at line 738:
|
||||||
|
(int)cardsubtypeid - 0x91U < 4 -> {0x91,0x92,0x93,0x94} take a custom-image path.
|
||||||
|
|
||||||
|
Decompile all six in full. Anything that ties one of these constants to a loc key,
|
||||||
|
an asset path or a UI slot is the mapping.
|
||||||
|
|
||||||
|
CONTROL: FUN_18013af30 must contain 0x1e/0x1f/0x94 somewhere in its text; if a
|
||||||
|
decompile comes back without the constants the scan attributed to it, the scan's
|
||||||
|
function attribution is wrong.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
for a in (0x1801017E0, 0x180108C00, 0x18011E3C0, 0x18011E9D0, 0x18013AF30,
|
||||||
|
0x180067D00):
|
||||||
|
src = dec(a)
|
||||||
|
print("=" * 78)
|
||||||
|
print("FUN_%x len=%d" % (a, len(src)))
|
||||||
|
print(src)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Q1 scaffolding: locate the fcc_discardcoins query site and its containing
|
||||||
|
function(s), and print the raw instruction stream around the three bind sites.
|
||||||
|
|
||||||
|
HYPOTHESIS: the SQL at 0x1802231e4.. is built and bound inside one function whose
|
||||||
|
frame holds the item pointer; cardtype/level/rare come from three distinct item
|
||||||
|
struct offsets, and the result is stored at item+0x3c.
|
||||||
|
|
||||||
|
CONTROL: 0x18013fe00 (the shared ITEM deserializer, known function) and
|
||||||
|
0x1800d8330 (the known cardsubtypeid->cardtype mapper) must both resolve to real
|
||||||
|
functions with sane sizes. If they do not, the project copy is wrong and every
|
||||||
|
other answer here is void.
|
||||||
|
|
||||||
|
Absence discipline: nothing in this file claims absence. It only prints.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q1_out.txt"
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines = []
|
||||||
|
def P(*a):
|
||||||
|
s = " ".join(str(x) for x in a)
|
||||||
|
lines.append(s)
|
||||||
|
|
||||||
|
LITS = {
|
||||||
|
0x1802231E4: "sql frag A",
|
||||||
|
0x1802231F0: "sql frag B",
|
||||||
|
0x1802231F4: "fcc_discardcoins",
|
||||||
|
0x180223208: "sql frag C",
|
||||||
|
0x180207848: "sql frag D",
|
||||||
|
0x18022315C: "sql frag E",
|
||||||
|
}
|
||||||
|
P("=== LITERALS ===")
|
||||||
|
for a, tag in sorted(LITS.items()):
|
||||||
|
try:
|
||||||
|
P("%#x %-18s %r" % (a, tag, rd_str(a, 120)))
|
||||||
|
except Exception as e:
|
||||||
|
P("%#x %-18s READ FAIL %s" % (a, tag, e))
|
||||||
|
|
||||||
|
P("")
|
||||||
|
P("=== XREFS TO LITERALS ===")
|
||||||
|
for a, tag in sorted(LITS.items()):
|
||||||
|
xs = xrefs_to(a)
|
||||||
|
P("%#x %s -> %d refs" % (a, tag, len(xs)))
|
||||||
|
for frm, typ, fn, ent in xs:
|
||||||
|
P(" from %#x %-14s in %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
|
||||||
|
P("")
|
||||||
|
P("=== FUNCTION IDENTITY ===")
|
||||||
|
for a in (0x18013FE00, 0x1800D8330, 0x180141025, 0x180141119, 0x180141140,
|
||||||
|
0x180141660, 0x180141E8A, 0x180140F00):
|
||||||
|
f = func(a)
|
||||||
|
if f is None:
|
||||||
|
P("%#x -> NO FUNCTION" % a)
|
||||||
|
continue
|
||||||
|
b = f.getBody()
|
||||||
|
P("%#x -> %s entry=%#x body=[%#x..%#x] size=%d"
|
||||||
|
% (a, f.getName(), int(f.getEntryPoint().getOffset()),
|
||||||
|
int(b.getMinAddress().getOffset()), int(b.getMaxAddress().getOffset()),
|
||||||
|
int(b.getNumAddresses())))
|
||||||
|
|
||||||
|
P("")
|
||||||
|
P("=== RAW INSTRUCTIONS 0x180140f80 .. 0x180141200 ===")
|
||||||
|
p = 0x180140F80
|
||||||
|
while p < 0x180141200:
|
||||||
|
ins = listing.getInstructionAt(addr(p))
|
||||||
|
if ins is None:
|
||||||
|
P("%#x <no instruction>" % p)
|
||||||
|
p += 1
|
||||||
|
continue
|
||||||
|
P("%#x %s" % (p, ins))
|
||||||
|
p += ins.getLength()
|
||||||
|
|
||||||
|
P("")
|
||||||
|
P("=== RAW INSTRUCTIONS 0x180141e40 .. 0x180141f00 (level derivation) ===")
|
||||||
|
p = 0x180141E40
|
||||||
|
while p < 0x180141F00:
|
||||||
|
ins = listing.getInstructionAt(addr(p))
|
||||||
|
if ins is None:
|
||||||
|
P("%#x <no instruction>" % p)
|
||||||
|
p += 1
|
||||||
|
continue
|
||||||
|
P("%#x %s" % (p, ins))
|
||||||
|
p += ins.getLength()
|
||||||
|
|
||||||
|
src = dec(0x180141025)
|
||||||
|
P("")
|
||||||
|
P("=== FULL DECOMPILE of function containing 0x180141025, len=%d ===" % len(src))
|
||||||
|
P(src)
|
||||||
|
|
||||||
|
with open(OUT, "w") as fh:
|
||||||
|
fh.write("\n".join(lines))
|
||||||
|
print("wrote %s (%d lines)" % (OUT, len(lines)))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Q: the client's fcc_discardcoins lookup does NOT miss (proved live). So why did
|
||||||
|
every card render "Quick Sell 0" when we omitted discardValue?
|
||||||
|
|
||||||
|
HYPOTHESIS H1: the consumer of the price reads item+0x38 (the wire discardValue)
|
||||||
|
only, and NEVER item+0x3c (where the client's own computation lands). That would
|
||||||
|
explain both halves of the observation: 0 with the field omitted, exact with it
|
||||||
|
sent.
|
||||||
|
|
||||||
|
RIVAL H2: the consumer reads +0x38 if non-zero else +0x3c. H2 predicts the
|
||||||
|
omitted-field test should have shown the right numbers, which it did not, but H2
|
||||||
|
survives if the failing observation was mis-made, so decide it on code.
|
||||||
|
|
||||||
|
METHOD: enumerate EVERY instruction in .text whose memory operand is a dword at
|
||||||
|
displacement 0x38 or 0x3c, tabulate by containing function, and then look at the
|
||||||
|
functions that touch BOTH (a candidate "sent else computed" selector) versus
|
||||||
|
functions that touch only one.
|
||||||
|
|
||||||
|
CONTROL: FUN_18013fe00 must appear in both tables (it provably reads +0x38 at
|
||||||
|
0x180141025 and writes +0x3c at 0x180141140). If the scan does not find those two
|
||||||
|
exact addresses, the scan is broken and its silence means nothing.
|
||||||
|
|
||||||
|
Also: FUN_180141660 in full, plus every string literal it references, for the
|
||||||
|
"does the miss degrade other screens" question.
|
||||||
|
|
||||||
|
Absence discipline: this scan enumerates operands, not immediates, so it is
|
||||||
|
immune to the ==/!=/switch/ladder trap; but it is scoped to CardsDLL only and
|
||||||
|
says nothing about FIFA17.exe.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q2_out.txt"
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines = []
|
||||||
|
def P(*a):
|
||||||
|
lines.append(" ".join(str(x) for x in a))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 1. operand scan
|
||||||
|
from ghidra.program.model.lang import OperandType
|
||||||
|
|
||||||
|
hits38, hits3c = {}, {}
|
||||||
|
n_ins = 0
|
||||||
|
it = listing.getInstructions(True)
|
||||||
|
while it.hasNext():
|
||||||
|
ins = it.next()
|
||||||
|
n_ins += 1
|
||||||
|
txt = str(ins)
|
||||||
|
if "0x38]" not in txt and "0x3c]" not in txt:
|
||||||
|
continue
|
||||||
|
a = int(ins.getAddress().getOffset())
|
||||||
|
f = fm.getFunctionContaining(ins.getAddress())
|
||||||
|
key = (f.getName() if f else "?",
|
||||||
|
int(f.getEntryPoint().getOffset()) if f else 0)
|
||||||
|
if "0x38]" in txt:
|
||||||
|
hits38.setdefault(key, []).append((a, txt))
|
||||||
|
if "0x3c]" in txt:
|
||||||
|
hits3c.setdefault(key, []).append((a, txt))
|
||||||
|
|
||||||
|
P("instructions scanned: %d" % n_ins)
|
||||||
|
P("functions touching a +0x38 operand: %d" % len(hits38))
|
||||||
|
P("functions touching a +0x3c operand: %d" % len(hits3c))
|
||||||
|
|
||||||
|
P("")
|
||||||
|
P("=== CONTROL ===")
|
||||||
|
ctl = [t for k, v in hits38.items() for t in v if t[0] == 0x180141025]
|
||||||
|
P("read of +0x38 at 0x180141025 found: %s" % (ctl or "NO -- SCAN BROKEN"))
|
||||||
|
ctl2 = [t for k, v in hits3c.items() for t in v if t[0] == 0x180141140]
|
||||||
|
P("write of +0x3c at 0x180141140 found: %s" % (ctl2 or "NO -- SCAN BROKEN"))
|
||||||
|
|
||||||
|
both = sorted(set(hits38) & set(hits3c), key=lambda k: k[1])
|
||||||
|
P("")
|
||||||
|
P("=== FUNCTIONS TOUCHING BOTH +0x38 AND +0x3c (%d) ===" % len(both))
|
||||||
|
for k in both:
|
||||||
|
P(" %s @ %#x" % k)
|
||||||
|
for a, t in sorted(hits38[k]):
|
||||||
|
P(" 38 %#x %s" % (a, t))
|
||||||
|
for a, t in sorted(hits3c[k]):
|
||||||
|
P(" 3c %#x %s" % (a, t))
|
||||||
|
|
||||||
|
only3c = sorted(set(hits3c) - set(hits38), key=lambda k: k[1])
|
||||||
|
P("")
|
||||||
|
P("=== FUNCTIONS TOUCHING +0x3c ONLY (%d) ===" % len(only3c))
|
||||||
|
for k in only3c:
|
||||||
|
P(" %s @ %#x" % k)
|
||||||
|
for a, t in sorted(hits3c[k]):
|
||||||
|
P(" %#x %s" % (a, t))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 2. tiny accessors
|
||||||
|
P("")
|
||||||
|
P("=== TINY ACCESSOR BYTE PATTERNS IN .text ===")
|
||||||
|
pats = {
|
||||||
|
"mov eax,[rcx+0x38]; ret": b"\x8b\x41\x38\xc3",
|
||||||
|
"mov eax,[rcx+0x3c]; ret": b"\x8b\x41\x3c\xc3",
|
||||||
|
"mov eax,[rcx+0x38]": b"\x8b\x41\x38",
|
||||||
|
"mov eax,[rcx+0x3c]": b"\x8b\x41\x3c",
|
||||||
|
}
|
||||||
|
for name, pat in pats.items():
|
||||||
|
hs = find_all(pat, blocks=(".text",))
|
||||||
|
P(" %-26s %d hits" % (name, len(hs)))
|
||||||
|
for h in hs[:40]:
|
||||||
|
f = fm.getFunctionContaining(addr(h))
|
||||||
|
P(" %#x in %s" % (h, f.getName() if f else "?"))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 3. FUN_180141660
|
||||||
|
src = dec(0x180141660)
|
||||||
|
P("")
|
||||||
|
P("=== FUN_180141660 FULL DECOMPILE, len=%d ===" % len(src))
|
||||||
|
P(src)
|
||||||
|
|
||||||
|
P("")
|
||||||
|
P("=== STRING LITERALS REFERENCED BY FUN_180141660 ===")
|
||||||
|
f = func(0x180141660)
|
||||||
|
seen = set()
|
||||||
|
for ad in f.getBody().getAddresses(True):
|
||||||
|
ins = listing.getInstructionAt(ad)
|
||||||
|
if ins is None:
|
||||||
|
continue
|
||||||
|
for r in ins.getReferencesFrom():
|
||||||
|
t = int(r.getToAddress().getOffset())
|
||||||
|
if t in seen or not (0x1801E5000 <= t <= 0x180290000):
|
||||||
|
continue
|
||||||
|
seen.add(t)
|
||||||
|
try:
|
||||||
|
s = rd_str(t, 80)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if s and all(32 <= ord(c) < 127 for c in s) and len(s) >= 3:
|
||||||
|
P(" %#x from %#x %r" % (t, int(ad.getOffset()), s))
|
||||||
|
|
||||||
|
with open(OUT, "w") as fh:
|
||||||
|
fh.write("\n".join(lines))
|
||||||
|
print("wrote %s (%d lines)" % (OUT, len(lines)))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Q: who READS the parsed item record's discard fields, item+0x38 (the wire
|
||||||
|
discardValue) and item+0x3c (the client's own fcc_discardcoins result)?
|
||||||
|
|
||||||
|
WHY q2's CONTROL FAILED, and why that was the control's fault: inside
|
||||||
|
FUN_18013fe00 the item is a STACK STRUCT at RBP+0x160, so the guard reads
|
||||||
|
[RBP+0x198] and the store writes [RBP+0x19c]. A scan for the displacements 0x38
|
||||||
|
and 0x3c can never see them. The control was invalid, not the scan.
|
||||||
|
|
||||||
|
NEW METHOD -- FINGERPRINT THE STRUCT, NOT THE OFFSET. The item record has several
|
||||||
|
displacements that are rare in general code: +0x146 (preferredPosition, u16),
|
||||||
|
+0x148 (nation), +0x154 (leagueId), +0x94 (teamid), +0xb4 (rating). Any function
|
||||||
|
that dereferences a pointer at two or more of those is handling an item record.
|
||||||
|
Collect the displacement set per function from the instruction text, select the
|
||||||
|
item handlers, and then report their +0x38 / +0x3c usage.
|
||||||
|
|
||||||
|
SECOND TEST, independent of the fingerprint: find every place in .text where a
|
||||||
|
dword is READ at [reg+0x38] and, within 0x40 bytes and off the SAME base
|
||||||
|
register, a dword is READ at [reg+0x3c]. That is the shape of a "server value
|
||||||
|
else computed value" selector. H1 (consumer reads +0x38 only) predicts no such
|
||||||
|
selector on an item; H2 predicts one.
|
||||||
|
|
||||||
|
CONTROL for this run: the fingerprint must select FUN_18013fe00 itself when the
|
||||||
|
frame register RBP is allowed, because that function demonstrably touches
|
||||||
|
RBP+0x2a6 (0x146+0x160), RBP+0x2a8, RBP+0x2b4 and RBP+0x214. I print the
|
||||||
|
frame-relative fingerprint hits separately for exactly that reason.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q3_out.txt"
|
||||||
|
RE_MEM = re.compile(r"\[(R[A-Z0-9]+) \+ (-?0x[0-9a-f]+)\]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines = []
|
||||||
|
def P(*a):
|
||||||
|
lines.append(" ".join(str(x) for x in a))
|
||||||
|
|
||||||
|
ITEM_MARKS = (0x146, 0x148, 0x154, 0x94, 0xb4, 0x8c)
|
||||||
|
FRAME_MARKS = tuple(m + 0x160 for m in ITEM_MARKS)
|
||||||
|
|
||||||
|
per_fn = {} # entry -> {'name', 'disp': {d: [(addr, base, txt)]}}
|
||||||
|
reads38 = [] # (addr, base, fnentry, txt)
|
||||||
|
reads3c = []
|
||||||
|
n_ins = 0
|
||||||
|
|
||||||
|
it = listing.getInstructions(True)
|
||||||
|
while it.hasNext():
|
||||||
|
ins = it.next()
|
||||||
|
n_ins += 1
|
||||||
|
txt = str(ins)
|
||||||
|
if "[" not in txt:
|
||||||
|
continue
|
||||||
|
ms = RE_MEM.findall(txt)
|
||||||
|
if not ms:
|
||||||
|
continue
|
||||||
|
a = int(ins.getAddress().getOffset())
|
||||||
|
f = fm.getFunctionContaining(ins.getAddress())
|
||||||
|
ent = int(f.getEntryPoint().getOffset()) if f else 0
|
||||||
|
rec = per_fn.setdefault(ent, {"name": f.getName() if f else "?", "disp": {}})
|
||||||
|
for base, dtxt in ms:
|
||||||
|
d = int(dtxt, 16)
|
||||||
|
rec["disp"].setdefault(d, []).append((a, base, txt))
|
||||||
|
if d == 0x38 and txt.startswith("MOV E") and "dword ptr [" + base in txt:
|
||||||
|
reads38.append((a, base, ent, txt))
|
||||||
|
if d == 0x3c and txt.startswith("MOV E") and "dword ptr [" + base in txt:
|
||||||
|
reads3c.append((a, base, ent, txt))
|
||||||
|
|
||||||
|
P("instructions scanned: %d ; functions with memory operands: %d" % (n_ins, len(per_fn)))
|
||||||
|
|
||||||
|
# ---- CONTROL: the frame-relative fingerprint must select FUN_18013fe00
|
||||||
|
P("")
|
||||||
|
P("=== CONTROL: frame-relative item fingerprint (marks + 0x160) ===")
|
||||||
|
ctl = []
|
||||||
|
for ent, rec in per_fn.items():
|
||||||
|
got = [m for m in FRAME_MARKS if m in rec["disp"]]
|
||||||
|
if len(got) >= 3:
|
||||||
|
ctl.append((ent, rec["name"], [hex(g) for g in got]))
|
||||||
|
for ent, nm, got in sorted(ctl):
|
||||||
|
P(" %-18s %#x marks %s %s" % (nm, ent, got,
|
||||||
|
"<== FUN_18013fe00" if ent == 0x18013FE00 else ""))
|
||||||
|
P(" control %s" % ("PASS" if any(e == 0x18013FE00 for e, _, _ in ctl)
|
||||||
|
else "FAIL -- fingerprint cannot see the known item handler"))
|
||||||
|
|
||||||
|
# ---- pointer-relative fingerprint: the real search
|
||||||
|
P("")
|
||||||
|
P("=== ITEM HANDLERS BY POINTER-RELATIVE FINGERPRINT (>=2 of %s) ==="
|
||||||
|
% [hex(m) for m in ITEM_MARKS])
|
||||||
|
cands = []
|
||||||
|
for ent, rec in per_fn.items():
|
||||||
|
got = []
|
||||||
|
for m in ITEM_MARKS:
|
||||||
|
for (a, base, txt) in rec["disp"].get(m, []):
|
||||||
|
if base not in ("RSP", "RBP"):
|
||||||
|
got.append(m)
|
||||||
|
break
|
||||||
|
if len(got) >= 2:
|
||||||
|
cands.append((ent, rec["name"], got))
|
||||||
|
P("candidates: %d" % len(cands))
|
||||||
|
for ent, nm, got in sorted(cands):
|
||||||
|
rec = per_fn[ent]
|
||||||
|
h38 = [(a, b, t) for (a, b, t) in rec["disp"].get(0x38, []) if b not in ("RSP", "RBP")]
|
||||||
|
h3c = [(a, b, t) for (a, b, t) in rec["disp"].get(0x3c, []) if b not in ("RSP", "RBP")]
|
||||||
|
P("")
|
||||||
|
P(" %-18s %#x marks %s +0x38:%d +0x3c:%d"
|
||||||
|
% (nm, ent, [hex(g) for g in got], len(h38), len(h3c)))
|
||||||
|
for a, b, t in h38:
|
||||||
|
P(" 38 %#x %s" % (a, t))
|
||||||
|
for a, b, t in h3c:
|
||||||
|
P(" 3c %#x %s" % (a, t))
|
||||||
|
|
||||||
|
# ---- selector shape
|
||||||
|
P("")
|
||||||
|
P("=== SELECTOR SHAPE: dword read [reg+0x38] then dword read [SAME reg+0x3c] within 0x40 ===")
|
||||||
|
idx3c = {}
|
||||||
|
for a, base, ent, txt in reads3c:
|
||||||
|
idx3c.setdefault(base, []).append((a, ent, txt))
|
||||||
|
nsel = 0
|
||||||
|
for a, base, ent, txt in reads38:
|
||||||
|
for a2, ent2, txt2 in idx3c.get(base, []):
|
||||||
|
if 0 < a2 - a <= 0x40:
|
||||||
|
nsel += 1
|
||||||
|
nm = per_fn.get(ent, {}).get("name", "?")
|
||||||
|
P(" %s @ %#x : %#x %s -> %#x %s" % (nm, ent, a, txt, a2, txt2))
|
||||||
|
P(" selectors found: %d" % nsel)
|
||||||
|
P(" (reads at +0x38: %d, reads at +0x3c: %d, over the whole .text)"
|
||||||
|
% (len(reads38), len(reads3c)))
|
||||||
|
|
||||||
|
with open(OUT, "w") as fh:
|
||||||
|
fh.write("\n".join(lines))
|
||||||
|
print("wrote %s (%d lines)" % (OUT, len(lines)))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Q: identify the CONSUMER of the item record's discard fields.
|
||||||
|
|
||||||
|
The parsed stack struct is local_188 (RBP+0x160) and it is handed to
|
||||||
|
`FUN_18011a830()->vtbl[0xa08](mgr, existingItem, &parsed)`. The heap records our
|
||||||
|
live probe walked have the SAME layout (discardValue at +0x38, client-computed at
|
||||||
|
+0x3c, both confirmed live), so a consumer reads [reg+0x38] / [reg+0x3c].
|
||||||
|
|
||||||
|
H1: no consumer reads +0x3c off an item; the display reads +0x38 only.
|
||||||
|
H2: some consumer reads +0x38 and falls back to +0x3c.
|
||||||
|
|
||||||
|
q3 found 40 "read 0x38 then read 0x3c off the same base" sites DLL-wide, three of
|
||||||
|
which sit in functions carrying an item fingerprint. Decompile those and decide.
|
||||||
|
|
||||||
|
CONTROL: FUN_18013fe00 is a known item handler and FUN_1800d8330 a known
|
||||||
|
cardsubtypeid mapper; both must decompile to something recognisable. Also resolve
|
||||||
|
FUN_18011a830's vtable slot 0xa08, which is the known sink, as a positive check
|
||||||
|
that vtable resolution works here at all.
|
||||||
|
|
||||||
|
Absence discipline: any "no consumer reads +0x3c" statement below is scoped to
|
||||||
|
CardsDLL's .text only. FIFA17.exe is Denuvo-packed and is NOT searched, so a
|
||||||
|
consumer living there cannot be excluded by this query.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q4_out.txt"
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines = []
|
||||||
|
def P(*a):
|
||||||
|
lines.append(" ".join(str(x) for x in a))
|
||||||
|
|
||||||
|
P("=== SINK: FUN_18011a830 and vtable slot 0xa08 ===")
|
||||||
|
src = dec(0x18011A830)
|
||||||
|
P("FUN_18011a830 decompile, len=%d" % len(src))
|
||||||
|
P(src)
|
||||||
|
|
||||||
|
for tgt in (0x1800AA440, 0x18007C5F0, 0x18007BF00, 0x1800D7920):
|
||||||
|
s = dec(tgt)
|
||||||
|
P("")
|
||||||
|
P("=" * 100)
|
||||||
|
P("=== FUN_%x FULL DECOMPILE, len=%d ===" % (tgt, len(s)))
|
||||||
|
P(s)
|
||||||
|
P("--- callers of %#x ---" % tgt)
|
||||||
|
for frm, typ, fn, ent in xrefs_to(tgt):
|
||||||
|
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
|
||||||
|
with open(OUT, "w") as fh:
|
||||||
|
fh.write("\n".join(lines))
|
||||||
|
print("wrote %s (%d lines)" % (OUT, len(lines)))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Q: which value does the UI actually receive for the quick-sell price?
|
||||||
|
|
||||||
|
LEAD: the UI layer is bound by NAME. q4 showed calls of the shape
|
||||||
|
(**(code **)(*view + 0x48))(view, "LATEST_GAMES_WON", value)
|
||||||
|
so every number the client renders is pushed through a named setter. If the
|
||||||
|
quick-sell price is pushed under a name, finding that name finds the reader, and
|
||||||
|
the reader tells us whether it reads item+0x38 (the wire discardValue) or
|
||||||
|
item+0x3c (the client's own fcc_discardcoins result).
|
||||||
|
|
||||||
|
METHOD: enumerate every printable string in .rdata/.data whose text contains
|
||||||
|
DISCARD / discard / QUICK / Quick / SELL / Sell / COIN / Coin, print it with its
|
||||||
|
xrefs and the containing function, then decompile the functions that push a
|
||||||
|
discard-ish name.
|
||||||
|
|
||||||
|
CONTROL: the search must find the strings we already know exist -- the SQL
|
||||||
|
fragments "fcc_discardcoins" (0x1802231f0) and "price" (0x1802231e4) both
|
||||||
|
contain the target substrings, and the known UI name "LATEST_GAMES_WON" must
|
||||||
|
show up under COIN?? no -- it must show up in a separate positive check that the
|
||||||
|
string enumerator sees UI names at all. Both checks are printed explicitly. If
|
||||||
|
either fails the enumerator is broken and its silence means nothing.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q5_out.txt"
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines = []
|
||||||
|
def P(*a):
|
||||||
|
lines.append(" ".join(str(x) for x in a))
|
||||||
|
|
||||||
|
NEEDLES = ("DISCARD", "discard", "Discard", "QUICK", "Quick", "quick",
|
||||||
|
"SELL", "Sell", "sell", "COIN", "Coin", "coin")
|
||||||
|
|
||||||
|
# gather printable C strings out of .rdata/.data
|
||||||
|
blocks = {}
|
||||||
|
for b in mem.getBlocks():
|
||||||
|
if b.getName() in (".rdata", ".data") and b.isInitialized():
|
||||||
|
s = int(b.getStart().getOffset())
|
||||||
|
n = int(b.getEnd().getOffset()) - s + 1
|
||||||
|
blocks[b.getName()] = (s, n)
|
||||||
|
P("blocks: %s" % {k: (hex(v[0]), v[1]) for k, v in blocks.items()})
|
||||||
|
|
||||||
|
RE_STR = re.compile(rb"[ -~]{4,120}\x00")
|
||||||
|
found = []
|
||||||
|
for bn, (s, n) in blocks.items():
|
||||||
|
off = 0
|
||||||
|
CH = 1 << 20
|
||||||
|
while off < n:
|
||||||
|
ln = min(CH, n - off)
|
||||||
|
data = read_bytes(s + off, ln)
|
||||||
|
for m in RE_STR.finditer(data):
|
||||||
|
txt = m.group()[:-1].decode("ascii")
|
||||||
|
if any(x in txt for x in NEEDLES):
|
||||||
|
found.append((s + off + m.start(), bn, txt))
|
||||||
|
off += ln - 130 if ln == CH else ln
|
||||||
|
# dedupe
|
||||||
|
seen = set()
|
||||||
|
found = [f for f in found if not (f[0] in seen or seen.add(f[0]))]
|
||||||
|
P("strings matching %s: %d" % (list(NEEDLES), len(found)))
|
||||||
|
|
||||||
|
P("")
|
||||||
|
P("=== CONTROL 1: the two known SQL literals must be in the hit list ===")
|
||||||
|
hits = {a for a, _, _ in found}
|
||||||
|
P(" 0x1802231f0 'fcc_discardcoins' present: %s" % (0x1802231F0 in hits))
|
||||||
|
P(" 0x1802231e4 'price' present (should be False, no needle): %s" % (0x1802231E4 in hits))
|
||||||
|
|
||||||
|
P("")
|
||||||
|
P("=== CONTROL 2: the enumerator sees UI names -- LATEST_GAMES_WON ===")
|
||||||
|
la = find_all(b"LATEST_GAMES_WON\x00", blocks=(".rdata", ".data"))
|
||||||
|
P(" LATEST_GAMES_WON found at %s" % [hex(x) for x in la])
|
||||||
|
|
||||||
|
P("")
|
||||||
|
P("=== HITS WITH XREFS ===")
|
||||||
|
for a, bn, txt in sorted(found):
|
||||||
|
xs = xrefs_to(a)
|
||||||
|
fns = sorted({(fn, ent) for _, _, fn, ent in xs if ent})
|
||||||
|
P("%#x [%s] %-46r xrefs=%d %s"
|
||||||
|
% (a, bn, txt, len(xs), ", ".join("%s@%#x" % f for f in fns[:8])))
|
||||||
|
|
||||||
|
with open(OUT, "w") as fh:
|
||||||
|
fh.write("\n".join(lines))
|
||||||
|
print("wrote %s (%d lines)" % (OUT, len(lines)))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Q: FUN_1800eb850 pushes BOTH "DISCARD_CREDITS" (3 refs) and
|
||||||
|
"CALCULATED_DISCARD_CREDITS" (1 ref) to the UI. Which item offset feeds each?
|
||||||
|
|
||||||
|
H1 predicts DISCARD_CREDITS <- item+0x38 (the wire discardValue) and
|
||||||
|
CALCULATED_DISCARD_CREDITS <- item+0x3c (the client's fcc_discardcoins result),
|
||||||
|
two independent UI properties with no native fallback between them. If so, which
|
||||||
|
one the card tile shows is decided in the Flash/Scaleform asset, not in native
|
||||||
|
code, and the "Quick Sell 0" observation means the tile binds DISCARD_CREDITS.
|
||||||
|
|
||||||
|
METHOD: full decompile plus the raw instruction stream around every reference to
|
||||||
|
the two names, so the register feeding the third argument is visible rather than
|
||||||
|
inferred from decompiler variable naming. Also the callers of FUN_1800eb850, and
|
||||||
|
the same treatment for the sibling names DISCARD / COINS_AWARDED.
|
||||||
|
|
||||||
|
CONTROL: FUN_18013fe00's own two sites are the reference semantics: the guard
|
||||||
|
reads [RBP+0x198] (= item+0x38) and the store writes [RBP+0x19c] (= item+0x3c).
|
||||||
|
Any offsets this query reports must be interpretable against a struct base held
|
||||||
|
in a register; I print the full function so the base can be traced, rather than
|
||||||
|
quoting a fragment.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q6_out.txt"
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines = []
|
||||||
|
def P(*a):
|
||||||
|
lines.append(" ".join(str(x) for x in a))
|
||||||
|
|
||||||
|
for nm, a in (("DISCARD_CREDITS", 0x1802161C8),
|
||||||
|
("CALCULATED_DISCARD_CREDITS", 0x1802161D8),
|
||||||
|
("DISCARD", 0x1801F4D28)):
|
||||||
|
P("=== xrefs to %r (%#x) ===" % (nm, a))
|
||||||
|
for frm, typ, fn, ent in xrefs_to(a):
|
||||||
|
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
P("")
|
||||||
|
|
||||||
|
f = func(0x1800EB850)
|
||||||
|
b = f.getBody()
|
||||||
|
lo = int(b.getMinAddress().getOffset())
|
||||||
|
hi = int(b.getMaxAddress().getOffset())
|
||||||
|
P("FUN_1800eb850 body [%#x..%#x] size=%d" % (lo, hi, int(b.getNumAddresses())))
|
||||||
|
|
||||||
|
src = dec(0x1800EB850)
|
||||||
|
P("")
|
||||||
|
P("=== FUN_1800eb850 FULL DECOMPILE, len=%d ===" % len(src))
|
||||||
|
P(src)
|
||||||
|
|
||||||
|
P("")
|
||||||
|
P("=== FUN_1800eb850 FULL DISASSEMBLY ===")
|
||||||
|
p = lo
|
||||||
|
while p <= hi:
|
||||||
|
ins = listing.getInstructionAt(addr(p))
|
||||||
|
if ins is None:
|
||||||
|
P("%#x <none>" % p)
|
||||||
|
p += 1
|
||||||
|
continue
|
||||||
|
extra = ""
|
||||||
|
for r in ins.getReferencesFrom():
|
||||||
|
t = int(r.getToAddress().getOffset())
|
||||||
|
if 0x1801E5000 <= t <= 0x180290000:
|
||||||
|
try:
|
||||||
|
s = rd_str(t, 60)
|
||||||
|
except Exception:
|
||||||
|
s = ""
|
||||||
|
if s:
|
||||||
|
extra = " ; %r" % s
|
||||||
|
P("%#x %s%s" % (p, ins, extra))
|
||||||
|
p += ins.getLength()
|
||||||
|
|
||||||
|
P("")
|
||||||
|
P("=== CALLERS OF FUN_1800eb850 ===")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x1800EB850):
|
||||||
|
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
|
||||||
|
with open(OUT, "w") as fh:
|
||||||
|
fh.write("\n".join(lines))
|
||||||
|
print("wrote %s (%d lines)" % (OUT, len(lines)))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Q: which struct offsets do the two UI getters read?
|
||||||
|
|
||||||
|
FUN_1800eb850 pushes:
|
||||||
|
"DISCARD_CREDITS" <- FUN_1801a8620(handle)
|
||||||
|
"CALCULATED_DISCARD_CREDITS" <- FUN_1801a8090(handle)
|
||||||
|
"CARD_LEVEL" <- FUN_1801a80c0(handle)
|
||||||
|
"CARD_RARITY" <- FUN_1801a8880 || FUN_1801a88c0
|
||||||
|
|
||||||
|
H1 predicts FUN_1801a8620 reads +0x38 and FUN_1801a8090 reads +0x3c, i.e. the
|
||||||
|
wire value and the client's own computation are exposed to the UI as two
|
||||||
|
SEPARATE named properties with no native fallback between them.
|
||||||
|
|
||||||
|
BONUS CONTROL, and it is a strong one: the live probe showed item+0x54 holding
|
||||||
|
1/2/3 exactly tracking rating (3 if >=75, 2 if >=65, else 1) on all 22 resident
|
||||||
|
records, which contradicts the existing field map's "itemType 3=player 2=staff".
|
||||||
|
If FUN_1801a80c0 ("CARD_LEVEL") reads +0x54, that independently settles it as
|
||||||
|
the fcc_discardcoins `level`.
|
||||||
|
|
||||||
|
Also print the callers of FUN_1800eb850 to name the screen.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q7_out.txt"
|
||||||
|
|
||||||
|
TARGETS = [0x1801A8620, 0x1801A8090, 0x1801A80C0, 0x1801A8880, 0x1801A88C0,
|
||||||
|
0x1801A8850, 0x1801A8810, 0x1801A7140, 0x1801A78F0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines = []
|
||||||
|
def P(*a):
|
||||||
|
lines.append(" ".join(str(x) for x in a))
|
||||||
|
|
||||||
|
for t in TARGETS:
|
||||||
|
f = func(t)
|
||||||
|
if f is None:
|
||||||
|
P("%#x NO FUNCTION" % t)
|
||||||
|
continue
|
||||||
|
b = f.getBody()
|
||||||
|
lo, hi = int(b.getMinAddress().getOffset()), int(b.getMaxAddress().getOffset())
|
||||||
|
P("=" * 92)
|
||||||
|
P("FUN_%x body [%#x..%#x] size=%d" % (t, lo, hi, int(b.getNumAddresses())))
|
||||||
|
P("--- disassembly (full) ---")
|
||||||
|
p = lo
|
||||||
|
while p <= hi:
|
||||||
|
ins = listing.getInstructionAt(addr(p))
|
||||||
|
if ins is None:
|
||||||
|
P(" %#x <none>" % p)
|
||||||
|
p += 1
|
||||||
|
continue
|
||||||
|
P(" %#x %s" % (p, ins))
|
||||||
|
p += ins.getLength()
|
||||||
|
s = dec(t)
|
||||||
|
P("--- decompile, len=%d ---" % len(s))
|
||||||
|
P(s)
|
||||||
|
P("")
|
||||||
|
|
||||||
|
P("=" * 92)
|
||||||
|
P("=== CALLERS OF FUN_1800eb850 ===")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x1800EB850):
|
||||||
|
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
if ent:
|
||||||
|
for f2, t2, n2, e2 in xrefs_to(ent):
|
||||||
|
P(" <- %#x %s @ %#x" % (f2, n2, e2))
|
||||||
|
|
||||||
|
with open(OUT, "w") as fh:
|
||||||
|
fh.write("\n".join(lines))
|
||||||
|
print("wrote %s (%d lines)" % (OUT, len(lines)))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Final batch.
|
||||||
|
|
||||||
|
Q-A How many places consume each getter?
|
||||||
|
FUN_1801a8620 = item+0x38 (wire discardValue, UI name DISCARD_CREDITS)
|
||||||
|
FUN_1801a8090 = item+0x3c (client fcc_discardcoins result, UI name
|
||||||
|
CALCULATED_DISCARD_CREDITS)
|
||||||
|
If +0x3c has exactly ONE consumer and it is the UI property push, then no
|
||||||
|
native code ever falls back from the wire value to the computed one, and
|
||||||
|
which number the tile shows is a Flash-asset decision. That is H1.
|
||||||
|
|
||||||
|
Q-B What is FUN_1800eb850 registered as? Its only xrefs are DATA slots at
|
||||||
|
0x180216130 / 0x1802658a0 / 0x1802f85ec, so it sits in a dispatch table.
|
||||||
|
Dump the qwords either side and resolve any string pointers to name it.
|
||||||
|
|
||||||
|
Q-C item+0x34 feeds the UI property "BOUGHT_FOR" and is NOT on the field map.
|
||||||
|
Print the deserializer sites that write +0x194 (= 0x34 + 0x160, the frame
|
||||||
|
form inside FUN_18013fe00) so the wire atom can be identified later.
|
||||||
|
|
||||||
|
CONTROL: FUN_1801a8620 and FUN_1801a8090 are 8-byte leaf functions whose bodies
|
||||||
|
were printed in q7, so their identity is not in doubt; the xref counts below are
|
||||||
|
the only new claim. A getter with zero xrefs would mean the xref index is stale,
|
||||||
|
which is checkable against FUN_1801a80c0 (CARD_LEVEL), known to be called from
|
||||||
|
FUN_1800eb850.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q8_out.txt"
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines = []
|
||||||
|
def P(*a):
|
||||||
|
lines.append(" ".join(str(x) for x in a))
|
||||||
|
|
||||||
|
P("=== Q-A getter consumers ===")
|
||||||
|
for nm, a in (("+0x38 DISCARD_CREDITS getter FUN_1801a8620", 0x1801A8620),
|
||||||
|
("+0x3c CALCULATED getter FUN_1801a8090", 0x1801A8090),
|
||||||
|
("+0x54 CARD_LEVEL getter FUN_1801a80c0", 0x1801A80C0),
|
||||||
|
("+0x58 rare==1 getter FUN_1801a88c0", 0x1801A88C0),
|
||||||
|
("+0x58 rare==0xc getter FUN_1801a8880", 0x1801A8880)):
|
||||||
|
xs = xrefs_to(a)
|
||||||
|
P("%-44s %d xrefs" % (nm, len(xs)))
|
||||||
|
for frm, typ, fn, ent in xs:
|
||||||
|
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
|
||||||
|
|
||||||
|
P("")
|
||||||
|
P("=== Q-B dispatch-table context around the FUN_1800eb850 slots ===")
|
||||||
|
for slot in (0x180216130, 0x1802658A0, 0x1802F85EC):
|
||||||
|
P("-- slot %#x --" % slot)
|
||||||
|
for off in range(-0x40, 0x48, 8):
|
||||||
|
a = slot + off
|
||||||
|
try:
|
||||||
|
v = qword(a)
|
||||||
|
except Exception:
|
||||||
|
P(" %#x <unreadable>" % a)
|
||||||
|
continue
|
||||||
|
tag = ""
|
||||||
|
f = fm.getFunctionAt(addr(v)) if 0x180000000 <= v < 0x181000000 else None
|
||||||
|
if f:
|
||||||
|
tag = "-> FUNC %s" % f.getName()
|
||||||
|
elif 0x1801E5000 <= v <= 0x180290000:
|
||||||
|
try:
|
||||||
|
s = rd_str(v, 60)
|
||||||
|
except Exception:
|
||||||
|
s = ""
|
||||||
|
if s and all(32 <= ord(c) < 127 for c in s):
|
||||||
|
tag = "-> %r" % s
|
||||||
|
P(" %#x %016x %s%s" % (a, v, tag, " <== the slot" if off == 0 else ""))
|
||||||
|
P("")
|
||||||
|
|
||||||
|
P("=== Q-C writes to the parsed item's +0x34 (frame form RBP+0x194) ===")
|
||||||
|
f = func(0x18013FE00)
|
||||||
|
lo = int(f.getBody().getMinAddress().getOffset())
|
||||||
|
hi = int(f.getBody().getMaxAddress().getOffset())
|
||||||
|
p = lo
|
||||||
|
while p <= hi:
|
||||||
|
ins = listing.getInstructionAt(addr(p))
|
||||||
|
if ins is None:
|
||||||
|
p += 1
|
||||||
|
continue
|
||||||
|
t = str(ins)
|
||||||
|
if "0x194]" in t or "0x190]" in t or "0x198]" in t or "0x19c]" in t:
|
||||||
|
P(" %#x %s" % (p, t))
|
||||||
|
p += ins.getLength()
|
||||||
|
|
||||||
|
with open(OUT, "w") as fh:
|
||||||
|
fh.write("\n".join(lines))
|
||||||
|
print("wrote %s (%d lines)" % (OUT, len(lines)))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Side finding follow-up: the parsed item's +0x34 is pushed to the UI as
|
||||||
|
"BOUGHT_FOR" (FUN_1800eb850, from local_38+0x34) and it is NOT on the field map.
|
||||||
|
It is written once in the deserializer, at 0x180140885 (MOV [RBP+0x194],EAX).
|
||||||
|
|
||||||
|
Q: which wire atom writes it? Print the instruction stream from 0x180140780 to
|
||||||
|
0x1801408c0 so the dispatch arm and its atom immediate are visible, in whatever
|
||||||
|
form the compiler chose (cmp / sub-ladder / switch), rather than grepping for
|
||||||
|
one form.
|
||||||
|
|
||||||
|
CONTROL: the same window must also show the neighbouring known write
|
||||||
|
0x180140e43 -> [RBP+0x190] (item+0x30) or, failing that, at least one arm whose
|
||||||
|
atom is already in docs/fut_atoms.tsv, so that the arm-reading method is shown
|
||||||
|
to work on a known case in the same function.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q9_out.txt"
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines = []
|
||||||
|
def P(*a):
|
||||||
|
lines.append(" ".join(str(x) for x in a))
|
||||||
|
|
||||||
|
for lo, hi, tag in ((0x180140760, 0x1801408D0, "around the +0x34 (BOUGHT_FOR) write"),
|
||||||
|
(0x180140DC0, 0x180140E60, "around the +0x30 write, as a control")):
|
||||||
|
P("=== %s : %#x..%#x ===" % (tag, lo, hi))
|
||||||
|
p = lo
|
||||||
|
while p < hi:
|
||||||
|
ins = listing.getInstructionAt(addr(p))
|
||||||
|
if ins is None:
|
||||||
|
p += 1
|
||||||
|
continue
|
||||||
|
P(" %#x %s" % (p, ins))
|
||||||
|
p += ins.getLength()
|
||||||
|
P("")
|
||||||
|
|
||||||
|
with open(OUT, "w") as fh:
|
||||||
|
fh.write("\n".join(lines))
|
||||||
|
print("wrote %s (%d lines)" % (OUT, len(lines)))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Q1: WHERE DOES THE DISPLAYED RATING COME FROM?
|
||||||
|
|
||||||
|
HYPOTHESIS: the card face is painted from an "attribute publisher" that reads the
|
||||||
|
0x180-stride parsed item record and pushes named ATTRIB_* keys to the UI layer.
|
||||||
|
CARD_SYSTEM.md already names one such function, FUN_1800e5940, which reads
|
||||||
|
record+0xe3 and publishes ATTRIB_CONTRACT_NEGOTIATION. If that function also
|
||||||
|
publishes a rating key, and it reads it from the record (offset 0xb4 per the live
|
||||||
|
field map) rather than re-querying the local players table, then the displayed
|
||||||
|
rating is OURS.
|
||||||
|
|
||||||
|
CONTROL (same syntactic form): the publisher must also emit the six attribute keys
|
||||||
|
from +0x98..+0xac. Those are KNOWN-OURS -- CARD_SYSTEM.md records invented
|
||||||
|
attributes rendering on screen (SILVA 72/44/58/60/62/61). If my read of the
|
||||||
|
publisher shows the attributes coming from somewhere OTHER than the record, my
|
||||||
|
read of the publisher is wrong, not the doc, and every rating conclusion in this
|
||||||
|
file is void.
|
||||||
|
|
||||||
|
Also enumerate every ATTRIB_* literal in .rdata with its xrefs, which is the raw
|
||||||
|
material for the Q2 "ours vs client's" table.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=== A: every ATTRIB_ literal in .rdata ===")
|
||||||
|
hits = find_all(b"ATTRIB_")
|
||||||
|
print("n=%d" % len(hits))
|
||||||
|
seen = {}
|
||||||
|
for h in hits:
|
||||||
|
s = rd_str(h, 96)
|
||||||
|
seen.setdefault(s, []).append(h)
|
||||||
|
for s in sorted(seen):
|
||||||
|
print(" %-46s %s" % (s, " ".join("%#x" % a for a in seen[s])))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== B: xrefs to each ATTRIB_ literal ===")
|
||||||
|
for s in sorted(seen):
|
||||||
|
for a in seen[s]:
|
||||||
|
xs = xrefs_to(a)
|
||||||
|
if not xs:
|
||||||
|
continue
|
||||||
|
print(" %-46s @%#x" % (s, a))
|
||||||
|
for frm, typ, fn, ent in xs:
|
||||||
|
print(" from %#x %-12s %s(%#x)" % (frm, typ, fn, ent))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== C: FUN_1800e5940 in full ===")
|
||||||
|
src = dec(0x1800E5940)
|
||||||
|
print("len=%d" % len(src))
|
||||||
|
print(src)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Q2: the card-accessor family and the OTHER card publishers.
|
||||||
|
|
||||||
|
FUN_1800e5940 is the MANAGER card publisher (gated on CARD_TYPE, emits
|
||||||
|
ManagerCardBio / TACTICAL_KNOWLEDGE / ATTRIB_TEAM_TALKS). It reads every value it
|
||||||
|
publishes through a family of tiny accessors at 0x1801a8xxx taking a 4-qword stack
|
||||||
|
wrapper, and pushes them to a UI sink via param_3 vtable slots +0x08 (bool),
|
||||||
|
+0x10 (int), +0x20 (string).
|
||||||
|
|
||||||
|
HYPOTHESIS: those accessors are thin field reads off the 0x180-stride parsed item
|
||||||
|
record, so FUN_1801a87f0 (OVERALL_RATING) reads record+0xb4, which is where atom
|
||||||
|
0x274 rating is stored. If so the DISPLAYED rating is OUR wire value.
|
||||||
|
|
||||||
|
CONTROL, same syntactic form: FUN_1801a8540 is published as LEAGUE_ID. LEAGUE_ID
|
||||||
|
is KNOWN to be client-derived -- FUN_180135890 "always recomputes leagueid +0x154"
|
||||||
|
on a DB hit. And FUN_1801a8480 is CONTRACT_REMAINING, known-ours (+0x8c, we send
|
||||||
|
contract 7 and the manager card printed "CONTRACT 7"). So the accessor family must
|
||||||
|
show BOTH kinds reading the SAME record: that proves the accessors are record
|
||||||
|
reads and that "ours vs theirs" is decided upstream in the merge, not here. If the
|
||||||
|
league accessor instead performs a DB query while the rating one does a field read,
|
||||||
|
that is the opposite answer and it is equally visible.
|
||||||
|
|
||||||
|
Also: enumerate every function in [0x1801a7000,0x1801a9000) with its decompiled
|
||||||
|
body, which is the whole accessor table, and find the sibling publishers by xref
|
||||||
|
on the UI key literals.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=== A: UI key literals and their xrefs (finds sibling publishers) ===")
|
||||||
|
keys = [b"OVERALL_RATING", b"CARD_LEVEL", b"CARD_RARITY", b"CARD_TYPE",
|
||||||
|
b"ASSET_ID", b"LEAGUE_ID", b"TEAM_ID", b"NATIONALITY",
|
||||||
|
b"CONTRACT_REMAINING", b"FIRST_NAME", b"LAST_NAME", b"KNOWN_AS",
|
||||||
|
b"PREFERRED_POSITION", b"POSITION", b"FITNESS", b"PLAYSTYLE",
|
||||||
|
b"PLAY_STYLE", b"CHEMISTRY", b"ATTRIBUTE", b"ATTR", b"MORALE",
|
||||||
|
b"TRAINING", b"DISCARD", b"QUICK_SELL", b"UNTRADEABLE",
|
||||||
|
b"RESOURCE_ID", b"DEFINITION_ID", b"CARD_ASSET", b"ITEM_STATE",
|
||||||
|
b"ITEM_TYPE", b"SUBTYPE", b"RARE", b"PHOTO", b"HEADSHOT", b"IS_"]
|
||||||
|
for k in keys:
|
||||||
|
for a in find_all(k):
|
||||||
|
s = rd_str(a, 64)
|
||||||
|
if not s.startswith(k.decode()):
|
||||||
|
continue
|
||||||
|
xs = xrefs_to(a)
|
||||||
|
if not xs:
|
||||||
|
continue
|
||||||
|
print(" %-34s @%#x" % (s, a))
|
||||||
|
for frm, typ, fn, ent in xs:
|
||||||
|
print(" from %#x %-10s %s(%#x)" % (frm, typ, fn, ent))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== B: every function in [0x1801a7000,0x1801a9000) decompiled ===")
|
||||||
|
it = fm.getFunctions(addr(0x1801A7000), True)
|
||||||
|
n = 0
|
||||||
|
while it.hasNext():
|
||||||
|
f = it.next()
|
||||||
|
e = int(f.getEntryPoint().getOffset())
|
||||||
|
if e >= 0x1801A9000:
|
||||||
|
break
|
||||||
|
n += 1
|
||||||
|
src = dec(e)
|
||||||
|
body = " ".join(src.split())
|
||||||
|
print("--- %s @%#x len=%d" % (f.getName(), e, len(src)))
|
||||||
|
print(" %s" % (body[:900]))
|
||||||
|
print("total functions in range: %d" % n)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== C: FUN_1801a78f0 (builds the wrapper) in full ===")
|
||||||
|
s = dec(0x1801A78F0)
|
||||||
|
print("len=%d" % len(s))
|
||||||
|
print(s)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Q3: the PLAYER card publisher, the attribute publisher, and the merge.
|
||||||
|
|
||||||
|
FUN_1800e6e20 emits OVERALL_RATING, CARD_LEVEL, CARD_RARITY, LEAGUE_ID, TEAM_ID,
|
||||||
|
LAST_NAME, PREFERRED_POSITION(+_ID), PLAY_STYLE, NATIONALITY(+ABBR/ABBR15/ASSET_ID),
|
||||||
|
CONTRACT_REMAINING and ASSET_ID -- that is the player card face. Get it in full and
|
||||||
|
map each published key to the accessor and therefore to a record offset.
|
||||||
|
|
||||||
|
FUN_1800e65d0 / FUN_1800e96f0 emit ATTRIBUTE_NAME / _ABBR / _VALUE: the six bars.
|
||||||
|
FUN_1800ea400 and FUN_1800e86f0 and FUN_1800eb850 are the other publishers.
|
||||||
|
|
||||||
|
HYPOTHESIS: every one of these is a pure field read off the same 0x180 record, so
|
||||||
|
the "ours vs the client's" split is decided ONLY in the merge FUN_180141660 /
|
||||||
|
FUN_180135890, never at draw time.
|
||||||
|
|
||||||
|
CONTROL: FUN_1800e6e20 must publish LAST_NAME from record+0xc8 (an inline char
|
||||||
|
array the merge WRITES from the local DB) and OVERALL_RATING from record+0xb4 (a
|
||||||
|
field the merge is documented never to touch). Both in the same function, same
|
||||||
|
call form. If they do not both appear as record reads, my model of the publisher
|
||||||
|
is wrong.
|
||||||
|
|
||||||
|
Also dump FUN_180135890 (players merge, the Q4 miss path) and FUN_180141660 in
|
||||||
|
full, plus the CARD_TYPE getter FUN_18003b800 and FUN_18003cbf0.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
FUNCS = [
|
||||||
|
("FUN_1800e6e20 PLAYER card publisher", 0x1800E6E20),
|
||||||
|
("FUN_1800e65d0 attribute publisher A", 0x1800E65D0),
|
||||||
|
("FUN_1800e96f0 attribute publisher B", 0x1800E96F0),
|
||||||
|
("FUN_1800ea400 publisher", 0x1800EA400),
|
||||||
|
("FUN_1800e86f0 publisher", 0x1800E86F0),
|
||||||
|
("FUN_1800eb850 publisher", 0x1800EB850),
|
||||||
|
("FUN_18015fa80 publisher", 0x18015FA80),
|
||||||
|
("FUN_1800e80f0 publisher", 0x1800E80F0),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
for label, ea in FUNCS:
|
||||||
|
src = dec(ea)
|
||||||
|
print("=" * 70)
|
||||||
|
print("### %s @%#x len=%d" % (label, ea, len(src)))
|
||||||
|
print(src)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Q4: the deserializer's atom->offset table, the merge, and the blank-card path.
|
||||||
|
|
||||||
|
Three questions, one batch.
|
||||||
|
|
||||||
|
(a) THE WIRE SIDE. FUN_18013fe00 is the shared ITEM element deserializer. Print it
|
||||||
|
IN FULL (never truncated -- see the absence trap) so the atom dispatch can be
|
||||||
|
enumerated in every form: == 0xNN, != 0xNN, switch case labels and running-sum
|
||||||
|
ladders. Atoms of interest: 0x274 rating, 0x287 resourceId, 0x23 assetId,
|
||||||
|
0x6b cardassetid, 0x6c cardsubtypeid, 0x23f playStyle, 0x24a preferredPosition,
|
||||||
|
0x31 attributeList, 0x271 rareflag, 0x172 itemState, 0x173 itemType.
|
||||||
|
HYPOTHESIS: rating 0x274 -> record+0xb4, and playStyle 0x23f has NO arm (the
|
||||||
|
live sweep found no 0xfa anywhere in a record although we send playStyle 250,
|
||||||
|
and the player publisher reads PLAY_STYLE from +0x88).
|
||||||
|
CONTROL: the same enumeration must FIND 0x274 and 0x287, which are known to be
|
||||||
|
parsed. An enumeration that finds neither is a broken enumeration, not an
|
||||||
|
absent atom.
|
||||||
|
|
||||||
|
(b) THE MERGE. FUN_180141660 (dispatch on record+0x4c) and FUN_180135890 (players
|
||||||
|
branch). Q4 asks what exactly the client failed to find for a blank card and
|
||||||
|
what the minimum viable identity is.
|
||||||
|
|
||||||
|
(c) THE ACCESSORS the player publisher uses, in full and untruncated, so each
|
||||||
|
published UI key gets an exact record offset.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
ACC = [0x1801A8010, 0x1801A8020, 0x1801A8090, 0x1801A80A0, 0x1801A80C0,
|
||||||
|
0x1801A8100, 0x1801A8110, 0x1801A8120, 0x1801A8130, 0x1801A8140,
|
||||||
|
0x1801A8460, 0x1801A8480, 0x1801A8490, 0x1801A84A0, 0x1801A8550,
|
||||||
|
0x1801A8590, 0x1801A85C0, 0x1801A85D0, 0x1801A8600, 0x1801A8620,
|
||||||
|
0x1801A8640, 0x1801A8660, 0x1801A86A0, 0x1801A86B0, 0x1801A87F0,
|
||||||
|
0x1801A8890, 0x1801A88D0, 0x1801A8950, 0x1801A8940]
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=== C: accessors in full ===")
|
||||||
|
for a in ACC:
|
||||||
|
s = dec(a)
|
||||||
|
print("--- %#x len=%d" % (a, len(s)))
|
||||||
|
print(s)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== B: merge dispatch FUN_180141660 ===")
|
||||||
|
s = dec(0x180141660)
|
||||||
|
print("len=%d" % len(s))
|
||||||
|
print(s)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== B2: players merge FUN_180135890 ===")
|
||||||
|
s = dec(0x180135890)
|
||||||
|
print("len=%d" % len(s))
|
||||||
|
print(s)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== A: item deserializer FUN_18013fe00 IN FULL ===")
|
||||||
|
s = dec(0x18013FE00, 600)
|
||||||
|
print("len=%d" % len(s))
|
||||||
|
print(s)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Q5: resourceId decomposition, the value mappers, and who reads assetId (+0x20).
|
||||||
|
|
||||||
|
Established so far, and the reason for each question here:
|
||||||
|
* FUN_180141660's TAIL, reached on EVERY family including the no-merge default,
|
||||||
|
does record+0x54 = 3 if record+0xb4 >= 0x4b else 2 if >= 0x41 else 1. So +0x54
|
||||||
|
is the bronze/silver/gold CARD LEVEL derived from rating, not itemType.
|
||||||
|
* The players merge queries `players` by record+0x18 & 0xffffff and, on 0 rows,
|
||||||
|
OVERWRITES rating=0x32, attrs=1, teamid=0x78d, nation=0xe, position=2, name=" ".
|
||||||
|
* The item deser routes atom 0x1d1 nation to +0x148 for family 1 and +0xde for
|
||||||
|
family 2 and NOWHERE otherwise, and atom 0x18a leagueId always to +0xe0.
|
||||||
|
|
||||||
|
Remaining:
|
||||||
|
(a) FUN_180166ca0(resourceId, &out_byte, buf, &out_dword) -- the resourceId
|
||||||
|
decomposition. out_dword lands at record+0x18 and out_byte at record+0x24,
|
||||||
|
and +0x24 gates the "p%d.dds" FUTPlayerHeads photo registration. This decides
|
||||||
|
Q3: what resourceId actually has to look like.
|
||||||
|
CONTROL: DAT_7364642e642570 must read as "p%d.dds"; if it does not, my read of
|
||||||
|
the photo path is wrong.
|
||||||
|
(b) FUN_180136480 playStyle mapper, FUN_180166810 preferredPosition mapper,
|
||||||
|
FUN_180166660 itemState mapper, FUN_1801666f0 injuryType mapper.
|
||||||
|
(c) WHO READS record+0x20 (assetId)? Accessors FUN_1801a8020 and FUN_1801a8990
|
||||||
|
read it. Enumerate their callers. HYPOTHESIS: only the staff/manager card
|
||||||
|
art path, which the merge itself fills from the local DB, so a wire assetId
|
||||||
|
is dead for every family.
|
||||||
|
CONTROL: the same enumeration run on FUN_1801a87f0 (+0xb4 rating) must return
|
||||||
|
the publishers we already found. An enumeration that returns nothing for
|
||||||
|
rating is broken.
|
||||||
|
(d) FUN_1800d84e0 -- the value written to record+0x30.
|
||||||
|
(e) FUN_1801356c0 manager merge, for the family table.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=== CONTROL: the format string at 0x1801eaf90..0x1801eafa8 region ===")
|
||||||
|
for a in (0x1801EAF98,):
|
||||||
|
print(" %#x -> %r" % (a, rd_str(a, 32)))
|
||||||
|
hits = find_all(b"p%d.dds")
|
||||||
|
print(" 'p%%d.dds' occurrences: %s" % " ".join("%#x" % h for h in hits))
|
||||||
|
for h in find_all(b"FUTPlayerHeads"):
|
||||||
|
print(" FUTPlayerHeads @%#x" % h)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== (a) FUN_180166ca0 resourceId decomposition ===")
|
||||||
|
print(dec(0x180166CA0))
|
||||||
|
|
||||||
|
print("=== (b) mappers ===")
|
||||||
|
for lbl, a in (("playStyle FUN_180136480", 0x180136480),
|
||||||
|
("preferredPosition FUN_180166810", 0x180166810),
|
||||||
|
("itemState FUN_180166660", 0x180166660),
|
||||||
|
("injuryType FUN_1801666f0", 0x1801666F0)):
|
||||||
|
s = dec(a)
|
||||||
|
print("--- %s len=%d" % (lbl, len(s)))
|
||||||
|
print(s)
|
||||||
|
|
||||||
|
print("=== (c) callers of the accessors ===")
|
||||||
|
for lbl, a in (("+0x20 assetId FUN_1801a8020", 0x1801A8020),
|
||||||
|
("+0x20/+0x4c FUN_1801a8990", 0x1801A8990),
|
||||||
|
("+0x1c cardassetid FUN_1801a8010", 0x1801A8010),
|
||||||
|
("+0x18 resourceId FUN_1801a80a0", 0x1801A80A0),
|
||||||
|
("CONTROL +0xb4 rating FUN_1801a87f0", 0x1801A87F0),
|
||||||
|
("+0x54 level FUN_1801a80c0", 0x1801A80C0),
|
||||||
|
("+0x54 FUN_1801a8870", 0x1801A8870),
|
||||||
|
("+0x54 FUN_1801a88f0", 0x1801A88F0)):
|
||||||
|
print("--- %s" % lbl)
|
||||||
|
for frm, typ, fn, ent in xrefs_to(a):
|
||||||
|
print(" %#x %-10s %s(%#x)" % (frm, typ, fn, ent))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== (d) FUN_1800d84e0 (record+0x30) ===")
|
||||||
|
print(dec(0x1800D84E0))
|
||||||
|
|
||||||
|
print("=== (e) FUN_1801356c0 manager merge ===")
|
||||||
|
s = dec(0x1801356C0)
|
||||||
|
print("len=%d" % len(s))
|
||||||
|
print(s)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Q6: loose ends.
|
||||||
|
|
||||||
|
(a) _DAT_1801f66a0 -- the qword the deser uses to initialise {cardsubtypeid(+0x50),
|
||||||
|
+0x54}. If +0x54 is the card LEVEL and no atom writes it, its pre-merge value
|
||||||
|
is whatever this constant carries.
|
||||||
|
(b) Does ANY writer of record+0x54 exist besides FUN_180141660's tail? Enumerate
|
||||||
|
every function that references the level accessors and, more directly, look for
|
||||||
|
the constant 0x4b/0x41 rating-tier ladder elsewhere.
|
||||||
|
(c) FUN_18003b800 -- the CARD_TYPE getter whose value 0x12 gates the manager
|
||||||
|
publisher. What is the enum?
|
||||||
|
(d) FUN_1801a7dd0 / FUN_1801a78f0 -- how the publisher's wrapper gets its record
|
||||||
|
pointer, to confirm wrapper+0x18 IS the 0x180 item record and not a copy.
|
||||||
|
CONTROL: FUN_1801a7100 already visibly does param_1[3] = *(param_2+0x10), i.e.
|
||||||
|
wrapper+0x18 = arg+0x10; if FUN_1801a7dd0 disagrees my offsets are off.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=== (a) _DAT_1801f66a0 ===")
|
||||||
|
q = qword(0x1801F66A0)
|
||||||
|
print(" qword %#018x -> low dword %#x (+0x50 init) high dword %#x (+0x54 init)"
|
||||||
|
% (q, q & 0xFFFFFFFF, q >> 32))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== (b) other rating-tier ladders: functions comparing a byte to 0x4b ===")
|
||||||
|
for lbl, a in (("FUN_180141660 tail", 0x180141660),
|
||||||
|
("FUN_1801a80c0 accessor", 0x1801A80C0)):
|
||||||
|
print(" known: %s" % lbl)
|
||||||
|
print(" callers of FUN_1800d8330 (family map), for context:")
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x1800D8330):
|
||||||
|
print(" %#x %-10s %s(%#x)" % (frm, typ, fn, ent))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== (c) FUN_18003b800 CARD_TYPE getter ===")
|
||||||
|
print(dec(0x18003B800))
|
||||||
|
print("=== FUN_18003b9d0 and FUN_18003cbf0 (manager publisher gates) ===")
|
||||||
|
print(dec(0x18003B9D0))
|
||||||
|
print(dec(0x18003CBF0))
|
||||||
|
|
||||||
|
print("=== (d) wrapper construction ===")
|
||||||
|
for a in (0x1801A7DD0, 0x1801A78F0, 0x1801A7100):
|
||||||
|
s = dec(a)
|
||||||
|
print("--- %#x len=%d" % (a, len(s)))
|
||||||
|
print(s)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Q7: completeness check on the headline claim.
|
||||||
|
|
||||||
|
CLAIM: the displayed rating is record+0xb4 and nothing else.
|
||||||
|
The evidence so far is that the literal OVERALL_RATING has exactly 4 xrefs and all 4
|
||||||
|
functions call FUN_1801a87f0 (+0xb4). That is only airtight if OVERALL_RATING is the
|
||||||
|
ONLY key under which a rating reaches the UI. Enumerate every .rdata literal that
|
||||||
|
contains "RATING" or "OVR" and report its xrefs, so a second rating key cannot hide.
|
||||||
|
|
||||||
|
CONTROL: the enumeration must re-find OVERALL_RATING with its 4 known xrefs.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
try:
|
||||||
|
seen = {}
|
||||||
|
for pat in (b"RATING", b"OVR", b"Rating"):
|
||||||
|
for a in find_all(pat):
|
||||||
|
# walk back to the start of the C string
|
||||||
|
s = a
|
||||||
|
while s > a - 64:
|
||||||
|
try:
|
||||||
|
if mem.getByte(addr(s - 1)) & 0xFF == 0:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
s -= 1
|
||||||
|
txt = rd_str(s, 96)
|
||||||
|
if txt:
|
||||||
|
seen.setdefault(txt, set()).add(s)
|
||||||
|
for txt in sorted(seen):
|
||||||
|
for a in sorted(seen[txt]):
|
||||||
|
xs = xrefs_to(a)
|
||||||
|
if not xs:
|
||||||
|
continue
|
||||||
|
print("%-44s @%#x" % (txt[:44], a))
|
||||||
|
for frm, typ, fn, ent in xs:
|
||||||
|
print(" %#x %-10s %s(%#x)" % (frm, typ, fn, ent))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Q8: the four publishers of the bare "RATING" key -- do any bypass record+0xb4?
|
||||||
|
|
||||||
|
If one of them reads a rating from the local DB instead of the item record, the
|
||||||
|
headline claim ("the displayed rating is ours") needs qualifying by screen.
|
||||||
|
CONTROL: FUN_18003ded0 also publishes FIRST_NAME/LAST_NAME, so it is card-shaped and
|
||||||
|
is the most likely counterexample. Print all four in full.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
try:
|
||||||
|
for a in (0x18003DED0, 0x180053AA0, 0x1800F0970, 0x18005F910):
|
||||||
|
s = dec(a)
|
||||||
|
print("=" * 60)
|
||||||
|
print("### %#x len=%d" % (a, len(s)))
|
||||||
|
print(s)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Q9: why is record+0xe0 zero although atom 0x18a leagueId writes local_a8?
|
||||||
|
|
||||||
|
The struct-offset formula (offset = 0x188 - localnum) is validated empirically on
|
||||||
|
NINE fields (contract +0x8c, rating +0xb4, discardValue +0x38, cardsubtypeid +0x50,
|
||||||
|
teamid +0x94, nation +0x148, owners +0x48, cardassetid +0x1c, assetId +0x20), yet a
|
||||||
|
live read shows +0xe0 == 0 on all 22 records while we send leagueId 13..353.
|
||||||
|
Candidate: FUN_180134cb0, called at the tail of FUN_180135890, clears the manager
|
||||||
|
block for family 1.
|
||||||
|
CONTROL: whatever it does must NOT clear +0xb4/+0x94/+0x148, which the live read
|
||||||
|
shows surviving.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
try:
|
||||||
|
for a in (0x180134CB0,):
|
||||||
|
s = dec(a)
|
||||||
|
print("### %#x len=%d" % (a, len(s)))
|
||||||
|
print(s)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""q_cd_route_1 -- the club route family.
|
||||||
|
|
||||||
|
HYPOTHESIS
|
||||||
|
The 125-row action table at 0x1802caa20 binds each client-originated request to a
|
||||||
|
URL base out of the 48-row base table at 0x18021df80 (16-byte rows: char* template,
|
||||||
|
char* symbolic name). Exactly four rows carry base index 3 = "ut/%s/club":
|
||||||
|
ClubSearch (fn 0x180123a60), ClubStats (0x180123a70), StaffStats (0x1801247e0),
|
||||||
|
ConsumablesSearch (0x180123a80). If that is right, those four factories are the
|
||||||
|
COMPLETE set of club-route request builders and everything the client can emit on
|
||||||
|
/club is one of them.
|
||||||
|
|
||||||
|
CONTROL
|
||||||
|
Decompile a NON-club factory from the same table in the same pass (PurchasedItems
|
||||||
|
0x180124260, base 26 = ut/%s/purchased) and confirm it resolves to a different
|
||||||
|
request class with a different suffix builder. If the four club factories and the
|
||||||
|
control all decompile to the same shape, the shape is real; if the control comes
|
||||||
|
back empty while the targets do too, the method is broken, not the answer.
|
||||||
|
|
||||||
|
Second control: the /stats/%s, /stats/staff and /consumables/%s literals were found
|
||||||
|
by RIP-relative displacement scan of .text on the ON-DISK PE, at 0x18012f5b5,
|
||||||
|
0x18012b086 and 0x18013090e. Those three functions must turn out to be the URL
|
||||||
|
builders of three of the four classes. If they are not, the base-index reading of
|
||||||
|
column 1 is wrong.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
TARGETS = [
|
||||||
|
("ClubSearch.factory", 0x180123a60),
|
||||||
|
("ClubStats.factory", 0x180123a70),
|
||||||
|
("StaffStats.factory", 0x1801247e0),
|
||||||
|
("ConsumablesSearch.factory", 0x180123a80),
|
||||||
|
("CONTROL PurchasedItems.factory", 0x180124260),
|
||||||
|
("uses /stats/%s", 0x18012f5b5),
|
||||||
|
("uses /stats/staff", 0x18012b086),
|
||||||
|
("uses /consumables/%s", 0x18013090e),
|
||||||
|
("uses both enum tables", 0x180166306),
|
||||||
|
]
|
||||||
|
for label, ea in TARGETS:
|
||||||
|
f = func(ea)
|
||||||
|
print("=" * 78)
|
||||||
|
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
|
||||||
|
if f is None:
|
||||||
|
continue
|
||||||
|
print(" entry %s" % f.getEntryPoint())
|
||||||
|
src = dec(f)
|
||||||
|
print(" len(src) = %d" % len(src))
|
||||||
|
print(src)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""q_cd_route_2 -- the ClubSearch (FutStickerBookSearch) query-string builder.
|
||||||
|
|
||||||
|
HYPOTHESIS
|
||||||
|
The club item list is the "StickerBookSearch" request (RS4 literal at 0x180221e48).
|
||||||
|
Its literal block holds "%s%s=%s" 0x180221e78, "%s%s=%d" 0x180221e80, "%s%s="
|
||||||
|
0x180221e94, "2017", "desc", "asc", so the query string is assembled key by key with
|
||||||
|
a separator+name+value printf, and the key names come out of the atom reverse
|
||||||
|
lookup FUN_180180cd0 exactly as they do in the /stats/%s builder FUN_18012f4f0
|
||||||
|
(already decompiled: 6 cases, atoms 0x87 club / 0x389 year / 0xbd country /
|
||||||
|
0x189 league / 0x1d7 newcards / 0xa5 consumables).
|
||||||
|
A RIP-displacement scan of .text on the on-disk PE puts every use of those three
|
||||||
|
printf formats in 0x18012deb0..0x18012e5bd, one contiguous region, plus two strays
|
||||||
|
at 0x180169357/0x1801693ac and one at 0x180163a1c.
|
||||||
|
|
||||||
|
CONTROL
|
||||||
|
FUN_18012f4f0 (/stats/%s) decompiled cleanly in q_cd_route_1 through the same
|
||||||
|
helpers, so a NO FUNC or an empty body here is a fact about this address, not about
|
||||||
|
the harness. Also included: the two enum reverse-lookups FUN_180166300 (table
|
||||||
|
0x180229ab0) and the sibling at 0x180166340 (table 0x180229c30), and their callers,
|
||||||
|
because those tables are the candidate ?type= vocabularies and a table with no
|
||||||
|
caller inside a URL builder proves nothing about the wire.
|
||||||
|
|
||||||
|
Absence discipline: the atom-id switch in a builder is a `switch` with case labels,
|
||||||
|
so it is enumerated by reading the decompile in full, not by grepping "== 0x".
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
def show(label, ea, full=True):
|
||||||
|
f = func(ea)
|
||||||
|
print("=" * 78)
|
||||||
|
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
|
||||||
|
if f is None:
|
||||||
|
return None
|
||||||
|
print(" entry %s body %s" % (f.getEntryPoint(), f.getBody()))
|
||||||
|
src = dec(f)
|
||||||
|
print(" len(src) = %d" % len(src))
|
||||||
|
if full:
|
||||||
|
print(src)
|
||||||
|
return f
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for ea in (0x18012deb0, 0x18012df5c, 0x18012e087, 0x18012e19f, 0x18012e202,
|
||||||
|
0x18012e256, 0x18012e2d0, 0x18012e322, 0x18012e5bd,
|
||||||
|
0x18012dfb0, 0x18012e003, 0x18012e36d, 0x18012e3b8, 0x18012e403,
|
||||||
|
0x18012e44e, 0x18012e499, 0x18012e0db):
|
||||||
|
f = fm.getFunctionContaining(addr(ea))
|
||||||
|
if f is None:
|
||||||
|
print("### query-fmt use @ %s -> NO FUNC" % hex(ea))
|
||||||
|
continue
|
||||||
|
k = int(f.getEntryPoint().getOffset())
|
||||||
|
if k in seen:
|
||||||
|
continue
|
||||||
|
seen.add(k)
|
||||||
|
show("query-fmt user (via %s)" % hex(ea), k)
|
||||||
|
|
||||||
|
for ea in (0x180169357, 0x180163a1c):
|
||||||
|
f = fm.getFunctionContaining(addr(ea))
|
||||||
|
if f is None:
|
||||||
|
print("### stray fmt use @ %s -> NO FUNC" % hex(ea))
|
||||||
|
continue
|
||||||
|
k = int(f.getEntryPoint().getOffset())
|
||||||
|
if k not in seen:
|
||||||
|
seen.add(k)
|
||||||
|
show("stray query-fmt user (via %s)" % hex(ea), k)
|
||||||
|
|
||||||
|
show("enum revlookup table 0x180229ab0", 0x180166300)
|
||||||
|
show("enum revlookup table 0x180229c30", 0x180166340)
|
||||||
|
for ea in (0x180166300, 0x180166340):
|
||||||
|
f = fm.getFunctionContaining(addr(ea))
|
||||||
|
if f is None:
|
||||||
|
continue
|
||||||
|
print("--- callers of %s:" % hex(ea))
|
||||||
|
for c in sorted(set(int(x.getEntryPoint().getOffset()) for x in f.getCallingFunctions(mon))):
|
||||||
|
print(" %s %s" % (hex(c), fm.getFunctionAt(addr(c)).getName()))
|
||||||
|
|
||||||
|
show("StaffStats suffix (/stats/staff user)", 0x18012b086)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""q_cd_route_3 -- the ?type= vocabulary itself, and the remaining club value maps.
|
||||||
|
|
||||||
|
HYPOTHESIS
|
||||||
|
FUN_18012ddf0 (the ClubSearch / FutStickerBookSearch query builder, decompiled in
|
||||||
|
q_cd_route_2) writes the type= parameter from FUN_18012ec50(code), where code is
|
||||||
|
*(req+0x10) after a 3-case remap of *(req+0x14). FUN_18012ec50 is therefore the
|
||||||
|
COMPLETE code->string map for club?type=, and enumerating its cases (switch labels
|
||||||
|
AND == AND != AND any sub/dec ladder, read from a full-length decompile) closes the
|
||||||
|
vocabulary. The one branch that bypasses it is code 0xf, which picks
|
||||||
|
badge/kit/stadium/ball/equippables from *(req+0x30).
|
||||||
|
|
||||||
|
CONTROLS
|
||||||
|
* FUN_18012ec50 is decompiled alongside three sibling value maps used by the same
|
||||||
|
builder for other parameters -- FUN_1800d8b50 (position=), FUN_180166620
|
||||||
|
(formation=), FUN_18012ee20 (state=). If FUN_18012ec50 came back as a stub while
|
||||||
|
those three came back as real tables, the stub is a fact; if all four are stubs
|
||||||
|
the method is at fault.
|
||||||
|
* Both enum reverse-lookups over the two candidate tables are re-printed with their
|
||||||
|
callers, so "table 0x180229c30 is/is not the type= vocabulary" is decided by a
|
||||||
|
caller edge and not by the table's contents looking plausible.
|
||||||
|
* len(src) is printed for every function and every body is printed in full, so no
|
||||||
|
absence claim here rests on a truncated decompile.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
def show(label, ea):
|
||||||
|
f = fm.getFunctionContaining(addr(ea)) if ea else None
|
||||||
|
print("=" * 78)
|
||||||
|
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
|
||||||
|
if f is None:
|
||||||
|
return
|
||||||
|
print(" entry %s body %s" % (f.getEntryPoint(), f.getBody()))
|
||||||
|
src = dec(f)
|
||||||
|
print(" len(src) = %d" % len(src))
|
||||||
|
print(src)
|
||||||
|
try:
|
||||||
|
cs = sorted(set(int(x.getEntryPoint().getOffset())
|
||||||
|
for x in f.getCallingFunctions(mon)))
|
||||||
|
print(" callers: %s" % ", ".join(hex(c) for c in cs))
|
||||||
|
except Exception as e:
|
||||||
|
print(" callers: <%s>" % e)
|
||||||
|
|
||||||
|
show("type= value map FUN_18012ec50", 0x18012ec50)
|
||||||
|
show("position= value map FUN_1800d8b50", 0x1800d8b50)
|
||||||
|
show("formation= value map FUN_180166620", 0x180166620)
|
||||||
|
show("state= value map FUN_18012ee20", 0x18012ee20)
|
||||||
|
show("enum revlookup 0x180229ab0", 0x180166300)
|
||||||
|
show("enum revlookup 0x180229c30", 0x180166340)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""q_cd_route_4 -- close the bound: how base+suffix are actually composed.
|
||||||
|
|
||||||
|
HYPOTHESIS
|
||||||
|
Column 1 of each 48-byte row of the 125-row action table at 0x1802caa20 is an index
|
||||||
|
into the 48-row URL-base table at 0x18021df80, and the URL is base-template then the
|
||||||
|
class's own suffix/query builder. Exactly four rows carry index 3 = "ut/%s/club".
|
||||||
|
If that is right, the club route family is closed by the table.
|
||||||
|
|
||||||
|
KNOWN SOFT SPOT, the reason for this query: no row carries index 43 = "ut/v2/%s/store",
|
||||||
|
yet ut/v2/%s/store is a live-proven route. So either the base index can be overridden
|
||||||
|
at runtime, or the base table is indexed from somewhere else as well. Until that is
|
||||||
|
settled the "exactly four" bound is a strong default, not an absolute.
|
||||||
|
|
||||||
|
CONTROL
|
||||||
|
The one RIP-relative reference to the action table found by scanning .text of the
|
||||||
|
on-disk PE is at 0x180123f46. Decompiling its container should show the row layout
|
||||||
|
being read (name, base index, upper name, flag, factory) -- if the field it reads at
|
||||||
|
+0x08 is NOT used as a table index, the base-index reading is wrong and every
|
||||||
|
"exactly four" statement in this dimension has to be withdrawn.
|
||||||
|
|
||||||
|
Second target: 0x18012ea74 is the only reference to the literal "club" at
|
||||||
|
0x180221e40, which sits inside the FutStickerBookSearch literal block. Printing it
|
||||||
|
says whether the club-search class composes its own base (which would make the base
|
||||||
|
table irrelevant for it) or uses the literal for something else entirely.
|
||||||
|
|
||||||
|
Everything is printed with len(src) and in full; no absence claim rests on a
|
||||||
|
truncated body.
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
def show(label, ea):
|
||||||
|
f = fm.getFunctionContaining(addr(ea))
|
||||||
|
print("=" * 78)
|
||||||
|
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
|
||||||
|
if f is None:
|
||||||
|
return
|
||||||
|
print(" entry %s body %s" % (f.getEntryPoint(), f.getBody()))
|
||||||
|
src = dec(f)
|
||||||
|
print(" len(src) = %d" % len(src))
|
||||||
|
print(src)
|
||||||
|
try:
|
||||||
|
cs = sorted(set(int(x.getEntryPoint().getOffset())
|
||||||
|
for x in f.getCallingFunctions(mon)))
|
||||||
|
print(" callers: %s" % ", ".join(hex(c) for c in cs))
|
||||||
|
except Exception as e:
|
||||||
|
print(" callers: <%s>" % e)
|
||||||
|
|
||||||
|
show("action-table reader (only ref to 0x1802caa20)", 0x180123f46)
|
||||||
|
show("only ref to \"club\" literal 0x180221e40", 0x18012ea74)
|
||||||
|
show("URL append helper used by every suffix builder", 0x180008020)
|
||||||
|
show("StaffStats suffix thunk (raw addr 0x18012b080)", 0x18012b080)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""q_cd_route_5 -- is column 1 of the action row really the URL-base index?
|
||||||
|
|
||||||
|
HYPOTHESIS
|
||||||
|
Row N of the action table 0x1802caa20 (48-byte rows) has column 1 = index into the
|
||||||
|
16-byte-stride (template, symbolic name) table at 0x18021df80, and the four club
|
||||||
|
actions carry index 3. RIP-displacement scan of .text on the on-disk PE finds only
|
||||||
|
four references anywhere near that table: 0x180123713 (-24), 0x180123790 (-64),
|
||||||
|
0x18012437e (+8) and 0x1801db62c (-64). Decompiling their containers should show
|
||||||
|
one of them doing table[i*2] / table[i*2+1] with i coming from the action row.
|
||||||
|
|
||||||
|
CONTROL / FALSIFIER
|
||||||
|
ut/v2/%s/store (index 43) is a LIVE-PROVEN route and NO action row carries index 43.
|
||||||
|
So if these functions show the index arriving only from the action row, the club
|
||||||
|
bound is exact for every action in the table but the store proves some other path
|
||||||
|
exists, and the bound must be stated with that caveat. If instead they show a
|
||||||
|
runtime override (a per-request base field, or a second table), the "exactly four
|
||||||
|
club actions" claim is a default and not a closure, and this query is what says so.
|
||||||
|
|
||||||
|
Every body is printed in full with len(src).
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
def show(label, ea):
|
||||||
|
f = fm.getFunctionContaining(addr(ea))
|
||||||
|
print("=" * 78)
|
||||||
|
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
|
||||||
|
if f is None:
|
||||||
|
return
|
||||||
|
print(" entry %s body %s" % (f.getEntryPoint(), f.getBody()))
|
||||||
|
src = dec(f)
|
||||||
|
print(" len(src) = %d" % len(src))
|
||||||
|
print(src)
|
||||||
|
try:
|
||||||
|
cs = sorted(set(int(x.getEntryPoint().getOffset())
|
||||||
|
for x in f.getCallingFunctions(mon)))
|
||||||
|
print(" callers: %s" % ", ".join(hex(c) for c in cs))
|
||||||
|
except Exception as e:
|
||||||
|
print(" callers: <%s>" % e)
|
||||||
|
|
||||||
|
for ea in (0x180123713, 0x180123790, 0x18012437e, 0x1801db62c):
|
||||||
|
show("base-table ref", ea)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""D3 Q1: the itemState vocabulary and the lifecycle-field arms in the shared item deser.
|
||||||
|
|
||||||
|
HYPOTHESIS: FUN_18013fe00 (shared ITEM element deser) has an arm for atom 0x172
|
||||||
|
(itemState) that reads a STR and maps it through the enum table documented at
|
||||||
|
0x180229d20 (stride 0x10: WAITING_FOR_GAME, inGame, forSale, offered, activeBadge,
|
||||||
|
activeHomeKit, activeAwayKit, activeBall, activeStadium, active). That documented
|
||||||
|
list omits "free", which we send on every card, so either the table is longer than
|
||||||
|
recorded or "free" is the default/no-match value. Also locate the arms for
|
||||||
|
pile 0x226, pileType 0x228, owners 0x207, untradeable 0x361, untradeableCount 0x362,
|
||||||
|
tradeId 0x331, loans 0x19b, itemLoans 0x16f, duplicateItemLoans 0xed,
|
||||||
|
tradeState 0x335, contract 0xa9?(unknown, resolved from tsv below).
|
||||||
|
|
||||||
|
CONTROL (same syntactic form as the target): the item deser is known to store our
|
||||||
|
discardValue (atom 0xd7) at item+0x38. If the immediate-scan below does not find
|
||||||
|
0xd7 in FUN_18013fe00 in the SAME form (case label / cmp / ladder) then the scan is
|
||||||
|
broken and every absence claim in this batch is void. Second control: 0x172 itself
|
||||||
|
appears in docs as reaching the enum table, so xrefs_to(0x180229d20) must be
|
||||||
|
non-empty.
|
||||||
|
|
||||||
|
ABSENCE TRAP GUARD: we do not grep "== 0x". We enumerate every immediate operand of
|
||||||
|
every instruction in the function (scalar operands of any size), which catches
|
||||||
|
`cmp ==`, `cmp !=`, jump-table `case` labels only indirectly, and running-sum
|
||||||
|
sub/dec ladders (the ladder deltas are computed and searched too). Jump tables are
|
||||||
|
handled separately by walking every switch construct Ghidra knows about.
|
||||||
|
|
||||||
|
OUTPUT: full decompiles printed untruncated with len(src); full table dump; full
|
||||||
|
immediate census.
|
||||||
|
"""
|
||||||
|
import traceback, os
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/"
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
ATOMS = {
|
||||||
|
0x172: "itemState", 0x226: "pile", 0x227: "pileSizeClientData", 0x228: "pileType",
|
||||||
|
0x207: "owners", 0x361: "untradeable", 0x362: "untradeableCount",
|
||||||
|
0x331: "tradeId", 0x332: "tradepile", 0x333: "tradePile",
|
||||||
|
0x19b: "loans", 0x16f: "itemLoans", 0xed: "duplicateItemLoans",
|
||||||
|
0x335: "tradeState", 0xd7: "discardValue(CONTROL)", 0x19: "allowUntradeableForSquadBuildingSets",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dump(tag, va, echo=True):
|
||||||
|
f = func(va)
|
||||||
|
if f is None:
|
||||||
|
print("%s %#x -> NO FUNCTION" % (tag, va))
|
||||||
|
return ""
|
||||||
|
src = dec(va)
|
||||||
|
print("=" * 78)
|
||||||
|
print("%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % (tag, va, f.getName(), len(src)))
|
||||||
|
print("=" * 78)
|
||||||
|
if echo:
|
||||||
|
print(src)
|
||||||
|
with open(OUT + "q1_%s_%x.c" % (tag, va), "w") as fh:
|
||||||
|
fh.write(src)
|
||||||
|
return src
|
||||||
|
|
||||||
|
|
||||||
|
def immediates(va):
|
||||||
|
"""Every scalar operand of every instruction in the function containing va."""
|
||||||
|
f = func(va)
|
||||||
|
out = {}
|
||||||
|
if f is None:
|
||||||
|
return out
|
||||||
|
body = f.getBody()
|
||||||
|
it = listing.getInstructions(body, True)
|
||||||
|
n = 0
|
||||||
|
while it.hasNext():
|
||||||
|
ins = it.next()
|
||||||
|
n += 1
|
||||||
|
for i in range(ins.getNumOperands()):
|
||||||
|
for o in ins.getOpObjects(i):
|
||||||
|
try:
|
||||||
|
v = int(o.getValue())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
out.setdefault(v & 0xFFFFFFFFFFFFFFFF, []).append(
|
||||||
|
(int(ins.getAddress().getOffset()), str(ins)))
|
||||||
|
print("[immediates] %s %#x: %d instructions, %d distinct scalars"
|
||||||
|
% (f.getName(), va, n, len(out)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def report_atoms(va, imms):
|
||||||
|
"""Direct hits + running-sum ladder reconstruction."""
|
||||||
|
print("--- atom census for %#x (%s)" % (va, fname(va)))
|
||||||
|
for a, name in sorted(ATOMS.items()):
|
||||||
|
hits = imms.get(a, [])
|
||||||
|
if hits:
|
||||||
|
print(" DIRECT 0x%-4x %-38s x%d first@%#x %s"
|
||||||
|
% (a, name, len(hits), hits[0][0], hits[0][1]))
|
||||||
|
# ladder reconstruction: walk instructions in address order, keep a running
|
||||||
|
# sum of sub/dec/add immediates on the dispatch register, report any partial
|
||||||
|
# sum that equals one of our atoms.
|
||||||
|
f = func(va)
|
||||||
|
if f is None:
|
||||||
|
return
|
||||||
|
it = listing.getInstructions(f.getBody(), True)
|
||||||
|
running = {}
|
||||||
|
while it.hasNext():
|
||||||
|
ins = it.next()
|
||||||
|
m = ins.getMnemonicString().lower()
|
||||||
|
if m not in ("sub", "add", "dec", "inc", "cmp", "lea"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
reg = str(ins.getOpObjects(0)[0])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
val = None
|
||||||
|
for o in ins.getOpObjects(1) if ins.getNumOperands() > 1 else []:
|
||||||
|
try:
|
||||||
|
val = int(o.getValue())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if m == "dec":
|
||||||
|
val = 1
|
||||||
|
if m == "inc":
|
||||||
|
val = -1
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
if m in ("sub", "dec"):
|
||||||
|
running[reg] = running.get(reg, 0) + val
|
||||||
|
elif m in ("add", "inc"):
|
||||||
|
running[reg] = running.get(reg, 0) - val
|
||||||
|
cur = running.get(reg)
|
||||||
|
if cur in ATOMS:
|
||||||
|
print(" LADDER 0x%-4x %-38s @%#x %s (running sum on %s)"
|
||||||
|
% (cur, ATOMS[cur], int(ins.getAddress().getOffset()), ins, reg))
|
||||||
|
|
||||||
|
|
||||||
|
def switch_labels(va):
|
||||||
|
"""Every switch case label Ghidra resolved inside the function."""
|
||||||
|
f = func(va)
|
||||||
|
if f is None:
|
||||||
|
return
|
||||||
|
from ghidra.program.model.symbol import FlowType # noqa
|
||||||
|
it = listing.getInstructions(f.getBody(), True)
|
||||||
|
tot = 0
|
||||||
|
while it.hasNext():
|
||||||
|
ins = it.next()
|
||||||
|
ft = ins.getFlowType()
|
||||||
|
if ft is not None and ft.isJump() and ft.isComputed():
|
||||||
|
tgts = ins.getFlows()
|
||||||
|
print(" SWITCH @%#x %s -> %d targets" % (int(ins.getAddress().getOffset()), ins, len(tgts)))
|
||||||
|
tot += len(tgts)
|
||||||
|
if tot:
|
||||||
|
print(" (computed-jump targets total %d)" % tot)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("###### PART A: the itemState enum table at 0x180229d20")
|
||||||
|
# walk generously in both directions; entries are (char* name, ...) stride 0x10
|
||||||
|
base = 0x180229d20
|
||||||
|
for off in range(-0x200, 0x400, 0x10):
|
||||||
|
a = base + off
|
||||||
|
try:
|
||||||
|
p = qword(a)
|
||||||
|
q = qword(a + 8)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
s = ""
|
||||||
|
if 0x180000000 <= p < 0x181000000:
|
||||||
|
try:
|
||||||
|
s = rd_str(p, 64)
|
||||||
|
except Exception:
|
||||||
|
s = "<unreadable>"
|
||||||
|
print(" %#x (%+#5x) p=%#018x q=%#018x str=%r" % (a, off, p, q, s))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("###### PART B: who references the table")
|
||||||
|
for t in (0x180229d20,):
|
||||||
|
for frm, typ, fn, ent in xrefs_to(t):
|
||||||
|
print(" xref %#x %s in %s (%#x)" % (frm, typ, fn, ent))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("###### PART C: shared item deser FUN_18013fe00")
|
||||||
|
src = dump("itemdeser", 0x18013fe00)
|
||||||
|
imms = immediates(0x18013fe00)
|
||||||
|
report_atoms(0x18013fe00, imms)
|
||||||
|
switch_labels(0x18013fe00)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("###### PART D: full sorted immediate dump for 0x18013fe00 (values < 0x1000)")
|
||||||
|
for v in sorted(k for k in imms if k < 0x1000):
|
||||||
|
print(" 0x%-4x n=%-3d %s" % (v, len(imms[v]), imms[v][0][1]))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("###### PART E: callees of the item deser")
|
||||||
|
try:
|
||||||
|
for c in callees(0x18013fe00):
|
||||||
|
print(" callee", c)
|
||||||
|
except Exception as e:
|
||||||
|
print(" callees() failed:", e)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""D3 Q10 (control): is FUN_1800e2a40 really vtable slot +0x40 of the object that
|
||||||
|
FUN_18003e370 calls? The whole action-flag attribution rests on that. Find every
|
||||||
|
.rdata/.data table containing the pointer and print the slot index and neighbours;
|
||||||
|
slot +0x40 = index 8. CONTROL in the same run: FUN_18013fe00 is a known vtable
|
||||||
|
member? no -- instead use FUN_1801a7260, whose two DATA xrefs we already saw, and
|
||||||
|
check the pointer search finds them."""
|
||||||
|
import struct, traceback
|
||||||
|
try:
|
||||||
|
for tag, va in (("actionflags_1800e2a40", 0x1800e2a40),
|
||||||
|
("gate_1801a7260(CONTROL: 2 DATA xrefs expected)", 0x1801a7260)):
|
||||||
|
hits = find_all(struct.pack("<Q", va), blocks=(".rdata", ".data"))
|
||||||
|
print("=== %s %#x : %d pointer slots" % (tag, va, len(hits)))
|
||||||
|
for h in hits:
|
||||||
|
print(" at %#x" % h)
|
||||||
|
for k in range(-10, 4):
|
||||||
|
try: t = qword(h + k*8)
|
||||||
|
except Exception: continue
|
||||||
|
nm = fname(t) if 0x180000000 <= t < 0x181000000 else ""
|
||||||
|
print(" slot %+#5x %#x %s" % (k*8, t, nm))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""D3 Q11: fix the failed control from Q10.
|
||||||
|
|
||||||
|
Q10's control was INVALID BY CONSTRUCTION, not a scan failure: xrefs_to(0x1801a7260)
|
||||||
|
reported two DATA references, but those live at 0x180284468 / 0x180300d1c which are
|
||||||
|
4-byte RVA entries (RVA of the function is 0x1a7260) in the exception/unwind tables,
|
||||||
|
so an 8-byte-pointer search can never find them. Re-do the control properly and then
|
||||||
|
answer the real question: what is the slot INDEX of FUN_1800e2a40 in the table that
|
||||||
|
contains it? The action-flag attribution needs index 8 (= vtable +0x40).
|
||||||
|
|
||||||
|
CONTROL (valid this time): the same 8-byte pointer search must find FUN_18013fe00,
|
||||||
|
which we know sits nowhere in a vtable, AND must find the item vtable 0x1801eaac0's
|
||||||
|
own three slots. Concretely: read 0x1801eaac0 and confirm its slots are .text
|
||||||
|
addresses -- an 8-byte-pointer table we have already relied on.
|
||||||
|
"""
|
||||||
|
import struct, traceback
|
||||||
|
try:
|
||||||
|
print("### the 4-byte RVA check that explains the Q10 control failure")
|
||||||
|
for a in (0x180284468, 0x180300d1c):
|
||||||
|
try:
|
||||||
|
v = dword(a)
|
||||||
|
print(" %#x -> dword %#x (RVA of 0x1801a7260 is 0x1a7260)" % (a, v))
|
||||||
|
except Exception as e:
|
||||||
|
print(" %#x unreadable %s" % (a, e))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("### CONTROL: the known item vtable 0x1801eaac0")
|
||||||
|
for k in range(4):
|
||||||
|
t = qword(0x1801eaac0 + k*8)
|
||||||
|
print(" slot %+#4x %#x %s" % (k*8, t, fname(t) if 0x180000000 <= t < 0x181000000 else ""))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("### walk back from 0x180215b50 to the start of its pointer table")
|
||||||
|
a = 0x180215b50
|
||||||
|
start = a
|
||||||
|
while True:
|
||||||
|
p = a - 8
|
||||||
|
try:
|
||||||
|
t = qword(p)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if not (0x180001000 <= t < 0x1801d0000):
|
||||||
|
break
|
||||||
|
start = p
|
||||||
|
a = p
|
||||||
|
if 0x180215b50 - start > 0x800:
|
||||||
|
break
|
||||||
|
print(" table start %#x, FUN_1800e2a40 is at %#x -> slot %#x (index %d)"
|
||||||
|
% (start, 0x180215b50, 0x180215b50 - start, (0x180215b50 - start)//8))
|
||||||
|
for off in range(0, 0x180215b50 - start + 0x40, 8):
|
||||||
|
t = qword(start + off)
|
||||||
|
print(" %+#6x %#x %s" % (off, t, fname(t) if 0x180000000 <= t < 0x181000000 else ""))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""D3 Q12: close the last gap -- is FUN_1800e2a40 the thing FUN_18003e370 calls?
|
||||||
|
Its only 8-byte pointer slot is at +0xd0 of one table, not +0x40, so either the call
|
||||||
|
goes through a thunk or the attribution is wrong. Print all callers, and print the
|
||||||
|
service constructor FUN_180018bd0 so the interface can be named.
|
||||||
|
CONTROL: the semantic anchor is independent of the vtable -- byte[2] of the output
|
||||||
|
array is computed as (isPlayer && squadHasRoom && !alreadyInSquad), which can only be
|
||||||
|
TO_ACTIVE_SQUAD, the 3rd of the eight names. If callers show a thunk, both lines of
|
||||||
|
evidence agree; if not, the semantic anchor still stands alone."""
|
||||||
|
import traceback
|
||||||
|
try:
|
||||||
|
print("### callers of FUN_1800e2a40")
|
||||||
|
for c in callers(0x1800e2a40):
|
||||||
|
print(" ", c)
|
||||||
|
for frm, typ, fn, ent in xrefs_to(0x1800e2a40):
|
||||||
|
print(" xref %#x %s in %s (%#x)" % (frm, typ, fn, ent))
|
||||||
|
print()
|
||||||
|
print("### FUN_180018bd0 (the service FUN_18003e370 asks for)")
|
||||||
|
print(dec(0x180018bd0))
|
||||||
|
print("### FUN_18003cbf0 (CARD_ID extraction, 2nd arg to slot 0x40)")
|
||||||
|
print(dec(0x18003cbf0))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""D3 Q2: the itemState string<->enum functions, and who READS the state slots.
|
||||||
|
|
||||||
|
ESTABLISHED BEFORE THIS QUERY (q_cd_state_1 + live read, both controls passed):
|
||||||
|
the deser's stack struct base is local_188 (anchored by local_118 = &PTR_LAB_1801eaac0
|
||||||
|
landing at record+0x70, the known item vtable), so record_off = 0x188 - N.
|
||||||
|
itemState (atom 0x172) -> FUN_180166660(str) -> record +0x5c (live: 1 == "free")
|
||||||
|
owners (atom 0x207) -> record +0x48 u8 (live: 1)
|
||||||
|
untradeable(atom 0x361)-> record +0x49 u8 = (untradeable == FALSE), a TRADEABLE flag
|
||||||
|
(live: 0 on all 22)
|
||||||
|
the enum table is at 0x180229cc0 (NOT 0x180229d20, which is mid-table):
|
||||||
|
invalid 0, free 1, WAITING_FOR_GAME 2, inGame 2, forSale 5, offered 6,
|
||||||
|
activeBadge 0x64, activeHomeKit 0x65, activeAwayKit 0x66, activeBall 0x67,
|
||||||
|
activeStadium 0x68, active 0xff, terminator {NULL, 0xffffffff}
|
||||||
|
|
||||||
|
HYPOTHESIS: FUN_180166660 walks that table and returns a default for no-match; there is
|
||||||
|
a matching enum->string writer used when the client SENDS itemState back; and the UI
|
||||||
|
gating for "list on transfer market" / "quick sell" reads +0x49 and/or +0x5c.
|
||||||
|
|
||||||
|
CONTROL for the reader scan (same syntactic form as the target -- a memory operand with
|
||||||
|
a small displacement): the scan must also find the KNOWN readers of +0x38/+0x3c
|
||||||
|
(discardValue) and of +0x70 (the vtable). If displacement 0x38 or 0x70 comes back with
|
||||||
|
zero hits the scan is broken and every absence below is void.
|
||||||
|
|
||||||
|
OUTPUT: full decompiles (len printed), full table walk, full grouped reader census.
|
||||||
|
"""
|
||||||
|
import traceback, os
|
||||||
|
|
||||||
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/"
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
STATE_VALUES = {0, 1, 2, 5, 6, 0x64, 0x65, 0x66, 0x67, 0x68, 0xff}
|
||||||
|
|
||||||
|
|
||||||
|
def dump(tag, va):
|
||||||
|
f = func(va)
|
||||||
|
if f is None:
|
||||||
|
print("%s %#x -> NO FUNCTION" % (tag, va))
|
||||||
|
return ""
|
||||||
|
src = dec(va)
|
||||||
|
print("=" * 78)
|
||||||
|
print("%s %#x %s len(src)=%d (FULL)" % (tag, va, f.getName(), len(src)))
|
||||||
|
print("=" * 78)
|
||||||
|
print(src)
|
||||||
|
with open(OUT + "q2_%s_%x.c" % (tag, va), "w") as fh:
|
||||||
|
fh.write(src)
|
||||||
|
return src
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("###### A. string->enum helpers used by the item deser")
|
||||||
|
for tag, va in (("itemState_0x172", 0x180166660),
|
||||||
|
("injuryType_0x168", 0x1801666f0),
|
||||||
|
("prefPos_0x24a", 0x180166810),
|
||||||
|
("resourceId_split_0x287", 0x180166ca0),
|
||||||
|
("playStyle_0x23f", 0x180136480)):
|
||||||
|
dump(tag, va)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("###### B. every xref into the itemState table block 0x180229cc0..0x180229d80")
|
||||||
|
for a in range(0x180229cc0, 0x180229d90, 8):
|
||||||
|
xs = xrefs_to(a)
|
||||||
|
for frm, typ, fn, ent in xs:
|
||||||
|
print(" table%+#5x %#x xref from %#x %s in %s (%#x)"
|
||||||
|
% (a - 0x180229cc0, a, frm, typ, fn, ent))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("###### C. xrefs to each itemState string literal (finds the enum->string writer)")
|
||||||
|
for name in (b"invalid\x00", b"free\x00", b"WAITING_FOR_GAME\x00", b"inGame\x00",
|
||||||
|
b"forSale\x00", b"offered\x00", b"activeBadge\x00", b"activeHomeKit\x00",
|
||||||
|
b"activeAwayKit\x00", b"activeBall\x00", b"activeStadium\x00",
|
||||||
|
b"tradepile\x00", b"club\x00", b"trade\x00", b"pile\x00"):
|
||||||
|
hits = find_all(name)
|
||||||
|
print(" %-20s occurrences=%d" % (name.decode().strip("\x00"), len(hits)))
|
||||||
|
for h in hits:
|
||||||
|
for frm, typ, fn, ent in xrefs_to(h):
|
||||||
|
print(" @%#x xref %#x %s in %s (%#x)" % (h, frm, typ, fn, ent))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("###### D. callers of FUN_180166660 (every itemState parse site)")
|
||||||
|
try:
|
||||||
|
for c in callers(0x180166660):
|
||||||
|
print(" caller", c)
|
||||||
|
except Exception as e:
|
||||||
|
print(" callers() failed:", e)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("###### E. reader census: instructions with memory displacement 0x49 / 0x5c")
|
||||||
|
print(" CONTROLS in the same scan: 0x38 and 0x70")
|
||||||
|
want = {0x49: [], 0x5c: [], 0x38: [], 0x70: []}
|
||||||
|
total = 0
|
||||||
|
it = listing.getInstructions(True)
|
||||||
|
while it.hasNext():
|
||||||
|
ins = it.next()
|
||||||
|
total += 1
|
||||||
|
try:
|
||||||
|
n = ins.getNumOperands()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for i in range(n):
|
||||||
|
try:
|
||||||
|
objs = ins.getOpObjects(i)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if len(objs) < 2:
|
||||||
|
continue
|
||||||
|
has_reg = any(hasattr(o, "getName") for o in objs)
|
||||||
|
if not has_reg:
|
||||||
|
continue
|
||||||
|
for o in objs:
|
||||||
|
v = None
|
||||||
|
try:
|
||||||
|
v = int(o.getValue())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if v in want:
|
||||||
|
f = fm.getFunctionContaining(ins.getAddress())
|
||||||
|
want[v].append((int(ins.getAddress().getOffset()),
|
||||||
|
f.getName() if f else "?",
|
||||||
|
int(f.getEntryPoint().getOffset()) if f else 0,
|
||||||
|
str(ins)))
|
||||||
|
print(" scanned %d instructions" % total)
|
||||||
|
for d in (0x38, 0x70, 0x49, 0x5c):
|
||||||
|
print(" disp %#04x -> %d hits (CONTROL)" % (d, len(want[d]))
|
||||||
|
if d in (0x38, 0x70) else " disp %#04x -> %d hits" % (d, len(want[d])))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("###### F. functions touching [reg+0x49], grouped (this displacement is rare)")
|
||||||
|
from collections import defaultdict
|
||||||
|
g = defaultdict(list)
|
||||||
|
for a, fn, ent, txt in want[0x49]:
|
||||||
|
g[(ent, fn)].append((a, txt))
|
||||||
|
for (ent, fn), lst in sorted(g.items()):
|
||||||
|
print(" %s (%#x) n=%d" % (fn, ent, len(lst)))
|
||||||
|
for a, txt in lst:
|
||||||
|
print(" %#x %s" % (a, txt))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("###### G. functions touching [reg+0x5c] that ALSO contain an itemState value")
|
||||||
|
g2 = defaultdict(list)
|
||||||
|
for a, fn, ent, txt in want[0x5c]:
|
||||||
|
g2[(ent, fn)].append((a, txt))
|
||||||
|
print(" total distinct functions touching +0x5c: %d" % len(g2))
|
||||||
|
for (ent, fn), lst in sorted(g2.items()):
|
||||||
|
if not ent:
|
||||||
|
continue
|
||||||
|
f = func(ent)
|
||||||
|
if f is None:
|
||||||
|
continue
|
||||||
|
imms = set()
|
||||||
|
it2 = listing.getInstructions(f.getBody(), True)
|
||||||
|
while it2.hasNext():
|
||||||
|
ins = it2.next()
|
||||||
|
for i in range(ins.getNumOperands()):
|
||||||
|
for o in ins.getOpObjects(i):
|
||||||
|
try:
|
||||||
|
imms.add(int(o.getValue()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
hit = imms & {0x64, 0x65, 0x66, 0x67, 0x68, 0xff, 5, 6}
|
||||||
|
if 0x64 in imms or 0x67 in imms or 0x68 in imms:
|
||||||
|
print(" *** %s (%#x) n=%d state-ish immediates=%s"
|
||||||
|
% (fn, ent, len(lst), sorted(hex(x) for x in hit)))
|
||||||
|
for a, txt in lst:
|
||||||
|
print(" %#x %s" % (a, txt))
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user