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>
291 lines
16 KiB
Markdown
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.
|