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.
184 lines
5.8 KiB
Go
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"
|
|
}
|