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

291 lines
16 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. Corre nativo en Windows y
en Linux contra el juego bajo Proton (mismos offsets y firmas: es el
mismo binario de Windows).
## Comandos
```bash
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`):
```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](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, 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.