diff --git a/.gitignore b/.gitignore index 46b1d3a..8547ef5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # binarios y estado local, no van al repo deathwatch.exe +deathwatch-linux *.exe totals.json client-id.txt diff --git a/CLAUDE.md b/CLAUDE.md index da034f8..5c23955 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,19 +2,26 @@ 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 -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 ```bash -GOOS=windows GOARCH=amd64 go build -o deathwatch.exe . # el binario (hoy, unico soportado) -go test ./... # tests (corren en cualquier SO) -gofmt -l *.go # formato -GOOS=windows GOARCH=amd64 go vet . # vet: SIEMPRE con GOOS=windows +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) +gofmt -l *.go # formato +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 -error del código: es que `main.go` e `i18n.go` solo compilan para Windows. +Dos plataformas reales ahora: correr `go vet` (y `go build`) con los dos +`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 @@ -35,8 +42,11 @@ idiomas del overlay, ni al revés. | Archivo | Qué hace | Plataforma | |---|---|---| -| `main.go` | Lectura de memoria, escaneo de firmas, loop de polling, HTTP | solo Windows | -| `i18n.go` | Carga de `locales/*.json`, idioma del sistema | solo Windows | +| `main.go` | HTTP, startup, arma todo | portable | +| `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//{maps,mem}` | solo Linux | | `counter.go` | **Contabilidad**: a qué personaje va cada muerte | portable | | `names.go` | `looksLikeName`: filtra basura binaria leída como nombre | 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. `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//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//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 `. **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 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 -- No hay `README.md` todavía. Escribir uno en inglés (instalación, modo - hub/peer, capturas) es lo único que falta del pendiente de idioma — el - código ya está en inglés de punta a punta, ver "Idioma del código" - arriba. +- No hay `README.md` todavía. Escribir uno en inglés (instalación en + Windows y Linux/Proton — incluyendo el paso de `setcap`, modo hub/peer, + capturas) es lo único que falta del pendiente de idioma — el código ya + 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 personajes homónimos. Documentado, no resuelto. -- **Compilar y correr en Linux (Proton).** Mucha gente juega Elden Ring - con Proton, y hoy el programa sólo existe para Windows. El juego sigue - siendo el mismo binario de Windows corriendo bajo Wine, así que **las - firmas AOB y todos los offsets valen igual**: lo único que cambia es - cómo se encuentra el proceso y cómo se lee su memoria. - - **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//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/). - +- Testear el escáner/poller (`process.go`) con un lector de memoria + falso. El split multiplataforma (ver "Multiplataforma" arriba) ya lo + habilita — falta escribir los tests en sí, con una variable de paquete + intercambiable por función de frontera (mismo patrón que `testExeDir` + en `totals.go`), no una interfaz nueva. - El README debería mencionar que meter todo adentro de una VPN sigue siendo una opción perfectamente válida, TLS+pinning aparte. diff --git a/i18n.go b/i18n.go index f47a982..c552ad8 100644 --- a/i18n.go +++ b/i18n.go @@ -1,5 +1,3 @@ -//go:build windows - // i18n.go: interface languages. // // 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 // 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. +// +// 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 import ( @@ -19,8 +22,6 @@ import ( "path" "sort" "strings" - "syscall" - "unsafe" ) //go:embed locales/*.json @@ -64,24 +65,8 @@ func availableLangs() []string { return out } -var procGetUserDefaultLocaleName = kernel32.NewProc("GetUserDefaultLocaleName") - -// 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. +// resolveLang decides the final language. "auto" (or empty) uses the +// system's; if that language isn't translated, it falls back to English. func resolveLang(want string) string { want = strings.ToLower(strings.TrimSpace(want)) if want == "" || want == "auto" { diff --git a/main.go b/main.go index beffff4..33df5d5 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,10 @@ -//go:build windows - // deathwatch: reads Elden Ring's death counter read-only, straight from // the process (the same byte pattern / offset used by LiveSplit's -// "eldenring_boss_timer.asl" ASL script, verified by hand on this PC). -// Writes nothing to the game's memory. +// "eldenring_boss_timer.asl" ASL script, verified by hand). Writes +// 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: // @@ -15,798 +16,16 @@ package main import ( "crypto/tls" _ "embed" - "encoding/binary" "encoding/json" - "fmt" "log" "net/http" "os" - "strconv" "strings" - "syscall" - "time" - "unicode/utf16" - "unsafe" ) //go:embed overlay.html 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 // partner can be told exactly what to put in their config. func portOf(addr string) string { diff --git a/process.go b/process.go new file mode 100644 index 0000000..d8a7363 --- /dev/null +++ b/process.go @@ -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) + } +} diff --git a/process_linux.go b/process_linux.go new file mode 100644 index 0000000..14ecd1f --- /dev/null +++ b/process_linux.go @@ -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//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//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//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" +} diff --git a/process_windows.go b/process_windows.go new file mode 100644 index 0000000..2121702 --- /dev/null +++ b/process_windows.go @@ -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) +}