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