//! # 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(&regions, &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(&regions, &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(&regions, 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, &regions);
        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(&regions, &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(&regions, Some(m), false)
    } else {
        // Default scope: anonymous private memory, where a packed executable's
        // decrypted data lives.
        let t = scan::scan_targets(&regions, 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(&regions, &mem);

    writeln!(out, "{va:#x}  {}", image::describe(va, &mods, &regions))?;
    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)
}
