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:
commit
e1f7e6f529
18 files changed
+3690
No files matched your search
+11
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user