From e1f7e6f529c96d8fcb728c6997397028d11d51e2 Mon Sep 17 00:00:00 2001 From: emmatherock Date: Thu, 17 Sep 2026 21:10:22 -0300 Subject: [PATCH] Initial commit: DeathWatch, an Elden Ring death counter for OBS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 11 + CLAUDE.md | 276 ++++++++++++++ auth.go | 105 ++++++ auth_test.go | 192 ++++++++++ config.go | 312 ++++++++++++++++ counter.go | 198 ++++++++++ counter_test.go | 113 ++++++ duo.go | 417 +++++++++++++++++++++ go.mod | 3 + i18n.go | 122 +++++++ locales/en.json | 30 ++ locales/es.json | 30 ++ main.go | 939 ++++++++++++++++++++++++++++++++++++++++++++++++ names.go | 90 +++++ names_test.go | 54 +++ overlay.html | 377 +++++++++++++++++++ totals.go | 142 ++++++++ ws.go | 279 ++++++++++++++ 18 files changed, 3690 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 auth.go create mode 100644 auth_test.go create mode 100644 config.go create mode 100644 counter.go create mode 100644 counter_test.go create mode 100644 duo.go create mode 100644 go.mod create mode 100644 i18n.go create mode 100644 locales/en.json create mode 100644 locales/es.json create mode 100644 main.go create mode 100644 names.go create mode 100644 names_test.go create mode 100644 overlay.html create mode 100644 totals.go create mode 100644 ws.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3f41c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# binarios y estado local, no van al repo +deathwatch.exe +*.exe +totals.json +client-id.txt +config.toml +offset.txt +token.txt + +# config local de Claude Code (auto mode), especifica de esta maquina +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..222fa1e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,276 @@ +# 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//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. diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..c0fe76f --- /dev/null +++ b/auth.go @@ -0,0 +1,105 @@ +// auth.go: autenticacion entre el hub y los peers. +// +// El transporte NO se asume confiable. Puede ser Tailscale, ZeroTier, +// WireGuard, o un puerto abierto directo a internet: eso lo decide quien +// lo usa, no nosotros. Asi que la conexion se defiende sola. +// +// El token es OBLIGATORIO y lo genera el programa (nadie elige "1234"), y +// nunca viaja por la red: el hub manda un desafio al azar y el peer +// responde con un HMAC del desafio usando el token como clave. Quien +// escuche el trafico no se lleva el token, y no puede repetir una +// respuesta vieja porque el desafio cambia en cada conexion. +// +// Lo que esto NO hace: cifrar. Los mensajes (nombre y numero de muertes) +// viajan en claro. Para un contador de muertes en un stream es +// aceptable, pero si te importa, poné la conexion adentro de una VPN o +// un tunel con TLS. Lo que el token garantiza es que nadie pueda +// INYECTAR datos falsos en tu overlay. +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "log" + "os" + "path/filepath" + "strings" +) + +const ( + tokenBytes = 16 // 128 bits + nonceBytes = 16 + // Plazo para autenticarse. Una conexion que se queda callada tras el + // desafio se cierra en vez de ocupar un lugar para siempre. + authTimeoutSeconds = 10 + // Techo de conexiones simultaneas: evita que alguien deje el puerto + // sin recursos abriendo sockets. + maxPeerConns = 8 +) + +func tokenPath() string { + if dir, ok := exeDir(); ok { + return filepath.Join(dir, "token.txt") + } + return "token.txt" +} + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// resolveToken decide el token efectivo. Si el config trae uno, manda ese +// (para quien quiera elegirlo a mano o compartir uno ya acordado). Si no, +// se usa el de token.txt, y si tampoco existe se genera uno y se guarda. +// +// Devuelve tambien si lo acaba de generar, para avisarlo fuerte en la +// consola: es lo que hay que pasarle al compañero. +func resolveToken(cfg config) (token string, generated bool, err error) { + if t := strings.TrimSpace(cfg.Token); t != "" { + return t, false, nil + } + path := tokenPath() + if data, rerr := os.ReadFile(path); rerr == nil { + if t := strings.TrimSpace(string(data)); t != "" { + return t, false, nil + } + } + t, gerr := randomHex(tokenBytes) + if gerr != nil { + return "", false, fmt.Errorf("no pude generar un token: %w", gerr) + } + if werr := os.WriteFile(path, []byte(t), 0600); werr != nil { + return "", false, fmt.Errorf("no pude guardar token.txt: %w", werr) + } + return t, true, nil +} + +// proofFor calcula la respuesta al desafio: HMAC-SHA256(token, nonce). +func proofFor(token, nonce string) string { + m := hmac.New(sha256.New, []byte(token)) + m.Write([]byte(nonce)) + return hex.EncodeToString(m.Sum(nil)) +} + +// proofValid compara en tiempo constante. Con == la comparacion corta en +// el primer byte distinto, y eso filtra informacion por tiempos. +func proofValid(token, nonce, got string) bool { + want := proofFor(token, nonce) + return subtle.ConstantTimeCompare([]byte(want), []byte(got)) == 1 +} + +func logTokenBanner(token string, generated bool) { + if generated { + log.Printf("generé un token nuevo y lo guardé en token.txt") + } + log.Printf("token de conexion: %s", token) + log.Printf("tu compañero tiene que poner ESE token en su config.toml; sin el, su conexion se rechaza") +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..597ad88 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,192 @@ +package main + +import ( + "encoding/json" + "net" + "net/http" + "testing" + "time" +) + +// arrancaHub levanta un hub con un token dado y devuelve su direccion. +func arrancaHub(t *testing.T, token string) (*peerRegistry, string) { + t.Helper() + reg := newPeerRegistry() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + mux.HandleFunc("/ws", reg.wsHandler(token)) + srv := &http.Server{Handler: mux} + go srv.Serve(ln) + t.Cleanup(func() { srv.Close() }) + return reg, ln.Addr().String() +} + +func peers(reg *peerRegistry) int { return len(reg.views()) } + +// El token correcto entra y sus datos llegan. +func TestTokenCorrectoEntra(t *testing.T) { + reg, addr := arrancaHub(t, "el-bueno") + c, err := wsDial(addr, "/ws", 3*time.Second) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if err := authenticate(c, config{Token: "el-bueno", clientID: "abc"}); err != nil { + t.Fatalf("deberia haber autenticado: %v", err) + } + msg, _ := json.Marshal(peerMessage{Type: "state", ID: "abc", Name: "Fulano", Deaths: 7}) + if err := c.WriteText(msg); err != nil { + t.Fatal(err) + } + esperarHasta(t, func() bool { return peers(reg) == 1 }, "el peer nunca aparecio") + if got := reg.views()[0].Deaths; got != 7 { + t.Fatalf("esperaba 7 muertes, dio %d", got) + } +} + +// El token equivocado NO entra y no deja rastro. +func TestTokenEquivocadoNoEntra(t *testing.T) { + reg, addr := arrancaHub(t, "el-bueno") + c, err := wsDial(addr, "/ws", 3*time.Second) + if err != nil { + t.Fatal(err) + } + defer c.Close() + _ = authenticate(c, config{Token: "el-malo", clientID: "x"}) + msg, _ := json.Marshal(peerMessage{Type: "state", ID: "x", Name: "Intruso", Deaths: 999}) + c.WriteText(msg) + time.Sleep(700 * time.Millisecond) + if peers(reg) != 0 { + t.Fatalf("el hub acepto datos con el token equivocado: %+v", reg.views()) + } +} + +// Mandar datos SIN autenticarse tampoco funciona. +func TestSinAutenticarNoEntra(t *testing.T) { + reg, addr := arrancaHub(t, "el-bueno") + c, err := wsDial(addr, "/ws", 3*time.Second) + if err != nil { + t.Fatal(err) + } + defer c.Close() + msg, _ := json.Marshal(peerMessage{Type: "state", ID: "x", Name: "Intruso", Deaths: 999}) + c.WriteText(msg) + time.Sleep(700 * time.Millisecond) + if peers(reg) != 0 { + t.Fatalf("el hub acepto datos sin autenticacion: %+v", reg.views()) + } +} + +// El token no viaja: lo unico que sale del peer es un HMAC del desafio. +func TestElTokenNoViajaPorLaRed(t *testing.T) { + _, addr := arrancaHub(t, "secreto-muy-secreto") + c, err := wsDial(addr, "/ws", 3*time.Second) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + raw, err := c.ReadMessage() // el desafio + if err != nil { + t.Fatal(err) + } + var ch authChallenge + json.Unmarshal(raw, &ch) + if ch.Nonce == "" { + t.Fatal("el hub no mando desafio") + } + reply, _ := json.Marshal(authReply{Type: "auth", ID: "x", Proof: proofFor("secreto-muy-secreto", ch.Nonce)}) + if string(reply) == "" { + t.Fatal("respuesta vacia") + } + for _, b := range [][]byte{raw, reply} { + if contiene(string(b), "secreto-muy-secreto") { + t.Fatalf("el token aparece en el trafico: %s", b) + } + } +} + +// Una respuesta capturada no sirve en otra conexion: el desafio cambia. +func TestNoSePuedeRepetirUnaRespuestaVieja(t *testing.T) { + reg, addr := arrancaHub(t, "el-bueno") + + c1, err := wsDial(addr, "/ws", 3*time.Second) + if err != nil { + t.Fatal(err) + } + raw, _ := c1.ReadMessage() + var ch1 authChallenge + json.Unmarshal(raw, &ch1) + proofViejo := proofFor("el-bueno", ch1.Nonce) + c1.Close() + + c2, err := wsDial(addr, "/ws", 3*time.Second) + if err != nil { + t.Fatal(err) + } + defer c2.Close() + raw2, _ := c2.ReadMessage() + var ch2 authChallenge + json.Unmarshal(raw2, &ch2) + if ch2.Nonce == ch1.Nonce { + t.Fatal("el desafio se repitio entre conexiones: el nonce tiene que ser distinto") + } + reply, _ := json.Marshal(authReply{Type: "auth", ID: "x", Proof: proofViejo}) + c2.WriteText(reply) + msg, _ := json.Marshal(peerMessage{Type: "state", ID: "x", Name: "Repetidor", Deaths: 1}) + c2.WriteText(msg) + time.Sleep(700 * time.Millisecond) + if peers(reg) != 0 { + t.Fatalf("el hub acepto una respuesta repetida: %+v", reg.views()) + } +} + +// El token generado es aleatorio y de largo razonable. +func TestTokenGeneradoEsFuerte(t *testing.T) { + testExeDir = t.TempDir() + a, gen, err := resolveToken(config{}) + if err != nil || !gen { + t.Fatalf("deberia haber generado uno: gen=%v err=%v", gen, err) + } + if len(a) != tokenBytes*2 { + t.Fatalf("largo inesperado: %d", len(a)) + } + // la segunda vez lo lee del archivo, no genera otro + b, gen2, _ := resolveToken(config{}) + if gen2 || a != b { + t.Fatalf("deberia reusar el guardado: %q vs %q (gen=%v)", a, b, gen2) + } + // y en otra instalacion sale distinto + testExeDir = t.TempDir() + c, _, _ := resolveToken(config{}) + if c == a { + t.Fatal("dos instalaciones generaron el mismo token") + } + testExeDir = "" +} + +func contiene(s, sub string) bool { + return len(sub) > 0 && len(s) >= len(sub) && (func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })() +} + +func esperarHasta(t *testing.T, cond func() bool, msg string) { + t.Helper() + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatal(msg) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..fc84f40 --- /dev/null +++ b/config.go @@ -0,0 +1,312 @@ +// config.go: lectura de config.toml. +// +// Parser de TOML hecho a mano y a proposito ACOTADO. La razon de no usar +// una libreria (BurntSushi/toml o similar) es que el entorno donde se +// compila esto no tiene acceso al proxy de modulos de Go, y ademas +// mantiene el programa como un unico .exe sin dependencias. +// +// Soporta: comentarios (#), cabeceras de seccion ([algo]), y claves +// "clave = valor" donde el valor es un string ("..." o '...'), un +// booleano o un entero. NO soporta arrays, tablas inline, strings +// multilinea ni claves con puntos. Cualquier cosa fuera de ese subconjunto +// se reporta con numero de linea en vez de ignorarse en silencio: es +// preferible enterarse al arrancar y no a mitad de un stream. +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" +) + +const ( + // Escucha en todas las interfaces: OBS suele correr en otra PC de la + // LAN, asi que 127.0.0.1 (solo local) no alcanza. + listenAddr = "0.0.0.0:47822" + buildTag = "build-20-auth-obligatoria" +) + +type config struct { + Name string // como se muestra este jugador; vacio = nombre del personaje + Mode string // "hub" o "peer" + Listen string // donde se sirve el panel/overlay + Hub string // modo peer: direccion del hub + Token string // secreto compartido opcional + Partner string // opcional: fuerza el modo coop reservando este nombre + Language string // idioma de la interfaz: "auto", "en", "es", ... + + clientID string // no viene del archivo: ver clientID() +} + +func defaultConfig() config { + return config{ + Mode: "hub", + Listen: listenAddr, + Language: "auto", + } +} + +func configPath() string { + exe, err := os.Executable() + if err != nil { + return "config.toml" + } + return filepath.Join(filepath.Dir(exe), "config.toml") +} + +// tomlValue es un valor ya parseado: guardamos el texto crudo porque +// todas nuestras claves son strings, pero validamos el tipo igual. +type tomlValue struct { + str string + line int +} + +// parseTOML devuelve el mapa de claves de la tabla raiz. Las claves +// dentro de una seccion quedan como "seccion.clave". +func parseTOML(src string) (map[string]tomlValue, []string) { + out := map[string]tomlValue{} + var problems []string + section := "" + + for i, raw := range strings.Split(src, "\n") { + lineNo := i + 1 + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "[") { + if !strings.HasSuffix(line, "]") || strings.HasPrefix(line, "[[") { + problems = append(problems, fmt.Sprintf("linea %d: cabecera de seccion que no entiendo: %s", lineNo, line)) + continue + } + section = strings.TrimSpace(line[1 : len(line)-1]) + continue + } + + key, rest, ok := strings.Cut(line, "=") + if !ok { + problems = append(problems, fmt.Sprintf("linea %d: esperaba \"clave = valor\" y encontre: %s", lineNo, line)) + continue + } + key = strings.TrimSpace(key) + if key == "" { + problems = append(problems, fmt.Sprintf("linea %d: falta el nombre de la clave", lineNo)) + continue + } + if section != "" { + key = section + "." + key + } + + val, err := parseTOMLValue(strings.TrimSpace(rest)) + if err != nil { + problems = append(problems, fmt.Sprintf("linea %d (%s): %v", lineNo, key, err)) + continue + } + out[strings.ToLower(key)] = tomlValue{str: val, line: lineNo} + } + return out, problems +} + +func parseTOMLValue(s string) (string, error) { + if s == "" { + return "", fmt.Errorf("falta el valor") + } + switch s[0] { + case '"': + return parseQuoted(s, '"', true) + case '\'': + return parseQuoted(s, '\'', false) + case '[', '{': + return "", fmt.Errorf("este programa no soporta listas ni tablas inline") + } + + // Sin comillas: booleano o entero. Cortamos un comentario al final. + if idx := strings.Index(s, "#"); idx >= 0 { + s = strings.TrimSpace(s[:idx]) + } + switch strings.ToLower(s) { + case "true": + return "true", nil + case "false": + return "false", nil + } + if _, err := strconv.ParseInt(s, 10, 64); err == nil { + return s, nil + } + return "", fmt.Errorf("valor sin comillas que no es booleano ni entero: %q (¿te faltaron las comillas?)", s) +} + +// parseQuoted lee un string entre comillas y descarta lo que venga +// despues si es un comentario. Con comillas dobles interpreta los +// escapes mas comunes; con simples el contenido es literal, como en TOML. +func parseQuoted(s string, quote byte, escapes bool) (string, error) { + var sb strings.Builder + for i := 1; i < len(s); i++ { + c := s[i] + if escapes && c == '\\' { + if i+1 >= len(s) { + return "", fmt.Errorf("la barra invertida final no escapa nada") + } + i++ + switch s[i] { + case 'n': + sb.WriteByte('\n') + case 't': + sb.WriteByte('\t') + case 'r': + sb.WriteByte('\r') + case '"': + sb.WriteByte('"') + case '\\': + sb.WriteByte('\\') + default: + return "", fmt.Errorf("escape no soportado: \\%c", s[i]) + } + continue + } + if c == quote { + trailing := strings.TrimSpace(s[i+1:]) + if trailing != "" && !strings.HasPrefix(trailing, "#") { + return "", fmt.Errorf("sobra texto despues del valor: %q", trailing) + } + return sb.String(), nil + } + sb.WriteByte(c) + } + return "", fmt.Errorf("falta la comilla de cierre") +} + +var knownKeys = map[string]bool{ + "name": true, "mode": true, "listen": true, + "hub": true, "token": true, "partner": true, + "language": true, +} + +func loadConfig() config { + cfg := defaultConfig() + + data, err := os.ReadFile(configPath()) + if err != nil { + if !os.IsNotExist(err) { + log.Printf("no pude leer config.toml (%v): sigo con los valores por defecto", err) + } + return cfg + } + + values, problems := parseTOML(string(data)) + for _, p := range problems { + log.Printf("config.toml: %s", p) + } + + for k, v := range values { + if !knownKeys[k] { + log.Printf("config.toml: clave desconocida %q (linea %d), la ignoro", k, v.line) + continue + } + switch k { + case "name": + cfg.Name = v.str + case "mode": + cfg.Mode = strings.ToLower(v.str) + case "listen": + if v.str != "" { + cfg.Listen = v.str + } + case "hub": + cfg.Hub = v.str + case "token": + cfg.Token = v.str + case "partner": + cfg.Partner = v.str + case "language": + cfg.Language = v.str + } + } + + if cfg.Mode != "hub" && cfg.Mode != "peer" { + log.Printf("config.toml: mode = %q no es ni \"hub\" ni \"peer\", uso \"hub\"", cfg.Mode) + cfg.Mode = "hub" + } + return cfg +} + +const sampleConfig = `# DeathWatch - Elden Ring death counter +# Edit this file and restart the program to apply changes. + +# Name shown in the overlay. +# Leave it empty to use your in-game character name. +name = "" + +# "hub" = this PC serves the overlay to OBS and receives partners. +# "peer" = this PC only reads its own game and pushes to the hub. +mode = "hub" + +# Address the panel and overlay listen on. +listen = "0.0.0.0:47822" + +# Peer mode only: the hub's address, e.g. "100.101.102.103:47822" +hub = "" + +# Connection secret. REQUIRED for co-op. +# The hub generates one on first run and saves it to token.txt; it is +# also printed in the console. Peers must set that exact value here. +# The token itself never travels over the network: the hub sends a random +# challenge and the peer answers with an HMAC of it. +token = "" + +# Interface language. "auto" follows your Windows language and falls +# back to English. Any file in locales/ is a valid value, e.g. "en", "es". +language = "auto" + +# Optional. Hub only: your partner's name. Setting it forces the co-op +# layout from startup, with their slot reserved before they connect. +# Leave it empty to switch to co-op automatically when they show up. +partner = "" +` + +// clientID devuelve un identificador estable de ESTA instalacion, +// guardado en client-id.txt al lado del programa. +// +// Existe porque el nombre no sirve para identificar a un jugador: desde +// que lo leemos del personaje, cambia cada vez que cargan otra partida. +// Con un id propio, el hub sigue viendo al mismo compañero aunque cambie +// de personaje, se reconecte o reinicie el programa. +func clientID() string { + path := "client-id.txt" + if exe, err := os.Executable(); err == nil { + path = filepath.Join(filepath.Dir(exe), "client-id.txt") + } + if data, err := os.ReadFile(path); err == nil { + if id := strings.TrimSpace(string(data)); id != "" { + return id + } + } + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + // Sin aleatoriedad igual devolvemos algo estable dentro de la corrida. + return fmt.Sprintf("pid-%d", os.Getpid()) + } + id := hex.EncodeToString(b[:]) + if err := os.WriteFile(path, []byte(id), 0644); err != nil { + log.Printf("no pude guardar client-id.txt (%v): uso un id nuevo en cada arranque", err) + } + return id +} + +// writeSampleConfig deja un config.toml comentado la primera vez. +func writeSampleConfig() { + path := configPath() + if _, err := os.Stat(path); err == nil { + return + } + if err := os.WriteFile(path, []byte(sampleConfig), 0644); err != nil { + log.Printf("no pude escribir el config.toml de ejemplo: %v", err) + return + } + log.Printf("dejé un config.toml de ejemplo al lado del programa") +} diff --git a/counter.go b/counter.go new file mode 100644 index 0000000..e988077 --- /dev/null +++ b/counter.go @@ -0,0 +1,198 @@ +// counter.go: la contabilidad de muertes. +// +// A proposito NO tiene nada especifico de Windows: toda la lectura de +// memoria vive en main.go. Asi esta parte, que es la que concentra las +// reglas (a que personaje va cada muerte, que pasa al cambiar de +// partida, que se muestra con el juego cerrado), se puede compilar y +// testear en cualquier plataforma. Las tres veces que algo conto mal, el +// error estuvo aca. +package main + +import ( + "log" + "strings" + "sync" +) + +// El total que se muestra ES el que lleva el juego para el personaje +// cargado. No hay ajustes ni acumuladores propios: al cambiar de +// personaje el numero salta al suyo en la misma lectura. +type state struct { + mu sync.Mutex + connected bool + playerLoaded bool + rawDeaths int32 + haveReading bool + bossFight bool + charName string + charKey string + slot int + total int64 + announced bool + lastErr string +} + +type stateSnapshot struct { + RawDeaths int32 + Slot int + Total int64 + BossFight bool + Connected bool + PlayerLoaded bool + CharName string +} + +var st state +var totals *totalsStore + +// init arranca mostrando el ultimo personaje conocido, para que el +// overlay tenga un numero con sentido antes de que abras el juego. +func (s *state) init(store *totalsStore) { + key, name, total := store.lastKnownTotal() + s.mu.Lock() + s.charName = name + s.charKey = key + s.slot = -1 + s.total = total + s.mu.Unlock() + if name != "" { + log.Printf("ultimo personaje conocido: %s con %d muertes", name, total) + } +} + +func (s *state) snapshot() stateSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + total := s.total + if s.haveReading { + total = int64(s.rawDeaths) + } + return stateSnapshot{ + RawDeaths: s.rawDeaths, + Slot: s.slot, + Total: total, + BossFight: s.bossFight, + Connected: s.connected, + PlayerLoaded: s.playerLoaded, + CharName: s.charName, + } +} + +func (s *state) name() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.charName +} + +// nombreMostrado: lo que diga el config manda; si esta vacio usamos el +// nombre del personaje que leemos del juego. +func nombreMostrado(cfg config) string { + if n := strings.TrimSpace(cfg.Name); n != "" { + return n + } + if n := st.name(); n != "" { + return n + } + // Vacio a proposito: que la etiqueta la elija la interfaz, que es la + // que sabe si esta mostrando un jugador o dos. + return "" +} + +// setCharacter se llama cuando cambia el personaje cargado: el total +// pasa a ser el que el juego lleva para ese personaje. +func (s *state) setCharacter(slot int, name string, raw int32) { + key := characterKey(slot, name) + if key == "" { + return // ni slot ni nombre: no sabemos a quien imputarle esto + } + + s.mu.Lock() + if key == s.charKey && s.haveReading { + // Mismo personaje: el nombre puede haber aparecido recien, porque + // el slot se lee antes que el nombre. + if name != "" && name != s.charName { + s.charName = name + total := s.total + s.mu.Unlock() + totals.save(key, charTotal{Name: name, Total: total}) + return + } + s.mu.Unlock() + return + } + s.charKey = key + s.slot = slot + if name != "" { + s.charName = name + } + name = s.charName + // Adoptamos ya la lectura del personaje nuevo: si no, el snapshot + // seguiria mostrando las muertes del personaje ANTERIOR hasta la + // siguiente muerte. + s.rawDeaths = raw + s.haveReading = true + s.total = int64(raw) + total := s.total + s.mu.Unlock() + + log.Printf("personaje: %s (slot %d) -> %d muertes", name, slot, total) + totals.save(key, charTotal{Name: name, Total: total}) +} + +func (s *state) setReading(raw int32, boss bool) { + s.mu.Lock() + s.connected = true + s.playerLoaded = true + s.announced = false + changed := !s.haveReading || s.rawDeaths != raw + s.haveReading = true + s.rawDeaths = raw + s.bossFight = boss + s.lastErr = "" + total := int64(raw) + grew := total > s.total + s.total = total + key, name := s.charKey, s.charName + s.mu.Unlock() + + if !changed { + return + } + if grew { + log.Printf("nueva muerte de %s -> %d", name, total) + } + totals.save(key, charTotal{Name: name, Total: total}) +} + +// setPlayerUnloaded: el juego sigue abierto pero no hay personaje en el +// mundo (menu principal o pantalla de carga). A diferencia de una +// desconexion, NO apagamos connected: el overlay tiene que seguir +// mostrando el total y no un guion, porque si no parpadearia en cada +// pantalla de carga (o sea, en cada muerte). +func (s *state) setPlayerUnloaded(reason string) { + s.mu.Lock() + was := s.playerLoaded + first := !s.announced + s.announced = true + s.connected = true + s.playerLoaded = false + s.bossFight = false + s.lastErr = reason + s.mu.Unlock() + if was || first { + log.Printf("sin personaje cargado (%s) - el total queda congelado hasta que vuelvas al mundo", reason) + } +} + +func (s *state) setDisconnected(reason string) { + s.mu.Lock() + wasConnected := s.connected + s.connected = false + s.playerLoaded = false + s.bossFight = false + s.lastErr = reason + s.mu.Unlock() + if wasConnected { + log.Printf("se perdio la conexion con el juego: %s", reason) + } +} diff --git a/counter_test.go b/counter_test.go new file mode 100644 index 0000000..7cc6f54 --- /dev/null +++ b/counter_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// cada test corre en su propio directorio: totals.json y offset.txt +// se resuelven contra el ejecutable, asi que lo apuntamos al temporal. +func fresh(t *testing.T, legacy string) { + t.Helper() + dir := t.TempDir() + testExeDir = dir + if legacy != "" { + if err := os.WriteFile(filepath.Join(dir, "offset.txt"), []byte(legacy), 0644); err != nil { + t.Fatal(err) + } + } + totals = newTotalsStore() + st = state{} + st.init(totals) +} + +func total(t *testing.T) int64 { t.Helper(); return st.snapshot().Total } + +// EL CASO DE EMMA: cambiar de personaje tiene que mover el numero YA, +// sin esperar a que alguien se muera. +func TestCambioDePersonajeActualizaAlInstante(t *testing.T) { + fresh(t, "") + + st.setCharacter(0, "emma", 40) + st.setReading(40, false) + if got := total(t); got != 40 { + t.Fatalf("emma deberia arrancar con sus 40 muertes, dio %d", got) + } + + // se va al menu y carga otro personaje, con otro conteo propio + st.setPlayerUnloaded("menu") + st.setCharacter(1, "emmatherock", 7) + if got := total(t); got != 7 { + t.Fatalf("al cargar emmatherock el total tenia que saltar a 7 SIN morir, dio %d", got) + } + st.setReading(7, false) + if got := total(t); got != 7 { + t.Fatalf("tras la lectura deberia seguir en 7, dio %d", got) + } + + // y volver muestra lo que el juego lleva para emma + st.setPlayerUnloaded("menu") + st.setCharacter(0, "emma", 41) + st.setReading(41, false) + if got := total(t); got != 41 { + t.Fatalf("volviendo a emma deberia dar 41, dio %d", got) + } +} + +// Dos personajes con el MISMO nombre no se tienen que mezclar. +func TestHomonimosNoSeMezclan(t *testing.T) { + fresh(t, "") + st.setCharacter(0, "Tarnished", 100) + st.setReading(100, false) + st.setCharacter(3, "Tarnished", 5) + st.setReading(5, false) + if got := total(t); got != 5 { + t.Fatalf("el del slot 3 deberia dar 5, dio %d", got) + } + if c, ok := totals.get("slot0"); !ok || c.Total != 100 { + t.Fatalf("el del slot 0 deberia seguir en 100, dio %+v", c) + } +} + +// El total es el del juego, sin importar que haya un offset.txt viejo +// dando vueltas al lado del programa. +func TestIgnoraElOffsetViejo(t *testing.T) { + fresh(t, "124") + st.setCharacter(0, "emma", 30) + st.setReading(30, false) + if got := total(t); got != 30 { + t.Fatalf("deberia mostrar las 30 del juego y no las 124 del archivo viejo, dio %d", got) + } +} + +// Sin poder leer el slot, cae al nombre y sigue funcionando. +func TestSinSlotCaeAlNombre(t *testing.T) { + fresh(t, "") + st.setCharacter(-1, "emma", 40) + st.setReading(40, false) + if got := total(t); got != 40 { + t.Fatalf("deberia dar 40, dio %d", got) + } + if _, ok := totals.get("name:emma"); !ok { + t.Fatal("deberia haberlo guardado por nombre") + } +} + +// Al reabrir el programa con el juego cerrado, muestra el ultimo +// personaje conocido en vez de un cero. +func TestRecuerdaElUltimoAlReabrir(t *testing.T) { + fresh(t, "") + st.setCharacter(2, "emma", 88) + st.setReading(88, false) + + totals = newTotalsStore() // simula reabrir el programa + st = state{} + st.init(totals) + if got := total(t); got != 88 { + t.Fatalf("al reabrir deberia mostrar 88, dio %d", got) + } + if st.snapshot().CharName != "emma" { + t.Fatalf("y recordar el nombre, dio %q", st.snapshot().CharName) + } +} diff --git a/duo.go b/duo.go new file mode 100644 index 0000000..b978534 --- /dev/null +++ b/duo.go @@ -0,0 +1,417 @@ +// duo.go: modo cooperativo. Cada jugador corre el programa en su propia +// PC leyendo su propio Elden Ring; uno hace de "hub" (sirve el overlay a +// OBS) y el resto se conectan a el por WebSocket y le empujan su contador. +// +// La conexion la abre SIEMPRE el peer hacia el hub, que es lo comodo con +// Tailscale: alcanza con que el hub tenga una IP estable en la tailnet. +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" +) + +// Si un peer no manda nada en este tiempo, lo damos por desconectado. +// El peer empuja una vez por segundo, asi que es holgado. +const peerTimeout = 6 * time.Second + +// Cuanto seguimos mostrando la interfaz de coop despues de que el +// compañero desaparece. Ver coopMode() para el porque. +const coopGrace = 2 * time.Minute + +// --------------------------- registro de peers --------------------------- + +type playerView struct { + Name string `json:"name"` + Deaths int64 `json:"deaths"` + BossFight bool `json:"bossFight"` + PlayerLoaded bool `json:"playerLoaded"` + Connected bool `json:"connected"` + Self bool `json:"self"` +} + +// peerEntry: OJO con la identidad. El nombre NO sirve como clave: desde +// que lo leemos del personaje, cambia cuando el jugador carga otra +// partida, y si indexaramos por nombre cada cambio de personaje crearia +// un jugador nuevo y dejaria el anterior colgado en pantalla. La +// identidad es el id que manda cada instancia (ver clientID), estable +// entre reconexiones y reinicios; el nombre es solo algo que mostramos. +type peerEntry struct { + key string // como lo indexamos: el id, o "name:x" si es viejo + name string + deaths int64 + bossFight bool + playerLoaded bool + lastSeen time.Time + everSeen bool + reserved bool // lugar apartado por config, todavia sin dueño +} + +type peerRegistry struct { + mu sync.Mutex + peers map[string]*peerEntry + order []string // orden de aparicion, para que el overlay no baile + + forced bool // el config nombro un compañero: coop desde el arranque + latched bool // ya vimos un compañero en esta corrida + lastCoop time.Time // ultima vez que hubo alguien conectado +} + +func newPeerRegistry() *peerRegistry { + return &peerRegistry{peers: map[string]*peerEntry{}} +} + +// declare reserva el lugar de un compañero antes de que se conecte y +// fija el modo coop desde el arranque. Es opcional: sirve cuando querés +// que el overlay tenga el tamaño definitivo desde el minuto cero en vez +// de cambiar cuando el otro aparece. +func (r *peerRegistry) declare(name string) { + if name == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + key := "reserved:" + name + if _, ok := r.peers[key]; !ok { + r.peers[key] = &peerEntry{key: key, name: name, reserved: true} + r.order = append(r.order, key) + } + r.forced = true +} + +// coopMode decide si el overlay va en dos columnas o en una. +// +// Se engancha cuando aparece un compañero y NO se suelta al primer +// bache: si volviera a modo solo cada vez que se corta la red un +// segundo, el overlay se redibujaria en vivo en pleno stream. Recien +// vuelve al modo de un jugador cuando el otro estuvo ausente un buen +// rato, que es la señal de "se fue a dormir", no de "se le colgo el wifi". +func (r *peerRegistry) coopMode() bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.forced { + return true + } + now := time.Now() + for _, p := range r.peers { + if p.everSeen && now.Sub(p.lastSeen) < peerTimeout { + if !r.latched { + r.latched = true + log.Printf("hay un compañero conectado: paso el overlay a modo coop") + } + r.lastCoop = now + return true + } + } + if r.latched { + if now.Sub(r.lastCoop) < coopGrace { + return true + } + r.latched = false + log.Printf("hace %v que no aparece ningun compañero: vuelvo al overlay de un jugador", coopGrace) + } + return false +} + +// keyFor: el id manda. Si viniera vacio (una version vieja del otro +// lado) caemos al nombre, que es lo que habia antes. +func keyFor(m peerMessage) string { + if id := strings.TrimSpace(m.ID); id != "" { + return "id:" + id + } + return "name:" + strings.ToLower(strings.TrimSpace(m.Name)) +} + +// rekey mueve una entrada a otra clave conservando su lugar en el orden, +// para que el overlay no reordene columnas por debajo. +func (r *peerRegistry) rekey(p *peerEntry, newKey string) { + delete(r.peers, p.key) + for i, k := range r.order { + if k == p.key { + r.order[i] = newKey + break + } + } + p.key = newKey + r.peers[newKey] = p +} + +func (r *peerRegistry) update(m peerMessage) { + r.mu.Lock() + defer r.mu.Unlock() + + key := keyFor(m) + p := r.peers[key] + + // Sin entrada propia: si hay un lugar apartado por config todavia sin + // dueño, lo toma el primero que llega. Apartarlo por nombre exacto no + // serviria, porque el nombre que manda es el del personaje. + if p == nil { + for _, k := range r.order { + if e := r.peers[k]; e.reserved && !e.everSeen { + e.reserved = false + r.rekey(e, key) + p = e + break + } + } + } + if p == nil { + p = &peerEntry{key: key} + r.peers[key] = p + r.order = append(r.order, key) + } + + if n := strings.TrimSpace(m.Name); n != "" && n != p.name { + if p.name != "" && p.everSeen { + log.Printf("%s cambio de personaje: ahora es %s", p.name, n) + } + p.name = n + } + p.deaths = m.Deaths + p.bossFight = m.BossFight + p.playerLoaded = m.PlayerLoaded + p.lastSeen = time.Now() + p.everSeen = true +} + +func (r *peerRegistry) views() []playerView { + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now() + out := make([]playerView, 0, len(r.order)) + for _, k := range r.order { + p := r.peers[k] + // Los que hace mucho que no aparecen se van del overlay: es el + // mismo umbral con el que volvemos al modo de un jugador. + if p.everSeen && now.Sub(p.lastSeen) > coopGrace { + continue + } + out = append(out, playerView{ + Name: p.name, + Deaths: p.deaths, + BossFight: p.bossFight, + PlayerLoaded: p.playerLoaded, + Connected: p.everSeen && now.Sub(p.lastSeen) < peerTimeout, + Self: false, + }) + } + return out +} + +// ------------------------- mensaje entre los dos ------------------------- + +// authChallenge lo manda el hub apenas se abre la conexion. +type authChallenge struct { + Type string `json:"type"` // "challenge" + Nonce string `json:"nonce"` +} + +// authReply es la respuesta del peer: prueba que conoce el token sin +// mandarlo. Ver auth.go. +type authReply struct { + Type string `json:"type"` // "auth" + ID string `json:"id"` + Proof string `json:"proof"` +} + +type peerMessage struct { + // ID identifica a la instalacion, no al personaje: es lo que permite + // que cambiar de personaje no cree un jugador nuevo en el overlay. + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` // "state" + Name string `json:"name"` + Deaths int64 `json:"deaths"` + BossFight bool `json:"bossFight"` + PlayerLoaded bool `json:"playerLoaded"` +} + +// ------------------------------- lado hub ------------------------------- + +func (r *peerRegistry) wsHandler(token string) http.HandlerFunc { + var activas atomic.Int32 + + return func(w http.ResponseWriter, req *http.Request) { + if n := activas.Add(1); n > maxPeerConns { + activas.Add(-1) + log.Printf("rechazo conexion de %s: ya hay %d conexiones abiertas", req.RemoteAddr, maxPeerConns) + http.Error(w, "too many connections", http.StatusServiceUnavailable) + return + } + defer activas.Add(-1) + + c, err := wsUpgrade(w, req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer c.Close() + + remote := c.RemoteAddr() + + // --- autenticacion: desafio al azar, el token no viaja --- + nonce, err := randomHex(nonceBytes) + if err != nil { + log.Printf("no pude generar el desafio para %s: %v", remote, err) + return + } + ch, _ := json.Marshal(authChallenge{Type: "challenge", Nonce: nonce}) + if err := c.WriteText(ch); err != nil { + return + } + + c.SetReadDeadline(time.Now().Add(authTimeoutSeconds * time.Second)) + raw, err := c.ReadMessage() + if err != nil { + log.Printf("%s se fue sin autenticarse: %v", remote, err) + return + } + var reply authReply + if err := json.Unmarshal(raw, &reply); err != nil || reply.Type != "auth" { + log.Printf("rechazo %s: no mando una respuesta de autenticacion valida", remote) + return + } + if !proofValid(token, nonce, reply.Proof) { + log.Printf("rechazo %s: el token no coincide", remote) + return + } + log.Printf("se autentico un compañero desde %s", remote) + + var who string + for { + c.SetReadDeadline(time.Now().Add(15 * time.Second)) + raw, err := c.ReadMessage() + if err != nil { + if who != "" { + log.Printf("se desconecto %s (%s): %v", who, remote, err) + } else { + log.Printf("se desconecto %s: %v", remote, err) + } + return + } + var m peerMessage + if err := json.Unmarshal(raw, &m); err != nil { + log.Printf("mensaje ilegible de %s: %v", remote, err) + continue + } + // El id de la conexion autenticada manda: que un mensaje + // declare otro id no lo convierte en otro jugador. + if reply.ID != "" { + m.ID = reply.ID + } + if strings.TrimSpace(m.Name) == "" { + m.Name = "Jugador 2" + } + if who == "" { + who = m.Name + log.Printf("%s entro al contador compartido", who) + } + r.update(m) + } + } +} + +// ------------------------------ lado peer ------------------------------ + +// peerLoop mantiene viva la conexion con el hub y le empuja el estado +// local una vez por segundo. Reintenta solo para siempre: si el hub se +// reinicia en medio del stream, se vuelve a enganchar sin tocar nada. +func peerLoop(cfg config) { + id := clientID() + log.Printf("mi id de cliente es %s (cambiar de personaje no crea un jugador nuevo del otro lado)", id) + cfg.clientID = id + for { + c, err := wsDial(cfg.Hub, "/ws", 8*time.Second) + if err != nil { + log.Printf("no me pude conectar al hub %s (%v) - reintento en 5s", cfg.Hub, err) + time.Sleep(5 * time.Second) + continue + } + if err := authenticate(c, cfg); err != nil { + c.Close() + log.Printf("el hub no me acepto (%v) - reintento en 5s", err) + time.Sleep(5 * time.Second) + continue + } + log.Printf("conectado y autenticado con el hub %s", cfg.Hub) + + err = pushLoop(c, cfg) + c.Close() + log.Printf("se corto la conexion con el hub (%v) - reintento en 5s", err) + time.Sleep(5 * time.Second) + } +} + +// authenticate responde el desafio del hub. El token no se manda: se +// manda un HMAC del desafio hecho con el token. +func authenticate(c *wsConn, cfg config) error { + c.SetReadDeadline(time.Now().Add(authTimeoutSeconds * time.Second)) + raw, err := c.ReadMessage() + if err != nil { + return fmt.Errorf("no llego el desafio: %w", err) + } + var ch authChallenge + if err := json.Unmarshal(raw, &ch); err != nil || ch.Type != "challenge" || ch.Nonce == "" { + return fmt.Errorf("el desafio del hub no se entiende") + } + if strings.TrimSpace(cfg.Token) == "" { + return fmt.Errorf("te falta el token en config.toml: pedíselo a quien corre el hub") + } + reply, _ := json.Marshal(authReply{ + Type: "auth", + ID: cfg.clientID, + Proof: proofFor(cfg.Token, ch.Nonce), + }) + if err := c.WriteText(reply); err != nil { + return err + } + c.SetReadDeadline(time.Time{}) + return nil +} + +func pushLoop(c *wsConn, cfg config) error { + // Un lector en segundo plano: no esperamos mensajes del hub, pero hay + // que atender sus pings y enterarnos si corta. + readErr := make(chan error, 1) + go func() { + for { + if _, err := c.ReadMessage(); err != nil { + readErr <- err + return + } + } + }() + + tick := time.NewTicker(1 * time.Second) + defer tick.Stop() + + for { + select { + case err := <-readErr: + return err + case <-tick.C: + snap := st.snapshot() + msg := peerMessage{ + Type: "state", + ID: cfg.clientID, + Name: nombreMostrado(cfg), + Deaths: snap.Total, + BossFight: snap.BossFight, + PlayerLoaded: snap.PlayerLoaded, + } + b, err := json.Marshal(msg) + if err != nil { + return err + } + if err := c.WriteText(b); err != nil { + return err + } + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9b953f4 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module deathwatch + +go 1.24.7 diff --git a/i18n.go b/i18n.go new file mode 100644 index 0000000..98d6c4a --- /dev/null +++ b/i18n.go @@ -0,0 +1,122 @@ +//go:build windows + +// i18n.go: idiomas de la interfaz. +// +// Las traducciones viven en locales/*.json y se embeben en el ejecutable. +// La razon de elegir archivos sueltos en vez de un mapa en el codigo es +// que agregar un idioma sea "copiar en.json, traducirlo, y listo": quien +// traduce no necesita saber Go ni tocar una linea de codigo. +// +// Los mensajes de la consola NO pasan por aca a proposito: son +// diagnostico, y conviene que esten siempre en el mismo idioma para que +// un log pegado en un issue se pueda leer sin importar de donde venga. +package main + +import ( + "embed" + "encoding/json" + "log" + "path" + "sort" + "strings" + "syscall" + "unsafe" +) + +//go:embed locales/*.json +var localeFS embed.FS + +const fallbackLang = "en" + +var locales = map[string]map[string]string{} + +func loadLocales() { + entries, err := localeFS.ReadDir("locales") + if err != nil { + log.Printf("no pude leer los idiomas embebidos: %v", err) + return + } + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".json") { + continue + } + data, err := localeFS.ReadFile(path.Join("locales", name)) + if err != nil { + log.Printf("no pude leer locales/%s: %v", name, err) + continue + } + var m map[string]string + if err := json.Unmarshal(data, &m); err != nil { + log.Printf("locales/%s tiene JSON invalido: %v", name, err) + continue + } + locales[strings.TrimSuffix(name, ".json")] = m + } +} + +func availableLangs() []string { + out := make([]string, 0, len(locales)) + for k := range locales { + out = append(out, k) + } + sort.Strings(out) + return out +} + +var procGetUserDefaultLocaleName = kernel32.NewProc("GetUserDefaultLocaleName") + +// systemLang devuelve el idioma de Windows ("es-AR" -> "es"). +func systemLang() string { + buf := make([]uint16, 85) // LOCALE_NAME_MAX_LENGTH + r, _, _ := procGetUserDefaultLocaleName.Call(uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) + if r == 0 { + return "" + } + name := syscall.UTF16ToString(buf[:r]) + if base, _, ok := strings.Cut(name, "-"); ok { + return strings.ToLower(base) + } + return strings.ToLower(name) +} + +// resolveLang decide el idioma final. "auto" (o vacio) usa el de +// Windows; si ese idioma no esta traducido, cae a ingles. +func resolveLang(want string) string { + want = strings.ToLower(strings.TrimSpace(want)) + if want == "" || want == "auto" { + want = systemLang() + } + if want == "" { + return fallbackLang + } + if _, ok := locales[want]; ok { + return want + } + // "es-AR" o "es_AR" igual tienen que encontrar "es". + if base, _, ok := strings.Cut(strings.NewReplacer("_", "-").Replace(want), "-"); ok { + if _, ok := locales[base]; ok { + return base + } + } + return fallbackLang +} + +// stringsFor devuelve el diccionario del idioma pedido, completado con +// el ingles: si una traduccion quedo a medias, se ve la clave que falta +// en ingles y no la clave cruda. +func stringsFor(lang string) map[string]string { + out := map[string]string{} + for k, v := range locales[fallbackLang] { + out[k] = v + } + if lang != fallbackLang { + for k, v := range locales[lang] { + if v != "" { + out[k] = v + } + } + } + out["_lang"] = lang + return out +} diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..0d3e189 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,30 @@ +{ + "_language": "English", + "overlay.deaths": "deaths", + "overlay.boss": "boss", + "overlay.offline": "no connection", + "overlay.waiting": "waiting for the game…", + "overlay.you": "You", + "overlay.partner": "Partner", + "panel.title": "Elden Ring", + "panel.subtitle": "Death Counter · live reading", + "panel.total": "Total deaths", + "panel.raw": "Read from process", + "panel.slot": "Save slot", + "panel.boss": "⚔ In a boss fight", + "panel.partners": "Partners", + "panel.obsUrl": "URL for OBS (Browser Source)", + "panel.copy": "Copy", + "panel.copied": "Copied", + "panel.copyManual": "Select and copy", + "panel.obsHint": "Paste this URL into a Browser source in OBS. Transparent background, updates on its own as long as this console window stays open.", + "panel.build": "Build", + "status.connecting": "Connecting…", + "status.waitingGame": "Waiting for you to open Elden Ring…", + "status.menu": "Elden Ring open — main menu or loading screen", + "status.live": "Live — reading Elden Ring", + "status.noServer": "No connection to the local program", + "peer.playing": "playing", + "peer.menu": "in the menu", + "peer.offline": "no connection" +} \ No newline at end of file diff --git a/locales/es.json b/locales/es.json new file mode 100644 index 0000000..0cc3554 --- /dev/null +++ b/locales/es.json @@ -0,0 +1,30 @@ +{ + "_language": "Español", + "overlay.deaths": "muertes", + "overlay.boss": "jefe", + "overlay.offline": "sin conexión", + "overlay.waiting": "esperando al juego…", + "overlay.you": "Vos", + "overlay.partner": "Compañero", + "panel.title": "Elden Ring", + "panel.subtitle": "Death Counter · lectura en vivo", + "panel.total": "Total muertes", + "panel.raw": "Leído del proceso", + "panel.slot": "Slot de guardado", + "panel.boss": "⚔ En combate contra jefe", + "panel.partners": "Compañeros", + "panel.obsUrl": "URL para OBS (Browser Source)", + "panel.copy": "Copiar", + "panel.copied": "Copiado", + "panel.copyManual": "Seleccioná y copiá", + "panel.obsHint": "Pegá esta URL en una fuente Navegador de OBS. Fondo transparente, se actualiza sola mientras esta ventanita de consola siga abierta.", + "panel.build": "Compilación", + "status.connecting": "Conectando…", + "status.waitingGame": "Esperando a que abras Elden Ring…", + "status.menu": "Elden Ring abierto — menú o pantalla de carga", + "status.live": "En vivo — leyendo Elden Ring", + "status.noServer": "Sin conexión con el programa local", + "peer.playing": "jugando", + "peer.menu": "en el menú", + "peer.offline": "sin conexión" +} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..b48b267 --- /dev/null +++ b/main.go @@ -0,0 +1,939 @@ +//go:build windows + +// deathwatch: lee en solo-lectura el contador de muertes de Elden Ring +// directamente del proceso (mismo patron de bytes / offset que usa el +// script ASL "eldenring_boss_timer.asl" de LiveSplit, ya verificado a +// mano en esta PC). No escribe nada en la memoria del juego. +// +// Expone: +// +// GET / -> panel de estado (HTML) +// GET /?view=overlay -> version transparente para OBS Browser Source +// GET /deaths -> {"deaths":N,"players":[...],"character":"...","slot":N,...} +package main + +import ( + _ "embed" + "encoding/binary" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "syscall" + "time" + "unicode/utf16" + "unsafe" +) + +//go:embed overlay.html +var overlayHTML []byte + +const ( + processName = "eldenring.exe" + + processQueryInformation = 0x0400 + processVMRead = 0x0010 + th32csSnapProcess = 0x00000002 + th32csSnapModule = 0x00000008 + th32csSnapModule32 = 0x00000010 + maxPath = 260 +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procOpenProcess = kernel32.NewProc("OpenProcess") + procCloseHandle = kernel32.NewProc("CloseHandle") + procReadProcessMemory = kernel32.NewProc("ReadProcessMemory") + procCreateToolhelp32Snapshot = kernel32.NewProc("CreateToolhelp32Snapshot") + procModule32FirstW = kernel32.NewProc("Module32FirstW") + procModule32NextW = kernel32.NewProc("Module32NextW") + procProcess32FirstW = kernel32.NewProc("Process32FirstW") + procProcess32NextW = kernel32.NewProc("Process32NextW") + + versionDLL = syscall.NewLazyDLL("version.dll") + procGetFileVersionInfoSizeW = versionDLL.NewProc("GetFileVersionInfoSizeW") + procGetFileVersionInfoW = versionDLL.NewProc("GetFileVersionInfoW") + procVerQueryValueW = versionDLL.NewProc("VerQueryValueW") +) + +// vsFixedFileInfo es la estructura VS_FIXEDFILEINFO de Windows: la usamos +// para sacar la version del juego del propio eldenring.exe, igual que +// SoulMemory (que lee MainModule.FileVersionInfo.ProductVersion). +type vsFixedFileInfo struct { + Signature uint32 + StrucVersion uint32 + FileVersionMS uint32 + FileVersionLS uint32 + ProductVersionMS uint32 + ProductVersionLS uint32 + FileFlagsMask uint32 + FileFlags uint32 + FileOS uint32 + FileType uint32 + FileSubtype uint32 + FileDateMS uint32 + FileDateLS uint32 +} + +type processEntry32 struct { + Size uint32 + CntUsage uint32 + ProcessID uint32 + DefaultHeapID uintptr + ModuleID uint32 + CntThreads uint32 + ParentProcessID uint32 + PriorityClassBase int32 + Flags uint32 + ExeFile [maxPath]uint16 +} + +type moduleEntry32 struct { + Size uint32 + ModuleID uint32 + ProcessID uint32 + GlblcntUsage uint32 + ProccntUsage uint32 + ModBaseAddr uintptr + ModBaseSize uint32 + HModule syscall.Handle + ModuleName [256]uint16 + ExePath [maxPath]uint16 +} + +// ---------------------------- Windows API helpers ---------------------------- + +func findProcessID(name string) (uint32, error) { + snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(th32csSnapProcess), 0) + if snap == 0 || snap == uintptr(^uintptr(0)) { + return 0, fmt.Errorf("no se pudo tomar snapshot de procesos") + } + defer procCloseHandle.Call(snap) + + var pe processEntry32 + pe.Size = uint32(unsafe.Sizeof(pe)) + r, _, _ := procProcess32FirstW.Call(snap, uintptr(unsafe.Pointer(&pe))) + if r == 0 { + return 0, fmt.Errorf("Process32First fallo") + } + for { + exe := syscall.UTF16ToString(pe.ExeFile[:]) + if strings.EqualFold(exe, name) { + return pe.ProcessID, nil + } + r, _, _ := procProcess32NextW.Call(snap, uintptr(unsafe.Pointer(&pe))) + if r == 0 { + break + } + } + return 0, fmt.Errorf("proceso no encontrado: %s", name) +} + +func findModuleBase(pid uint32, name string) (uintptr, uint32, string, error) { + snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(th32csSnapModule|th32csSnapModule32), uintptr(pid)) + if snap == 0 || snap == uintptr(^uintptr(0)) { + return 0, 0, "", fmt.Errorf("no se pudo tomar snapshot de modulos") + } + defer procCloseHandle.Call(snap) + + var me moduleEntry32 + me.Size = uint32(unsafe.Sizeof(me)) + r, _, _ := procModule32FirstW.Call(snap, uintptr(unsafe.Pointer(&me))) + if r == 0 { + return 0, 0, "", fmt.Errorf("Module32First fallo") + } + for { + mname := syscall.UTF16ToString(me.ModuleName[:]) + if strings.EqualFold(mname, name) { + return me.ModBaseAddr, me.ModBaseSize, syscall.UTF16ToString(me.ExePath[:]), nil + } + r, _, _ := procModule32NextW.Call(snap, uintptr(unsafe.Pointer(&me))) + if r == 0 { + break + } + } + return 0, 0, "", fmt.Errorf("modulo no encontrado: %s", name) +} + +// productVersion lee la version del ejecutable del juego. label trae las +// dos versiones completas (producto y archivo) porque no siempre +// coinciden, y sirve para diagnosticar si algun dia hay que ajustar el +// offset de PlayerIns. +func productVersion(path string) (major, minor uint16, label string, ok bool) { + if path == "" { + return 0, 0, "", false + } + p, err := syscall.UTF16PtrFromString(path) + if err != nil { + return 0, 0, "", false + } + size, _, _ := procGetFileVersionInfoSizeW.Call(uintptr(unsafe.Pointer(p)), 0) + if size == 0 { + return 0, 0, "", false + } + buf := make([]byte, size) + r, _, _ := procGetFileVersionInfoW.Call(uintptr(unsafe.Pointer(p)), 0, size, uintptr(unsafe.Pointer(&buf[0]))) + if r == 0 { + return 0, 0, "", false + } + sub, err := syscall.UTF16PtrFromString(`\`) + if err != nil { + return 0, 0, "", false + } + var info *vsFixedFileInfo + var infoLen uint32 + r, _, _ = procVerQueryValueW.Call( + uintptr(unsafe.Pointer(&buf[0])), + uintptr(unsafe.Pointer(sub)), + uintptr(unsafe.Pointer(&info)), + uintptr(unsafe.Pointer(&infoLen)), + ) + if r == 0 || info == nil || infoLen == 0 { + return 0, 0, "", false + } + quad := func(ms, ls uint32) string { + return fmt.Sprintf("%d.%d.%d.%d", ms>>16, ms&0xFFFF, ls>>16, ls&0xFFFF) + } + label = fmt.Sprintf("producto %s / archivo %s", + quad(info.ProductVersionMS, info.ProductVersionLS), + quad(info.FileVersionMS, info.FileVersionLS)) + return uint16(info.ProductVersionMS >> 16), uint16(info.ProductVersionMS & 0xFFFF), label, true +} + +// playerInsOffsetForVersion replica la tabla de SoulMemory +// (InitializeOffsets): hasta 1.06 el offset de PlayerIns dentro de +// WorldChrMan es 0x18468, de 1.07 en adelante 0x1E508. +// +// OJO: la version que reporta el exe NO es la que muestra el juego en +// pantalla (el exe puede decir 2.7.1.0 mientras el juego dice 1.17.1), y +// la tabla de SoulMemory esta escrita con los numeros del juego. Por eso +// esto es solo una CORAZONADA para decidir cual probar primero: quien +// decide de verdad es playerInsCandidates + la verificacion en memoria. +func playerInsOffsetForVersion(major, minor uint16, ok bool) uintptr { + if ok && major == 1 && minor <= 6 { + return 0x18468 + } + return 0x1E508 +} + +// playerInsCandidates devuelve los offsets conocidos a probar, con el que +// sugiere la version primero. +func playerInsCandidates(major, minor uint16, ok bool) []uintptr { + if playerInsOffsetForVersion(major, minor, ok) == 0x18468 { + return []uintptr{0x18468, 0x1E508} + } + return []uintptr{0x1E508, 0x18468} +} + +func openProcessHandle(pid uint32) (syscall.Handle, error) { + h, _, err := procOpenProcess.Call(uintptr(processQueryInformation|processVMRead), 0, uintptr(pid)) + if h == 0 { + return 0, err + } + return syscall.Handle(h), nil +} + +func readMemory(h syscall.Handle, addr uintptr, size int) ([]byte, bool) { + if addr == 0 { + return nil, false + } + buf := make([]byte, size) + var n uintptr + r, _, _ := procReadProcessMemory.Call(uintptr(h), addr, uintptr(unsafe.Pointer(&buf[0])), uintptr(size), uintptr(unsafe.Pointer(&n))) + if r == 0 || int(n) != size { + return nil, false + } + return buf, true +} + +// ------------------------- signature scans ------------------------- +// +// Dos firmas, las dos con la misma forma: instruccion de 7 bytes +// "mov reg,[rip+disp32]", donde los 3 primeros bytes son el opcode y los +// 4 siguientes el desplazamiento. El slot estatico resuelto contiene el +// puntero al objeto (una dereferencia mas). +// +// GameDataMan -> tiene el contador de muertes (+0x94) y el flag de jefe (+0xC0). +// Mismo patron que usa eldenring_boss_timer.asl (LiveSplit). +// WorldChrMan -> tiene el puntero a PlayerIns (+playerInsOffset). Si ese +// puntero es nulo, no hay personaje en el mundo: estas en el +// menu principal o en una pantalla de carga. Es exactamente +// lo que hace SoulMemory.IsPlayerLoaded(). + +type patByte struct { + val byte + wildcard bool +} + +// parsePattern acepta "48 8B 05 ?? ?? ?? ??" (?? = comodin). +func parsePattern(s string) []patByte { + var out []patByte + for _, tok := range strings.Fields(s) { + if strings.HasPrefix(tok, "?") { + out = append(out, patByte{wildcard: true}) + continue + } + v, err := strconv.ParseUint(tok, 16, 8) + if err != nil { + panic("patron invalido: " + tok) + } + out = append(out, patByte{val: byte(v)}) + } + return out +} + +var ( + // mov rax,[rip+disp32]; test rax,rax; jz +5; mov rax,[rax+58]; ret; ret + gameDataManPattern = parsePattern("48 8B 05 ?? ?? ?? ?? 48 85 C0 74 05 48 8B 40 58 C3 C3") + // mov rsi,[rip+disp32]; test rsi,rsi; ... (WorldChrManImp, igual que SoulMemory) + worldChrManPattern = parsePattern("48 8B 35 ?? ?? ?? ?? 48 85 F6 ?? ?? BB 01 00 00 00 89 5C 24 20 48 8B B6") + // mov rax,[rip+disp32]; cmp byte ptr [rax+disp32],0D; sete al; ret + // (GameMan: en +0xAC0 tiene el slot de guardado del personaje cargado) + gameManPattern = parsePattern("48 8B 05 ?? ?? ?? ?? 80 B8 ?? ?? ?? ?? 0D 0F 94 C0 C3") +) + +// saveSlotOffset: donde GameMan guarda el indice de slot (0-9) de la +// partida cargada. Es la identidad REAL de un personaje: no depende del +// nombre, asi que dos personajes que se llamen igual no se mezclan. +const saveSlotOffset = 0xAC0 + +func matchAt(buf []byte, i int, pattern []patByte) bool { + if i+len(pattern) > len(buf) { + return false + } + for j, p := range pattern { + if !p.wildcard && buf[i+j] != p.val { + return false + } + } + return true +} + +// scanModule busca varios patrones en una sola pasada por el modulo, +// leyendolo en chunks (con solape, por si un patron cae justo en el borde +// de un chunk). Devuelve, por cada patron, la direccion del match o 0. +func scanModule(h syscall.Handle, base uintptr, size uint32, patterns [][]patByte) []uintptr { + const chunk = 1 << 20 // 1 MiB + const overlap = 64 + + found := make([]uintptr, len(patterns)) + remaining := len(patterns) + + var pos uint32 + for pos < size && remaining > 0 { + readSize := chunk + if rem := int(size - pos); readSize > rem { + readSize = rem + } + buf, ok := readMemory(h, base+uintptr(pos), readSize) + if ok { + for i := 0; i < len(buf); i++ { + for p := range patterns { + if found[p] != 0 { + continue + } + if matchAt(buf, i, patterns[p]) { + found[p] = base + uintptr(pos) + uintptr(i) + remaining-- + } + } + } + } + if uint32(readSize) <= overlap { + break + } + pos += uint32(readSize) - overlap + } + return found +} + +// ripSlot convierte la direccion de una instruccion "mov reg,[rip+disp32]" +// de 7 bytes en la direccion del slot estatico al que apunta. +func ripSlot(h syscall.Handle, matchAddr uintptr) (uintptr, error) { + if matchAddr == 0 { + return 0, fmt.Errorf("patron no encontrado") + } + codeLocation := matchAddr + 3 // los 3 primeros bytes son el opcode + dispBytes, ok := readMemory(h, codeLocation, 4) + if !ok { + return 0, fmt.Errorf("no se pudo leer el desplazamiento RIP-relativo") + } + disp := int32(binary.LittleEndian.Uint32(dispBytes)) + return codeLocation + 4 + uintptr(int64(disp)), nil +} + +// gamePointers junta todo lo que se resuelve una sola vez por sesion de +// proceso: los slots estaticos (que no se mueven) y la version del juego. +type gamePointers struct { + gameDataManSlot uintptr + worldChrManSlot uintptr // 0 si no se encontro el patron (seguimos sin el chequeo de menu) + gameManSlot uintptr // 0 si no se encontro: caemos a identificar por nombre + playerInsOffset uintptr // el que estamos usando (o el candidato preferido) + playerInsTried []uintptr + offsetConfirmed bool // true cuando lo verificamos leyendo memoria de verdad + versionLabel string + + nameChain nameCandidate // como llegamos al nombre del personaje + nameConfirmed bool + namePending string // candidato a la espera de repetirse (ver resolveCharName) + namePendingOf nameCandidate +} + +// resolvePointers hace los escaneos de firma (caro: recorre todo el +// modulo) una sola vez por sesion de proceso. A proposito NO devuelve los +// objetos ya resueltos: esos punteros se re-leen en cada tick, porque el +// juego puede destruir y recrear GameDataMan (por ejemplo al volver al +// menu principal y cargar de nuevo). Si nos quedaramos con una direccion +// vieja en cache, seguiriamos leyendola con exito (la pagina de memoria +// sigue siendo valida) pero el contenido pasaria a ser datos de otra cosa +// - la causa mas probable de un contador que "sube solo" sin que hayas +// muerto en verdad. SoulMemory hace lo mismo: su clase Pointer resuelve la +// cadena entera en cada lectura, no cachea la direccion final. +func resolvePointers(h syscall.Handle, pid uint32) (gamePointers, error) { + var gp gamePointers + + base, size, exePath, err := findModuleBase(pid, processName) + if err != nil { + return gp, err + } + + major, minor, label, okVer := productVersion(exePath) + gp.playerInsTried = playerInsCandidates(major, minor, okVer) + gp.playerInsOffset = gp.playerInsTried[0] + gp.versionLabel = label + if !okVer { + gp.versionLabel = "desconocida" + } + + matches := scanModule(h, base, size, [][]patByte{gameDataManPattern, worldChrManPattern, gameManPattern}) + + gp.gameDataManSlot, err = ripSlot(h, matches[0]) + if err != nil { + return gp, fmt.Errorf("no se encontro el patron de GameDataMan (¿cambio de version del juego?)") + } + + // WorldChrMan es opcional: si no aparece, seguimos contando muertes, + // solo perdemos la deteccion de menu/pantalla de carga. + if slot, werr := ripSlot(h, matches[1]); werr == nil { + gp.worldChrManSlot = slot + } + // GameMan tambien es opcional: sin el, identificamos por nombre. + if slot, gerr := ripSlot(h, matches[2]); gerr == nil { + gp.gameManSlot = slot + } + return gp, nil +} + +// derefPointer lee un slot estatico (barato: 8 bytes) y devuelve la +// direccion ACTUAL del objeto. Se llama en cada tick, no solo una vez. +func derefPointer(h syscall.Handle, slot uintptr) (uintptr, bool) { + if slot == 0 { + return 0, false + } + buf, ok := readMemory(h, slot, 8) + if !ok { + return 0, false + } + return uintptr(binary.LittleEndian.Uint64(buf)), true +} + +// ------------------------- nombre del personaje ------------------------- +// +// Dato sacado de la tabla de Cheat Engine: "GameDataMan +0C +9C, unicode, +// largo 19". Esa notacion admite mas de una lectura (¿0x0C es un puntero +// que hay que dereferenciar, o los dos offsets se suman?), y ademas la +// comunidad/el ASL usan GameDataMan+0x08 para llegar a PlayerGameData. +// Asi que no elegimos: probamos las tres y nos quedamos con la que +// devuelva algo que parezca un nombre de verdad. + +type nameCandidate struct { + ptrOffset uintptr // offset donde vive el puntero (0 = sin dereferencia) + nameOffset uintptr // offset del texto dentro del objeto + label string +} + +var nameCandidates = []nameCandidate{ + {0x08, 0x9C, "[GameDataMan+0x08]+0x9C (PlayerGameData)"}, + {0x0C, 0x9C, "[GameDataMan+0x0C]+0x9C"}, + {0x00, 0xA8, "GameDataMan+0xA8 (0x0C y 0x9C sumados)"}, +} + +// looksLikeName (y charNameMaxChars) viven en names.go: no dependen de +// Windows, asi que quedan afuera de este archivo para poder testearlos +// sin una PC con el juego abierto. + +func readCharName(h syscall.Handle, gameDataMan uintptr, c nameCandidate) (string, bool) { + base := gameDataMan + if c.ptrOffset != 0 { + p, ok := derefPointer(h, gameDataMan+c.ptrOffset) + if !ok || p == 0 { + return "", false + } + base = p + } + buf, ok := readMemory(h, base+c.nameOffset, charNameMaxChars*2) + if !ok { + return "", false + } + u16 := make([]uint16, 0, charNameMaxChars) + for i := 0; i+1 < len(buf); i += 2 { + ch := binary.LittleEndian.Uint16(buf[i : i+2]) + if ch == 0 { + break + } + u16 = append(u16, ch) + } + s := strings.TrimSpace(string(utf16.Decode(u16))) + if !looksLikeName(s) { + return "", false + } + return s, true +} + +// resolveCharName devuelve el nombre del personaje. Antes de fijar una +// variante exige verla dar el MISMO texto dos lecturas seguidas: el +// nombre real no cambia de un segundo a otro, pero un pedazo de memoria +// que casualmente pasa el filtro es mucho menos probable que se repita. +func resolveCharName(h syscall.Handle, gameDataMan uintptr, gp *gamePointers) (string, bool) { + if gp.nameConfirmed { + return readCharName(h, gameDataMan, gp.nameChain) + } + for _, c := range nameCandidates { + s, ok := readCharName(h, gameDataMan, c) + if !ok { + continue + } + if gp.namePending == s && gp.namePendingOf == c { + gp.nameChain = c + gp.nameConfirmed = true + gp.namePending = "" + log.Printf("nombre del personaje: \"%s\" (leido con %s)", s, c.label) + return s, true + } + gp.namePending = s + gp.namePendingOf = c + return "", false + } + gp.namePending = "" + return "", false +} + +// readSaveSlot devuelve el indice de slot (0-9) de la partida cargada, o +// -1 si no lo pudimos leer. Elden Ring tiene 10 slots, asi que cualquier +// otro valor es basura y se descarta. +func readSaveSlot(h syscall.Handle, gp gamePointers) int { + if gp.gameManSlot == 0 { + return -1 + } + gameMan, ok := derefPointer(h, gp.gameManSlot) + if !ok || gameMan == 0 { + return -1 + } + buf, ok := readMemory(h, gameMan+saveSlotOffset, 1) + if !ok { + return -1 + } + slot := int(buf[0]) + if slot < 0 || slot > 9 { + return -1 + } + return slot +} + +// isPlayerLoaded replica SoulMemory.IsPlayerLoaded(): resuelve +// WorldChrMan y lee el puntero a PlayerIns; si es nulo, no hay personaje +// en el mundo. El segundo valor indica si pudimos evaluarlo. +// +// Mientras no tengamos confirmado el offset, en vez de confiar en el +// numero de version (que en Elden Ring no coincide con el que muestra el +// juego) probamos los offsets conocidos y nos quedamos con el primero que +// apunte a memoria realmente legible. Eso lo decide la maquina, no una +// tabla que puede envejecer mal. +func isPlayerLoaded(h syscall.Handle, gp *gamePointers) (loaded bool, known bool) { + if gp.worldChrManSlot == 0 { + return true, false + } + worldChrMan, ok := derefPointer(h, gp.worldChrManSlot) + if !ok { + return true, false + } + if worldChrMan == 0 { + return false, true + } + + if gp.offsetConfirmed { + playerIns, ok := derefPointer(h, worldChrMan+gp.playerInsOffset) + if !ok { + return true, false + } + return playerIns != 0, true + } + + for _, cand := range gp.playerInsTried { + playerIns, ok := derefPointer(h, worldChrMan+cand) + if !ok || playerIns == 0 { + continue + } + // Un puntero de verdad apunta a memoria mapeada; uno de basura + // casi nunca sobrevive esta lectura. + if _, ok := readMemory(h, playerIns, 8); !ok { + continue + } + gp.playerInsOffset = cand + gp.offsetConfirmed = true + log.Printf("PlayerIns confirmado en +0x%X (verificado leyendo el objeto, no por el numero de version)", cand) + return true, true + } + return false, true +} + +// -------------------------------- poller loop -------------------------------- + +// maxPlausibleDeltaPerTick: entre dos lecturas separadas por ~1s, con el +// personaje cargado todo el tiempo, el contador de muertes real no puede +// subir mas que esto (ni bajar nunca). Un salto mas grande casi siempre +// significa que agarramos memoria que ya no es GameDataMan (direccion +// vieja/invalida) y no una muerte real. +const maxPlausibleDeltaPerTick = 3 + +func pollLoop() { + var ( + handle syscall.Handle + pid uint32 + gp gamePointers + resolved bool + lastRaw int32 + haveLastRaw bool + sawUnloaded bool + warnedNoWCM bool + lastBossRead bool + + // Vigilancia de la deteccion de menu: mientras creemos que no hay + // personaje cargado, igual espiamos el contador de muertes. Si sube + // como sube una muerte de verdad, entonces nuestra deteccion esta + // mintiendo (estabas jugando) y la desactivamos. + unloadedRaw int32 + unloadedRawFirst int32 + haveUnloadedRaw bool + ) + + closeHandle := func() { + if handle != 0 { + procCloseHandle.Call(uintptr(handle)) + handle = 0 + } + pid = 0 + gp = gamePointers{} + resolved = false + haveLastRaw = false + sawUnloaded = false + haveUnloadedRaw = false + } + + for { + if handle == 0 { + newPid, err := findProcessID(processName) + if err != nil { + st.setDisconnected("esperando a eldenring.exe") + time.Sleep(3 * time.Second) + continue + } + h, err := openProcessHandle(newPid) + if err != nil { + st.setDisconnected("no se pudo abrir el proceso (¿permisos?): " + err.Error()) + time.Sleep(3 * time.Second) + continue + } + pid = newPid + handle = h + log.Printf("eldenring.exe encontrado (PID %d), escaneando firmas...", pid) + } + + if !resolved { + p, err := resolvePointers(handle, pid) + if err != nil { + st.setDisconnected(err.Error()) + time.Sleep(2 * time.Second) + // si el proceso murio, soltamos el handle para reintentar desde cero + if _, ferr := findProcessID(processName); ferr != nil { + closeHandle() + } + continue + } + gp = p + resolved = true + haveLastRaw = false + log.Printf("version del juego: %s | GameDataMan slot 0x%X", gp.versionLabel, gp.gameDataManSlot) + if gp.gameManSlot != 0 { + log.Printf("GameMan slot 0x%X (identifico personajes por su slot de guardado)", gp.gameManSlot) + } else { + log.Printf("aviso: no encontre el patron de GameMan; identifico personajes por nombre") + } + if gp.worldChrManSlot != 0 { + log.Printf("WorldChrMan slot 0x%X | PlayerIns: pruebo +0x%X y confirmo contra la memoria", gp.worldChrManSlot, gp.playerInsOffset) + } else if !warnedNoWCM { + warnedNoWCM = true + log.Printf("aviso: no se encontro el patron de WorldChrMan; sigo contando muertes pero sin detectar menu/pantalla de carga") + } + } + + // Igual que el ASL de LiveSplit, que hace "if (!IsPlayerLoaded) return;": + // sin personaje en el mundo no leemos nada. El total queda congelado en + // pantalla (no mostramos guion) para no parpadear en cada carga. + if loaded, known := isPlayerLoaded(handle, &gp); known && !loaded { + // Red de seguridad. El offset de PlayerIns depende de la version + // del juego: si algun parche lo mueve, leeriamos nulo para + // siempre y el contador quedaria congelado en pleno stream. + // + // El unico juez confiable es el contador de muertes en si: en el + // menu de inicio NO sube nunca. Asi que espiamos el crudo sin + // usarlo, y si sube como sube una muerte real (+1, +2, +3), + // entonces estabas jugando y nuestra deteccion estaba mintiendo. + // A diferencia de un timeout, esto no puede dispararse por dejar + // el juego parado en el menu un rato largo. + if gdm, ok := derefPointer(handle, gp.gameDataManSlot); ok && gdm != 0 { + if buf, ok := readMemory(handle, gdm+0x94, 4); ok { + raw := int32(binary.LittleEndian.Uint32(buf)) + if raw >= 0 && raw < 1_000_000 { + if haveUnloadedRaw { + if d := raw - unloadedRaw; d >= 1 && d <= maxPlausibleDeltaPerTick { + log.Printf("el contador de muertes subio de %d a %d mientras yo creia que no habia personaje cargado: la deteccion de menu esta equivocada en esta version, la desactivo y sigo contando", unloadedRaw, raw) + gp.worldChrManSlot = 0 + // Rescatamos lo ocurrido durante el rato confundido: + // dejamos la referencia en la primera lectura de ese + // periodo para que la logica de "cruce de carga" + // acredite las muertes si fueron pocas. + lastRaw = unloadedRawFirst + haveLastRaw = true + sawUnloaded = true + haveUnloadedRaw = false + continue + } + } else { + unloadedRawFirst = raw + } + unloadedRaw = raw + haveUnloadedRaw = true + } + } + } + // Ojo: NO tocamos haveLastRaw/lastRaw, justamente para poder + // comparar contra la ultima lectura buena cuando vuelva el mundo. + st.setPlayerUnloaded("menu principal o pantalla de carga") + sawUnloaded = true + time.Sleep(1 * time.Second) + continue + } + haveUnloadedRaw = false + + // Re-dereferenciamos el slot en CADA tick (no solo al conectar) para + // nunca quedarnos con una direccion vieja de GameDataMan. + gameDataMan, ok := derefPointer(handle, gp.gameDataManSlot) + if !ok { + st.setDisconnected("se perdio la lectura de memoria (el juego se cerro o reinicio)") + closeHandle() + time.Sleep(2 * time.Second) + continue + } + if gameDataMan == 0 { + st.setPlayerUnloaded("sin partida cargada") + sawUnloaded = true + time.Sleep(1 * time.Second) + continue + } + + deathsBuf, ok1 := readMemory(handle, gameDataMan+0x94, 4) + bossBuf, ok2 := readMemory(handle, gameDataMan+0xC0, 1) + if !ok1 { + st.setDisconnected("se perdio la lectura de memoria (el juego se cerro o reinicio)") + closeHandle() + time.Sleep(2 * time.Second) + continue + } + raw := int32(binary.LittleEndian.Uint32(deathsBuf)) + boss := lastBossRead + if ok2 { + boss = bossBuf[0] != 0 + lastBossRead = boss + } + if raw < 0 || raw > 1_000_000 { + log.Printf("lectura imposible descartada (raw %d) - re-escaneando firmas", raw) + resolved = false + haveLastRaw = false + time.Sleep(1 * time.Second) + continue + } + + // Dentro de una misma partida el contador no baja ni pega saltos: + // si pasa, es memoria que ya no es GameDataMan. Cruzando una carga + // en cambio puede cambiar a cualquier cosa, porque puede ser otro + // personaje, y de eso se encarga setCharacter. + if haveLastRaw && !sawUnloaded { + delta := int64(raw) - int64(lastRaw) + if delta < 0 || delta > maxPlausibleDeltaPerTick { + log.Printf("lectura sospechosa descartada (raw %d, anterior %d) - re-escaneando firmas", raw, lastRaw) + resolved = false + haveLastRaw = false + time.Sleep(1 * time.Second) + continue + } + } + + // Quien es este personaje se resuelve ANTES de registrar la + // lectura: si cambiaste de personaje, el total salta al suyo en + // esta misma vuelta y no hay que esperar a que alguien muera. + name, _ := resolveCharName(handle, gameDataMan, &gp) + st.setCharacter(readSaveSlot(handle, gp), name, raw) + + st.setReading(raw, boss) + + lastRaw = raw + haveLastRaw = true + sawUnloaded = false + + time.Sleep(1 * time.Second) + } +} + +// ---------------------------------- HTTP ---------------------------------- + +// puertoDe saca el puerto de una direccion tipo "0.0.0.0:47822", para +// poder decirle al compañero exactamente que escribir en su config. +func puertoDe(addr string) string { + if _, port, ok := strings.Cut(addr, ":"); ok { + return port + } + return addr +} + +func withCORS(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Cache-Control", "no-store") +} + +func main() { + log.SetFlags(log.Ltime) + log.Println("=== Elden Ring Death Counter (lectura local, solo lectura) ===") + + loadLocales() + cfg := loadConfig() + writeSampleConfig() + esPeer := cfg.Mode == "peer" + + lang := resolveLang(cfg.Language) + log.Printf("version: %s | PID %d | modo %s | idioma %s (disponibles: %s)", + buildTag, os.Getpid(), cfg.Mode, lang, strings.Join(availableLangs(), ", ")) + + totals = newTotalsStore() + st.init(totals) + + go pollLoop() + + registro := newPeerRegistry() + + // El token es obligatorio en las dos puntas: sin el, cualquiera que + // alcance el puerto podria inyectar datos en el overlay. + var token string + if !esPeer { + t, generado, err := resolveToken(cfg) + if err != nil { + log.Fatalf("no pude preparar el token: %v", err) + } + token = t + logTokenBanner(token, generado) + } + + if esPeer { + if cfg.Hub == "" { + log.Println("¡ojo! modo peer sin 'hub' en el config.toml: no tengo a donde mandar el contador") + } else if strings.TrimSpace(cfg.Token) == "" { + log.Println("¡ojo! modo peer sin 'token' en el config.toml: el hub te va a rechazar. Pedíle el token a quien lo corre.") + } else { + go peerLoop(cfg) + } + } else { + registro.declare(cfg.Partner) + } + + mux := http.NewServeMux() + + if !esPeer { + mux.HandleFunc("/ws", registro.wsHandler(token)) + } + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-Build", buildTag) + w.Write(overlayHTML) + }) + + // Los textos de la interfaz: la pagina los pide una vez al cargar. + // ?lang= permite forzar un idioma sin tocar el config, comodo para + // tener el overlay en un idioma y el panel en otro. + mux.HandleFunc("/strings.json", func(w http.ResponseWriter, r *http.Request) { + withCORS(w) + want := lang + if q := r.URL.Query().Get("lang"); q != "" { + want = resolveLang(q) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + json.NewEncoder(w).Encode(stringsFor(want)) + }) + + mux.HandleFunc("/deaths", func(w http.ResponseWriter, r *http.Request) { + withCORS(w) + snap := st.snapshot() + + // Este jugador primero, despues los compañeros en orden de aparicion. + players := []playerView{{ + Name: nombreMostrado(cfg), + Deaths: snap.Total, + BossFight: snap.BossFight, + PlayerLoaded: snap.PlayerLoaded, + Connected: snap.Connected, + Self: true, + }} + // Modo coop solo cuando hay (o hubo recien) un compañero conectado. + if registro.coopMode() { + players = append(players, registro.views()...) + } + + var combined int64 + for _, p := range players { + combined += p.Deaths + } + + resp := map[string]interface{}{ + "players": players, + "combined": combined, + "build": buildTag, + // Campos de la version de un solo jugador: los dejamos para no + // romper nada que ya este apuntando aca. + "deaths": snap.Total, + "rawDeaths": snap.RawDeaths, + "character": snap.CharName, + "slot": snap.Slot, + "bossFight": snap.BossFight, + "connected": snap.Connected, + "playerLoaded": snap.PlayerLoaded, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + + log.Printf("Panel: http://%s/", cfg.Listen) + if esPeer { + log.Printf("Mandando el contador al hub %s. Esta ventana tiene que quedar abierta mientras jugás.", cfg.Hub) + } else { + log.Printf("OBS URL: http://%s/?view=overlay", cfg.Listen) + log.Printf("Tu compañero tiene que poner en su config.toml: hub = \":%s\" y el token de arriba", puertoDe(cfg.Listen)) + log.Println("Dejá esta ventana abierta mientras streameás. Ctrl+C para cerrar.") + } + + if err := http.ListenAndServe(cfg.Listen, mux); err != nil { + log.Fatalf("no se pudo iniciar el servidor local: %v", err) + } +} diff --git a/names.go b/names.go new file mode 100644 index 0000000..cfd4e8c --- /dev/null +++ b/names.go @@ -0,0 +1,90 @@ +// looksLikeName decides whether text read from the game's memory looks +// like a real character name, and not binary garbage decoded as if it +// were UTF-16. It lives apart from main.go (which is Windows-only) +// because it's pure string logic: it can be tested without a PC with the +// game open. +package main + +import ( + "unicode" + "unicode/utf8" +) + +// charNameMaxChars: from the Cheat Engine table, "GameDataMan +// +0C +9C, unicode, length 19". +const charNameMaxChars = 19 + +// script identifies the writing-system family of a character in the name. +type script int + +const ( + scriptNone script = iota + scriptLatin + scriptCyrillic + scriptHangul + scriptCJK // Han, Hiragana, and Katakana count as a single family +) + +func scriptOf(r rune) script { + switch { + case unicode.In(r, unicode.Latin): + return scriptLatin + case unicode.In(r, unicode.Cyrillic): + return scriptCyrillic + case unicode.In(r, unicode.Hangul): + return scriptHangul + case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana): + return scriptCJK + default: + return scriptNone + } +} + +// looksLikeName guards against garbage. "Printable" isn't nearly enough: +// a 64-bit pointer read as UTF-16 decodes into things like "ᾘ䱷翶", which +// is printable and would pass a filter that only checked that. +// +// This used to require the Latin alphabet, which left out Chinese, +// Korean, and Japanese names. Instead it requires SCRIPT COHERENCE: the +// whole name has to come from a single family (Latin, Cyrillic, Hangul, +// or Han+Hiragana+Katakana as one family, since Japanese mixes all three +// in one name) plus digits/space/punctuation (which don't belong to any +// particular family). Mixing families — like Greek + Han in "ᾘ䱷翶" — is +// still rejected: that's exactly the signature of binary garbage. +func looksLikeName(s string) bool { + n := utf8.RuneCountInString(s) + if n < 2 || n > charNameMaxChars { + return false + } + + sc := scriptNone + hadLetter := false + + for i, r := range s { + if r == utf8.RuneError || !unicode.IsPrint(r) { + return false + } + if unicode.IsDigit(r) { + continue + } + if r == ' ' || r == '-' || r == '_' || r == '.' || r == '\'' { + if i == 0 { + return false // a name doesn't start with punctuation + } + continue + } + + f := scriptOf(r) + if f == scriptNone { + return false // script not accepted + } + if sc == scriptNone { + sc = f + } else if sc != f { + return false // mixed families: the signature of binary garbage + } + hadLetter = true + } + + return hadLetter +} diff --git a/names_test.go b/names_test.go new file mode 100644 index 0000000..42e1526 --- /dev/null +++ b/names_test.go @@ -0,0 +1,54 @@ +package main + +import "testing" + +func TestLooksLikeName(t *testing.T) { + cases := []struct { + name string + ok bool + }{ + // Latin: the usual case. + {"Vaike", true}, + {"Jean-Paul", true}, + {"O'Brien", true}, + {"Tarnished 2", true}, + + // Cyrillic. + {"Иван", true}, + {"Мария Петровна", true}, + + // Hangul. + {"지우", true}, + {"김민준", true}, + + // CJK: Han, Hiragana, and Katakana count as a single family + // because Japanese mixes them in one name. + {"田中", true}, // Han alone (also valid in Chinese) + {"たろう", true}, // Hiragana alone + {"タロウ", true}, // Katakana alone + {"田中たかし", true}, // Han + Hiragana, typical Japanese name + + // Cases that MUST keep failing (regression, see CLAUDE.md): + // Greek + Han mixed, the garbage a 64-bit pointer decodes into + // when read as UTF-16. + {"ᾘ䱷翶", false}, + + // Mixing two ACCEPTED families is still mixing: not a valid + // name in any real language. + {"Аnna", false}, // Cyrillic А + Latin nna + + // The usual rules: length, leading punctuation, digits-only. + {"", false}, + {"A", false}, + {"WayTooLongOfANameToBeRealHonestly", false}, + {"-Starts", false}, + {"123", false}, + {"Ok 123", true}, + } + + for _, c := range cases { + if got := looksLikeName(c.name); got != c.ok { + t.Errorf("looksLikeName(%q) = %v, wanted %v", c.name, got, c.ok) + } + } +} diff --git a/overlay.html b/overlay.html new file mode 100644 index 0000000..b2a7510 --- /dev/null +++ b/overlay.html @@ -0,0 +1,377 @@ + + + + + +Elden Ring Death Counter + + + + + + + + + + + + + + diff --git a/totals.go b/totals.go new file mode 100644 index 0000000..f34f691 --- /dev/null +++ b/totals.go @@ -0,0 +1,142 @@ +// totals.go: los totales, uno por personaje. +// +// El juego ya lleva la cuenta de muertes de cada personaje por separado +// (esta en la partida, no en la PC), asi que el programa hace lo mismo: +// para cada personaje guarda un "base", y el numero que se muestra es +// siempre total = muertes que lee del juego + base. +// +// El numero que se muestra es, sin intermediarios, el que lleva el juego +// para ese personaje. Lo que se guarda en disco NO es un ajuste: es solo +// lo ultimo que se vio, para poder mostrar algo cuando abris el contador +// antes que el juego y todavia no hay nada que leer. +// +// Hubo dos modelos anteriores. Primero un unico total de por vida que +// nunca bajaba, que con varios personajes dejaba el numero quieto hasta +// la siguiente muerte. Despues un "base" por personaje que se sumaba a +// la lectura, heredado de cuando no confiabamos en lo que leiamos: una +// vez que el total sale del juego en cada lectura, corregir a mano dejo +// de arreglar nada y solo servia para que el numero mintiera en silencio. +package main + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "strconv" + "strings" + "sync" +) + +type charTotal struct { + Name string `json:"name"` + Total int64 `json:"total"` // ultimo total visto: para mostrar algo antes de leer el juego +} + +type totalsFile struct { + LastCharacter string `json:"lastCharacter"` + Characters map[string]*charTotal `json:"characters"` +} + +type totalsStore struct { + mu sync.Mutex + path string + data totalsFile +} + +// testExeDir permite a los tests apuntar a un directorio temporal. +var testExeDir string + +func exeDir() (string, bool) { + if testExeDir != "" { + return testExeDir, true + } + if exe, err := os.Executable(); err == nil { + return filepath.Dir(exe), true + } + return "", false +} + +func totalsPath() string { + if dir, ok := exeDir(); ok { + return filepath.Join(dir, "totals.json") + } + return "totals.json" +} + +func newTotalsStore() *totalsStore { + s := &totalsStore{path: totalsPath()} + s.data.Characters = map[string]*charTotal{} + + data, err := os.ReadFile(s.path) + if err == nil { + if err := json.Unmarshal(data, &s.data); err != nil { + log.Printf("totals.json ilegible (%v): arranco de cero, no lo piso hasta la proxima escritura", err) + s.data = totalsFile{Characters: map[string]*charTotal{}} + } + if s.data.Characters == nil { + s.data.Characters = map[string]*charTotal{} + } + } + return s +} + +func (s *totalsStore) get(key string) (*charTotal, bool) { + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.data.Characters[key] + if !ok { + return nil, false + } + cp := *c + return &cp, true +} + +// lastKnownTotal: que mostrar mientras el juego no este abierto. +func (s *totalsStore) lastKnownTotal() (key, name string, total int64) { + s.mu.Lock() + defer s.mu.Unlock() + if c, ok := s.data.Characters[s.data.LastCharacter]; ok { + return s.data.LastCharacter, c.Name, c.Total + } + return "", "", 0 +} + +func (s *totalsStore) save(key string, c charTotal) { + s.mu.Lock() + s.data.Characters[key] = &c + s.data.LastCharacter = key + blob, err := json.MarshalIndent(s.data, "", " ") + s.mu.Unlock() + if err != nil { + log.Printf("no pude serializar totals.json: %v", err) + return + } + // Escritura atomica: si se corta la luz a mitad, el archivo viejo + // queda entero en vez de quedar un JSON cortado por la mitad. + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, blob, 0644); err != nil { + log.Printf("no pude escribir totals.json: %v", err) + return + } + if err := os.Rename(tmp, s.path); err != nil { + log.Printf("no pude reemplazar totals.json: %v", err) + } +} + +// characterKey: la identidad de un personaje. +// +// El slot de guardado (GameMan+0xAC0) es la identidad buena: no cambia +// nunca y dos personajes que se llamen igual siguen siendo distintos. El +// nombre queda solo como respaldo para cuando no podemos leer el slot, +// con la limitacion conocida de que ahi si dos homonimos comparten +// contador. +func characterKey(slot int, name string) string { + if slot >= 0 { + return "slot" + strconv.Itoa(slot) + } + if n := strings.ToLower(strings.TrimSpace(name)); n != "" { + return "name:" + n + } + return "" +} diff --git a/ws.go b/ws.go new file mode 100644 index 0000000..933abd6 --- /dev/null +++ b/ws.go @@ -0,0 +1,279 @@ +// ws.go: implementacion minima de WebSocket (RFC 6455) con la libreria +// estandar, servidor y cliente. No usamos gorilla/websocket para que el +// programa siga siendo un unico .exe sin dependencias que haya que +// repartir ni vendorear. +// +// Alcance a proposito acotado a lo que necesitamos: mensajes de texto +// chicos, sin fragmentacion, sin compresion, sin TLS (va por Tailscale, +// que ya cifra el tramo entre las dos PCs). +package main + +import ( + "bufio" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "encoding/binary" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" +) + +const ( + wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + opContinuation = 0x0 + opText = 0x1 + opBinary = 0x2 + opClose = 0x8 + opPing = 0x9 + opPong = 0xA + + maxFrameSize = 1 << 20 // 1 MiB: nuestros mensajes son de ~100 bytes +) + +type wsConn struct { + conn net.Conn + br *bufio.Reader + isClient bool // solo el cliente enmascara, segun el RFC + wmu sync.Mutex + closed bool +} + +func wsAcceptKey(key string) string { + h := sha1.New() + io.WriteString(h, key+wsGUID) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +// wsUpgrade convierte una peticion HTTP entrante en una conexion +// WebSocket (lado servidor). +func wsUpgrade(w http.ResponseWriter, r *http.Request) (*wsConn, error) { + if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") || + !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + return nil, fmt.Errorf("no es un upgrade a websocket") + } + key := r.Header.Get("Sec-WebSocket-Key") + if key == "" { + return nil, fmt.Errorf("falta la cabecera Sec-WebSocket-Key") + } + hj, ok := w.(http.Hijacker) + if !ok { + return nil, fmt.Errorf("este servidor no soporta hijack") + } + conn, brw, err := hj.Hijack() + if err != nil { + return nil, err + } + resp := "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Accept: " + wsAcceptKey(key) + "\r\n\r\n" + if _, err := conn.Write([]byte(resp)); err != nil { + conn.Close() + return nil, err + } + return &wsConn{conn: conn, br: brw.Reader}, nil +} + +// wsDial abre una conexion WebSocket contra un hub (lado cliente). +func wsDial(addr, path string, timeout time.Duration) (*wsConn, error) { + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return nil, err + } + var keyBytes [16]byte + if _, err := rand.Read(keyBytes[:]); err != nil { + conn.Close() + return nil, err + } + key := base64.StdEncoding.EncodeToString(keyBytes[:]) + + req := "GET " + path + " HTTP/1.1\r\n" + + "Host: " + addr + "\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: " + key + "\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n" + + conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.Write([]byte(req)); err != nil { + conn.Close() + return nil, err + } + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, nil) + if err != nil { + conn.Close() + return nil, err + } + resp.Body.Close() + if resp.StatusCode != http.StatusSwitchingProtocols { + conn.Close() + return nil, fmt.Errorf("el hub respondio %s (esperaba 101)", resp.Status) + } + if !strings.EqualFold(resp.Header.Get("Sec-WebSocket-Accept"), wsAcceptKey(key)) { + conn.Close() + return nil, fmt.Errorf("el handshake no valida (¿del otro lado hay un websocket?)") + } + conn.SetDeadline(time.Time{}) + return &wsConn{conn: conn, br: br, isClient: true}, nil +} + +func (c *wsConn) writeFrame(opcode byte, payload []byte) error { + hdr := make([]byte, 0, 14) + hdr = append(hdr, 0x80|opcode) // FIN + opcode + + maskBit := byte(0) + if c.isClient { + maskBit = 0x80 + } + n := len(payload) + switch { + case n <= 125: + hdr = append(hdr, maskBit|byte(n)) + case n <= 65535: + var ext [2]byte + binary.BigEndian.PutUint16(ext[:], uint16(n)) + hdr = append(hdr, maskBit|126) + hdr = append(hdr, ext[:]...) + default: + var ext [8]byte + binary.BigEndian.PutUint64(ext[:], uint64(n)) + hdr = append(hdr, maskBit|127) + hdr = append(hdr, ext[:]...) + } + + body := payload + if c.isClient { + var mask [4]byte + if _, err := rand.Read(mask[:]); err != nil { + return err + } + hdr = append(hdr, mask[:]...) + body = make([]byte, n) + for i := 0; i < n; i++ { + body[i] = payload[i] ^ mask[i%4] + } + } + + c.wmu.Lock() + defer c.wmu.Unlock() + if c.closed { + return io.ErrClosedPipe + } + if _, err := c.conn.Write(hdr); err != nil { + return err + } + if n > 0 { + if _, err := c.conn.Write(body); err != nil { + return err + } + } + return nil +} + +func (c *wsConn) readFrame() (opcode byte, payload []byte, err error) { + var h [2]byte + if _, err = io.ReadFull(c.br, h[:]); err != nil { + return + } + fin := h[0]&0x80 != 0 + opcode = h[0] & 0x0F + masked := h[1]&0x80 != 0 + n := int(h[1] & 0x7F) + + switch n { + case 126: + var ext [2]byte + if _, err = io.ReadFull(c.br, ext[:]); err != nil { + return + } + n = int(binary.BigEndian.Uint16(ext[:])) + case 127: + var ext [8]byte + if _, err = io.ReadFull(c.br, ext[:]); err != nil { + return + } + v := binary.BigEndian.Uint64(ext[:]) + if v > maxFrameSize { + err = fmt.Errorf("frame demasiado grande (%d bytes)", v) + return + } + n = int(v) + } + if n > maxFrameSize { + err = fmt.Errorf("frame demasiado grande (%d bytes)", n) + return + } + + var mask [4]byte + if masked { + if _, err = io.ReadFull(c.br, mask[:]); err != nil { + return + } + } + payload = make([]byte, n) + if n > 0 { + if _, err = io.ReadFull(c.br, payload); err != nil { + return + } + } + if masked { + for i := range payload { + payload[i] ^= mask[i%4] + } + } + if !fin || opcode == opContinuation { + err = fmt.Errorf("frames fragmentados no soportados") + } + return +} + +// ReadMessage devuelve el proximo mensaje de texto/binario, respondiendo +// los pings por dentro. Un close del otro lado se reporta como io.EOF. +func (c *wsConn) ReadMessage() ([]byte, error) { + for { + op, payload, err := c.readFrame() + if err != nil { + return nil, err + } + switch op { + case opText, opBinary: + return payload, nil + case opPing: + if err := c.writeFrame(opPong, payload); err != nil { + return nil, err + } + case opPong: + // nada que hacer + case opClose: + c.writeFrame(opClose, nil) + return nil, io.EOF + default: + return nil, fmt.Errorf("opcode desconocido: 0x%X", op) + } + } +} + +func (c *wsConn) WriteText(b []byte) error { return c.writeFrame(opText, b) } +func (c *wsConn) Ping() error { return c.writeFrame(opPing, nil) } + +func (c *wsConn) SetReadDeadline(t time.Time) error { return c.conn.SetReadDeadline(t) } + +func (c *wsConn) RemoteAddr() string { return c.conn.RemoteAddr().String() } + +func (c *wsConn) Close() error { + c.wmu.Lock() + if c.closed { + c.wmu.Unlock() + return nil + } + c.closed = true + c.wmu.Unlock() + return c.conn.Close() +}