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

277 lines
14 KiB
Markdown

# 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
```bash
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](https://github.com/FrankvdStam/SoulSplitter) (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:
```bash
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](https://github.com/delfianto/pika),
[cheat-engine-linux](https://github.com/wleeaf/cheat-engine-linux),
[un trainer para D2R en Linux paso a paso](https://axiom0x0.sh/posts/d2r-memory-trainer-part2/).
- **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.