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