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

+312
View File
@@ -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")
}