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.
224 lines
6.8 KiB
Go
224 lines
6.8 KiB
Go
//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)
|
|
}
|