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.
143 lines
4.0 KiB
Go
143 lines
4.0 KiB
Go
// 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 ""
|
|
}
|