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.
This commit is contained in:
emmatherock committed 2026-09-18 01:25:44 -03:00
1 parent 5a2c3272e9
commit 2ba312a833
7 files changed
+1128 -883

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
//go:build windows
// process_windows.go: the Windows side of the portable boundary defined
// in process.go — finding the game process, opening/closing it, reading
// its memory, finding a loaded module, reading the exe's file version,
// and the system's UI language. All via raw Windows API calls (no
// external dependencies, per the project's single-.exe goal).
package main
import (
"fmt"
"strings"
"syscall"
"unsafe"
)
const (
processQueryInformation = 0x0400
processVMRead = 0x0010
th32csSnapProcess = 0x00000002
th32csSnapModule = 0x00000008
th32csSnapModule32 = 0x00000010
maxPath = 260
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procOpenProcess = kernel32.NewProc("OpenProcess")
procCloseHandle = kernel32.NewProc("CloseHandle")
procReadProcessMemory = kernel32.NewProc("ReadProcessMemory")
procCreateToolhelp32Snapshot = kernel32.NewProc("CreateToolhelp32Snapshot")
procModule32FirstW = kernel32.NewProc("Module32FirstW")
procModule32NextW = kernel32.NewProc("Module32NextW")
procProcess32FirstW = kernel32.NewProc("Process32FirstW")
procProcess32NextW = kernel32.NewProc("Process32NextW")
procGetUserDefaultLocaleName = kernel32.NewProc("GetUserDefaultLocaleName")
versionDLL = syscall.NewLazyDLL("version.dll")
procGetFileVersionInfoSizeW = versionDLL.NewProc("GetFileVersionInfoSizeW")
procGetFileVersionInfoW = versionDLL.NewProc("GetFileVersionInfoW")
procVerQueryValueW = versionDLL.NewProc("VerQueryValueW")
)
// vsFixedFileInfo is Windows's VS_FIXEDFILEINFO struct: used to pull the
// game's version straight from eldenring.exe, same as SoulMemory (which
// reads MainModule.FileVersionInfo.ProductVersion).
type vsFixedFileInfo struct {
Signature uint32
StrucVersion uint32
FileVersionMS uint32
FileVersionLS uint32
ProductVersionMS uint32
ProductVersionLS uint32
FileFlagsMask uint32
FileFlags uint32
FileOS uint32
FileType uint32
FileSubtype uint32
FileDateMS uint32
FileDateLS uint32
}
type processEntry32 struct {
Size uint32
CntUsage uint32
ProcessID uint32
DefaultHeapID uintptr
ModuleID uint32
CntThreads uint32
ParentProcessID uint32
PriorityClassBase int32
Flags uint32
ExeFile [maxPath]uint16
}
type moduleEntry32 struct {
Size uint32
ModuleID uint32
ProcessID uint32
GlblcntUsage uint32
ProccntUsage uint32
ModBaseAddr uintptr
ModBaseSize uint32
HModule syscall.Handle
ModuleName [256]uint16
ExePath [maxPath]uint16
}
func findProcessID(name string) (uint32, error) {
snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(th32csSnapProcess), 0)
if snap == 0 || snap == uintptr(^uintptr(0)) {
return 0, fmt.Errorf("couldn't take a process snapshot")
}
defer procCloseHandle.Call(snap)
var pe processEntry32
pe.Size = uint32(unsafe.Sizeof(pe))
r, _, _ := procProcess32FirstW.Call(snap, uintptr(unsafe.Pointer(&pe)))
if r == 0 {
return 0, fmt.Errorf("Process32First failed")
}
for {
exe := syscall.UTF16ToString(pe.ExeFile[:])
if strings.EqualFold(exe, name) {
return pe.ProcessID, nil
}
r, _, _ := procProcess32NextW.Call(snap, uintptr(unsafe.Pointer(&pe)))
if r == 0 {
break
}
}
return 0, fmt.Errorf("process not found: %s", name)
}
func openProcess(pid uint32) (procHandle, error) {
h, _, err := procOpenProcess.Call(uintptr(processQueryInformation|processVMRead), 0, uintptr(pid))
if h == 0 {
return 0, err
}
return procHandle(h), nil
}
func closeProcessHandle(h procHandle) {
procCloseHandle.Call(uintptr(h))
}
func readMemory(h procHandle, addr uintptr, size int) ([]byte, bool) {
if addr == 0 {
return nil, false
}
buf := make([]byte, size)
var n uintptr
r, _, _ := procReadProcessMemory.Call(uintptr(h), addr, uintptr(unsafe.Pointer(&buf[0])), uintptr(size), uintptr(unsafe.Pointer(&n)))
if r == 0 || int(n) != size {
return nil, false
}
return buf, true
}
func findModuleBase(pid uint32, name string) (uintptr, uint32, string, error) {
snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(th32csSnapModule|th32csSnapModule32), uintptr(pid))
if snap == 0 || snap == uintptr(^uintptr(0)) {
return 0, 0, "", fmt.Errorf("couldn't take a module snapshot")
}
defer procCloseHandle.Call(snap)
var me moduleEntry32
me.Size = uint32(unsafe.Sizeof(me))
r, _, _ := procModule32FirstW.Call(snap, uintptr(unsafe.Pointer(&me)))
if r == 0 {
return 0, 0, "", fmt.Errorf("Module32First failed")
}
for {
mname := syscall.UTF16ToString(me.ModuleName[:])
if strings.EqualFold(mname, name) {
return me.ModBaseAddr, me.ModBaseSize, syscall.UTF16ToString(me.ExePath[:]), nil
}
r, _, _ := procModule32NextW.Call(snap, uintptr(unsafe.Pointer(&me)))
if r == 0 {
break
}
}
return 0, 0, "", fmt.Errorf("module not found: %s", name)
}
// productVersion reads the game executable's version. label carries both
// full version numbers (product and file) because they don't always
// match, which helps diagnose whether the PlayerIns offset ever needs
// adjusting.
func productVersion(path string) (major, minor uint16, label string, ok bool) {
if path == "" {
return 0, 0, "", false
}
p, err := syscall.UTF16PtrFromString(path)
if err != nil {
return 0, 0, "", false
}
size, _, _ := procGetFileVersionInfoSizeW.Call(uintptr(unsafe.Pointer(p)), 0)
if size == 0 {
return 0, 0, "", false
}
buf := make([]byte, size)
r, _, _ := procGetFileVersionInfoW.Call(uintptr(unsafe.Pointer(p)), 0, size, uintptr(unsafe.Pointer(&buf[0])))
if r == 0 {
return 0, 0, "", false
}
sub, err := syscall.UTF16PtrFromString(`\`)
if err != nil {
return 0, 0, "", false
}
var info *vsFixedFileInfo
var infoLen uint32
r, _, _ = procVerQueryValueW.Call(
uintptr(unsafe.Pointer(&buf[0])),
uintptr(unsafe.Pointer(sub)),
uintptr(unsafe.Pointer(&info)),
uintptr(unsafe.Pointer(&infoLen)),
)
if r == 0 || info == nil || infoLen == 0 {
return 0, 0, "", false
}
quad := func(ms, ls uint32) string {
return fmt.Sprintf("%d.%d.%d.%d", ms>>16, ms&0xFFFF, ls>>16, ls&0xFFFF)
}
label = fmt.Sprintf("product %s / file %s",
quad(info.ProductVersionMS, info.ProductVersionLS),
quad(info.FileVersionMS, info.FileVersionLS))
return uint16(info.ProductVersionMS >> 16), uint16(info.ProductVersionMS & 0xFFFF), label, true
}
// systemLang returns Windows's language ("es-AR" -> "es").
func systemLang() string {
buf := make([]uint16, 85) // LOCALE_NAME_MAX_LENGTH
r, _, _ := procGetUserDefaultLocaleName.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
if r == 0 {
return ""
}
name := syscall.UTF16ToString(buf[:r])
if base, _, ok := strings.Cut(name, "-"); ok {
return strings.ToLower(base)
}
return strings.ToLower(name)
}