Files
deathwatch/CLAUDE.md
T
emmatherockandClaude Sonnet 5 356df6cbd5 Fix Linux module-bounds detection: extend through Wine's anonymous PE mapping
Running the Linux build against a live game showed it stuck forever on
"scanning signatures": scanMaps only collected mappings whose file matched
eldenring.exe, but this Proton build backs just the 4 KB PE header with the
real path and maps the rest of the module (~94 MB of .text/.rdata/.data,
where every AOB signature lives) as one anonymous mapping with no path.
findModuleBase was handing the scanner the header alone.

moduleSpan (the matching logic, now pulled out of scanMaps as a pure
function for process_linux_test.go) extends the span through contiguous
anonymous mappings that follow the last named one, and stops at the first
mapping with its own path so it can't merge in an unrelated module.
Confirmed against the live process: all three signatures resolve, PlayerIns
confirms, and /deaths reports real numbers end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 01:54:52 -03:00

16 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. Corre nativo en Windows y en Linux contra el juego bajo Proton (mismos offsets y firmas: es el mismo binario de Windows).

Comandos

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 .

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

El repo va a publicarse en GitHub. Identificadores, comentarios y mensajes de log/consola van en inglés. Ya se hizo la pasada completa: todo .go (identificadores, comentarios, logs) y los comentarios de overlay.html están en inglés.

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 (i18n.go), elegido por idioma del sistema. Son cosas distintas a propósito: el código es para quien lo lee en GitHub, el overlay es para quien lo mira en el stream — y por eso el inglés del código no absorbió el sistema de idiomas del overlay, ni al revés.

Estructura

Archivo Qué hace Plataforma
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/<pid>/{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
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
tlscert.go Certificado TLS autofirmado del hub, generación y persistencia portable
pin.go Certificate pinning: fingerprint, tls.Config del hub y del peer portable
invite.go Código de invitación (encode/decode) y adivinar el host 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, 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):

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 (moduleSpan, la parte pura de scanMaps, testeada en process_linux_test.go) — pero el tamaño real no sale de sumar los tramos con ese nombre. Confirmado corriendo contra un Proton real: el loader de PE de Wine sólo mapea con el archivo real la página del header (unos pocos KB); el resto del módulo — .text/.rdata/.data, donde viven las firmas — es UN mapeo anónimo enorme (acá, ~94 MB) pegado justo después, sin ruta. Quedarse con el span de los tramos nombrados solos deja al escáner con el header y nada más: todas las firmas fallan y resolvePointers da vueltas para siempre. moduleSpan extiende el span a través de mapeos anónimos contiguos que sigan al último tramo nombrado — contiguos y sin ruta nada más, para no comerse por accidente un módulo distinto que justo cargue pegado.
  • Leer memoria vía /proc/<pid>/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/<pid>/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 <ruta>. 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.

El split habilita testear el escáner/poller sin una PC con el juego abierto: readMemoryFn, findModuleBaseFn y productVersionFn (process.go) son variables de paquete que por defecto apuntan a las implementaciones de plataforma, mismo patrón que testExeDir en totals.go. process_test.go las pisa con un fakeProcess — un buffer en memoria direccionado como memoria de proceso real — y ejercita el escaneo de las tres firmas, el overlap de scanModule entre chunks de 1 MiB, la confirmación de PlayerIns por lectura (el camino que toma Linux siempre, al no haber versión), el slot de guardado, y la regla de doble lectura para confirmar el nombre del personaje.

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, en dos capas independientes: TLS cifra y autentica al HUB frente al peer (tlscert.go, pin.go), y el token autentica al PEER frente al hub (auth.go). Ninguna reemplaza a la otra.

Dos puertos, no uno, a propósito — no se puede servir HTTP y HTTPS en el mismo puerto, y mezclarlos igual sería mala idea:

Puerto Config Sirve Protocolo Audiencia
47822 listen overlay, panel, /deaths, /strings.json HTTP plano OBS y el navegador, local o LAN
47823 peer_listen sólo /ws TLS 1.3 + pinning el peer, cruzando redes ajenas

Un certificado autofirmado haría que OBS y el navegador tiren advertencias o no carguen; con los puertos separados el overlay nunca ve un certificado. Y el beneficio mayor: quien abre el 47823 a internet para que entre su compañero expone únicamente el endpoint autenticado+cifrado — el panel y /deaths (que no piden nada y responden con Access-Control-Allow-Origin: *) se quedan en 47822, en casa.

TLS: certificado propio + pinning, no autoridad certificante. No hay dominio ni CA posible para una IP de Tailscale o LAN, así que no se intenta: el hub genera un certificado autofirmado (ed25519 + crypto/x509) la primera vez y lo guarda (hub-cert.pem/hub-key.pem, gitignored, junto al token). El peer se conecta con InsecureSkipVerify: true y un VerifyPeerCertificate que exige que la huella SHA-256 sea exactamente la esperada (pin.go) — el nombre del flag asusta y en una revisión parece un error: no lo es. Apaga la validación por CA/dominio, que acá no aplica, y la reemplaza por pinning, que para este caso es más estricto que la validación normal. Regenerar el certificado (borrar los dos archivos) cambia la huella: cualquier invitación vieja deja de servir.

Invitación de un solo paste. El hub imprime un código — base64 de {host, puerto, huella, token} (invite.go) — que el peer pega como invite = "..." en su config.toml. Reemplaza copiar la IP y el token por separado, sin agregar un paso. El host se adivina solo (candidateIPv4s/pickBestHost, prefiere una IP de Tailscale), pero es sólo una adivinanza: hub = en el config del peer pisa ese campo si hace falta. También existe la ruta manual (hub+token+fingerprint, los tres juntos) para quien prefiera no pegar el blob; nunca hay una conexión sin fijar la huella — un peer a medio configurar falla fuerte al arrancar en vez de conectarse sin pinning.

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

Los tests de esto están en auth_test.go (token correcto, token equivocado, mensajes sin autenticar, que el token no aparezca en el tráfico, que una respuesta vieja no se pueda repetir), pin_test.go (handshake TLS completo: acepta la huella correcta, rechaza cualquier otra) e invite_test.go (ida y vuelta del código, entradas rotas).

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

  • 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.
  • El README debería mencionar que meter todo adentro de una VPN sigue siendo una opción perfectamente válida, TLS+pinning aparte.