Initial commit: DeathWatch, an Elden Ring death counter for OBS

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.
This commit is contained in:
emmatherock committed 2026-09-17 21:10:22 -03:00
commit e1f7e6f529
18 files changed
+3690

No files matched your search

+11
View File
@@ -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
+276
View File
@@ -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/<pid>/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.
+105
View File
@@ -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")
}
+192
View File
@@ -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)
}
+312
View File
@@ -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")
}
+198
View File
@@ -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)
}
}
+113
View File
@@ -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)
}
}
+417
View File
@@ -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
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
module deathwatch
go 1.24.7
+122
View File
@@ -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
}
+30
View File
@@ -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"
}
+30
View File
@@ -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"
}
+939
View File
@@ -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 = \"<tu IP>:%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)
}
}
+90
View File
@@ -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
}
+54
View File
@@ -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)
}
}
}
+377
View File
@@ -0,0 +1,377 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Elden Ring Death Counter</title>
<style>
:root{
--bg:#0b0906;
--bg-elevated:#17130f;
--bg-card:#1d1712;
--gold:#c9a24b;
--gold-bright:#e8c874;
--ash-red:#8a2a2a;
--ash-red-bright:#b23a3a;
--parchment:#e9e2cf;
--muted:#948a76;
--rule:#3a3226;
--ok:#7fae6e;
--shadow:0 20px 60px rgba(0,0,0,.55);
}
*{box-sizing:border-box;}
html,body{background:var(--bg);}
body{
margin:0; min-height:100vh; background:var(--bg); color:var(--parchment);
font-family:'EB Garamond', Georgia, 'Times New Roman', serif;
-webkit-font-smoothing:antialiased;
}
[hidden]{display:none!important;}
.display,h1,.num{font-family:'Cinzel', Georgia, serif;}
.eyebrow{font-family:'Cinzel', Georgia, serif;font-size:.72rem;letter-spacing:.32em;text-transform:uppercase;color:var(--muted);}
.num{font-variant-numeric:tabular-nums;font-feature-settings:"tnum";}
@media (prefers-reduced-motion: reduce){*{animation-duration:.001ms!important;transition-duration:.001ms!important;}}
/* overlay (OBS) */
html.mode-overlay,html.mode-overlay body{background:transparent;}
#overlay{background:transparent;min-height:100vh;display:flex;align-items:flex-start;justify-content:flex-start;padding:16px;}
.plate{
background:rgba(9,7,5,.9); backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px);
border-top:1px solid var(--gold); border-bottom:1px solid var(--gold);
padding:18px 24px; width:600px; box-sizing:border-box; box-shadow:var(--shadow);
}
/* Con un solo jugador la placa se angosta; el canvas de OBS no cambia,
simplemente ocupa menos. Asi las dimensiones se configuran una vez. */
.plate.solo{width:420px;}
.players{display:flex;align-items:stretch;justify-content:center;}
.pcol{flex:1 1 0;min-width:0;text-align:center;}
.pname{
font-family:'Cinzel', Georgia, serif;font-size:.78rem;letter-spacing:.24em;text-transform:uppercase;
color:var(--gold);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.pnum{
display:block;font-family:'Cinzel', Georgia, serif;font-variant-numeric:tabular-nums;
font-size:3.6rem;line-height:1.05;font-weight:600;color:var(--parchment);margin-top:.25rem;
transition:color .15s ease, transform .15s ease;
}
.pnum.pulse{color:var(--gold-bright);transform:scale(1.06);}
/* Una sola linea bajo el numero: dice "muertes" y, cuando hay algo que
contar (jefe, sin conexion), lo agrega ahi mismo. Asi la palabra
siempre esta y el alto del overlay no cambia nunca. */
.pnote{
font-family:'Cinzel', Georgia, serif;font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;
color:var(--muted);min-height:1.5em;margin-top:.35rem;
}
.pnote .st{color:var(--ash-red-bright);font-style:italic;}
.pnote .sep{opacity:.5;margin:0 .45em;}
.pcol.off .pname,.pcol.off .pnum{color:var(--muted);}
.pdiv{width:1px;flex:0 0 1px;margin:0 20px;background:linear-gradient(180deg,transparent,var(--gold),transparent);opacity:.75;}
/* control / status */
#control{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:3rem 1.25rem;}
.card{width:100%;max-width:560px;background:var(--bg-card);border:1px solid var(--rule);box-shadow:var(--shadow);padding:2.4rem 2.4rem 2rem;position:relative;}
.card::before,.card::after{content:"";position:absolute;left:2.4rem;right:2.4rem;height:1px;background:linear-gradient(90deg,transparent,var(--gold),transparent);opacity:.6;}
.card::before{top:1.35rem;} .card::after{bottom:1.35rem;}
.title{text-align:center;font-size:1.05rem;letter-spacing:.28em;text-transform:uppercase;color:var(--gold);margin:0 0 .3rem;}
.title small{display:block;font-family:'EB Garamond', Georgia, serif;letter-spacing:0;text-transform:none;font-style:italic;color:var(--muted);font-size:.95rem;margin-top:.3rem;}
.hero{text-align:center;margin:2.1rem 0 1.6rem;}
.hero .num{font-size:6rem;line-height:1;font-weight:700;color:var(--parchment);transition:color .15s ease, transform .15s ease;}
.hero .num.pulse{color:var(--gold-bright);transform:scale(1.05);}
.hero .eyebrow{margin-top:.5rem;}
.stats{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--rule);margin:1.6rem 0;border:1px solid var(--rule);}
.stat{background:var(--bg-elevated);padding:.9rem 1rem;text-align:center;}
.stat .stat-num{font-size:1.5rem;font-weight:600;color:var(--gold);}
.stat .stat-label{font-size:.65rem;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-top:.2rem;}
.field{margin:1.4rem 0;}
.field label{display:block;font-size:.7rem;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);margin-bottom:.4rem;}
.field .row{display:flex;gap:.5rem;}
.field input[type=number],.field input[type=text]{
width:100%;background:var(--bg-elevated);border:1px solid var(--rule);color:var(--parchment);
font-family:'EB Garamond', Georgia, serif;font-size:1.05rem;padding:.55rem .75rem;
}
.field input:focus{border-color:var(--gold);outline:none;}
.btn{border:1px solid var(--gold);background:transparent;color:var(--gold-bright);padding:.6rem 1rem;font-size:.85rem;letter-spacing:.1em;text-transform:uppercase;cursor:pointer;transition:background .15s ease;}
.btn:hover{background:rgba(201,162,75,.12);}
.hint{font-size:.78rem;color:var(--muted);margin-top:.5rem;line-height:1.5;}
.obs-box{margin-top:1.8rem;padding-top:1.3rem;border-top:1px solid var(--rule);}
.obs-url-row{display:flex;gap:.5rem;}
.obs-url-row input{flex:1;background:var(--bg-elevated);border:1px solid var(--rule);color:var(--muted);font-family:'EB Garamond', monospace;font-size:.85rem;padding:.5rem .6rem;}
.copy-btn{border:1px solid var(--rule);background:transparent;color:var(--muted);padding:.5rem .9rem;font-size:.8rem;letter-spacing:.08em;text-transform:uppercase;cursor:pointer;}
.copy-btn:hover{border-color:var(--gold);color:var(--gold);}
.status{text-align:center;font-size:.75rem;color:var(--muted);margin-top:1.3rem;letter-spacing:.05em;}
.status .dot{display:inline-block;width:.45em;height:.45em;border-radius:50%;background:var(--muted);margin-right:.4em;}
.status.live .dot{background:var(--ok);}
.status.err .dot{background:var(--ash-red-bright);}
.build{text-align:center;font-size:.68rem;color:var(--muted);margin-top:.4rem;letter-spacing:.04em;opacity:.7;}
.boss-flag{display:none;text-align:center;color:var(--ash-red-bright);font-style:italic;letter-spacing:.08em;margin-top:.3rem;}
.boss-flag.on{display:block;}
.peer-row{display:flex;justify-content:space-between;align-items:baseline;gap:1rem;padding:.45rem .75rem;background:var(--bg-elevated);border:1px solid var(--rule);margin-bottom:.35rem;}
.peer-row .pn{color:var(--parchment);}
.peer-row .pv{font-family:'Cinzel', Georgia, serif;color:var(--gold);font-variant-numeric:tabular-nums;}
.peer-row.off .pn,.peer-row.off .pv{color:var(--muted);}
.peer-row .ps{font-size:.72rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);}
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
<script>
(function(){
var params = new URLSearchParams(location.search);
var mode = (params.get('view') || 'control').toLowerCase();
if (mode !== 'overlay') mode = 'control';
document.documentElement.classList.add('mode-' + mode);
window.__mode = mode;
})();
</script>
</head>
<body>
<div id="overlay" hidden>
<div class="plate solo" id="ov-plate">
<div class="players" id="ov-players"></div>
</div>
</div>
<div id="control" hidden>
<div class="card">
<p class="title"><span data-i18n="panel.title">Elden Ring</span><small data-i18n="panel.subtitle">Death Counter</small></p>
<div class="hero">
<span class="num" id="c-total">–</span>
<div class="eyebrow" data-i18n="panel.total">Total deaths</div>
</div>
<div class="boss-flag" id="c-boss" data-i18n="panel.boss">⚔ In a boss fight</div>
<div class="stats">
<div class="stat"><div class="stat-num" id="c-raw">–</div><div class="stat-label" data-i18n="panel.raw">Read from process</div></div>
<div class="stat"><div class="stat-num" id="c-slot">–</div><div class="stat-label" data-i18n="panel.slot">Save slot</div></div>
</div>
<div class="field" id="c-peers-box" hidden>
<label data-i18n="panel.partners">Partners</label>
<div id="c-peers"></div>
</div>
<div class="obs-box">
<div class="field">
<label data-i18n="panel.obsUrl">URL for OBS (Browser Source)</label>
<div class="obs-url-row">
<input type="text" id="obs-url" readonly>
<button class="copy-btn" id="copy-btn" data-i18n="panel.copy">Copy</button>
</div>
</div>
<p class="hint" data-i18n="panel.obsHint"></p>
</div>
<p class="status" id="status"><span class="dot"></span><span id="status-text" data-i18n="status.connecting">Connecting…</span></p>
<p class="build" id="c-build"></p>
</div>
</div>
<script>
(function(){
var mode = window.__mode || 'control';
// Textos de la interfaz. El diccionario real viene de /strings.json
// (ver i18n.go y locales/*.json). Este de aca es el bote salvavidas:
// si esa peticion falla, se ve ingles y nunca una clave cruda.
var FALLBACK = {
"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"
};
var STR = {};
function t(k){ return STR[k] || FALLBACK[k] || k; }
function applyStrings(){
var nodes = document.querySelectorAll('[data-i18n]');
for (var i = 0; i < nodes.length; i++){
nodes[i].textContent = t(nodes[i].getAttribute('data-i18n'));
}
document.documentElement.lang = STR['_lang'] || 'en';
}
document.getElementById(mode === 'overlay' ? 'overlay' : 'control').hidden = false;
function fmtElapsed(ms){
if (ms == null) return '—';
var s = Math.floor(ms/1000);
var h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;
function pad(n){return (n<10?'0':'')+n;}
return h>0 ? (h+':'+pad(m)+':'+pad(ss)) : (pad(m)+':'+pad(ss));
}
var prevTotal = null;
function pulse(el){ el.classList.add('pulse'); setTimeout(function(){el.classList.remove('pulse');},260); }
if (mode === 'control'){
document.getElementById('obs-url').value = location.origin + '/?view=overlay';
document.getElementById('copy-btn').addEventListener('click', function(){
var btn=this, url=document.getElementById('obs-url');
function done(ok){ btn.textContent = ok?t('panel.copied'):t('panel.copyManual'); setTimeout(function(){btn.textContent=t('panel.copy');},1400); }
if (navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(url.value).then(function(){done(true);}, function(){url.select();done(false);});
} else { url.select(); done(false); }
});
}
function setStatus(text, cls){
var el = document.getElementById('status');
if (!el) return;
document.getElementById('status-text').textContent = text;
el.className = 'status' + (cls ? ' ' + cls : '');
}
// Cache de las columnas: reconstruir el DOM en cada tick haria
// parpadear el overlay y perderia la animacion del pulso.
//
// Se indexan por POSICION, nunca por nombre. Indexarlas por nombre fue
// un bug real: al cambiar de personaje cambiaba la clave, se creaba una
// columna nueva y la vieja quedaba colgada en el DOM, asi que un mismo
// jugador aparecia dos veces. El nombre es contenido, no identidad.
var slots = [];
var prevByName = {};
// ensureSlots deja exactamente n columnas, creando o sacando las que
// hagan falta (con su divisor).
function ensureSlots(list, n){
while (slots.length > n){
var old = slots.pop();
if (old.div) list.removeChild(old.div);
list.removeChild(old.col);
}
while (slots.length < n){
var i = slots.length;
var div = null;
if (i > 0){
div = document.createElement('div');
div.className = 'pdiv';
list.appendChild(div);
}
var col = document.createElement('div'); col.className = 'pcol';
var nm = document.createElement('div'); nm.className = 'pname';
var vl = document.createElement('span'); vl.className = 'pnum';
var nt = document.createElement('div'); nt.className = 'pnote';
col.appendChild(nm); col.appendChild(vl); col.appendChild(nt);
list.appendChild(col);
slots.push({col:col, div:div, name:nm, num:vl, note:nt});
}
}
function playersOf(d){
if (d.players && d.players.length) return d.players;
// Respuesta de la version de un solo jugador.
return [{name:'Deaths', deaths:d.deaths, bossFight:d.bossFight,
playerLoaded:d.playerLoaded, connected:d.connected, self:true}];
}
function renderOverlay(d){
var players = playersOf(d);
var list = document.getElementById('ov-players');
document.getElementById('ov-plate').className = 'plate' + (players.length < 2 ? ' solo' : '');
ensureSlots(list, players.length);
for (var i = 0; i < players.length; i++){
var p = players[i];
var slot = slots[i];
// Sin nombre de personaje todavía: etiqueta según el modo.
slot.name.textContent = p.name ||
(players.length < 2 ? t('overlay.deaths') : (p.self ? t('overlay.you') : t('overlay.partner')));
slot.num.textContent = p.connected ? p.deaths : '–';
slot.col.className = 'pcol' + (p.connected ? '' : ' off');
slot.note.textContent = '';
if (!p.connected){
slot.note.textContent = p.self ? t('overlay.waiting') : t('overlay.offline');
} else {
slot.note.appendChild(document.createTextNode(t('overlay.deaths')));
if (p.bossFight){
var sep = document.createElement('span');
sep.className = 'sep'; sep.textContent = '·';
var st = document.createElement('span');
st.className = 'st'; st.textContent = '⚔ ' + t('overlay.boss');
slot.note.appendChild(sep); slot.note.appendChild(st);
}
}
var prev = prevByName[p.name];
if (p.connected && prev !== undefined && p.deaths > prev) pulse(slot.num);
prevByName[p.name] = p.connected ? p.deaths : undefined;
}
}
function render(d){
var total = d.deaths;
var display = d.connected ? total : '–';
if (mode === 'overlay'){
renderOverlay(d);
} else {
var cTotal = document.getElementById('c-total');
cTotal.textContent = display;
document.getElementById('c-raw').textContent = d.connected ? d.rawDeaths : '–';
document.getElementById('c-slot').textContent = (d.slot === undefined || d.slot < 0) ? '–' : d.slot;
document.getElementById('c-boss').classList.toggle('on', !!d.bossFight);
document.getElementById('c-build').textContent = d.build ? (t('panel.build') + ': ' + d.build) : '';
if (d.connected && prevTotal !== null && total > prevTotal) pulse(cTotal);
if (!d.connected) setStatus(t('status.waitingGame'), '');
else if (d.playerLoaded === false) setStatus(t('status.menu'), '');
else setStatus(t('status.live'), 'live');
var peers = (d.players || []).filter(function(p){ return !p.self; });
document.getElementById('c-peers-box').hidden = peers.length === 0;
var box = document.getElementById('c-peers');
box.textContent = '';
peers.forEach(function(p){
var row = document.createElement('div');
row.className = 'peer-row' + (p.connected ? '' : ' off');
var n = document.createElement('span'); n.className = 'pn'; n.textContent = p.name;
var s = document.createElement('span'); s.className = 'ps';
s.textContent = p.connected ? (p.playerLoaded ? t('peer.playing') : t('peer.menu')) : t('peer.offline');
var v = document.createElement('span'); v.className = 'pv';
v.textContent = p.connected ? p.deaths : '–';
var left = document.createElement('span');
left.appendChild(n); left.appendChild(document.createTextNode(' ')); left.appendChild(s);
row.appendChild(left); row.appendChild(v);
box.appendChild(row);
});
}
prevTotal = d.connected ? total : null;
}
function poll(){
fetch('/deaths', {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(d){
render(d);
}).catch(function(){
setStatus(t('status.noServer'), 'err');
}).finally(function(){
setTimeout(poll, 1000);
});
}
// ?lang= en la URL pisa el idioma del config, comodo para tener el
// overlay en un idioma y el panel en otro sin tocar archivos.
var langParam = new URLSearchParams(location.search).get('lang');
fetch('/strings.json' + (langParam ? '?lang=' + encodeURIComponent(langParam) : ''), {cache:'no-store'})
.then(function(r){ return r.json(); })
.then(function(d){ if (d && typeof d === 'object') STR = d; })
.catch(function(){ /* nos quedamos con FALLBACK */ })
.finally(function(){ applyStrings(); poll(); });
})();
</script>
</body>
</html>
+142
View File
@@ -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 ""
}
+279
View File
@@ -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()
}