afdbb364ca
A twelve-agent pass over the parts of pack opening we did not understand, run against
the live client (CardsDLL slide proven, not assumed) plus static CardsDLL. Findings
below survived an adversarial verification round that corrected several of them; where
a verifier and a finder disagreed, the verifier won.
THE HEADLINE IS A NEGATIVE, and it deletes work rather than creating it. There is no
pack-inventory endpoint in FIFA 17 and there never was. Proven three independent ways:
the 48-entry UTAS route template array at 0x18021df80, a regex for "ut/" over the whole
PE, and the 125-row client action table at 0x1802caa20, which is the complete set of
requests the client can originate. "Serve the pack inventory" comes off the backlog.
The unclaimed-pack tile and My Packs are two fields on responses we already build.
Corrections to ENDPOINT_MAP.md, both freeze-risky as written:
* duplicateItemIdList is an array of OBJECTS (element parser 0x180138e10: itemId
0x16d, duplicateItemId 0xeb, itemLoans 0x16f, duplicateItemLoans 0xed), not the
int list documented at :1095 and :218. Control that this is not a misread:
dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array and parses with no
inner object loop. We serve [], so this is a docs bug today and a live freeze the
moment somebody implements it from the map as written.
* FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
top-level id. :968-971 is wrong twice over.
packContentInfo is DECORATIVE. It is read only into a store-tile view model, and
nothing compares the declared counts against the delivered itemList, so open_pack()
does not have to honour the distribution.
The reveal is entirely CLIENT-SIDE. Walkout, tiering, colours and ordering are
arithmetic over fields we already send. Genuine outstanding server work reduces to
three items: duplicates, quick-sell credit, unopenedPacks.
Perishable intel captured: the real FIFA 17 retail pack catalogue, 41 SKUs with Origin
offer ids, recovered from the client heap as a parsed copy of data/store/storecfg.xml.
It is in no file on disk, only in a running process.
futmem/ is a standalone read-only Rust crate for this kind of work (maps, find,
strings, read). Read-only by construction: it opens /proc/<pid>/mem with File::open
and there is no code path in it that can write to another process, because a live game
session depends on that. Its own [workspace] table keeps it out of the parent
workspace. Chunked scanning overlaps by pattern_len-1 so a match spanning a chunk
boundary is still found.
utas_server.py gains FUT_PORT/FUT_LOG so a throwaway instance can be started without
bouncing the one the live client is using. Defaults unchanged (8099, /tmp/utas_server.log).
Noted for the record: this edit came from a research agent that had been told not to
touch server code. It is benign and useful, but it was out of scope.
Not committed: the doc proposes ENDPOINT_MAP.md changes as pasteable text rather than
applying them, and every proposed server change defaults off per the house rule.
Nothing in this commit changes a response the client sees.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
250 lines
10 KiB
Markdown
250 lines
10 KiB
Markdown
# 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.
|