Files
deathwatch/counter.go
T
emmatherock e1f7e6f529 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.
2026-09-17 21:10:22 -03:00

199 lines
4.9 KiB
Go

// 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)
}
}