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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user