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

Reads the game's process memory read-only (AOB signature scanning,
re-resolved every tick — never a cached pointer) and serves a live
death counter as a browser-source overlay plus a status panel.

Supports co-op: each player runs the program locally, one acts as
hub. The peer link is authenticated (HMAC challenge/response, no
token on the wire, constant-time compare) but not yet encrypted —
see CLAUDE.md's TODO section for the planned TLS+pinning split.

Zero external dependencies — including a hand-rolled WebSocket
implementation — so the whole thing ships as one .exe.
This commit is contained in:
emmatherock committed 2026-09-17 21:10:22 -03:00
commit e1f7e6f529
18 files changed
+3690

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
// totals.go: los totales, uno por personaje.
//
// El juego ya lleva la cuenta de muertes de cada personaje por separado
// (esta en la partida, no en la PC), asi que el programa hace lo mismo:
// para cada personaje guarda un "base", y el numero que se muestra es
// siempre total = muertes que lee del juego + base.
//
// El numero que se muestra es, sin intermediarios, el que lleva el juego
// para ese personaje. Lo que se guarda en disco NO es un ajuste: es solo
// lo ultimo que se vio, para poder mostrar algo cuando abris el contador
// antes que el juego y todavia no hay nada que leer.
//
// Hubo dos modelos anteriores. Primero un unico total de por vida que
// nunca bajaba, que con varios personajes dejaba el numero quieto hasta
// la siguiente muerte. Despues un "base" por personaje que se sumaba a
// la lectura, heredado de cuando no confiabamos en lo que leiamos: una
// vez que el total sale del juego en cada lectura, corregir a mano dejo
// de arreglar nada y solo servia para que el numero mintiera en silencio.
package main
import (
"encoding/json"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
type charTotal struct {
Name string `json:"name"`
Total int64 `json:"total"` // ultimo total visto: para mostrar algo antes de leer el juego
}
type totalsFile struct {
LastCharacter string `json:"lastCharacter"`
Characters map[string]*charTotal `json:"characters"`
}
type totalsStore struct {
mu sync.Mutex
path string
data totalsFile
}
// testExeDir permite a los tests apuntar a un directorio temporal.
var testExeDir string
func exeDir() (string, bool) {
if testExeDir != "" {
return testExeDir, true
}
if exe, err := os.Executable(); err == nil {
return filepath.Dir(exe), true
}
return "", false
}
func totalsPath() string {
if dir, ok := exeDir(); ok {
return filepath.Join(dir, "totals.json")
}
return "totals.json"
}
func newTotalsStore() *totalsStore {
s := &totalsStore{path: totalsPath()}
s.data.Characters = map[string]*charTotal{}
data, err := os.ReadFile(s.path)
if err == nil {
if err := json.Unmarshal(data, &s.data); err != nil {
log.Printf("totals.json ilegible (%v): arranco de cero, no lo piso hasta la proxima escritura", err)
s.data = totalsFile{Characters: map[string]*charTotal{}}
}
if s.data.Characters == nil {
s.data.Characters = map[string]*charTotal{}
}
}
return s
}
func (s *totalsStore) get(key string) (*charTotal, bool) {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.data.Characters[key]
if !ok {
return nil, false
}
cp := *c
return &cp, true
}
// lastKnownTotal: que mostrar mientras el juego no este abierto.
func (s *totalsStore) lastKnownTotal() (key, name string, total int64) {
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.data.Characters[s.data.LastCharacter]; ok {
return s.data.LastCharacter, c.Name, c.Total
}
return "", "", 0
}
func (s *totalsStore) save(key string, c charTotal) {
s.mu.Lock()
s.data.Characters[key] = &c
s.data.LastCharacter = key
blob, err := json.MarshalIndent(s.data, "", " ")
s.mu.Unlock()
if err != nil {
log.Printf("no pude serializar totals.json: %v", err)
return
}
// Escritura atomica: si se corta la luz a mitad, el archivo viejo
// queda entero en vez de quedar un JSON cortado por la mitad.
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, blob, 0644); err != nil {
log.Printf("no pude escribir totals.json: %v", err)
return
}
if err := os.Rename(tmp, s.path); err != nil {
log.Printf("no pude reemplazar totals.json: %v", err)
}
}
// characterKey: la identidad de un personaje.
//
// El slot de guardado (GameMan+0xAC0) es la identidad buena: no cambia
// nunca y dos personajes que se llamen igual siguen siendo distintos. El
// nombre queda solo como respaldo para cuando no podemos leer el slot,
// con la limitacion conocida de que ahi si dos homonimos comparten
// contador.
func characterKey(slot int, name string) string {
if slot >= 0 {
return "slot" + strconv.Itoa(slot)
}
if n := strings.ToLower(strings.TrimSpace(name)); n != "" {
return "name:" + n
}
return ""
}