Files
OpenFUT/fifa17-recon/futmem/src/image.rs
T
funman300 afdbb364ca fifa17-recon: pack opening reversed end to end, and there is no pack-inventory endpoint
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>
2026-08-05 19:24:37 -07:00

202 lines
7.8 KiB
Rust

//! 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(),
}
}