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:
1 parent
5a2c3272e9
commit
2ba312a833
7 files changed
+1126
-881
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user