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
+1126 -881

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
# binarios y estado local, no van al repo # binarios y estado local, no van al repo
deathwatch.exe deathwatch.exe
deathwatch-linux
*.exe *.exe
totals.json totals.json
client-id.txt client-id.txt
+91 -73
View File
@@ -2,19 +2,26 @@
Lee en **solo lectura** la memoria del proceso de Elden Ring y expone el Lee en **solo lectura** la memoria del proceso de Elden Ring y expone el
contador de muertes como overlay web para OBS. Soporta co-op: cada jugador contador de muertes como overlay web para OBS. Soporta co-op: cada jugador
corre el programa en su PC y uno hace de hub. corre el programa en su PC y uno hace de hub. Corre nativo en Windows y
en Linux contra el juego bajo Proton (mismos offsets y firmas: es el
mismo binario de Windows).
## Comandos ## Comandos
```bash ```bash
GOOS=windows GOARCH=amd64 go build -o deathwatch.exe . # el binario (hoy, unico soportado) GOOS=windows GOARCH=amd64 go build -o deathwatch.exe . # binario Windows
GOOS=linux GOARCH=amd64 go build -o deathwatch-linux . # binario Linux (Proton)
go test ./... # tests (corren en cualquier SO) go test ./... # tests (corren en cualquier SO)
gofmt -l *.go # formato gofmt -l *.go # formato
GOOS=windows GOARCH=amd64 go vet . # vet: SIEMPRE con GOOS=windows GOOS=windows GOARCH=amd64 go vet . # vet en los dos GOOS
GOOS=linux GOARCH=amd64 go vet .
``` ```
`go vet` sin `GOOS=windows` falla con `syscall.Handle undefined`. No es un Dos plataformas reales ahora: correr `go vet` (y `go build`) con los dos
error del código: es que `main.go` e `i18n.go` solo compilan para Windows. `GOOS` es la única forma de agarrar una rotura especifica de una
plataforma antes de que la vea alguien que corre la otra. `process.go`
es portable; `process_windows.go` y `process_linux.go` son cada uno
solo para su SO (ver "Multiplataforma" más abajo).
## Idioma del código ## Idioma del código
@@ -35,8 +42,11 @@ idiomas del overlay, ni al revés.
| Archivo | Qué hace | Plataforma | | Archivo | Qué hace | Plataforma |
|---|---|---| |---|---|---|
| `main.go` | Lectura de memoria, escaneo de firmas, loop de polling, HTTP | solo Windows | | `main.go` | HTTP, startup, arma todo | portable |
| `i18n.go` | Carga de `locales/*.json`, idioma del sistema | solo Windows | | `i18n.go` | Carga de `locales/*.json`, elige idioma | portable |
| `process.go` | Escaneo de firmas, resolución de punteros, loop de polling | portable |
| `process_windows.go` | Primitivas de SO: abrir proceso, leer memoria, version.dll, idioma | solo Windows |
| `process_linux.go` | Lo mismo que `process_windows.go`, vía `/proc/<pid>/{maps,mem}` | solo Linux |
| `counter.go` | **Contabilidad**: a qué personaje va cada muerte | portable | | `counter.go` | **Contabilidad**: a qué personaje va cada muerte | portable |
| `names.go` | `looksLikeName`: filtra basura binaria leída como nombre | portable | | `names.go` | `looksLikeName`: filtra basura binaria leída como nombre | portable |
| `totals.go` | Persistencia por personaje (`totals.json`) | portable | | `totals.go` | Persistencia por personaje (`totals.json`) | portable |
@@ -53,6 +63,71 @@ los tres bugs de conteo, y separarlo es lo que permite testearlo sin una PC
con el juego abierto. Si agregás reglas de conteo, van ahí, con test. con el juego abierto. Si agregás reglas de conteo, van ahí, con test.
`names.go`, `tlscert.go`, `pin.go` e `invite.go` siguen el mismo principio. `names.go`, `tlscert.go`, `pin.go` e `invite.go` siguen el mismo principio.
## Multiplataforma
`process.go` no sabe nada de Windows ni de Linux: escanea firmas, resuelve
punteros y corre el loop de polling contra siete funciones que cruzan la
frontera con el SO, cada una implementada una vez por plataforma
(`process_windows.go` / `process_linux.go`):
```go
type procHandle uintptr // opaco: en Windows es el HANDLE real; en Linux, el pid
func findProcessID(name string) (uint32, error)
func openProcess(pid uint32) (procHandle, error)
func closeProcessHandle(h procHandle)
func readMemory(h procHandle, addr uintptr, size int) ([]byte, bool)
func findModuleBase(pid uint32, name string) (uintptr, uint32, string, error)
func productVersion(path string) (major, minor uint16, label string, ok bool)
func systemLang() string
```
En Linux, contra el juego corriendo bajo Proton (mismo binario de
Windows, mismas firmas y offsets):
- **Encontrar el proceso por sus mapeos, no por el nombre**
(`findProcessID`/`scanMaps`): Proton levanta varios procesos: se
recorre `/proc/*/maps` y se toma el pid que tenga mapeado un archivo
terminado en `eldenring.exe`. Los mismos mapeos dan la base y el
tamaño del módulo para `findModuleBase`: puede venir partido en varios
tramos (`.text`/`.rdata`/`.data`), así que se toma el span completo
(mínimo inicio, máximo final) — alcanza, porque el escáner ya lee por
chunks y saltea los que no puede leer.
- **Leer memoria vía `/proc/<pid>/mem`** (`ReadAt`, sin dependencias
externas), no `process_vm_readv(2)` crudo: mismo resultado, sin tener
que hacer un syscall a mano con structs `iovec` sin `golang.org/x/sys`.
- **El obstáculo real es `ptrace_scope`.** Sin la capacidad, abrir
`/proc/<pid>/mem` da `EPERM`. `openProcess` lo prueba una vez al
arrancar y, si falla, el error (que sale por el mismo camino que ya
existía: `st.setDisconnected(err.Error())` en el loop de polling) trae
el comando exacto con la ruta real del binario:
`sudo setcap cap_sys_ptrace+ep <ruta>`. **Nunca** sugiere
`sysctl kernel.yama.ptrace_scope=0` ni correr como root — eso baja la
defensa de todo el sistema, no solo la de este programa.
- **`productVersion` devuelve `ok=false` siempre.** No hay equivalente a
`version.dll` en Linux, pero nunca hizo falta: la versión era solo una
corazonada para elegir qué offset de `PlayerIns` probar primero
(`playerInsCandidates`); el que vale se confirma leyendo memoria en
`isPlayerLoaded` igual, con o sin la corazonada.
- **`systemLang`** sale de `$LC_ALL` / `$LC_MESSAGES` / `$LANG` en vez de
`GetUserDefaultLocaleName`, devolviendo el mismo formato que ya
devuelve la versión de Windows (el código corto: `"es"`, no
`"es-AR"` ni `"es_AR.UTF-8"`), para que `resolveLang` (`i18n.go`) no
tenga que distinguir de dónde vino.
**Modo sólo-hub en Linux sale gratis, sin código extra.** `main()` llama
`go pollLoop()` sin importar el modo. Si no hay ningún `eldenring.exe`
local (el caso de una PC con el OBS en Linux mientras se juega en otra),
`findProcessID` simplemente no encuentra nada y `pollLoop` reintenta cada
3s sin nunca llegar a `openProcess` — `setcap`/`ptrace_scope` no entran
en juego para nada en ese caso.
**Pendiente, no parte de este cambio:** testear el escáner/poller con un
lector de memoria falso. El split ya lo habilita, pero escribir esos
tests (siguiendo el patrón de variable de paquete intercambiable que ya
usa `testExeDir` en `totals.go`, no una interfaz nueva) queda para
después — ver Pendientes.
## Offsets de memoria ## Offsets de memoria
Todo se resuelve escaneando firmas AOB en el módulo del juego. Los tres Todo se resuelve escaneando firmas AOB en el módulo del juego. Los tres
@@ -191,73 +266,16 @@ No son preferencias de estilo. Cada una costó un bug en producción.
## Pendientes ## Pendientes
- No hay `README.md` todavía. Escribir uno en inglés (instalación, modo - No hay `README.md` todavía. Escribir uno en inglés (instalación en
hub/peer, capturas) es lo único que falta del pendiente de idioma — el Windows y Linux/Proton — incluyendo el paso de `setcap`, modo hub/peer,
código ya está en inglés de punta a punta, ver "Idioma del código" capturas) es lo único que falta del pendiente de idioma — el código ya
arriba. está en inglés de punta a punta, ver "Idioma del código" arriba.
- La identificación por nombre (respaldo cuando no se lee el slot) mezcla - La identificación por nombre (respaldo cuando no se lee el slot) mezcla
personajes homónimos. Documentado, no resuelto. personajes homónimos. Documentado, no resuelto.
- **Compilar y correr en Linux (Proton).** Mucha gente juega Elden Ring - Testear el escáner/poller (`process.go`) con un lector de memoria
con Proton, y hoy el programa sólo existe para Windows. El juego sigue falso. El split multiplataforma (ver "Multiplataforma" arriba) ya lo
siendo el mismo binario de Windows corriendo bajo Wine, así que **las habilita — falta escribir los tests en sí, con una variable de paquete
firmas AOB y todos los offsets valen igual**: lo único que cambia es intercambiable por función de frontera (mismo patrón que `testExeDir`
cómo se encuentra el proceso y cómo se lee su memoria. en `totals.go`), no una interfaz nueva.
**Refactor primero.** Hoy `main.go` mezcla lo específico de Windows con
lo que no lo es. Separar en `process_windows.go` y `process_linux.go`
detrás de unas pocas funciones — `findProcessID`, `openProcess`,
`readMemory`, `findModuleBase`, `productVersion`, `systemLang` — y dejar
el resto (escaneo de firmas, resolución de punteros, loop de polling,
lectura del nombre) en un archivo portable. Beneficio extra que vale por
sí solo: con eso el escaneo y el polling **se pueden testear con un
lector de memoria falso**, que es justo la parte que hoy no tiene tests.
Lo específico de Linux:
1. **Encontrar el proceso por sus mapeos, no por el nombre.** Proton
levanta varios procesos. Lo robusto es recorrer `/proc/*/maps` y
quedarse con el pid que tenga mapeado un archivo terminado en
`eldenring.exe`. De paso, esos mismos mapeos dan la base y el tamaño
del módulo, que es lo que `findModuleBase` necesita. Puede venir
partido en varios tramos (`.text`, `.rdata`, `.data`) con permisos
distintos: tomar el span completo alcanza, porque el escáner ya lee
por chunks y saltea los que no puede leer.
2. **Leer con `process_vm_readv(2)`**, que no necesita adjuntarse al
proceso. `/proc/<pid>/mem` sirve de alternativa.
3. **El obstáculo real es `ptrace_scope`.** En casi todas las distros
vale `1`, y con eso `process_vm_readv` sobre un proceso ajeno falla
con `EPERM`. La salida recomendada es darle la capacidad al binario:
```bash
sudo setcap cap_sys_ptrace+ep ./deathwatch
```
**No** recomendar `sysctl kernel.yama.ptrace_scope=0`, que baja la
defensa de todo el sistema, ni correrlo como root. Y que el mensaje
de error diga exactamente esto cuando falle: sin eso el programa
parece simplemente roto, y es el primer problema que va a tener
cualquiera que lo pruebe.
4. **`productVersion` no tiene equivalente** (usa `version.dll` sobre el
exe). En Linux devolver `ok=false` y listo: la versión es sólo una
corazonada para decidir qué offset de `PlayerIns` probar primero, y
el valor bueno se confirma leyendo memoria igual. Una decisión vieja
que acá se paga sola.
5. **`systemLang`** sale de `$LC_ALL` / `$LANG` en vez de
`GetUserDefaultLocaleName`.
No hay que tocar: los offsets, las firmas, `counter.go`, `names.go`,
`totals.go`, `duo.go`, `ws.go`, `tlscert.go`, `pin.go`, `invite.go`,
`config.go` ni el overlay. Ya son portables.
**Modo sólo-hub, de regalo.** Un hub que no lee memoria —que sólo junta
lo de los peers y sirve el overlay— no necesita `setcap` ni permiso
alguno. Es exactamente el caso de quien tiene el OBS en una PC con Linux
y juega en otra, y sale casi gratis una vez separado lo de arriba.
Referencias de gente que ya leyó memoria de juegos bajo Proton:
[pika](https://github.com/delfianto/pika),
[cheat-engine-linux](https://github.com/wleeaf/cheat-engine-linux),
[un trainer para D2R en Linux paso a paso](https://axiom0x0.sh/posts/d2r-memory-trainer-part2/).
- El README debería mencionar que meter todo adentro de una VPN sigue - El README debería mencionar que meter todo adentro de una VPN sigue
siendo una opción perfectamente válida, TLS+pinning aparte. siendo una opción perfectamente válida, TLS+pinning aparte.
+7 -22
View File
@@ -1,5 +1,3 @@
//go:build windows
// i18n.go: interface languages. // i18n.go: interface languages.
// //
// Translations live in locales/*.json and get embedded into the binary. // Translations live in locales/*.json and get embedded into the binary.
@@ -10,6 +8,11 @@
// Console messages deliberately do NOT go through here: they're // Console messages deliberately do NOT go through here: they're
// diagnostics, and it helps if they're always in the same language so a // diagnostics, and it helps if they're always in the same language so a
// log pasted into an issue reads the same no matter where it came from. // log pasted into an issue reads the same no matter where it came from.
//
// systemLang(), which this file calls to pick the default language, is
// the one OS-specific piece — implemented in process_windows.go/
// process_linux.go, not here, since this file's own job (embedding and
// picking a dictionary) doesn't depend on the OS at all.
package main package main
import ( import (
@@ -19,8 +22,6 @@ import (
"path" "path"
"sort" "sort"
"strings" "strings"
"syscall"
"unsafe"
) )
//go:embed locales/*.json //go:embed locales/*.json
@@ -64,24 +65,8 @@ func availableLangs() []string {
return out return out
} }
var procGetUserDefaultLocaleName = kernel32.NewProc("GetUserDefaultLocaleName") // resolveLang decides the final language. "auto" (or empty) uses the
// system's; if that language isn't translated, it falls back to English.
// 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)
}
// resolveLang decides the final language. "auto" (or empty) uses
// Windows's; if that language isn't translated, it falls back to English.
func resolveLang(want string) string { func resolveLang(want string) string {
want = strings.ToLower(strings.TrimSpace(want)) want = strings.ToLower(strings.TrimSpace(want))
if want == "" || want == "auto" { if want == "" || want == "auto" {
+5 -786
View File
@@ -1,9 +1,10 @@
//go:build windows
// deathwatch: reads Elden Ring's death counter read-only, straight from // deathwatch: reads Elden Ring's death counter read-only, straight from
// the process (the same byte pattern / offset used by LiveSplit's // the process (the same byte pattern / offset used by LiveSplit's
// "eldenring_boss_timer.asl" ASL script, verified by hand on this PC). // "eldenring_boss_timer.asl" ASL script, verified by hand). Writes
// Writes nothing to the game's memory. // nothing to the game's memory. Runs on Windows natively and on Linux
// against the game running under Proton — same signatures and offsets
// either way, since it's the same Windows binary in memory; see
// process.go/process_windows.go/process_linux.go for the split.
// //
// Exposes: // Exposes:
// //
@@ -15,798 +16,16 @@ package main
import ( import (
"crypto/tls" "crypto/tls"
_ "embed" _ "embed"
"encoding/binary"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
"strconv"
"strings" "strings"
"syscall"
"time"
"unicode/utf16"
"unsafe"
) )
//go:embed overlay.html //go:embed overlay.html
var overlayHTML []byte var overlayHTML []byte
const (
processName = "eldenring.exe"
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")
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
}
// ---------------------------- Windows API helpers ----------------------------
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 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
}
// playerInsOffsetForVersion mirrors SoulMemory's table (InitializeOffsets):
// up to 1.06 the PlayerIns offset inside WorldChrMan is 0x18468, from
// 1.07 onward it's 0x1E508.
//
// WATCH OUT: the version the exe reports is NOT what the game shows on
// screen (the exe can say 2.7.1.0 while the game says 1.17.1), and
// SoulMemory's table is written with the game's own numbers. So this is
// only a HUNCH for deciding which one to try first: what actually
// decides is playerInsCandidates plus the in-memory verification.
func playerInsOffsetForVersion(major, minor uint16, ok bool) uintptr {
if ok && major == 1 && minor <= 6 {
return 0x18468
}
return 0x1E508
}
// playerInsCandidates returns the known offsets to try, with the one the
// version suggests listed first.
func playerInsCandidates(major, minor uint16, ok bool) []uintptr {
if playerInsOffsetForVersion(major, minor, ok) == 0x18468 {
return []uintptr{0x18468, 0x1E508}
}
return []uintptr{0x1E508, 0x18468}
}
func openProcessHandle(pid uint32) (syscall.Handle, error) {
h, _, err := procOpenProcess.Call(uintptr(processQueryInformation|processVMRead), 0, uintptr(pid))
if h == 0 {
return 0, err
}
return syscall.Handle(h), nil
}
func readMemory(h syscall.Handle, 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
}
// ------------------------- signature scans -------------------------
//
// Two signatures, both the same shape: a 7-byte "mov reg,[rip+disp32]"
// instruction, where the first 3 bytes are the opcode and the next 4 are
// the displacement. The resolved static slot holds the pointer to the
// object (one more dereference needed).
//
// GameDataMan -> holds the death counter (+0x94) and the boss-fight flag
// (+0xC0). Same pattern eldenring_boss_timer.asl (LiveSplit)
// uses.
// WorldChrMan -> holds the pointer to PlayerIns (+playerInsOffset). If
// that pointer is null, there's no character in the world:
// you're at the main menu or on a loading screen. Exactly
// what SoulMemory.IsPlayerLoaded() does.
type patByte struct {
val byte
wildcard bool
}
// parsePattern accepts "48 8B 05 ?? ?? ?? ??" (?? = wildcard).
func parsePattern(s string) []patByte {
var out []patByte
for _, tok := range strings.Fields(s) {
if strings.HasPrefix(tok, "?") {
out = append(out, patByte{wildcard: true})
continue
}
v, err := strconv.ParseUint(tok, 16, 8)
if err != nil {
panic("invalid pattern: " + tok)
}
out = append(out, patByte{val: byte(v)})
}
return out
}
var (
// mov rax,[rip+disp32]; test rax,rax; jz +5; mov rax,[rax+58]; ret; ret
gameDataManPattern = parsePattern("48 8B 05 ?? ?? ?? ?? 48 85 C0 74 05 48 8B 40 58 C3 C3")
// mov rsi,[rip+disp32]; test rsi,rsi; ... (WorldChrManImp, same as SoulMemory)
worldChrManPattern = parsePattern("48 8B 35 ?? ?? ?? ?? 48 85 F6 ?? ?? BB 01 00 00 00 89 5C 24 20 48 8B B6")
// mov rax,[rip+disp32]; cmp byte ptr [rax+disp32],0D; sete al; ret
// (GameMan: +0xAC0 holds the loaded character's save slot)
gameManPattern = parsePattern("48 8B 05 ?? ?? ?? ?? 80 B8 ?? ?? ?? ?? 0D 0F 94 C0 C3")
)
// saveSlotOffset: where GameMan stores the save-slot index (0-9) of the
// loaded game. This is a character's REAL identity: it doesn't depend on
// the name, so two characters sharing a name never mix.
const saveSlotOffset = 0xAC0
func matchAt(buf []byte, i int, pattern []patByte) bool {
if i+len(pattern) > len(buf) {
return false
}
for j, p := range pattern {
if !p.wildcard && buf[i+j] != p.val {
return false
}
}
return true
}
// scanModule looks for several patterns in a single pass over the module,
// reading it in chunks (with overlap, in case a pattern straddles a chunk
// boundary). Returns, for each pattern, the match address or 0.
func scanModule(h syscall.Handle, base uintptr, size uint32, patterns [][]patByte) []uintptr {
const chunk = 1 << 20 // 1 MiB
const overlap = 64
found := make([]uintptr, len(patterns))
remaining := len(patterns)
var pos uint32
for pos < size && remaining > 0 {
readSize := chunk
if rem := int(size - pos); readSize > rem {
readSize = rem
}
buf, ok := readMemory(h, base+uintptr(pos), readSize)
if ok {
for i := 0; i < len(buf); i++ {
for p := range patterns {
if found[p] != 0 {
continue
}
if matchAt(buf, i, patterns[p]) {
found[p] = base + uintptr(pos) + uintptr(i)
remaining--
}
}
}
}
if uint32(readSize) <= overlap {
break
}
pos += uint32(readSize) - overlap
}
return found
}
// ripSlot turns the address of a 7-byte "mov reg,[rip+disp32]"
// instruction into the address of the static slot it points to.
func ripSlot(h syscall.Handle, matchAddr uintptr) (uintptr, error) {
if matchAddr == 0 {
return 0, fmt.Errorf("pattern not found")
}
codeLocation := matchAddr + 3 // the first 3 bytes are the opcode
dispBytes, ok := readMemory(h, codeLocation, 4)
if !ok {
return 0, fmt.Errorf("couldn't read the RIP-relative displacement")
}
disp := int32(binary.LittleEndian.Uint32(dispBytes))
return codeLocation + 4 + uintptr(int64(disp)), nil
}
// gamePointers gathers everything resolved just once per process session:
// the static slots (which don't move) and the game's version.
type gamePointers struct {
gameDataManSlot uintptr
worldChrManSlot uintptr // 0 if the pattern wasn't found (we keep going without the menu check)
gameManSlot uintptr // 0 if not found: falls back to identifying by name
playerInsOffset uintptr // the one currently in use (or the preferred candidate)
playerInsTried []uintptr
offsetConfirmed bool // true once verified by actually reading memory
versionLabel string
nameChain nameCandidate // how we got to the character's name
nameConfirmed bool
namePending string // candidate waiting to repeat (see resolveCharName)
namePendingOf nameCandidate
}
// resolvePointers does the signature scans (expensive: walks the whole
// module) just once per process session. Deliberately does NOT return
// the resolved objects themselves: those pointers get re-read every tick,
// because the game can destroy and recreate GameDataMan (e.g. going back
// to the main menu and loading again). If we cached a stale address,
// we'd keep reading it successfully (the memory page is still valid) but
// its contents would belong to something else entirely — the most likely
// cause of a counter that "goes up on its own" without an actual death.
// SoulMemory does the same: its Pointer class resolves the whole chain on
// every read, never caching the final address.
func resolvePointers(h syscall.Handle, pid uint32) (gamePointers, error) {
var gp gamePointers
base, size, exePath, err := findModuleBase(pid, processName)
if err != nil {
return gp, err
}
major, minor, label, okVer := productVersion(exePath)
gp.playerInsTried = playerInsCandidates(major, minor, okVer)
gp.playerInsOffset = gp.playerInsTried[0]
gp.versionLabel = label
if !okVer {
gp.versionLabel = "unknown"
}
matches := scanModule(h, base, size, [][]patByte{gameDataManPattern, worldChrManPattern, gameManPattern})
gp.gameDataManSlot, err = ripSlot(h, matches[0])
if err != nil {
return gp, fmt.Errorf("GameDataMan's pattern wasn't found (did the game update?)")
}
// WorldChrMan is optional: if it's missing, we keep counting deaths,
// we just lose menu/loading-screen detection.
if slot, werr := ripSlot(h, matches[1]); werr == nil {
gp.worldChrManSlot = slot
}
// GameMan is optional too: without it, we identify by name.
if slot, gerr := ripSlot(h, matches[2]); gerr == nil {
gp.gameManSlot = slot
}
return gp, nil
}
// derefPointer reads a static slot (cheap: 8 bytes) and returns the
// object's CURRENT address. Called every tick, not just once.
func derefPointer(h syscall.Handle, slot uintptr) (uintptr, bool) {
if slot == 0 {
return 0, false
}
buf, ok := readMemory(h, slot, 8)
if !ok {
return 0, false
}
return uintptr(binary.LittleEndian.Uint64(buf)), true
}
// ------------------------- character name -------------------------
//
// Sourced from a Cheat Engine table: "GameDataMan +0C +9C, unicode,
// length 19". That notation allows more than one reading (is 0x0C a
// pointer to dereference, or do the two offsets just add up?), and on
// top of that the community/the ASL use GameDataMan+0x08 to reach
// PlayerGameData. So instead of picking one, all three get tried, and
// whichever gives back something that looks like a real name wins.
type nameCandidate struct {
ptrOffset uintptr // offset where the pointer lives (0 = no dereference)
nameOffset uintptr // offset of the text within the object
label string
}
var nameCandidates = []nameCandidate{
{0x08, 0x9C, "[GameDataMan+0x08]+0x9C (PlayerGameData)"},
{0x0C, 0x9C, "[GameDataMan+0x0C]+0x9C"},
{0x00, 0xA8, "GameDataMan+0xA8 (0x0C and 0x9C added together)"},
}
// looksLikeName (and charNameMaxChars) live in names.go: they don't
// depend on Windows, so they're kept out of this file to be testable
// without a PC with the game open.
func readCharName(h syscall.Handle, gameDataMan uintptr, c nameCandidate) (string, bool) {
base := gameDataMan
if c.ptrOffset != 0 {
p, ok := derefPointer(h, gameDataMan+c.ptrOffset)
if !ok || p == 0 {
return "", false
}
base = p
}
buf, ok := readMemory(h, base+c.nameOffset, charNameMaxChars*2)
if !ok {
return "", false
}
u16 := make([]uint16, 0, charNameMaxChars)
for i := 0; i+1 < len(buf); i += 2 {
ch := binary.LittleEndian.Uint16(buf[i : i+2])
if ch == 0 {
break
}
u16 = append(u16, ch)
}
s := strings.TrimSpace(string(utf16.Decode(u16)))
if !looksLikeName(s) {
return "", false
}
return s, true
}
// resolveCharName returns the character's name. Before locking in a
// variant, it requires seeing it give the SAME text on two readings in a
// row: the real name doesn't change from one second to the next, but a
// chunk of memory that happens to pass the filter is far less likely to
// repeat.
func resolveCharName(h syscall.Handle, gameDataMan uintptr, gp *gamePointers) (string, bool) {
if gp.nameConfirmed {
return readCharName(h, gameDataMan, gp.nameChain)
}
for _, c := range nameCandidates {
s, ok := readCharName(h, gameDataMan, c)
if !ok {
continue
}
if gp.namePending == s && gp.namePendingOf == c {
gp.nameChain = c
gp.nameConfirmed = true
gp.namePending = ""
log.Printf("character name: \"%s\" (read via %s)", s, c.label)
return s, true
}
gp.namePending = s
gp.namePendingOf = c
return "", false
}
gp.namePending = ""
return "", false
}
// readSaveSlot returns the loaded game's slot index (0-9), or -1 if it
// couldn't be read. Elden Ring has 10 slots, so any other value is
// garbage and gets discarded.
func readSaveSlot(h syscall.Handle, gp gamePointers) int {
if gp.gameManSlot == 0 {
return -1
}
gameMan, ok := derefPointer(h, gp.gameManSlot)
if !ok || gameMan == 0 {
return -1
}
buf, ok := readMemory(h, gameMan+saveSlotOffset, 1)
if !ok {
return -1
}
slot := int(buf[0])
if slot < 0 || slot > 9 {
return -1
}
return slot
}
// isPlayerLoaded mirrors SoulMemory.IsPlayerLoaded(): resolves
// WorldChrMan and reads the pointer to PlayerIns; if it's null, there's
// no character in the world. The second return value says whether we
// were able to evaluate it at all.
//
// Until the offset is confirmed, instead of trusting the version number
// (which in Elden Ring doesn't match what the game displays), the known
// offsets are tried and whichever one first points at genuinely readable
// memory wins. That's decided by the machine, not by a table that can
// age badly.
func isPlayerLoaded(h syscall.Handle, gp *gamePointers) (loaded bool, known bool) {
if gp.worldChrManSlot == 0 {
return true, false
}
worldChrMan, ok := derefPointer(h, gp.worldChrManSlot)
if !ok {
return true, false
}
if worldChrMan == 0 {
return false, true
}
if gp.offsetConfirmed {
playerIns, ok := derefPointer(h, worldChrMan+gp.playerInsOffset)
if !ok {
return true, false
}
return playerIns != 0, true
}
for _, cand := range gp.playerInsTried {
playerIns, ok := derefPointer(h, worldChrMan+cand)
if !ok || playerIns == 0 {
continue
}
// A real pointer points at mapped memory; a garbage one almost
// never survives this read.
if _, ok := readMemory(h, playerIns, 8); !ok {
continue
}
gp.playerInsOffset = cand
gp.offsetConfirmed = true
log.Printf("PlayerIns confirmed at +0x%X (verified by reading the object, not by version number)", cand)
return true, true
}
return false, true
}
// -------------------------------- poller loop --------------------------------
// maxPlausibleDeltaPerTick: between two readings ~1s apart, with the
// character loaded the whole time, the real death counter can't go up by
// more than this (and never goes down). A bigger jump almost always
// means we grabbed memory that's no longer GameDataMan (a stale/invalid
// address), not an actual death.
const maxPlausibleDeltaPerTick = 3
func pollLoop() {
var (
handle syscall.Handle
pid uint32
gp gamePointers
resolved bool
lastRaw int32
haveLastRaw bool
sawUnloaded bool
warnedNoWCM bool
lastBossRead bool
// Menu-detection watchdog: while we believe no character is
// loaded, we still peek at the death counter. If it climbs the
// way a real death does, our detection is lying (you were
// actually playing) and we turn it off.
unloadedRaw int32
unloadedRawFirst int32
haveUnloadedRaw bool
)
closeHandle := func() {
if handle != 0 {
procCloseHandle.Call(uintptr(handle))
handle = 0
}
pid = 0
gp = gamePointers{}
resolved = false
haveLastRaw = false
sawUnloaded = false
haveUnloadedRaw = false
}
for {
if handle == 0 {
newPid, err := findProcessID(processName)
if err != nil {
st.setDisconnected("waiting for eldenring.exe")
time.Sleep(3 * time.Second)
continue
}
h, err := openProcessHandle(newPid)
if err != nil {
st.setDisconnected("couldn't open the process (permissions?): " + err.Error())
time.Sleep(3 * time.Second)
continue
}
pid = newPid
handle = h
log.Printf("eldenring.exe found (PID %d), scanning signatures...", pid)
}
if !resolved {
p, err := resolvePointers(handle, pid)
if err != nil {
st.setDisconnected(err.Error())
time.Sleep(2 * time.Second)
// if the process died, release the handle to retry from scratch
if _, ferr := findProcessID(processName); ferr != nil {
closeHandle()
}
continue
}
gp = p
resolved = true
haveLastRaw = false
log.Printf("game version: %s | GameDataMan slot 0x%X", gp.versionLabel, gp.gameDataManSlot)
if gp.gameManSlot != 0 {
log.Printf("GameMan slot 0x%X (identifying characters by their save slot)", gp.gameManSlot)
} else {
log.Printf("warning: GameMan's pattern wasn't found; identifying characters by name")
}
if gp.worldChrManSlot != 0 {
log.Printf("WorldChrMan slot 0x%X | PlayerIns: trying +0x%X and confirming against memory", gp.worldChrManSlot, gp.playerInsOffset)
} else if !warnedNoWCM {
warnedNoWCM = true
log.Printf("warning: WorldChrMan's pattern wasn't found; still counting deaths but without menu/loading-screen detection")
}
}
// Same as LiveSplit's ASL, which does "if (!IsPlayerLoaded) return;":
// with no character in the world, nothing gets read. The total
// stays frozen on screen (no dash shown) so it doesn't flicker on
// every loading screen.
if loaded, known := isPlayerLoaded(handle, &gp); known && !loaded {
// Safety net. The PlayerIns offset depends on the game's
// version: if a patch ever moves it, we'd read null forever
// and the counter would freeze mid-stream.
//
// The only judge we can trust is the death counter itself:
// at the start menu it NEVER goes up. So the raw value gets
// peeked at without being used, and if it climbs the way a
// real death does (+1, +2, +3), you were actually playing
// and our detection was lying. Unlike a timeout, this can't
// fire just from leaving the game sitting at the menu a while.
if gdm, ok := derefPointer(handle, gp.gameDataManSlot); ok && gdm != 0 {
if buf, ok := readMemory(handle, gdm+0x94, 4); ok {
raw := int32(binary.LittleEndian.Uint32(buf))
if raw >= 0 && raw < 1_000_000 {
if haveUnloadedRaw {
if d := raw - unloadedRaw; d >= 1 && d <= maxPlausibleDeltaPerTick {
log.Printf("the death counter went from %d to %d while I thought no character was loaded: menu detection is wrong on this version, turning it off and continuing to count", unloadedRaw, raw)
gp.worldChrManSlot = 0
// Recover what happened during the confused
// stretch: keep the reference at that
// period's first reading so the "crossed a
// loading screen" logic can credit the
// deaths if there weren't many.
lastRaw = unloadedRawFirst
haveLastRaw = true
sawUnloaded = true
haveUnloadedRaw = false
continue
}
} else {
unloadedRawFirst = raw
}
unloadedRaw = raw
haveUnloadedRaw = true
}
}
}
// Careful: haveLastRaw/lastRaw are NOT touched, precisely so
// they can be compared against the last good reading once
// the world comes back.
st.setPlayerUnloaded("main menu or loading screen")
sawUnloaded = true
time.Sleep(1 * time.Second)
continue
}
haveUnloadedRaw = false
// Re-dereference the slot on EVERY tick (not just on connect) to
// never end up stuck with a stale GameDataMan address.
gameDataMan, ok := derefPointer(handle, gp.gameDataManSlot)
if !ok {
st.setDisconnected("lost the memory reading (the game closed or restarted)")
closeHandle()
time.Sleep(2 * time.Second)
continue
}
if gameDataMan == 0 {
st.setPlayerUnloaded("no game loaded")
sawUnloaded = true
time.Sleep(1 * time.Second)
continue
}
deathsBuf, ok1 := readMemory(handle, gameDataMan+0x94, 4)
bossBuf, ok2 := readMemory(handle, gameDataMan+0xC0, 1)
if !ok1 {
st.setDisconnected("lost the memory reading (the game closed or restarted)")
closeHandle()
time.Sleep(2 * time.Second)
continue
}
raw := int32(binary.LittleEndian.Uint32(deathsBuf))
boss := lastBossRead
if ok2 {
boss = bossBuf[0] != 0
lastBossRead = boss
}
if raw < 0 || raw > 1_000_000 {
log.Printf("discarding an impossible reading (raw %d) - rescanning signatures", raw)
resolved = false
haveLastRaw = false
time.Sleep(1 * time.Second)
continue
}
// Within the same save, the counter never goes down or jumps: if
// it does, it's memory that's no longer GameDataMan. Crossing a
// load, on the other hand, can change to anything, since it might
// be a different character — and setCharacter handles that case.
if haveLastRaw && !sawUnloaded {
delta := int64(raw) - int64(lastRaw)
if delta < 0 || delta > maxPlausibleDeltaPerTick {
log.Printf("discarding a suspicious reading (raw %d, previous %d) - rescanning signatures", raw, lastRaw)
resolved = false
haveLastRaw = false
time.Sleep(1 * time.Second)
continue
}
}
// Which character this is gets resolved BEFORE recording the
// reading: if you switched characters, the total jumps to theirs
// on this very pass, with no need to wait for a death.
name, _ := resolveCharName(handle, gameDataMan, &gp)
st.setCharacter(readSaveSlot(handle, gp), name, raw)
st.setReading(raw, boss)
lastRaw = raw
haveLastRaw = true
sawUnloaded = false
time.Sleep(1 * time.Second)
}
}
// ---------------------------------- HTTP ----------------------------------
// portOf pulls the port out of an address like "0.0.0.0:47822", so your // portOf pulls the port out of an address like "0.0.0.0:47822", so your
// partner can be told exactly what to put in their config. // partner can be told exactly what to put in their config.
func portOf(addr string) string { func portOf(addr string) string {
+616
View File
@@ -0,0 +1,616 @@
// process.go: everything about finding the death counter in the game's
// memory that does NOT depend on which OS is doing the reading — AOB
// signature scanning, pointer resolution, character-name reading, and the
// poll loop that ties it all together.
//
// The actual reading is behind procHandle and a handful of functions
// (findProcessID, openProcess, closeProcessHandle, readMemory,
// findModuleBase, productVersion, systemLang) implemented once per
// platform in process_windows.go/process_linux.go. Elden Ring under
// Proton on Linux is the exact same Windows binary Wine is running, so
// every signature and offset below is identical on both platforms — only
// how the process gets found and read differs.
package main
import (
"encoding/binary"
"fmt"
"log"
"strconv"
"strings"
"time"
"unicode/utf16"
)
const processName = "eldenring.exe"
// procHandle is an opaque reference to an open process, produced by
// openProcess and consumed by readMemory/closeProcessHandle. What it
// actually holds is platform-specific: a real Windows HANDLE value, or
// just a pid on Linux (which needs no persistent OS resource — see
// process_linux.go).
type procHandle uintptr
// ------------------------- signature scans -------------------------
//
// Two signatures, both the same shape: a 7-byte "mov reg,[rip+disp32]"
// instruction, where the first 3 bytes are the opcode and the next 4 are
// the displacement. The resolved static slot holds the pointer to the
// object (one more dereference needed).
//
// GameDataMan -> holds the death counter (+0x94) and the boss-fight flag
// (+0xC0). Same pattern eldenring_boss_timer.asl (LiveSplit)
// uses.
// WorldChrMan -> holds the pointer to PlayerIns (+playerInsOffset). If
// that pointer is null, there's no character in the world:
// you're at the main menu or on a loading screen. Exactly
// what SoulMemory.IsPlayerLoaded() does.
type patByte struct {
val byte
wildcard bool
}
// parsePattern accepts "48 8B 05 ?? ?? ?? ??" (?? = wildcard).
func parsePattern(s string) []patByte {
var out []patByte
for _, tok := range strings.Fields(s) {
if strings.HasPrefix(tok, "?") {
out = append(out, patByte{wildcard: true})
continue
}
v, err := strconv.ParseUint(tok, 16, 8)
if err != nil {
panic("invalid pattern: " + tok)
}
out = append(out, patByte{val: byte(v)})
}
return out
}
var (
// mov rax,[rip+disp32]; test rax,rax; jz +5; mov rax,[rax+58]; ret; ret
gameDataManPattern = parsePattern("48 8B 05 ?? ?? ?? ?? 48 85 C0 74 05 48 8B 40 58 C3 C3")
// mov rsi,[rip+disp32]; test rsi,rsi; ... (WorldChrManImp, same as SoulMemory)
worldChrManPattern = parsePattern("48 8B 35 ?? ?? ?? ?? 48 85 F6 ?? ?? BB 01 00 00 00 89 5C 24 20 48 8B B6")
// mov rax,[rip+disp32]; cmp byte ptr [rax+disp32],0D; sete al; ret
// (GameMan: +0xAC0 holds the loaded character's save slot)
gameManPattern = parsePattern("48 8B 05 ?? ?? ?? ?? 80 B8 ?? ?? ?? ?? 0D 0F 94 C0 C3")
)
// saveSlotOffset: where GameMan stores the save-slot index (0-9) of the
// loaded game. This is a character's REAL identity: it doesn't depend on
// the name, so two characters sharing a name never mix.
const saveSlotOffset = 0xAC0
func matchAt(buf []byte, i int, pattern []patByte) bool {
if i+len(pattern) > len(buf) {
return false
}
for j, p := range pattern {
if !p.wildcard && buf[i+j] != p.val {
return false
}
}
return true
}
// scanModule looks for several patterns in a single pass over the module,
// reading it in chunks (with overlap, in case a pattern straddles a chunk
// boundary). Returns, for each pattern, the match address or 0.
func scanModule(h procHandle, base uintptr, size uint32, patterns [][]patByte) []uintptr {
const chunk = 1 << 20 // 1 MiB
const overlap = 64
found := make([]uintptr, len(patterns))
remaining := len(patterns)
var pos uint32
for pos < size && remaining > 0 {
readSize := chunk
if rem := int(size - pos); readSize > rem {
readSize = rem
}
buf, ok := readMemory(h, base+uintptr(pos), readSize)
if ok {
for i := 0; i < len(buf); i++ {
for p := range patterns {
if found[p] != 0 {
continue
}
if matchAt(buf, i, patterns[p]) {
found[p] = base + uintptr(pos) + uintptr(i)
remaining--
}
}
}
}
if uint32(readSize) <= overlap {
break
}
pos += uint32(readSize) - overlap
}
return found
}
// ripSlot turns the address of a 7-byte "mov reg,[rip+disp32]"
// instruction into the address of the static slot it points to.
func ripSlot(h procHandle, matchAddr uintptr) (uintptr, error) {
if matchAddr == 0 {
return 0, fmt.Errorf("pattern not found")
}
codeLocation := matchAddr + 3 // the first 3 bytes are the opcode
dispBytes, ok := readMemory(h, codeLocation, 4)
if !ok {
return 0, fmt.Errorf("couldn't read the RIP-relative displacement")
}
disp := int32(binary.LittleEndian.Uint32(dispBytes))
return codeLocation + 4 + uintptr(int64(disp)), nil
}
// gamePointers gathers everything resolved just once per process session:
// the static slots (which don't move) and the game's version.
type gamePointers struct {
gameDataManSlot uintptr
worldChrManSlot uintptr // 0 if the pattern wasn't found (we keep going without the menu check)
gameManSlot uintptr // 0 if not found: falls back to identifying by name
playerInsOffset uintptr // the one currently in use (or the preferred candidate)
playerInsTried []uintptr
offsetConfirmed bool // true once verified by actually reading memory
versionLabel string
nameChain nameCandidate // how we got to the character's name
nameConfirmed bool
namePending string // candidate waiting to repeat (see resolveCharName)
namePendingOf nameCandidate
}
// resolvePointers does the signature scans (expensive: walks the whole
// module) just once per process session. Deliberately does NOT return
// the resolved objects themselves: those pointers get re-read every tick,
// because the game can destroy and recreate GameDataMan (e.g. going back
// to the main menu and loading again). If we cached a stale address,
// we'd keep reading it successfully (the memory page is still valid) but
// its contents would belong to something else entirely — the most likely
// cause of a counter that "goes up on its own" without an actual death.
// SoulMemory does the same: its Pointer class resolves the whole chain on
// every read, never caching the final address.
func resolvePointers(h procHandle, pid uint32) (gamePointers, error) {
var gp gamePointers
base, size, exePath, err := findModuleBase(pid, processName)
if err != nil {
return gp, err
}
major, minor, label, okVer := productVersion(exePath)
gp.playerInsTried = playerInsCandidates(major, minor, okVer)
gp.playerInsOffset = gp.playerInsTried[0]
gp.versionLabel = label
if !okVer {
gp.versionLabel = "unknown"
}
matches := scanModule(h, base, size, [][]patByte{gameDataManPattern, worldChrManPattern, gameManPattern})
gp.gameDataManSlot, err = ripSlot(h, matches[0])
if err != nil {
return gp, fmt.Errorf("GameDataMan's pattern wasn't found (did the game update?)")
}
// WorldChrMan is optional: if it's missing, we keep counting deaths,
// we just lose menu/loading-screen detection.
if slot, werr := ripSlot(h, matches[1]); werr == nil {
gp.worldChrManSlot = slot
}
// GameMan is optional too: without it, we identify by name.
if slot, gerr := ripSlot(h, matches[2]); gerr == nil {
gp.gameManSlot = slot
}
return gp, nil
}
// derefPointer reads a static slot (cheap: 8 bytes) and returns the
// object's CURRENT address. Called every tick, not just once.
func derefPointer(h procHandle, slot uintptr) (uintptr, bool) {
if slot == 0 {
return 0, false
}
buf, ok := readMemory(h, slot, 8)
if !ok {
return 0, false
}
return uintptr(binary.LittleEndian.Uint64(buf)), true
}
// ------------------------- character name -------------------------
//
// Sourced from a Cheat Engine table: "GameDataMan +0C +9C, unicode,
// length 19". That notation allows more than one reading (is 0x0C a
// pointer to dereference, or do the two offsets just add up?), and on
// top of that the community/the ASL use GameDataMan+0x08 to reach
// PlayerGameData. So instead of picking one, all three get tried, and
// whichever gives back something that looks like a real name wins.
type nameCandidate struct {
ptrOffset uintptr // offset where the pointer lives (0 = no dereference)
nameOffset uintptr // offset of the text within the object
label string
}
var nameCandidates = []nameCandidate{
{0x08, 0x9C, "[GameDataMan+0x08]+0x9C (PlayerGameData)"},
{0x0C, 0x9C, "[GameDataMan+0x0C]+0x9C"},
{0x00, 0xA8, "GameDataMan+0xA8 (0x0C and 0x9C added together)"},
}
// looksLikeName (and charNameMaxChars) live in names.go: they don't
// depend on the OS at all, let alone Windows vs. Linux, so they're kept
// out of this file to be testable without a PC with the game open.
func readCharName(h procHandle, gameDataMan uintptr, c nameCandidate) (string, bool) {
base := gameDataMan
if c.ptrOffset != 0 {
p, ok := derefPointer(h, gameDataMan+c.ptrOffset)
if !ok || p == 0 {
return "", false
}
base = p
}
buf, ok := readMemory(h, base+c.nameOffset, charNameMaxChars*2)
if !ok {
return "", false
}
u16 := make([]uint16, 0, charNameMaxChars)
for i := 0; i+1 < len(buf); i += 2 {
ch := binary.LittleEndian.Uint16(buf[i : i+2])
if ch == 0 {
break
}
u16 = append(u16, ch)
}
s := strings.TrimSpace(string(utf16.Decode(u16)))
if !looksLikeName(s) {
return "", false
}
return s, true
}
// resolveCharName returns the character's name. Before locking in a
// variant, it requires seeing it give the SAME text on two readings in a
// row: the real name doesn't change from one second to the next, but a
// chunk of memory that happens to pass the filter is far less likely to
// repeat.
func resolveCharName(h procHandle, gameDataMan uintptr, gp *gamePointers) (string, bool) {
if gp.nameConfirmed {
return readCharName(h, gameDataMan, gp.nameChain)
}
for _, c := range nameCandidates {
s, ok := readCharName(h, gameDataMan, c)
if !ok {
continue
}
if gp.namePending == s && gp.namePendingOf == c {
gp.nameChain = c
gp.nameConfirmed = true
gp.namePending = ""
log.Printf("character name: \"%s\" (read via %s)", s, c.label)
return s, true
}
gp.namePending = s
gp.namePendingOf = c
return "", false
}
gp.namePending = ""
return "", false
}
// readSaveSlot returns the loaded game's slot index (0-9), or -1 if it
// couldn't be read. Elden Ring has 10 slots, so any other value is
// garbage and gets discarded.
func readSaveSlot(h procHandle, gp gamePointers) int {
if gp.gameManSlot == 0 {
return -1
}
gameMan, ok := derefPointer(h, gp.gameManSlot)
if !ok || gameMan == 0 {
return -1
}
buf, ok := readMemory(h, gameMan+saveSlotOffset, 1)
if !ok {
return -1
}
slot := int(buf[0])
if slot < 0 || slot > 9 {
return -1
}
return slot
}
// isPlayerLoaded mirrors SoulMemory.IsPlayerLoaded(): resolves
// WorldChrMan and reads the pointer to PlayerIns; if it's null, there's
// no character in the world. The second return value says whether we
// were able to evaluate it at all.
//
// Until the offset is confirmed, instead of trusting the version number
// (which in Elden Ring doesn't match what the game displays, and on
// Linux isn't available at all — see productVersion), the known offsets
// are tried and whichever one first points at genuinely readable memory
// wins. That's decided by the machine, not by a table that can age badly
// or an OS that can't report a version at all.
func isPlayerLoaded(h procHandle, gp *gamePointers) (loaded bool, known bool) {
if gp.worldChrManSlot == 0 {
return true, false
}
worldChrMan, ok := derefPointer(h, gp.worldChrManSlot)
if !ok {
return true, false
}
if worldChrMan == 0 {
return false, true
}
if gp.offsetConfirmed {
playerIns, ok := derefPointer(h, worldChrMan+gp.playerInsOffset)
if !ok {
return true, false
}
return playerIns != 0, true
}
for _, cand := range gp.playerInsTried {
playerIns, ok := derefPointer(h, worldChrMan+cand)
if !ok || playerIns == 0 {
continue
}
// A real pointer points at mapped memory; a garbage one almost
// never survives this read.
if _, ok := readMemory(h, playerIns, 8); !ok {
continue
}
gp.playerInsOffset = cand
gp.offsetConfirmed = true
log.Printf("PlayerIns confirmed at +0x%X (verified by reading the object, not by version number)", cand)
return true, true
}
return false, true
}
// playerInsOffsetForVersion mirrors SoulMemory's table (InitializeOffsets):
// up to 1.06 the PlayerIns offset inside WorldChrMan is 0x18468, from
// 1.07 onward it's 0x1E508.
//
// WATCH OUT: the version the exe reports is NOT what the game shows on
// screen (the exe can say 2.7.1.0 while the game says 1.17.1), and
// SoulMemory's table is written with the game's own numbers. So this is
// only a HUNCH for deciding which one to try first: what actually
// decides is playerInsCandidates plus the in-memory verification in
// isPlayerLoaded. On Linux, where productVersion always reports ok=false,
// this hunch is simply skipped — the in-memory verification still nails
// it down.
func playerInsOffsetForVersion(major, minor uint16, ok bool) uintptr {
if ok && major == 1 && minor <= 6 {
return 0x18468
}
return 0x1E508
}
// playerInsCandidates returns the known offsets to try, with the one the
// version suggests listed first.
func playerInsCandidates(major, minor uint16, ok bool) []uintptr {
if playerInsOffsetForVersion(major, minor, ok) == 0x18468 {
return []uintptr{0x18468, 0x1E508}
}
return []uintptr{0x1E508, 0x18468}
}
// -------------------------------- poller loop --------------------------------
// maxPlausibleDeltaPerTick: between two readings ~1s apart, with the
// character loaded the whole time, the real death counter can't go up by
// more than this (and never goes down). A bigger jump almost always
// means we grabbed memory that's no longer GameDataMan (a stale/invalid
// address), not an actual death.
const maxPlausibleDeltaPerTick = 3
func pollLoop() {
var (
handle procHandle
pid uint32
gp gamePointers
resolved bool
lastRaw int32
haveLastRaw bool
sawUnloaded bool
warnedNoWCM bool
lastBossRead bool
// Menu-detection watchdog: while we believe no character is
// loaded, we still peek at the death counter. If it climbs the
// way a real death does, our detection is lying (you were
// actually playing) and we turn it off.
unloadedRaw int32
unloadedRawFirst int32
haveUnloadedRaw bool
)
closeHandle := func() {
if handle != 0 {
closeProcessHandle(handle)
handle = 0
}
pid = 0
gp = gamePointers{}
resolved = false
haveLastRaw = false
sawUnloaded = false
haveUnloadedRaw = false
}
for {
if handle == 0 {
newPid, err := findProcessID(processName)
if err != nil {
st.setDisconnected("waiting for eldenring.exe")
time.Sleep(3 * time.Second)
continue
}
h, err := openProcess(newPid)
if err != nil {
st.setDisconnected("couldn't open the process (permissions?): " + err.Error())
time.Sleep(3 * time.Second)
continue
}
pid = newPid
handle = h
log.Printf("eldenring.exe found (PID %d), scanning signatures...", pid)
}
if !resolved {
p, err := resolvePointers(handle, pid)
if err != nil {
st.setDisconnected(err.Error())
time.Sleep(2 * time.Second)
// if the process died, release the handle to retry from scratch
if _, ferr := findProcessID(processName); ferr != nil {
closeHandle()
}
continue
}
gp = p
resolved = true
haveLastRaw = false
log.Printf("game version: %s | GameDataMan slot 0x%X", gp.versionLabel, gp.gameDataManSlot)
if gp.gameManSlot != 0 {
log.Printf("GameMan slot 0x%X (identifying characters by their save slot)", gp.gameManSlot)
} else {
log.Printf("warning: GameMan's pattern wasn't found; identifying characters by name")
}
if gp.worldChrManSlot != 0 {
log.Printf("WorldChrMan slot 0x%X | PlayerIns: trying +0x%X and confirming against memory", gp.worldChrManSlot, gp.playerInsOffset)
} else if !warnedNoWCM {
warnedNoWCM = true
log.Printf("warning: WorldChrMan's pattern wasn't found; still counting deaths but without menu/loading-screen detection")
}
}
// Same as LiveSplit's ASL, which does "if (!IsPlayerLoaded) return;":
// with no character in the world, nothing gets read. The total
// stays frozen on screen (no dash shown) so it doesn't flicker on
// every loading screen.
if loaded, known := isPlayerLoaded(handle, &gp); known && !loaded {
// Safety net. The PlayerIns offset depends on the game's
// version: if a patch ever moves it, we'd read null forever
// and the counter would freeze mid-stream.
//
// The only judge we can trust is the death counter itself:
// at the start menu it NEVER goes up. So the raw value gets
// peeked at without being used, and if it climbs the way a
// real death does (+1, +2, +3), you were actually playing
// and our detection was lying. Unlike a timeout, this can't
// fire just from leaving the game sitting at the menu a while.
if gdm, ok := derefPointer(handle, gp.gameDataManSlot); ok && gdm != 0 {
if buf, ok := readMemory(handle, gdm+0x94, 4); ok {
raw := int32(binary.LittleEndian.Uint32(buf))
if raw >= 0 && raw < 1_000_000 {
if haveUnloadedRaw {
if d := raw - unloadedRaw; d >= 1 && d <= maxPlausibleDeltaPerTick {
log.Printf("the death counter went from %d to %d while I thought no character was loaded: menu detection is wrong on this version, turning it off and continuing to count", unloadedRaw, raw)
gp.worldChrManSlot = 0
// Recover what happened during the confused
// stretch: keep the reference at that
// period's first reading so the "crossed a
// loading screen" logic can credit the
// deaths if there weren't many.
lastRaw = unloadedRawFirst
haveLastRaw = true
sawUnloaded = true
haveUnloadedRaw = false
continue
}
} else {
unloadedRawFirst = raw
}
unloadedRaw = raw
haveUnloadedRaw = true
}
}
}
// Careful: haveLastRaw/lastRaw are NOT touched, precisely so
// they can be compared against the last good reading once
// the world comes back.
st.setPlayerUnloaded("main menu or loading screen")
sawUnloaded = true
time.Sleep(1 * time.Second)
continue
}
haveUnloadedRaw = false
// Re-dereference the slot on EVERY tick (not just on connect) to
// never end up stuck with a stale GameDataMan address.
gameDataMan, ok := derefPointer(handle, gp.gameDataManSlot)
if !ok {
st.setDisconnected("lost the memory reading (the game closed or restarted)")
closeHandle()
time.Sleep(2 * time.Second)
continue
}
if gameDataMan == 0 {
st.setPlayerUnloaded("no game loaded")
sawUnloaded = true
time.Sleep(1 * time.Second)
continue
}
deathsBuf, ok1 := readMemory(handle, gameDataMan+0x94, 4)
bossBuf, ok2 := readMemory(handle, gameDataMan+0xC0, 1)
if !ok1 {
st.setDisconnected("lost the memory reading (the game closed or restarted)")
closeHandle()
time.Sleep(2 * time.Second)
continue
}
raw := int32(binary.LittleEndian.Uint32(deathsBuf))
boss := lastBossRead
if ok2 {
boss = bossBuf[0] != 0
lastBossRead = boss
}
if raw < 0 || raw > 1_000_000 {
log.Printf("discarding an impossible reading (raw %d) - rescanning signatures", raw)
resolved = false
haveLastRaw = false
time.Sleep(1 * time.Second)
continue
}
// Within the same save, the counter never goes down or jumps: if
// it does, it's memory that's no longer GameDataMan. Crossing a
// load, on the other hand, can change to anything, since it might
// be a different character — and setCharacter handles that case.
if haveLastRaw && !sawUnloaded {
delta := int64(raw) - int64(lastRaw)
if delta < 0 || delta > maxPlausibleDeltaPerTick {
log.Printf("discarding a suspicious reading (raw %d, previous %d) - rescanning signatures", raw, lastRaw)
resolved = false
haveLastRaw = false
time.Sleep(1 * time.Second)
continue
}
}
// Which character this is gets resolved BEFORE recording the
// reading: if you switched characters, the total jumps to theirs
// on this very pass, with no need to wait for a death.
name, _ := resolveCharName(handle, gameDataMan, &gp)
st.setCharacter(readSaveSlot(handle, gp), name, raw)
st.setReading(raw, boss)
lastRaw = raw
haveLastRaw = true
sawUnloaded = false
time.Sleep(1 * time.Second)
}
}
+183
View File
@@ -0,0 +1,183 @@
//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"
}
+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)
}