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.
14 KiB
DeathWatch — contador de muertes de Elden Ring
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.
Comandos
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
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.
Idioma del código
El repo va a publicarse en GitHub. Identificadores, comentarios y
mensajes de log/consola van en inglés — código nuevo se escribe
directamente en inglés, sin excepción. names.go y names_test.go son
el primer archivo escrito bajo esta regla; úsenlo de referencia.
El resto del código (main.go, i18n.go, counter.go, totals.go,
config.go, duo.go, ws.go, auth.go, comentarios de overlay.html)
todavía está en español — ver el pendiente de traducción más abajo. Esto
no afecta a los textos que ve el usuario final en el overlay/panel
(locales/*.json, overlay.html): esos siguen soportando español e
inglés vía el sistema de i18n existente, elegido por idioma del sistema.
Estructura
| 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 |
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 |
config.go |
Parser TOML propio + config.toml |
portable |
duo.go |
Modo co-op: registro de peers, hub y peer | portable |
ws.go |
WebSocket hecho a mano (RFC 6455, subconjunto) | portable |
overlay.html |
Overlay e interfaz, embebido con go:embed |
— |
counter.go está separado de main.go a propósito: es donde vivieron
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 sigue el mismo principio para looksLikeName.
Offsets de memoria
Todo se resuelve escaneando firmas AOB en el módulo del juego. Los tres punteros se re-dereferencian en cada tick, nunca se cachea la dirección final (ver "Reglas" abajo).
| Qué | Firma | Uso |
|---|---|---|
GameDataMan |
48 8B 05 ?? ?? ?? ?? 48 85 C0 74 05 48 8B 40 58 C3 C3 |
+0x94 muertes (int32), +0xC0 flag de jefe (byte) |
WorldChrMan |
48 8B 35 ?? ?? ?? ?? 48 85 F6 ?? ?? BB 01 00 00 00 89 5C 24 20 48 8B B6 |
+0x1E508 → PlayerIns; si es nulo, no hay personaje en el mundo |
GameMan |
48 8B 05 ?? ?? ?? ?? 80 B8 ?? ?? ?? ?? 0D 0F 94 C0 C3 |
+0xAC0 (byte) = slot de guardado 0-9 |
Nombre del personaje: [GameDataMan+0x08]+0x9C, UTF-16, máx 19 caracteres.
Se prueban tres variantes y gana la que devuelva algo que parezca un nombre
dos lecturas seguidas. "Parecer un nombre" (looksLikeName en names.go)
exige coherencia de escritura: todo el texto tiene que venir de una
sola familia (Latina, Cirílica, Hangul, o Han+Hiragana+Katakana como una
sola familia porque el japonés las mezcla) más dígitos/espacio/puntuación.
Mezclar familias — como griego + han, que es lo que sale de decodificar un
puntero crudo como si fuera UTF-16 — se rechaza.
Procedencia: GameDataMan y WorldChrMan salen de
SoulMemory (el que usa el
auto-splitter de LiveSplit). GameMan y el nombre salen de una tabla de
Cheat Engine.
Seguridad del enlace entre hub y peer
El transporte no se asume confiable: puede ser Tailscale, ZeroTier,
WireGuard, o un puerto abierto directo a internet. La conexión se defiende
sola (auth.go).
- El token es obligatorio. Lo genera el programa (
token.txt, 128 bits);tokenenconfig.tomllo pisa si alguien quiere elegirlo. - El token nunca viaja. El hub manda un nonce al azar y el peer
responde
HMAC-SHA256(token, nonce). Quien escuche el tráfico no se lleva el token, y una respuesta capturada no sirve en otra conexión porque el nonce cambia. - Comparación en tiempo constante (
subtle.ConstantTimeCompare). - Sin autenticar no se acepta ningún mensaje; hay plazo de 10 s para autenticarse y techo de 8 conexiones simultáneas.
- El
idque vale es el de la conexión autenticada, no el que declare cada mensaje.
Lo que esto NO hace: cifrar. Los mensajes (nombre y número de muertes) viajan en claro, y un atacante activo en el medio podría alterarlos. El token garantiza que nadie inyecte datos falsos, no que nadie los lea. Está pendiente cifrarlo (ver Pendientes).
Los tests de esto están en auth_test.go y cubren: token correcto, token
equivocado, mensajes sin autenticar, que el token no aparezca en el
tráfico, y que una respuesta vieja no se pueda repetir.
Reglas que salieron de bugs reales
No son preferencias de estilo. Cada una costó un bug en producción.
-
Re-dereferenciar los punteros en cada tick. Cachear la dirección de
GameDataManhacía que, tras volver al menú, se siguiera leyendo con éxito una dirección que ya era otra cosa: el contador subía solo. La clasePointerde SoulMemory también resuelve la cadena en cada lectura. -
El nombre NO es identidad. Cambia al cargar otro personaje. La identidad de un personaje es el slot (
characterKey); la de una instalación es el client-id (client-id.txt). Indexar por nombre produjo jugadores fantasma dos veces, una en Go y otra en el DOM. -
Las columnas del overlay se indexan por posición, nunca por nombre. Mismo bug que el anterior, del lado del navegador.
-
No recalibrar en pantallas de carga. Hay una pantalla de carga en cada muerte; tratarlas como cambio de partida se come muertes.
-
Descartar lecturas imposibles. Dentro de una misma partida el contador no baja ni sube de a más de 3 por segundo. Si pasa, es memoria inválida: descartar y re-escanear las firmas.
-
Nada de timeouts como heurística. Un watchdog basado en "pasaron N minutos" dio falso positivo con el juego parado en el menú. Si hace falta detectar que algo anda mal, usar evidencia (¿subió el contador?), no el reloj.
-
Los logs de consola no se traducen. Son diagnóstico; conviene que un log pegado en un issue se lea igual venga de donde venga.
Detalles que sorprenden
- La versión del exe no es la del juego. El exe dice
2.7.1.0mientras el juego muestra1.17.1. Por eso el offset dePlayerInsno se elige por número de versión: se prueban los candidatos conocidos y se verifica leyendo memoria. - El parser TOML es propio y acotado (
config.go): sin arrays, tablas inline, strings multilínea ni claves con puntos. Se hizo así porque el entorno donde se compiló no tenía acceso aproxy.golang.org. Si se puede usarBurntSushi/toml, se cambia sóloloadConfig. - Cero dependencias externas, incluido el WebSocket. Es deliberado: el
programa se reparte como un único
.exe.
Pendientes
-
Pasada completa a inglés: identificadores, comentarios, mensajes de log/consola y el README (hoy todo está en español salvo
names.goynames_test.go, ver "Idioma del código" arriba). Conviene que sea un commit aparte del resto, dado el volumen del diff. -
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.gomezcla lo específico de Windows con lo que no lo es. Separar enprocess_windows.goyprocess_linux.godetrá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:
-
Encontrar el proceso por sus mapeos, no por el nombre. Proton levanta varios procesos. Lo robusto es recorrer
/proc/*/mapsy quedarse con el pid que tenga mapeado un archivo terminado eneldenring.exe. De paso, esos mismos mapeos dan la base y el tamaño del módulo, que es lo quefindModuleBasenecesita. 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. -
Leer con
process_vm_readv(2), que no necesita adjuntarse al proceso./proc/<pid>/memsirve de alternativa. -
El obstáculo real es
ptrace_scope. En casi todas las distros vale1, y con esoprocess_vm_readvsobre un proceso ajeno falla conEPERM. La salida recomendada es darle la capacidad al binario:sudo setcap cap_sys_ptrace+ep ./deathwatchNo 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. -
productVersionno tiene equivalente (usaversion.dllsobre el exe). En Linux devolverok=falsey listo: la versión es sólo una corazonada para decidir qué offset dePlayerInsprobar primero, y el valor bueno se confirma leyendo memoria igual. Una decisión vieja que acá se paga sola. -
systemLangsale de$LC_ALL/$LANGen vez deGetUserDefaultLocaleName.
No hay que tocar: los offsets, las firmas,
counter.go,names.go,totals.go,duo.go,ws.go,config.goni 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
setcapni 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, cheat-engine-linux, un trainer para D2R en Linux paso a paso.
-
-
Cifrar el enlace con TLS (certificado propio + pinning). Hoy la conexión está autenticada pero no cifrada.
La forma seria es TLS 1.3 de la biblioteca estándar, no un protocolo hecho a mano. El obstáculo aparente es que no hay dominio ni autoridad certificadora posible: nadie emite un certificado para
100.x.y.z. Pero eso deja de importar al ver que el problema de confianza ya está resuelto fuera de banda: los dos jugadores ya se pasan un secreto a mano. Ese mismo canal puede llevar la huella del certificado.- El hub genera un certificado autofirmado en el primer arranque
(
crypto/x509+ed25519) y lo guarda junto al token. - El peer se conecta con
tls.ClientusandoInsecureSkipVerify: truey unVerifyPeerCertificateque exige que la huella SHA-256 sea exactamente la esperada. El nombre del flag asusta y en una revisión parece un error: no lo es. Apaga la validación por CA y nombre de dominio, que acá no aplican, y la reemplaza por pinning, que para este caso es más estricto que la validación normal. - Un solo código de invitación que el hub imprime y el peer pega:
base64 de
{host, puerto, huella, token}. Como ya tenían que copiar algo, esto no agrega ni un paso, y a cambio la conexión pasa a estar cifrada de verdad. Para quien lo usa, el tema desaparece.
Con eso se obtiene TLS 1.3 real: forward secrecy, AEAD y un handshake revisado por medio mundo, sin dependencias externas y sin que nadie tenga que entender nada.
Dos puertos, no uno. No se puede servir HTTP y HTTPS en el mismo puerto, así que la separación no es un detalle de implementación sino parte del diseño:
Puerto Sirve Protocolo Audiencia 47822 overlay, panel, /deaths,/strings.jsonHTTP plano OBS y el navegador, local o LAN 47823 sólo /wsTLS + pinning el peer, cruzando redes ajenas Esto resuelve lo obvio — un certificado autofirmado hace que OBS y el navegador tiren advertencias o no carguen, y con puertos separados el overlay nunca ve un certificado — pero el beneficio mayor es otro y conviene no perderlo de vista:
Achica lo que queda expuesto. Hoy, quien abra el 47822 a internet para que entre su compañero está publicando también el panel y
/deaths, que no piden nada y encima responden conAccess-Control-Allow-Origin: *: cualquiera puede leer los nombres de los personajes y los contadores. Con la separación, el único puerto que hace falta exponer sirve exclusivamente el endpoint autenticado, y el del overlay se queda en casa. Si se implementa TLS, esta parte va primero: vale por sí sola aunque el cifrado quede para después.Implica una clave nueva en el config (algo como
peer_listen), y que el código de invitación lleve ese puerto y no el del overlay.Detalles: si se regenera el certificado (reinstalación, borrado del archivo) cambia la huella y hay que pasar un código nuevo; documentarlo. Y el README igual debería mencionar que meter todo adentro de una VPN sigue siendo una opción perfectamente válida.
- El hub genera un certificado autofirmado en el primer arranque
(