The co-op link was authenticated (HMAC token, never sent over the wire) but not encrypted. The hub now generates a self-signed cert on first run; the peer pins its exact fingerprint (no CA involved — there isn't one for a Tailscale/LAN address), delivered via a single invite-code paste that also carries the token, replacing today's separate IP+token copy. The peer link moves to its own TLS-only port (peer_listen, 47823) so the plain overlay/panel port (47822, OBS-facing) never needs to be exposed alongside it — today, opening the overlay port to a remote partner also exposes /deaths and the panel to anyone. Mandatory pinning, no insecure fallback: a half-configured peer (some but not all of hub/token/fingerprint, or a broken invite) fails loudly at startup rather than connecting unpinned. An unconfigured peer still runs fine as a local-only overlay, same as before. New: tlscert.go (cert generation/persistence), pin.go (fingerprint pinning), invite.go (invite-code encode/decode, host auto-detection), each with tests. main.go/config.go/duo.go/ws.go carry the wiring for this — the dual listener, new config keys, and the TLS-aware WebSocket dial — and were rewritten in English in the process, per the project's new English-only code convention (see CLAUDE.md).
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. 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 |
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 |
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.
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);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.
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.
-
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
-
No hay
README.mdtodavía. Escribir uno en inglés (instalación, 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.
-
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,tlscert.go,pin.go,invite.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.
-
-
El README debería mencionar que meter todo adentro de una VPN sigue siendo una opción perfectamente válida, TLS+pinning aparte.