//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 = \":%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) } }