process.go's boundary functions (readMemory, findModuleBase, productVersion, findProcessID, openProcess, closeProcessHandle) become swappable package variables, same pattern as testExeDir in totals.go. process_test.go builds a fakeProcess (an in-memory buffer addressed like real process memory) to exercise signature scanning, scanModule's chunk-overlap logic, PlayerIns confirmation by memory read (the path Linux always takes), the save-slot read, the double-read name confirmation rule, and rejection of raw-pointer-as-UTF16 garbage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
15 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. Corre nativo en Windows y en Linux contra el juego bajo Proton (mismos offsets y firmas: es el mismo binario de Windows).
Comandos
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):
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/*/mapsy se toma el pid que tenga mapeado un archivo terminado eneldenring.exe. Los mismos mapeos dan la base y el tamaño del módulo parafindModuleBase: puede venir partido en varios tramos (.text/.rdata/.data), así que se toma el span completo (mínimo inicio, máximo final) — alcanza, porque el escáner ya lee por chunks y saltea los que no puede leer. - Leer memoria vía
/proc/<pid>/mem(ReadAt, sin dependencias externas), noprocess_vm_readv(2)crudo: mismo resultado, sin tener que hacer un syscall a mano con structsiovecsingolang.org/x/sys. - El obstáculo real es
ptrace_scope. Sin la capacidad, abrir/proc/<pid>/memdaEPERM.openProcesslo 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 sugieresysctl kernel.yama.ptrace_scope=0ni correr como root — eso baja la defensa de todo el sistema, no solo la de este programa. productVersiondevuelveok=falsesiempre. No hay equivalente aversion.dllen Linux, pero nunca hizo falta: la versión era solo una corazonada para elegir qué offset dePlayerInsprobar primero (playerInsCandidates); el que vale se confirma leyendo memoria enisPlayerLoadedigual, con o sin la corazonada.systemLangsale de$LC_ALL/$LC_MESSAGES/$LANGen vez deGetUserDefaultLocaleName, 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 queresolveLang(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 (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 en Windows y Linux/Proton — incluyendo el paso desetcap, 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.