Files
deathwatch/process_linux.go
T
emmatherock 2ba312a833 Add Linux/Proton support
Elden Ring under Proton on Linux is the same Windows binary, so every
AOB signature and memory offset is unchanged — only how the process
gets found and read differs. Split main.go/i18n.go (previously
Windows-only) into a portable process.go (signature scanning, pointer
resolution, the poll loop) plus process_windows.go/process_linux.go
behind a small boundary: findProcessID, openProcess,
closeProcessHandle, readMemory, findModuleBase, productVersion,
systemLang.

Linux side: finds the process by walking /proc/*/maps for a mapping
ending in eldenring.exe (Proton runs several helper processes, so
matching by name alone isn't reliable), reads memory via
/proc/<pid>/mem (stdlib only, no external deps), and has no
productVersion equivalent (returns ok=false — this was always just a
hint for which PlayerIns offset to try first; the real one is
confirmed by a live memory read regardless). openProcess probes
/proc/<pid>/mem up front so a ptrace_scope permission failure surfaces
immediately with the exact `sudo setcap cap_sys_ptrace+ep <path>` fix,
never suggesting the system-wide ptrace_scope=0 weakening or running
as root.

main.go and i18n.go are fully portable now, no build tags. Verified:
Windows build/vet/test plus a real run (no regression from moving
~500 lines). Linux is cross-compile build/vet only in this session —
not yet run against a real Proton process.
2026-09-18 01:25:44 -03:00

184 lines
5.8 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)
}
// scanMaps walks /proc/<pid>/maps looking for lines whose mapped file's
// base name matches name (case-insensitively), and returns the full span
// across every matching line: the module can be split into several
// segments (.text/.rdata/.data with different permissions), and the
// scanner in process.go already reads in chunks and tolerates unreadable
// ones, so the min-start/max-end span across all of them is enough.
func scanMaps(pid uint32, name string) (base, end uintptr, path string, ok bool) {
f, err := os.Open(fmt.Sprintf("/proc/%d/maps", pid))
if err != nil {
return 0, 0, "", false
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
// 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".
fields := strings.Fields(sc.Text())
if len(fields) < 6 {
continue
}
mapPath := strings.Join(fields[5:], " ")
if !strings.EqualFold(filepath.Base(mapPath), name) {
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
}
if !ok || uintptr(start) < base {
base = uintptr(start)
}
if uintptr(stop) > end {
end = uintptr(stop)
}
path = mapPath
ok = true
}
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"
}