//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//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//maps. type mapLine struct { start, end uintptr path string // empty for an anonymous mapping } // readMaps parses every line of /proc//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//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//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" }