Files
deathwatch/CLAUDE.md
T
emmatherock e1f7e6f529 Initial commit: DeathWatch, an Elden Ring death counter for OBS
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.
2026-09-17 21:10:22 -03:00

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); token en config.toml lo 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 id que 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.

  1. Re-dereferenciar los punteros en cada tick. Cachear la dirección de GameDataMan hací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 clase Pointer de SoulMemory también resuelve la cadena en cada lectura.

  2. 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.

  3. Las columnas del overlay se indexan por posición, nunca por nombre. Mismo bug que el anterior, del lado del navegador.

  4. No recalibrar en pantallas de carga. Hay una pantalla de carga en cada muerte; tratarlas como cambio de partida se come muertes.

  5. 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.

  6. 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.

  7. 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.0 mientras el juego muestra 1.17.1. Por eso el offset de PlayerIns no 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 a proxy.golang.org. Si se puede usar BurntSushi/toml, se cambia sólo loadConfig.
  • 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.go y names_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.go mezcla lo específico de Windows con lo que no lo es. Separar en process_windows.go y process_linux.go detrás de unas pocas funciones — findProcessID, openProcess, readMemory, findModuleBase, productVersion, systemLang — y dejar el resto (escaneo de firmas, resolución de punteros, loop de polling, lectura del nombre) en un archivo portable. Beneficio extra que vale por sí solo: con eso el escaneo y el polling se pueden testear con un lector de memoria falso, que es justo la parte que hoy no tiene tests.

    Lo específico de Linux:

    1. Encontrar el proceso por sus mapeos, no por el nombre. Proton levanta varios procesos. Lo robusto es recorrer /proc/*/maps y quedarse con el pid que tenga mapeado un archivo terminado en eldenring.exe. De paso, esos mismos mapeos dan la base y el tamaño del módulo, que es lo que findModuleBase necesita. Puede venir partido en varios tramos (.text, .rdata, .data) con permisos distintos: tomar el span completo alcanza, porque el escáner ya lee por chunks y saltea los que no puede leer.

    2. Leer con process_vm_readv(2), que no necesita adjuntarse al proceso. /proc/<pid>/mem sirve de alternativa.

    3. El obstáculo real es ptrace_scope. En casi todas las distros vale 1, y con eso process_vm_readv sobre un proceso ajeno falla con EPERM. La salida recomendada es darle la capacidad al binario:

      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, 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, 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.

    1. El hub genera un certificado autofirmado en el primer arranque (crypto/x509 + ed25519) y lo guarda junto al token.
    2. El peer se conecta con tls.Client usando InsecureSkipVerify: true y un VerifyPeerCertificate que 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.
    3. 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.json HTTP plano OBS y el navegador, local o LAN
    47823 sólo /ws TLS + 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 con Access-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.