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.
313 lines
8.8 KiB
Go
313 lines
8.8 KiB
Go
// 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")
|
|
}
|