Files
deathwatch/process_linux.go
T
emmatherockandClaude Sonnet 5 356df6cbd5 Fix Linux module-bounds detection: extend through Wine's anonymous PE mapping
Running the Linux build against a live game showed it stuck forever on
"scanning signatures": scanMaps only collected mappings whose file matched
eldenring.exe, but this Proton build backs just the 4 KB PE header with the
real path and maps the rest of the module (~94 MB of .text/.rdata/.data,
where every AOB signature lives) as one anonymous mapping with no path.
findModuleBase was handing the scanner the header alone.

moduleSpan (the matching logic, now pulled out of scanMaps as a pure
function for process_linux_test.go) extends the span through contiguous
anonymous mappings that follow the last named one, and stops at the first
mapping with its own path so it can't merge in an unrelated module.
Confirmed against the live process: all three signatures resolve, PlayerIns
confirms, and /deaths reports real numbers end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 01:54:52 -03:00

230 lines
7.4 KiB
Go

//go:build linux
// process_linux.go: the Linux side of the portable boundary defined in
// process.go — for players running Elden Ring through Proton. Proton
// runs the exact same Windows binary under Wine, so every AOB signature
// and memory offset in process.go is unchanged; only how the process
// gets found and read differs.
//
// No external dependencies (matching the project's single-binary goal):
// process memory is read via /proc/<pid>/mem instead of hand-rolling a
// raw process_vm_readv(2) syscall, which CLAUDE.md explicitly allows as
// an equivalent alternative.
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// findProcessID finds Elden Ring's pid by walking every process's memory
// mappings, not by matching a process name: Proton runs several helper
// processes, and the one that actually has eldenring.exe mapped is the
// one we want.
func findProcessID(name string) (uint32, error) {
entries, err := os.ReadDir("/proc")
if err != nil {
return 0, fmt.Errorf("couldn't list /proc: %w", err)
}
for _, e := range entries {
pid, err := strconv.ParseUint(e.Name(), 10, 32)
if err != nil {
continue // not a pid directory
}
if _, _, _, ok := scanMaps(uint32(pid), name); ok {
return uint32(pid), nil
}
}
return 0, fmt.Errorf("process not found: %s", name)
}
// mapLine is one parsed line of /proc/<pid>/maps.
type mapLine struct {
start, end uintptr
path string // empty for an anonymous mapping
}
// readMaps parses every line of /proc/<pid>/maps. Format: "start-end perms
// offset dev inode [pathname]" — the pathname (anonymous mappings don't
// have one) is everything after the first 5 fields, rejoined with single
// spaces. A pathname with unusual internal spacing could theoretically
// come out collapsed, but that's a cosmetic edge case that doesn't affect
// matching against a base filename like "eldenring.exe".
func readMaps(pid uint32) ([]mapLine, error) {
f, err := os.Open(fmt.Sprintf("/proc/%d/maps", pid))
if err != nil {
return nil, err
}
defer f.Close()
var lines []mapLine
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 5 {
continue
}
startStr, endStr, cut := strings.Cut(fields[0], "-")
if !cut {
continue
}
start, err1 := strconv.ParseUint(startStr, 16, 64)
stop, err2 := strconv.ParseUint(endStr, 16, 64)
if err1 != nil || err2 != nil {
continue
}
var path string
if len(fields) >= 6 {
path = strings.Join(fields[5:], " ")
}
lines = append(lines, mapLine{start: uintptr(start), end: uintptr(stop), path: path})
}
return lines, sc.Err()
}
// scanMaps looks for mappings whose file's base name matches name
// (case-insensitively) and returns the full span across every matching
// line, then — this is the part that matters in practice — extends that
// span through any anonymous mappings that follow it with no gap.
//
// Confirmed live against a running Proton build: Wine's PE loader maps
// only the PE header (a handful of KB) as a real file-backed mapping;
// the rest of the module — .text/.rdata/.data, everything the AOB
// signatures actually live in — comes right after as ONE large anonymous
// mapping with no path at all. Stopping at the last named line, like an
// ELF/native loader's split-by-section layout would suggest, leaves the
// scanner holding a few KB of PE header and nothing else: every signature
// scan fails and resolvePointers loops forever ("GameDataMan's pattern
// wasn't found") without ever reading real code. The extension is
// restricted to path=="" so it can't wander into a genuinely different,
// unrelated module that just happens to load right after this one.
func scanMaps(pid uint32, name string) (base, end uintptr, path string, ok bool) {
lines, err := readMaps(pid)
if err != nil {
return 0, 0, "", false
}
return moduleSpan(lines, name)
}
// moduleSpan is scanMaps' matching/extension logic, pulled out as a pure
// function of an already-parsed maps listing so it's testable (see
// process_linux_test.go) without a real /proc/<pid>/maps to read.
func moduleSpan(lines []mapLine, name string) (base, end uintptr, path string, ok bool) {
lastMatch := -1
for i, l := range lines {
if !strings.EqualFold(filepath.Base(l.path), name) {
continue
}
if !ok || l.start < base {
base = l.start
}
if l.end > end {
end = l.end
}
path = l.path
ok = true
lastMatch = i
}
if !ok {
return 0, 0, "", false
}
for i := lastMatch + 1; i < len(lines) && lines[i].path == "" && lines[i].start == end; i++ {
end = lines[i].end
}
return base, end, path, ok
}
func findModuleBase(pid uint32, name string) (uintptr, uint32, string, error) {
base, end, path, ok := scanMaps(pid, name)
if !ok {
return 0, 0, "", fmt.Errorf("module not found: %s", name)
}
return base, uint32(end - base), path, nil
}
// openProcess doesn't need to attach to anything (readMemory reads via
// /proc/<pid>/mem per call, no persistent handle involved) — it just
// probes that memory is actually readable now, so a permissions problem
// surfaces here with a clear explanation instead of as a silent stream
// of failed reads later.
func openProcess(pid uint32) (procHandle, error) {
if _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)); err != nil {
return 0, fmt.Errorf("process %d not found: %w", pid, err)
}
f, err := os.OpenFile(fmt.Sprintf("/proc/%d/mem", pid), os.O_RDONLY, 0)
if err != nil {
return 0, fmt.Errorf(
"can't read process %d's memory (%v).\n"+
"This is almost always ptrace_scope blocking it. Grant this binary the capability once with:\n\n"+
" sudo setcap cap_sys_ptrace+ep %s\n\n"+
"(don't lower kernel.yama.ptrace_scope or run this as root instead — that weakens "+
"ptrace protection for your whole system, not just this program)",
pid, err, exePathForSetcap())
}
f.Close()
return procHandle(pid), nil
}
// closeProcessHandle has nothing to release: see openProcess.
func closeProcessHandle(h procHandle) {}
func readMemory(h procHandle, addr uintptr, size int) ([]byte, bool) {
if addr == 0 {
return nil, false
}
f, err := os.OpenFile(fmt.Sprintf("/proc/%d/mem", uint32(h)), os.O_RDONLY, 0)
if err != nil {
return nil, false
}
defer f.Close()
buf := make([]byte, size)
if _, err := f.ReadAt(buf, int64(addr)); err != nil {
return nil, false
}
return buf, true
}
// productVersion has no Linux equivalent: it reads version.dll's
// resource off the exe. This was always only a hint for which PlayerIns
// offset to try first — isPlayerLoaded (process.go) confirms the real
// one by reading memory regardless, so ok=false just skips straight to
// that confirmation.
func productVersion(path string) (major, minor uint16, label string, ok bool) {
return 0, 0, "", false
}
// systemLang reads the Unix locale environment instead of calling a
// Windows API. Matches the Windows implementation's contract: returns
// just the short base language code ("es", not "es_AR.UTF-8" or
// "es-AR"), since that's what resolveLang (i18n.go) expects.
func systemLang() string {
for _, key := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
v := os.Getenv(key)
if v == "" || v == "C" || v == "POSIX" {
continue
}
v = strings.ToLower(v)
cut := len(v)
for _, sep := range []byte{'_', '.', '@'} {
if i := strings.IndexByte(v, sep); i >= 0 && i < cut {
cut = i
}
}
return v[:cut]
}
return ""
}
func exePathForSetcap() string {
if exe, err := os.Executable(); err == nil {
return exe
}
return "./deathwatch"
}