Reads the game's process memory read-only (AOB signature scanning, re-resolved every tick — never a cached pointer) and serves a live death counter as a browser-source overlay plus a status panel. Supports co-op: each player runs the program locally, one acts as hub. The peer link is authenticated (HMAC challenge/response, no token on the wire, constant-time compare) but not yet encrypted — see CLAUDE.md's TODO section for the planned TLS+pinning split. Zero external dependencies — including a hand-rolled WebSocket implementation — so the whole thing ships as one .exe.
940 lines
30 KiB
Go
940 lines
30 KiB
Go
//go:build windows
|
|
|
|
// deathwatch: lee en solo-lectura el contador de muertes de Elden Ring
|
|
// directamente del proceso (mismo patron de bytes / offset que usa el
|
|
// script ASL "eldenring_boss_timer.asl" de LiveSplit, ya verificado a
|
|
// mano en esta PC). No escribe nada en la memoria del juego.
|
|
//
|
|
// Expone:
|
|
//
|
|
// GET / -> panel de estado (HTML)
|
|
// GET /?view=overlay -> version transparente para OBS Browser Source
|
|
// GET /deaths -> {"deaths":N,"players":[...],"character":"...","slot":N,...}
|
|
package main
|
|
|
|
import (
|
|
_ "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 es la estructura VS_FIXEDFILEINFO de Windows: la usamos
|
|
// para sacar la version del juego del propio eldenring.exe, igual que
|
|
// SoulMemory (que lee 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("no se pudo tomar snapshot de procesos")
|
|
}
|
|
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 fallo")
|
|
}
|
|
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("proceso no encontrado: %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("no se pudo tomar snapshot de modulos")
|
|
}
|
|
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 fallo")
|
|
}
|
|
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("modulo no encontrado: %s", name)
|
|
}
|
|
|
|
// productVersion lee la version del ejecutable del juego. label trae las
|
|
// dos versiones completas (producto y archivo) porque no siempre
|
|
// coinciden, y sirve para diagnosticar si algun dia hay que ajustar el
|
|
// offset de PlayerIns.
|
|
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("producto %s / archivo %s",
|
|
quad(info.ProductVersionMS, info.ProductVersionLS),
|
|
quad(info.FileVersionMS, info.FileVersionLS))
|
|
return uint16(info.ProductVersionMS >> 16), uint16(info.ProductVersionMS & 0xFFFF), label, true
|
|
}
|
|
|
|
// playerInsOffsetForVersion replica la tabla de SoulMemory
|
|
// (InitializeOffsets): hasta 1.06 el offset de PlayerIns dentro de
|
|
// WorldChrMan es 0x18468, de 1.07 en adelante 0x1E508.
|
|
//
|
|
// OJO: la version que reporta el exe NO es la que muestra el juego en
|
|
// pantalla (el exe puede decir 2.7.1.0 mientras el juego dice 1.17.1), y
|
|
// la tabla de SoulMemory esta escrita con los numeros del juego. Por eso
|
|
// esto es solo una CORAZONADA para decidir cual probar primero: quien
|
|
// decide de verdad es playerInsCandidates + la verificacion en memoria.
|
|
func playerInsOffsetForVersion(major, minor uint16, ok bool) uintptr {
|
|
if ok && major == 1 && minor <= 6 {
|
|
return 0x18468
|
|
}
|
|
return 0x1E508
|
|
}
|
|
|
|
// playerInsCandidates devuelve los offsets conocidos a probar, con el que
|
|
// sugiere la version primero.
|
|
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 -------------------------
|
|
//
|
|
// Dos firmas, las dos con la misma forma: instruccion de 7 bytes
|
|
// "mov reg,[rip+disp32]", donde los 3 primeros bytes son el opcode y los
|
|
// 4 siguientes el desplazamiento. El slot estatico resuelto contiene el
|
|
// puntero al objeto (una dereferencia mas).
|
|
//
|
|
// GameDataMan -> tiene el contador de muertes (+0x94) y el flag de jefe (+0xC0).
|
|
// Mismo patron que usa eldenring_boss_timer.asl (LiveSplit).
|
|
// WorldChrMan -> tiene el puntero a PlayerIns (+playerInsOffset). Si ese
|
|
// puntero es nulo, no hay personaje en el mundo: estas en el
|
|
// menu principal o en una pantalla de carga. Es exactamente
|
|
// lo que hace SoulMemory.IsPlayerLoaded().
|
|
|
|
type patByte struct {
|
|
val byte
|
|
wildcard bool
|
|
}
|
|
|
|
// parsePattern acepta "48 8B 05 ?? ?? ?? ??" (?? = comodin).
|
|
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("patron invalido: " + 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, igual que 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: en +0xAC0 tiene el slot de guardado del personaje cargado)
|
|
gameManPattern = parsePattern("48 8B 05 ?? ?? ?? ?? 80 B8 ?? ?? ?? ?? 0D 0F 94 C0 C3")
|
|
)
|
|
|
|
// saveSlotOffset: donde GameMan guarda el indice de slot (0-9) de la
|
|
// partida cargada. Es la identidad REAL de un personaje: no depende del
|
|
// nombre, asi que dos personajes que se llamen igual no se mezclan.
|
|
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 busca varios patrones en una sola pasada por el modulo,
|
|
// leyendolo en chunks (con solape, por si un patron cae justo en el borde
|
|
// de un chunk). Devuelve, por cada patron, la direccion del match o 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 convierte la direccion de una instruccion "mov reg,[rip+disp32]"
|
|
// de 7 bytes en la direccion del slot estatico al que apunta.
|
|
func ripSlot(h syscall.Handle, matchAddr uintptr) (uintptr, error) {
|
|
if matchAddr == 0 {
|
|
return 0, fmt.Errorf("patron no encontrado")
|
|
}
|
|
codeLocation := matchAddr + 3 // los 3 primeros bytes son el opcode
|
|
dispBytes, ok := readMemory(h, codeLocation, 4)
|
|
if !ok {
|
|
return 0, fmt.Errorf("no se pudo leer el desplazamiento RIP-relativo")
|
|
}
|
|
disp := int32(binary.LittleEndian.Uint32(dispBytes))
|
|
return codeLocation + 4 + uintptr(int64(disp)), nil
|
|
}
|
|
|
|
// gamePointers junta todo lo que se resuelve una sola vez por sesion de
|
|
// proceso: los slots estaticos (que no se mueven) y la version del juego.
|
|
type gamePointers struct {
|
|
gameDataManSlot uintptr
|
|
worldChrManSlot uintptr // 0 si no se encontro el patron (seguimos sin el chequeo de menu)
|
|
gameManSlot uintptr // 0 si no se encontro: caemos a identificar por nombre
|
|
playerInsOffset uintptr // el que estamos usando (o el candidato preferido)
|
|
playerInsTried []uintptr
|
|
offsetConfirmed bool // true cuando lo verificamos leyendo memoria de verdad
|
|
versionLabel string
|
|
|
|
nameChain nameCandidate // como llegamos al nombre del personaje
|
|
nameConfirmed bool
|
|
namePending string // candidato a la espera de repetirse (ver resolveCharName)
|
|
namePendingOf nameCandidate
|
|
}
|
|
|
|
// resolvePointers hace los escaneos de firma (caro: recorre todo el
|
|
// modulo) una sola vez por sesion de proceso. A proposito NO devuelve los
|
|
// objetos ya resueltos: esos punteros se re-leen en cada tick, porque el
|
|
// juego puede destruir y recrear GameDataMan (por ejemplo al volver al
|
|
// menu principal y cargar de nuevo). Si nos quedaramos con una direccion
|
|
// vieja en cache, seguiriamos leyendola con exito (la pagina de memoria
|
|
// sigue siendo valida) pero el contenido pasaria a ser datos de otra cosa
|
|
// - la causa mas probable de un contador que "sube solo" sin que hayas
|
|
// muerto en verdad. SoulMemory hace lo mismo: su clase Pointer resuelve la
|
|
// cadena entera en cada lectura, no cachea la direccion final.
|
|
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 = "desconocida"
|
|
}
|
|
|
|
matches := scanModule(h, base, size, [][]patByte{gameDataManPattern, worldChrManPattern, gameManPattern})
|
|
|
|
gp.gameDataManSlot, err = ripSlot(h, matches[0])
|
|
if err != nil {
|
|
return gp, fmt.Errorf("no se encontro el patron de GameDataMan (¿cambio de version del juego?)")
|
|
}
|
|
|
|
// WorldChrMan es opcional: si no aparece, seguimos contando muertes,
|
|
// solo perdemos la deteccion de menu/pantalla de carga.
|
|
if slot, werr := ripSlot(h, matches[1]); werr == nil {
|
|
gp.worldChrManSlot = slot
|
|
}
|
|
// GameMan tambien es opcional: sin el, identificamos por nombre.
|
|
if slot, gerr := ripSlot(h, matches[2]); gerr == nil {
|
|
gp.gameManSlot = slot
|
|
}
|
|
return gp, nil
|
|
}
|
|
|
|
// derefPointer lee un slot estatico (barato: 8 bytes) y devuelve la
|
|
// direccion ACTUAL del objeto. Se llama en cada tick, no solo una vez.
|
|
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
|
|
}
|
|
|
|
// ------------------------- nombre del personaje -------------------------
|
|
//
|
|
// Dato sacado de la tabla de Cheat Engine: "GameDataMan +0C +9C, unicode,
|
|
// largo 19". Esa notacion admite mas de una lectura (¿0x0C es un puntero
|
|
// que hay que dereferenciar, o los dos offsets se suman?), y ademas la
|
|
// comunidad/el ASL usan GameDataMan+0x08 para llegar a PlayerGameData.
|
|
// Asi que no elegimos: probamos las tres y nos quedamos con la que
|
|
// devuelva algo que parezca un nombre de verdad.
|
|
|
|
type nameCandidate struct {
|
|
ptrOffset uintptr // offset donde vive el puntero (0 = sin dereferencia)
|
|
nameOffset uintptr // offset del texto dentro del objeto
|
|
label string
|
|
}
|
|
|
|
var nameCandidates = []nameCandidate{
|
|
{0x08, 0x9C, "[GameDataMan+0x08]+0x9C (PlayerGameData)"},
|
|
{0x0C, 0x9C, "[GameDataMan+0x0C]+0x9C"},
|
|
{0x00, 0xA8, "GameDataMan+0xA8 (0x0C y 0x9C sumados)"},
|
|
}
|
|
|
|
// looksLikeName (y charNameMaxChars) viven en names.go: no dependen de
|
|
// Windows, asi que quedan afuera de este archivo para poder testearlos
|
|
// sin una PC con el juego abierto.
|
|
|
|
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 devuelve el nombre del personaje. Antes de fijar una
|
|
// variante exige verla dar el MISMO texto dos lecturas seguidas: el
|
|
// nombre real no cambia de un segundo a otro, pero un pedazo de memoria
|
|
// que casualmente pasa el filtro es mucho menos probable que se repita.
|
|
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("nombre del personaje: \"%s\" (leido con %s)", s, c.label)
|
|
return s, true
|
|
}
|
|
gp.namePending = s
|
|
gp.namePendingOf = c
|
|
return "", false
|
|
}
|
|
gp.namePending = ""
|
|
return "", false
|
|
}
|
|
|
|
// readSaveSlot devuelve el indice de slot (0-9) de la partida cargada, o
|
|
// -1 si no lo pudimos leer. Elden Ring tiene 10 slots, asi que cualquier
|
|
// otro valor es basura y se descarta.
|
|
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 replica SoulMemory.IsPlayerLoaded(): resuelve
|
|
// WorldChrMan y lee el puntero a PlayerIns; si es nulo, no hay personaje
|
|
// en el mundo. El segundo valor indica si pudimos evaluarlo.
|
|
//
|
|
// Mientras no tengamos confirmado el offset, en vez de confiar en el
|
|
// numero de version (que en Elden Ring no coincide con el que muestra el
|
|
// juego) probamos los offsets conocidos y nos quedamos con el primero que
|
|
// apunte a memoria realmente legible. Eso lo decide la maquina, no una
|
|
// tabla que puede envejecer mal.
|
|
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
|
|
}
|
|
// Un puntero de verdad apunta a memoria mapeada; uno de basura
|
|
// casi nunca sobrevive esta lectura.
|
|
if _, ok := readMemory(h, playerIns, 8); !ok {
|
|
continue
|
|
}
|
|
gp.playerInsOffset = cand
|
|
gp.offsetConfirmed = true
|
|
log.Printf("PlayerIns confirmado en +0x%X (verificado leyendo el objeto, no por el numero de version)", cand)
|
|
return true, true
|
|
}
|
|
return false, true
|
|
}
|
|
|
|
// -------------------------------- poller loop --------------------------------
|
|
|
|
// maxPlausibleDeltaPerTick: entre dos lecturas separadas por ~1s, con el
|
|
// personaje cargado todo el tiempo, el contador de muertes real no puede
|
|
// subir mas que esto (ni bajar nunca). Un salto mas grande casi siempre
|
|
// significa que agarramos memoria que ya no es GameDataMan (direccion
|
|
// vieja/invalida) y no una muerte real.
|
|
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
|
|
|
|
// Vigilancia de la deteccion de menu: mientras creemos que no hay
|
|
// personaje cargado, igual espiamos el contador de muertes. Si sube
|
|
// como sube una muerte de verdad, entonces nuestra deteccion esta
|
|
// mintiendo (estabas jugando) y la desactivamos.
|
|
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("esperando a eldenring.exe")
|
|
time.Sleep(3 * time.Second)
|
|
continue
|
|
}
|
|
h, err := openProcessHandle(newPid)
|
|
if err != nil {
|
|
st.setDisconnected("no se pudo abrir el proceso (¿permisos?): " + err.Error())
|
|
time.Sleep(3 * time.Second)
|
|
continue
|
|
}
|
|
pid = newPid
|
|
handle = h
|
|
log.Printf("eldenring.exe encontrado (PID %d), escaneando firmas...", pid)
|
|
}
|
|
|
|
if !resolved {
|
|
p, err := resolvePointers(handle, pid)
|
|
if err != nil {
|
|
st.setDisconnected(err.Error())
|
|
time.Sleep(2 * time.Second)
|
|
// si el proceso murio, soltamos el handle para reintentar desde cero
|
|
if _, ferr := findProcessID(processName); ferr != nil {
|
|
closeHandle()
|
|
}
|
|
continue
|
|
}
|
|
gp = p
|
|
resolved = true
|
|
haveLastRaw = false
|
|
log.Printf("version del juego: %s | GameDataMan slot 0x%X", gp.versionLabel, gp.gameDataManSlot)
|
|
if gp.gameManSlot != 0 {
|
|
log.Printf("GameMan slot 0x%X (identifico personajes por su slot de guardado)", gp.gameManSlot)
|
|
} else {
|
|
log.Printf("aviso: no encontre el patron de GameMan; identifico personajes por nombre")
|
|
}
|
|
if gp.worldChrManSlot != 0 {
|
|
log.Printf("WorldChrMan slot 0x%X | PlayerIns: pruebo +0x%X y confirmo contra la memoria", gp.worldChrManSlot, gp.playerInsOffset)
|
|
} else if !warnedNoWCM {
|
|
warnedNoWCM = true
|
|
log.Printf("aviso: no se encontro el patron de WorldChrMan; sigo contando muertes pero sin detectar menu/pantalla de carga")
|
|
}
|
|
}
|
|
|
|
// Igual que el ASL de LiveSplit, que hace "if (!IsPlayerLoaded) return;":
|
|
// sin personaje en el mundo no leemos nada. El total queda congelado en
|
|
// pantalla (no mostramos guion) para no parpadear en cada carga.
|
|
if loaded, known := isPlayerLoaded(handle, &gp); known && !loaded {
|
|
// Red de seguridad. El offset de PlayerIns depende de la version
|
|
// del juego: si algun parche lo mueve, leeriamos nulo para
|
|
// siempre y el contador quedaria congelado en pleno stream.
|
|
//
|
|
// El unico juez confiable es el contador de muertes en si: en el
|
|
// menu de inicio NO sube nunca. Asi que espiamos el crudo sin
|
|
// usarlo, y si sube como sube una muerte real (+1, +2, +3),
|
|
// entonces estabas jugando y nuestra deteccion estaba mintiendo.
|
|
// A diferencia de un timeout, esto no puede dispararse por dejar
|
|
// el juego parado en el menu un rato largo.
|
|
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("el contador de muertes subio de %d a %d mientras yo creia que no habia personaje cargado: la deteccion de menu esta equivocada en esta version, la desactivo y sigo contando", unloadedRaw, raw)
|
|
gp.worldChrManSlot = 0
|
|
// Rescatamos lo ocurrido durante el rato confundido:
|
|
// dejamos la referencia en la primera lectura de ese
|
|
// periodo para que la logica de "cruce de carga"
|
|
// acredite las muertes si fueron pocas.
|
|
lastRaw = unloadedRawFirst
|
|
haveLastRaw = true
|
|
sawUnloaded = true
|
|
haveUnloadedRaw = false
|
|
continue
|
|
}
|
|
} else {
|
|
unloadedRawFirst = raw
|
|
}
|
|
unloadedRaw = raw
|
|
haveUnloadedRaw = true
|
|
}
|
|
}
|
|
}
|
|
// Ojo: NO tocamos haveLastRaw/lastRaw, justamente para poder
|
|
// comparar contra la ultima lectura buena cuando vuelva el mundo.
|
|
st.setPlayerUnloaded("menu principal o pantalla de carga")
|
|
sawUnloaded = true
|
|
time.Sleep(1 * time.Second)
|
|
continue
|
|
}
|
|
haveUnloadedRaw = false
|
|
|
|
// Re-dereferenciamos el slot en CADA tick (no solo al conectar) para
|
|
// nunca quedarnos con una direccion vieja de GameDataMan.
|
|
gameDataMan, ok := derefPointer(handle, gp.gameDataManSlot)
|
|
if !ok {
|
|
st.setDisconnected("se perdio la lectura de memoria (el juego se cerro o reinicio)")
|
|
closeHandle()
|
|
time.Sleep(2 * time.Second)
|
|
continue
|
|
}
|
|
if gameDataMan == 0 {
|
|
st.setPlayerUnloaded("sin partida cargada")
|
|
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("se perdio la lectura de memoria (el juego se cerro o reinicio)")
|
|
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("lectura imposible descartada (raw %d) - re-escaneando firmas", raw)
|
|
resolved = false
|
|
haveLastRaw = false
|
|
time.Sleep(1 * time.Second)
|
|
continue
|
|
}
|
|
|
|
// Dentro de una misma partida el contador no baja ni pega saltos:
|
|
// si pasa, es memoria que ya no es GameDataMan. Cruzando una carga
|
|
// en cambio puede cambiar a cualquier cosa, porque puede ser otro
|
|
// personaje, y de eso se encarga setCharacter.
|
|
if haveLastRaw && !sawUnloaded {
|
|
delta := int64(raw) - int64(lastRaw)
|
|
if delta < 0 || delta > maxPlausibleDeltaPerTick {
|
|
log.Printf("lectura sospechosa descartada (raw %d, anterior %d) - re-escaneando firmas", raw, lastRaw)
|
|
resolved = false
|
|
haveLastRaw = false
|
|
time.Sleep(1 * time.Second)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Quien es este personaje se resuelve ANTES de registrar la
|
|
// lectura: si cambiaste de personaje, el total salta al suyo en
|
|
// esta misma vuelta y no hay que esperar a que alguien muera.
|
|
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 ----------------------------------
|
|
|
|
// puertoDe saca el puerto de una direccion tipo "0.0.0.0:47822", para
|
|
// poder decirle al compañero exactamente que escribir en su config.
|
|
func puertoDe(addr string) string {
|
|
if _, port, ok := strings.Cut(addr, ":"); ok {
|
|
return port
|
|
}
|
|
return addr
|
|
}
|
|
|
|
func withCORS(w http.ResponseWriter) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
}
|
|
|
|
func main() {
|
|
log.SetFlags(log.Ltime)
|
|
log.Println("=== Elden Ring Death Counter (lectura local, solo lectura) ===")
|
|
|
|
loadLocales()
|
|
cfg := loadConfig()
|
|
writeSampleConfig()
|
|
esPeer := cfg.Mode == "peer"
|
|
|
|
lang := resolveLang(cfg.Language)
|
|
log.Printf("version: %s | PID %d | modo %s | idioma %s (disponibles: %s)",
|
|
buildTag, os.Getpid(), cfg.Mode, lang, strings.Join(availableLangs(), ", "))
|
|
|
|
totals = newTotalsStore()
|
|
st.init(totals)
|
|
|
|
go pollLoop()
|
|
|
|
registro := newPeerRegistry()
|
|
|
|
// El token es obligatorio en las dos puntas: sin el, cualquiera que
|
|
// alcance el puerto podria inyectar datos en el overlay.
|
|
var token string
|
|
if !esPeer {
|
|
t, generado, err := resolveToken(cfg)
|
|
if err != nil {
|
|
log.Fatalf("no pude preparar el token: %v", err)
|
|
}
|
|
token = t
|
|
logTokenBanner(token, generado)
|
|
}
|
|
|
|
if esPeer {
|
|
if cfg.Hub == "" {
|
|
log.Println("¡ojo! modo peer sin 'hub' en el config.toml: no tengo a donde mandar el contador")
|
|
} else if strings.TrimSpace(cfg.Token) == "" {
|
|
log.Println("¡ojo! modo peer sin 'token' en el config.toml: el hub te va a rechazar. Pedíle el token a quien lo corre.")
|
|
} else {
|
|
go peerLoop(cfg)
|
|
}
|
|
} else {
|
|
registro.declare(cfg.Partner)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
if !esPeer {
|
|
mux.HandleFunc("/ws", registro.wsHandler(token))
|
|
}
|
|
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("X-Build", buildTag)
|
|
w.Write(overlayHTML)
|
|
})
|
|
|
|
// Los textos de la interfaz: la pagina los pide una vez al cargar.
|
|
// ?lang= permite forzar un idioma sin tocar el config, comodo para
|
|
// tener el overlay en un idioma y el panel en otro.
|
|
mux.HandleFunc("/strings.json", func(w http.ResponseWriter, r *http.Request) {
|
|
withCORS(w)
|
|
want := lang
|
|
if q := r.URL.Query().Get("lang"); q != "" {
|
|
want = resolveLang(q)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
json.NewEncoder(w).Encode(stringsFor(want))
|
|
})
|
|
|
|
mux.HandleFunc("/deaths", func(w http.ResponseWriter, r *http.Request) {
|
|
withCORS(w)
|
|
snap := st.snapshot()
|
|
|
|
// Este jugador primero, despues los compañeros en orden de aparicion.
|
|
players := []playerView{{
|
|
Name: nombreMostrado(cfg),
|
|
Deaths: snap.Total,
|
|
BossFight: snap.BossFight,
|
|
PlayerLoaded: snap.PlayerLoaded,
|
|
Connected: snap.Connected,
|
|
Self: true,
|
|
}}
|
|
// Modo coop solo cuando hay (o hubo recien) un compañero conectado.
|
|
if registro.coopMode() {
|
|
players = append(players, registro.views()...)
|
|
}
|
|
|
|
var combined int64
|
|
for _, p := range players {
|
|
combined += p.Deaths
|
|
}
|
|
|
|
resp := map[string]interface{}{
|
|
"players": players,
|
|
"combined": combined,
|
|
"build": buildTag,
|
|
// Campos de la version de un solo jugador: los dejamos para no
|
|
// romper nada que ya este apuntando aca.
|
|
"deaths": snap.Total,
|
|
"rawDeaths": snap.RawDeaths,
|
|
"character": snap.CharName,
|
|
"slot": snap.Slot,
|
|
"bossFight": snap.BossFight,
|
|
"connected": snap.Connected,
|
|
"playerLoaded": snap.PlayerLoaded,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
})
|
|
|
|
log.Printf("Panel: http://%s/", cfg.Listen)
|
|
if esPeer {
|
|
log.Printf("Mandando el contador al hub %s. Esta ventana tiene que quedar abierta mientras jugás.", cfg.Hub)
|
|
} else {
|
|
log.Printf("OBS URL: http://%s/?view=overlay", cfg.Listen)
|
|
log.Printf("Tu compañero tiene que poner en su config.toml: hub = \"<tu IP>:%s\" y el token de arriba", puertoDe(cfg.Listen))
|
|
log.Println("Dejá esta ventana abierta mientras streameás. Ctrl+C para cerrar.")
|
|
}
|
|
|
|
if err := http.ListenAndServe(cfg.Listen, mux); err != nil {
|
|
log.Fatalf("no se pudo iniciar el servidor local: %v", err)
|
|
}
|
|
}
|