Add TLS + certificate pinning for the peer link
The co-op link was authenticated (HMAC token, never sent over the wire) but not encrypted. The hub now generates a self-signed cert on first run; the peer pins its exact fingerprint (no CA involved — there isn't one for a Tailscale/LAN address), delivered via a single invite-code paste that also carries the token, replacing today's separate IP+token copy. The peer link moves to its own TLS-only port (peer_listen, 47823) so the plain overlay/panel port (47822, OBS-facing) never needs to be exposed alongside it — today, opening the overlay port to a remote partner also exposes /deaths and the panel to anyone. Mandatory pinning, no insecure fallback: a half-configured peer (some but not all of hub/token/fingerprint, or a broken invite) fails loudly at startup rather than connecting unpinned. An unconfigured peer still runs fine as a local-only overlay, same as before. New: tlscert.go (cert generation/persistence), pin.go (fingerprint pinning), invite.go (invite-code encode/decode, host auto-detection), each with tests. main.go/config.go/duo.go/ws.go carry the wiring for this — the dual listener, new config keys, and the TLS-aware WebSocket dial — and were rewritten in English in the process, per the project's new English-only code convention (see CLAUDE.md).
This commit is contained in:
1 parent
e1f7e6f529
commit
e9fe10f0f7
12 files changed
+1198
-465
No files matched your search
@@ -1,18 +1,19 @@
|
||||
//go:build windows
|
||||
|
||||
// deathwatch: lee en solo-lectura el contador de muertes de Elden Ring
|
||||
// directamente del proceso (mismo patron de bytes / offset que usa el
|
||||
// script ASL "eldenring_boss_timer.asl" de LiveSplit, ya verificado a
|
||||
// mano en esta PC). No escribe nada en la memoria del juego.
|
||||
// deathwatch: reads Elden Ring's death counter read-only, straight from
|
||||
// the process (the same byte pattern / offset used by LiveSplit's
|
||||
// "eldenring_boss_timer.asl" ASL script, verified by hand on this PC).
|
||||
// Writes nothing to the game's memory.
|
||||
//
|
||||
// Expone:
|
||||
// Exposes:
|
||||
//
|
||||
// GET / -> panel de estado (HTML)
|
||||
// GET /?view=overlay -> version transparente para OBS Browser Source
|
||||
// GET / -> status panel (HTML)
|
||||
// GET /?view=overlay -> transparent version for OBS Browser Source
|
||||
// GET /deaths -> {"deaths":N,"players":[...],"character":"...","slot":N,...}
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
@@ -59,9 +60,9 @@ var (
|
||||
procVerQueryValueW = versionDLL.NewProc("VerQueryValueW")
|
||||
)
|
||||
|
||||
// vsFixedFileInfo es la estructura VS_FIXEDFILEINFO de Windows: la usamos
|
||||
// para sacar la version del juego del propio eldenring.exe, igual que
|
||||
// SoulMemory (que lee MainModule.FileVersionInfo.ProductVersion).
|
||||
// vsFixedFileInfo is Windows's VS_FIXEDFILEINFO struct: used to pull the
|
||||
// game's version straight from eldenring.exe, same as SoulMemory (which
|
||||
// reads MainModule.FileVersionInfo.ProductVersion).
|
||||
type vsFixedFileInfo struct {
|
||||
Signature uint32
|
||||
StrucVersion uint32
|
||||
@@ -109,7 +110,7 @@ type moduleEntry32 struct {
|
||||
func findProcessID(name string) (uint32, error) {
|
||||
snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(th32csSnapProcess), 0)
|
||||
if snap == 0 || snap == uintptr(^uintptr(0)) {
|
||||
return 0, fmt.Errorf("no se pudo tomar snapshot de procesos")
|
||||
return 0, fmt.Errorf("couldn't take a process snapshot")
|
||||
}
|
||||
defer procCloseHandle.Call(snap)
|
||||
|
||||
@@ -117,7 +118,7 @@ func findProcessID(name string) (uint32, error) {
|
||||
pe.Size = uint32(unsafe.Sizeof(pe))
|
||||
r, _, _ := procProcess32FirstW.Call(snap, uintptr(unsafe.Pointer(&pe)))
|
||||
if r == 0 {
|
||||
return 0, fmt.Errorf("Process32First fallo")
|
||||
return 0, fmt.Errorf("Process32First failed")
|
||||
}
|
||||
for {
|
||||
exe := syscall.UTF16ToString(pe.ExeFile[:])
|
||||
@@ -129,13 +130,13 @@ func findProcessID(name string) (uint32, error) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("proceso no encontrado: %s", name)
|
||||
return 0, fmt.Errorf("process not found: %s", name)
|
||||
}
|
||||
|
||||
func findModuleBase(pid uint32, name string) (uintptr, uint32, string, error) {
|
||||
snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(th32csSnapModule|th32csSnapModule32), uintptr(pid))
|
||||
if snap == 0 || snap == uintptr(^uintptr(0)) {
|
||||
return 0, 0, "", fmt.Errorf("no se pudo tomar snapshot de modulos")
|
||||
return 0, 0, "", fmt.Errorf("couldn't take a module snapshot")
|
||||
}
|
||||
defer procCloseHandle.Call(snap)
|
||||
|
||||
@@ -143,7 +144,7 @@ func findModuleBase(pid uint32, name string) (uintptr, uint32, string, error) {
|
||||
me.Size = uint32(unsafe.Sizeof(me))
|
||||
r, _, _ := procModule32FirstW.Call(snap, uintptr(unsafe.Pointer(&me)))
|
||||
if r == 0 {
|
||||
return 0, 0, "", fmt.Errorf("Module32First fallo")
|
||||
return 0, 0, "", fmt.Errorf("Module32First failed")
|
||||
}
|
||||
for {
|
||||
mname := syscall.UTF16ToString(me.ModuleName[:])
|
||||
@@ -155,13 +156,13 @@ func findModuleBase(pid uint32, name string) (uintptr, uint32, string, error) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return 0, 0, "", fmt.Errorf("modulo no encontrado: %s", name)
|
||||
return 0, 0, "", fmt.Errorf("module not found: %s", name)
|
||||
}
|
||||
|
||||
// productVersion lee la version del ejecutable del juego. label trae las
|
||||
// dos versiones completas (producto y archivo) porque no siempre
|
||||
// coinciden, y sirve para diagnosticar si algun dia hay que ajustar el
|
||||
// offset de PlayerIns.
|
||||
// productVersion reads the game executable's version. label carries both
|
||||
// full version numbers (product and file) because they don't always
|
||||
// match, which helps diagnose whether the PlayerIns offset ever needs
|
||||
// adjusting.
|
||||
func productVersion(path string) (major, minor uint16, label string, ok bool) {
|
||||
if path == "" {
|
||||
return 0, 0, "", false
|
||||
@@ -197,21 +198,21 @@ func productVersion(path string) (major, minor uint16, label string, ok bool) {
|
||||
quad := func(ms, ls uint32) string {
|
||||
return fmt.Sprintf("%d.%d.%d.%d", ms>>16, ms&0xFFFF, ls>>16, ls&0xFFFF)
|
||||
}
|
||||
label = fmt.Sprintf("producto %s / archivo %s",
|
||||
label = fmt.Sprintf("product %s / file %s",
|
||||
quad(info.ProductVersionMS, info.ProductVersionLS),
|
||||
quad(info.FileVersionMS, info.FileVersionLS))
|
||||
return uint16(info.ProductVersionMS >> 16), uint16(info.ProductVersionMS & 0xFFFF), label, true
|
||||
}
|
||||
|
||||
// playerInsOffsetForVersion replica la tabla de SoulMemory
|
||||
// (InitializeOffsets): hasta 1.06 el offset de PlayerIns dentro de
|
||||
// WorldChrMan es 0x18468, de 1.07 en adelante 0x1E508.
|
||||
// playerInsOffsetForVersion mirrors SoulMemory's table (InitializeOffsets):
|
||||
// up to 1.06 the PlayerIns offset inside WorldChrMan is 0x18468, from
|
||||
// 1.07 onward it's 0x1E508.
|
||||
//
|
||||
// OJO: la version que reporta el exe NO es la que muestra el juego en
|
||||
// pantalla (el exe puede decir 2.7.1.0 mientras el juego dice 1.17.1), y
|
||||
// la tabla de SoulMemory esta escrita con los numeros del juego. Por eso
|
||||
// esto es solo una CORAZONADA para decidir cual probar primero: quien
|
||||
// decide de verdad es playerInsCandidates + la verificacion en memoria.
|
||||
// WATCH OUT: the version the exe reports is NOT what the game shows on
|
||||
// screen (the exe can say 2.7.1.0 while the game says 1.17.1), and
|
||||
// SoulMemory's table is written with the game's own numbers. So this is
|
||||
// only a HUNCH for deciding which one to try first: what actually
|
||||
// decides is playerInsCandidates plus the in-memory verification.
|
||||
func playerInsOffsetForVersion(major, minor uint16, ok bool) uintptr {
|
||||
if ok && major == 1 && minor <= 6 {
|
||||
return 0x18468
|
||||
@@ -219,8 +220,8 @@ func playerInsOffsetForVersion(major, minor uint16, ok bool) uintptr {
|
||||
return 0x1E508
|
||||
}
|
||||
|
||||
// playerInsCandidates devuelve los offsets conocidos a probar, con el que
|
||||
// sugiere la version primero.
|
||||
// playerInsCandidates returns the known offsets to try, with the one the
|
||||
// version suggests listed first.
|
||||
func playerInsCandidates(major, minor uint16, ok bool) []uintptr {
|
||||
if playerInsOffsetForVersion(major, minor, ok) == 0x18468 {
|
||||
return []uintptr{0x18468, 0x1E508}
|
||||
@@ -251,24 +252,25 @@ func readMemory(h syscall.Handle, addr uintptr, size int) ([]byte, bool) {
|
||||
|
||||
// ------------------------- signature scans -------------------------
|
||||
//
|
||||
// Dos firmas, las dos con la misma forma: instruccion de 7 bytes
|
||||
// "mov reg,[rip+disp32]", donde los 3 primeros bytes son el opcode y los
|
||||
// 4 siguientes el desplazamiento. El slot estatico resuelto contiene el
|
||||
// puntero al objeto (una dereferencia mas).
|
||||
// Two signatures, both the same shape: a 7-byte "mov reg,[rip+disp32]"
|
||||
// instruction, where the first 3 bytes are the opcode and the next 4 are
|
||||
// the displacement. The resolved static slot holds the pointer to the
|
||||
// object (one more dereference needed).
|
||||
//
|
||||
// GameDataMan -> tiene el contador de muertes (+0x94) y el flag de jefe (+0xC0).
|
||||
// Mismo patron que usa eldenring_boss_timer.asl (LiveSplit).
|
||||
// WorldChrMan -> tiene el puntero a PlayerIns (+playerInsOffset). Si ese
|
||||
// puntero es nulo, no hay personaje en el mundo: estas en el
|
||||
// menu principal o en una pantalla de carga. Es exactamente
|
||||
// lo que hace SoulMemory.IsPlayerLoaded().
|
||||
// GameDataMan -> holds the death counter (+0x94) and the boss-fight flag
|
||||
// (+0xC0). Same pattern eldenring_boss_timer.asl (LiveSplit)
|
||||
// uses.
|
||||
// WorldChrMan -> holds the pointer to PlayerIns (+playerInsOffset). If
|
||||
// that pointer is null, there's no character in the world:
|
||||
// you're at the main menu or on a loading screen. Exactly
|
||||
// what SoulMemory.IsPlayerLoaded() does.
|
||||
|
||||
type patByte struct {
|
||||
val byte
|
||||
wildcard bool
|
||||
}
|
||||
|
||||
// parsePattern acepta "48 8B 05 ?? ?? ?? ??" (?? = comodin).
|
||||
// parsePattern accepts "48 8B 05 ?? ?? ?? ??" (?? = wildcard).
|
||||
func parsePattern(s string) []patByte {
|
||||
var out []patByte
|
||||
for _, tok := range strings.Fields(s) {
|
||||
@@ -278,7 +280,7 @@ func parsePattern(s string) []patByte {
|
||||
}
|
||||
v, err := strconv.ParseUint(tok, 16, 8)
|
||||
if err != nil {
|
||||
panic("patron invalido: " + tok)
|
||||
panic("invalid pattern: " + tok)
|
||||
}
|
||||
out = append(out, patByte{val: byte(v)})
|
||||
}
|
||||
@@ -288,16 +290,16 @@ func parsePattern(s string) []patByte {
|
||||
var (
|
||||
// mov rax,[rip+disp32]; test rax,rax; jz +5; mov rax,[rax+58]; ret; ret
|
||||
gameDataManPattern = parsePattern("48 8B 05 ?? ?? ?? ?? 48 85 C0 74 05 48 8B 40 58 C3 C3")
|
||||
// mov rsi,[rip+disp32]; test rsi,rsi; ... (WorldChrManImp, igual que SoulMemory)
|
||||
// mov rsi,[rip+disp32]; test rsi,rsi; ... (WorldChrManImp, same as SoulMemory)
|
||||
worldChrManPattern = parsePattern("48 8B 35 ?? ?? ?? ?? 48 85 F6 ?? ?? BB 01 00 00 00 89 5C 24 20 48 8B B6")
|
||||
// mov rax,[rip+disp32]; cmp byte ptr [rax+disp32],0D; sete al; ret
|
||||
// (GameMan: en +0xAC0 tiene el slot de guardado del personaje cargado)
|
||||
// (GameMan: +0xAC0 holds the loaded character's save slot)
|
||||
gameManPattern = parsePattern("48 8B 05 ?? ?? ?? ?? 80 B8 ?? ?? ?? ?? 0D 0F 94 C0 C3")
|
||||
)
|
||||
|
||||
// saveSlotOffset: donde GameMan guarda el indice de slot (0-9) de la
|
||||
// partida cargada. Es la identidad REAL de un personaje: no depende del
|
||||
// nombre, asi que dos personajes que se llamen igual no se mezclan.
|
||||
// saveSlotOffset: where GameMan stores the save-slot index (0-9) of the
|
||||
// loaded game. This is a character's REAL identity: it doesn't depend on
|
||||
// the name, so two characters sharing a name never mix.
|
||||
const saveSlotOffset = 0xAC0
|
||||
|
||||
func matchAt(buf []byte, i int, pattern []patByte) bool {
|
||||
@@ -312,9 +314,9 @@ func matchAt(buf []byte, i int, pattern []patByte) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// scanModule busca varios patrones en una sola pasada por el modulo,
|
||||
// leyendolo en chunks (con solape, por si un patron cae justo en el borde
|
||||
// de un chunk). Devuelve, por cada patron, la direccion del match o 0.
|
||||
// scanModule looks for several patterns in a single pass over the module,
|
||||
// reading it in chunks (with overlap, in case a pattern straddles a chunk
|
||||
// boundary). Returns, for each pattern, the match address or 0.
|
||||
func scanModule(h syscall.Handle, base uintptr, size uint32, patterns [][]patByte) []uintptr {
|
||||
const chunk = 1 << 20 // 1 MiB
|
||||
const overlap = 64
|
||||
@@ -350,48 +352,48 @@ func scanModule(h syscall.Handle, base uintptr, size uint32, patterns [][]patByt
|
||||
return found
|
||||
}
|
||||
|
||||
// ripSlot convierte la direccion de una instruccion "mov reg,[rip+disp32]"
|
||||
// de 7 bytes en la direccion del slot estatico al que apunta.
|
||||
// ripSlot turns the address of a 7-byte "mov reg,[rip+disp32]"
|
||||
// instruction into the address of the static slot it points to.
|
||||
func ripSlot(h syscall.Handle, matchAddr uintptr) (uintptr, error) {
|
||||
if matchAddr == 0 {
|
||||
return 0, fmt.Errorf("patron no encontrado")
|
||||
return 0, fmt.Errorf("pattern not found")
|
||||
}
|
||||
codeLocation := matchAddr + 3 // los 3 primeros bytes son el opcode
|
||||
codeLocation := matchAddr + 3 // the first 3 bytes are the opcode
|
||||
dispBytes, ok := readMemory(h, codeLocation, 4)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("no se pudo leer el desplazamiento RIP-relativo")
|
||||
return 0, fmt.Errorf("couldn't read the RIP-relative displacement")
|
||||
}
|
||||
disp := int32(binary.LittleEndian.Uint32(dispBytes))
|
||||
return codeLocation + 4 + uintptr(int64(disp)), nil
|
||||
}
|
||||
|
||||
// gamePointers junta todo lo que se resuelve una sola vez por sesion de
|
||||
// proceso: los slots estaticos (que no se mueven) y la version del juego.
|
||||
// gamePointers gathers everything resolved just once per process session:
|
||||
// the static slots (which don't move) and the game's version.
|
||||
type gamePointers struct {
|
||||
gameDataManSlot uintptr
|
||||
worldChrManSlot uintptr // 0 si no se encontro el patron (seguimos sin el chequeo de menu)
|
||||
gameManSlot uintptr // 0 si no se encontro: caemos a identificar por nombre
|
||||
playerInsOffset uintptr // el que estamos usando (o el candidato preferido)
|
||||
worldChrManSlot uintptr // 0 if the pattern wasn't found (we keep going without the menu check)
|
||||
gameManSlot uintptr // 0 if not found: falls back to identifying by name
|
||||
playerInsOffset uintptr // the one currently in use (or the preferred candidate)
|
||||
playerInsTried []uintptr
|
||||
offsetConfirmed bool // true cuando lo verificamos leyendo memoria de verdad
|
||||
offsetConfirmed bool // true once verified by actually reading memory
|
||||
versionLabel string
|
||||
|
||||
nameChain nameCandidate // como llegamos al nombre del personaje
|
||||
nameChain nameCandidate // how we got to the character's name
|
||||
nameConfirmed bool
|
||||
namePending string // candidato a la espera de repetirse (ver resolveCharName)
|
||||
namePending string // candidate waiting to repeat (see resolveCharName)
|
||||
namePendingOf nameCandidate
|
||||
}
|
||||
|
||||
// resolvePointers hace los escaneos de firma (caro: recorre todo el
|
||||
// modulo) una sola vez por sesion de proceso. A proposito NO devuelve los
|
||||
// objetos ya resueltos: esos punteros se re-leen en cada tick, porque el
|
||||
// juego puede destruir y recrear GameDataMan (por ejemplo al volver al
|
||||
// menu principal y cargar de nuevo). Si nos quedaramos con una direccion
|
||||
// vieja en cache, seguiriamos leyendola con exito (la pagina de memoria
|
||||
// sigue siendo valida) pero el contenido pasaria a ser datos de otra cosa
|
||||
// - la causa mas probable de un contador que "sube solo" sin que hayas
|
||||
// muerto en verdad. SoulMemory hace lo mismo: su clase Pointer resuelve la
|
||||
// cadena entera en cada lectura, no cachea la direccion final.
|
||||
// resolvePointers does the signature scans (expensive: walks the whole
|
||||
// module) just once per process session. Deliberately does NOT return
|
||||
// the resolved objects themselves: those pointers get re-read every tick,
|
||||
// because the game can destroy and recreate GameDataMan (e.g. going back
|
||||
// to the main menu and loading again). If we cached a stale address,
|
||||
// we'd keep reading it successfully (the memory page is still valid) but
|
||||
// its contents would belong to something else entirely — the most likely
|
||||
// cause of a counter that "goes up on its own" without an actual death.
|
||||
// SoulMemory does the same: its Pointer class resolves the whole chain on
|
||||
// every read, never caching the final address.
|
||||
func resolvePointers(h syscall.Handle, pid uint32) (gamePointers, error) {
|
||||
var gp gamePointers
|
||||
|
||||
@@ -405,30 +407,30 @@ func resolvePointers(h syscall.Handle, pid uint32) (gamePointers, error) {
|
||||
gp.playerInsOffset = gp.playerInsTried[0]
|
||||
gp.versionLabel = label
|
||||
if !okVer {
|
||||
gp.versionLabel = "desconocida"
|
||||
gp.versionLabel = "unknown"
|
||||
}
|
||||
|
||||
matches := scanModule(h, base, size, [][]patByte{gameDataManPattern, worldChrManPattern, gameManPattern})
|
||||
|
||||
gp.gameDataManSlot, err = ripSlot(h, matches[0])
|
||||
if err != nil {
|
||||
return gp, fmt.Errorf("no se encontro el patron de GameDataMan (¿cambio de version del juego?)")
|
||||
return gp, fmt.Errorf("GameDataMan's pattern wasn't found (did the game update?)")
|
||||
}
|
||||
|
||||
// WorldChrMan es opcional: si no aparece, seguimos contando muertes,
|
||||
// solo perdemos la deteccion de menu/pantalla de carga.
|
||||
// WorldChrMan is optional: if it's missing, we keep counting deaths,
|
||||
// we just lose menu/loading-screen detection.
|
||||
if slot, werr := ripSlot(h, matches[1]); werr == nil {
|
||||
gp.worldChrManSlot = slot
|
||||
}
|
||||
// GameMan tambien es opcional: sin el, identificamos por nombre.
|
||||
// GameMan is optional too: without it, we identify by name.
|
||||
if slot, gerr := ripSlot(h, matches[2]); gerr == nil {
|
||||
gp.gameManSlot = slot
|
||||
}
|
||||
return gp, nil
|
||||
}
|
||||
|
||||
// derefPointer lee un slot estatico (barato: 8 bytes) y devuelve la
|
||||
// direccion ACTUAL del objeto. Se llama en cada tick, no solo una vez.
|
||||
// derefPointer reads a static slot (cheap: 8 bytes) and returns the
|
||||
// object's CURRENT address. Called every tick, not just once.
|
||||
func derefPointer(h syscall.Handle, slot uintptr) (uintptr, bool) {
|
||||
if slot == 0 {
|
||||
return 0, false
|
||||
@@ -440,30 +442,30 @@ func derefPointer(h syscall.Handle, slot uintptr) (uintptr, bool) {
|
||||
return uintptr(binary.LittleEndian.Uint64(buf)), true
|
||||
}
|
||||
|
||||
// ------------------------- nombre del personaje -------------------------
|
||||
// ------------------------- character name -------------------------
|
||||
//
|
||||
// Dato sacado de la tabla de Cheat Engine: "GameDataMan +0C +9C, unicode,
|
||||
// largo 19". Esa notacion admite mas de una lectura (¿0x0C es un puntero
|
||||
// que hay que dereferenciar, o los dos offsets se suman?), y ademas la
|
||||
// comunidad/el ASL usan GameDataMan+0x08 para llegar a PlayerGameData.
|
||||
// Asi que no elegimos: probamos las tres y nos quedamos con la que
|
||||
// devuelva algo que parezca un nombre de verdad.
|
||||
// Sourced from a Cheat Engine table: "GameDataMan +0C +9C, unicode,
|
||||
// length 19". That notation allows more than one reading (is 0x0C a
|
||||
// pointer to dereference, or do the two offsets just add up?), and on
|
||||
// top of that the community/the ASL use GameDataMan+0x08 to reach
|
||||
// PlayerGameData. So instead of picking one, all three get tried, and
|
||||
// whichever gives back something that looks like a real name wins.
|
||||
|
||||
type nameCandidate struct {
|
||||
ptrOffset uintptr // offset donde vive el puntero (0 = sin dereferencia)
|
||||
nameOffset uintptr // offset del texto dentro del objeto
|
||||
ptrOffset uintptr // offset where the pointer lives (0 = no dereference)
|
||||
nameOffset uintptr // offset of the text within the object
|
||||
label string
|
||||
}
|
||||
|
||||
var nameCandidates = []nameCandidate{
|
||||
{0x08, 0x9C, "[GameDataMan+0x08]+0x9C (PlayerGameData)"},
|
||||
{0x0C, 0x9C, "[GameDataMan+0x0C]+0x9C"},
|
||||
{0x00, 0xA8, "GameDataMan+0xA8 (0x0C y 0x9C sumados)"},
|
||||
{0x00, 0xA8, "GameDataMan+0xA8 (0x0C and 0x9C added together)"},
|
||||
}
|
||||
|
||||
// looksLikeName (y charNameMaxChars) viven en names.go: no dependen de
|
||||
// Windows, asi que quedan afuera de este archivo para poder testearlos
|
||||
// sin una PC con el juego abierto.
|
||||
// looksLikeName (and charNameMaxChars) live in names.go: they don't
|
||||
// depend on Windows, so they're kept out of this file to be testable
|
||||
// without a PC with the game open.
|
||||
|
||||
func readCharName(h syscall.Handle, gameDataMan uintptr, c nameCandidate) (string, bool) {
|
||||
base := gameDataMan
|
||||
@@ -493,10 +495,11 @@ func readCharName(h syscall.Handle, gameDataMan uintptr, c nameCandidate) (strin
|
||||
return s, true
|
||||
}
|
||||
|
||||
// resolveCharName devuelve el nombre del personaje. Antes de fijar una
|
||||
// variante exige verla dar el MISMO texto dos lecturas seguidas: el
|
||||
// nombre real no cambia de un segundo a otro, pero un pedazo de memoria
|
||||
// que casualmente pasa el filtro es mucho menos probable que se repita.
|
||||
// resolveCharName returns the character's name. Before locking in a
|
||||
// variant, it requires seeing it give the SAME text on two readings in a
|
||||
// row: the real name doesn't change from one second to the next, but a
|
||||
// chunk of memory that happens to pass the filter is far less likely to
|
||||
// repeat.
|
||||
func resolveCharName(h syscall.Handle, gameDataMan uintptr, gp *gamePointers) (string, bool) {
|
||||
if gp.nameConfirmed {
|
||||
return readCharName(h, gameDataMan, gp.nameChain)
|
||||
@@ -510,7 +513,7 @@ func resolveCharName(h syscall.Handle, gameDataMan uintptr, gp *gamePointers) (s
|
||||
gp.nameChain = c
|
||||
gp.nameConfirmed = true
|
||||
gp.namePending = ""
|
||||
log.Printf("nombre del personaje: \"%s\" (leido con %s)", s, c.label)
|
||||
log.Printf("character name: \"%s\" (read via %s)", s, c.label)
|
||||
return s, true
|
||||
}
|
||||
gp.namePending = s
|
||||
@@ -521,9 +524,9 @@ func resolveCharName(h syscall.Handle, gameDataMan uintptr, gp *gamePointers) (s
|
||||
return "", false
|
||||
}
|
||||
|
||||
// readSaveSlot devuelve el indice de slot (0-9) de la partida cargada, o
|
||||
// -1 si no lo pudimos leer. Elden Ring tiene 10 slots, asi que cualquier
|
||||
// otro valor es basura y se descarta.
|
||||
// readSaveSlot returns the loaded game's slot index (0-9), or -1 if it
|
||||
// couldn't be read. Elden Ring has 10 slots, so any other value is
|
||||
// garbage and gets discarded.
|
||||
func readSaveSlot(h syscall.Handle, gp gamePointers) int {
|
||||
if gp.gameManSlot == 0 {
|
||||
return -1
|
||||
@@ -543,15 +546,16 @@ func readSaveSlot(h syscall.Handle, gp gamePointers) int {
|
||||
return slot
|
||||
}
|
||||
|
||||
// isPlayerLoaded replica SoulMemory.IsPlayerLoaded(): resuelve
|
||||
// WorldChrMan y lee el puntero a PlayerIns; si es nulo, no hay personaje
|
||||
// en el mundo. El segundo valor indica si pudimos evaluarlo.
|
||||
// isPlayerLoaded mirrors SoulMemory.IsPlayerLoaded(): resolves
|
||||
// WorldChrMan and reads the pointer to PlayerIns; if it's null, there's
|
||||
// no character in the world. The second return value says whether we
|
||||
// were able to evaluate it at all.
|
||||
//
|
||||
// Mientras no tengamos confirmado el offset, en vez de confiar en el
|
||||
// numero de version (que en Elden Ring no coincide con el que muestra el
|
||||
// juego) probamos los offsets conocidos y nos quedamos con el primero que
|
||||
// apunte a memoria realmente legible. Eso lo decide la maquina, no una
|
||||
// tabla que puede envejecer mal.
|
||||
// Until the offset is confirmed, instead of trusting the version number
|
||||
// (which in Elden Ring doesn't match what the game displays), the known
|
||||
// offsets are tried and whichever one first points at genuinely readable
|
||||
// memory wins. That's decided by the machine, not by a table that can
|
||||
// age badly.
|
||||
func isPlayerLoaded(h syscall.Handle, gp *gamePointers) (loaded bool, known bool) {
|
||||
if gp.worldChrManSlot == 0 {
|
||||
return true, false
|
||||
@@ -577,14 +581,14 @@ func isPlayerLoaded(h syscall.Handle, gp *gamePointers) (loaded bool, known bool
|
||||
if !ok || playerIns == 0 {
|
||||
continue
|
||||
}
|
||||
// Un puntero de verdad apunta a memoria mapeada; uno de basura
|
||||
// casi nunca sobrevive esta lectura.
|
||||
// A real pointer points at mapped memory; a garbage one almost
|
||||
// never survives this read.
|
||||
if _, ok := readMemory(h, playerIns, 8); !ok {
|
||||
continue
|
||||
}
|
||||
gp.playerInsOffset = cand
|
||||
gp.offsetConfirmed = true
|
||||
log.Printf("PlayerIns confirmado en +0x%X (verificado leyendo el objeto, no por el numero de version)", cand)
|
||||
log.Printf("PlayerIns confirmed at +0x%X (verified by reading the object, not by version number)", cand)
|
||||
return true, true
|
||||
}
|
||||
return false, true
|
||||
@@ -592,11 +596,11 @@ func isPlayerLoaded(h syscall.Handle, gp *gamePointers) (loaded bool, known bool
|
||||
|
||||
// -------------------------------- poller loop --------------------------------
|
||||
|
||||
// maxPlausibleDeltaPerTick: entre dos lecturas separadas por ~1s, con el
|
||||
// personaje cargado todo el tiempo, el contador de muertes real no puede
|
||||
// subir mas que esto (ni bajar nunca). Un salto mas grande casi siempre
|
||||
// significa que agarramos memoria que ya no es GameDataMan (direccion
|
||||
// vieja/invalida) y no una muerte real.
|
||||
// maxPlausibleDeltaPerTick: between two readings ~1s apart, with the
|
||||
// character loaded the whole time, the real death counter can't go up by
|
||||
// more than this (and never goes down). A bigger jump almost always
|
||||
// means we grabbed memory that's no longer GameDataMan (a stale/invalid
|
||||
// address), not an actual death.
|
||||
const maxPlausibleDeltaPerTick = 3
|
||||
|
||||
func pollLoop() {
|
||||
@@ -611,10 +615,10 @@ func pollLoop() {
|
||||
warnedNoWCM bool
|
||||
lastBossRead bool
|
||||
|
||||
// Vigilancia de la deteccion de menu: mientras creemos que no hay
|
||||
// personaje cargado, igual espiamos el contador de muertes. Si sube
|
||||
// como sube una muerte de verdad, entonces nuestra deteccion esta
|
||||
// mintiendo (estabas jugando) y la desactivamos.
|
||||
// Menu-detection watchdog: while we believe no character is
|
||||
// loaded, we still peek at the death counter. If it climbs the
|
||||
// way a real death does, our detection is lying (you were
|
||||
// actually playing) and we turn it off.
|
||||
unloadedRaw int32
|
||||
unloadedRawFirst int32
|
||||
haveUnloadedRaw bool
|
||||
@@ -637,19 +641,19 @@ func pollLoop() {
|
||||
if handle == 0 {
|
||||
newPid, err := findProcessID(processName)
|
||||
if err != nil {
|
||||
st.setDisconnected("esperando a eldenring.exe")
|
||||
st.setDisconnected("waiting for eldenring.exe")
|
||||
time.Sleep(3 * time.Second)
|
||||
continue
|
||||
}
|
||||
h, err := openProcessHandle(newPid)
|
||||
if err != nil {
|
||||
st.setDisconnected("no se pudo abrir el proceso (¿permisos?): " + err.Error())
|
||||
st.setDisconnected("couldn't open the process (permissions?): " + err.Error())
|
||||
time.Sleep(3 * time.Second)
|
||||
continue
|
||||
}
|
||||
pid = newPid
|
||||
handle = h
|
||||
log.Printf("eldenring.exe encontrado (PID %d), escaneando firmas...", pid)
|
||||
log.Printf("eldenring.exe found (PID %d), scanning signatures...", pid)
|
||||
}
|
||||
|
||||
if !resolved {
|
||||
@@ -657,7 +661,7 @@ func pollLoop() {
|
||||
if err != nil {
|
||||
st.setDisconnected(err.Error())
|
||||
time.Sleep(2 * time.Second)
|
||||
// si el proceso murio, soltamos el handle para reintentar desde cero
|
||||
// if the process died, release the handle to retry from scratch
|
||||
if _, ferr := findProcessID(processName); ferr != nil {
|
||||
closeHandle()
|
||||
}
|
||||
@@ -666,46 +670,48 @@ func pollLoop() {
|
||||
gp = p
|
||||
resolved = true
|
||||
haveLastRaw = false
|
||||
log.Printf("version del juego: %s | GameDataMan slot 0x%X", gp.versionLabel, gp.gameDataManSlot)
|
||||
log.Printf("game version: %s | GameDataMan slot 0x%X", gp.versionLabel, gp.gameDataManSlot)
|
||||
if gp.gameManSlot != 0 {
|
||||
log.Printf("GameMan slot 0x%X (identifico personajes por su slot de guardado)", gp.gameManSlot)
|
||||
log.Printf("GameMan slot 0x%X (identifying characters by their save slot)", gp.gameManSlot)
|
||||
} else {
|
||||
log.Printf("aviso: no encontre el patron de GameMan; identifico personajes por nombre")
|
||||
log.Printf("warning: GameMan's pattern wasn't found; identifying characters by name")
|
||||
}
|
||||
if gp.worldChrManSlot != 0 {
|
||||
log.Printf("WorldChrMan slot 0x%X | PlayerIns: pruebo +0x%X y confirmo contra la memoria", gp.worldChrManSlot, gp.playerInsOffset)
|
||||
log.Printf("WorldChrMan slot 0x%X | PlayerIns: trying +0x%X and confirming against memory", gp.worldChrManSlot, gp.playerInsOffset)
|
||||
} else if !warnedNoWCM {
|
||||
warnedNoWCM = true
|
||||
log.Printf("aviso: no se encontro el patron de WorldChrMan; sigo contando muertes pero sin detectar menu/pantalla de carga")
|
||||
log.Printf("warning: WorldChrMan's pattern wasn't found; still counting deaths but without menu/loading-screen detection")
|
||||
}
|
||||
}
|
||||
|
||||
// Igual que el ASL de LiveSplit, que hace "if (!IsPlayerLoaded) return;":
|
||||
// sin personaje en el mundo no leemos nada. El total queda congelado en
|
||||
// pantalla (no mostramos guion) para no parpadear en cada carga.
|
||||
// Same as LiveSplit's ASL, which does "if (!IsPlayerLoaded) return;":
|
||||
// with no character in the world, nothing gets read. The total
|
||||
// stays frozen on screen (no dash shown) so it doesn't flicker on
|
||||
// every loading screen.
|
||||
if loaded, known := isPlayerLoaded(handle, &gp); known && !loaded {
|
||||
// Red de seguridad. El offset de PlayerIns depende de la version
|
||||
// del juego: si algun parche lo mueve, leeriamos nulo para
|
||||
// siempre y el contador quedaria congelado en pleno stream.
|
||||
// Safety net. The PlayerIns offset depends on the game's
|
||||
// version: if a patch ever moves it, we'd read null forever
|
||||
// and the counter would freeze mid-stream.
|
||||
//
|
||||
// El unico juez confiable es el contador de muertes en si: en el
|
||||
// menu de inicio NO sube nunca. Asi que espiamos el crudo sin
|
||||
// usarlo, y si sube como sube una muerte real (+1, +2, +3),
|
||||
// entonces estabas jugando y nuestra deteccion estaba mintiendo.
|
||||
// A diferencia de un timeout, esto no puede dispararse por dejar
|
||||
// el juego parado en el menu un rato largo.
|
||||
// The only judge we can trust is the death counter itself:
|
||||
// at the start menu it NEVER goes up. So the raw value gets
|
||||
// peeked at without being used, and if it climbs the way a
|
||||
// real death does (+1, +2, +3), you were actually playing
|
||||
// and our detection was lying. Unlike a timeout, this can't
|
||||
// fire just from leaving the game sitting at the menu a while.
|
||||
if gdm, ok := derefPointer(handle, gp.gameDataManSlot); ok && gdm != 0 {
|
||||
if buf, ok := readMemory(handle, gdm+0x94, 4); ok {
|
||||
raw := int32(binary.LittleEndian.Uint32(buf))
|
||||
if raw >= 0 && raw < 1_000_000 {
|
||||
if haveUnloadedRaw {
|
||||
if d := raw - unloadedRaw; d >= 1 && d <= maxPlausibleDeltaPerTick {
|
||||
log.Printf("el contador de muertes subio de %d a %d mientras yo creia que no habia personaje cargado: la deteccion de menu esta equivocada en esta version, la desactivo y sigo contando", unloadedRaw, raw)
|
||||
log.Printf("the death counter went from %d to %d while I thought no character was loaded: menu detection is wrong on this version, turning it off and continuing to count", unloadedRaw, raw)
|
||||
gp.worldChrManSlot = 0
|
||||
// Rescatamos lo ocurrido durante el rato confundido:
|
||||
// dejamos la referencia en la primera lectura de ese
|
||||
// periodo para que la logica de "cruce de carga"
|
||||
// acredite las muertes si fueron pocas.
|
||||
// Recover what happened during the confused
|
||||
// stretch: keep the reference at that
|
||||
// period's first reading so the "crossed a
|
||||
// loading screen" logic can credit the
|
||||
// deaths if there weren't many.
|
||||
lastRaw = unloadedRawFirst
|
||||
haveLastRaw = true
|
||||
sawUnloaded = true
|
||||
@@ -720,26 +726,27 @@ func pollLoop() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ojo: NO tocamos haveLastRaw/lastRaw, justamente para poder
|
||||
// comparar contra la ultima lectura buena cuando vuelva el mundo.
|
||||
st.setPlayerUnloaded("menu principal o pantalla de carga")
|
||||
// Careful: haveLastRaw/lastRaw are NOT touched, precisely so
|
||||
// they can be compared against the last good reading once
|
||||
// the world comes back.
|
||||
st.setPlayerUnloaded("main menu or loading screen")
|
||||
sawUnloaded = true
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
haveUnloadedRaw = false
|
||||
|
||||
// Re-dereferenciamos el slot en CADA tick (no solo al conectar) para
|
||||
// nunca quedarnos con una direccion vieja de GameDataMan.
|
||||
// Re-dereference the slot on EVERY tick (not just on connect) to
|
||||
// never end up stuck with a stale GameDataMan address.
|
||||
gameDataMan, ok := derefPointer(handle, gp.gameDataManSlot)
|
||||
if !ok {
|
||||
st.setDisconnected("se perdio la lectura de memoria (el juego se cerro o reinicio)")
|
||||
st.setDisconnected("lost the memory reading (the game closed or restarted)")
|
||||
closeHandle()
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
if gameDataMan == 0 {
|
||||
st.setPlayerUnloaded("sin partida cargada")
|
||||
st.setPlayerUnloaded("no game loaded")
|
||||
sawUnloaded = true
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
@@ -748,7 +755,7 @@ func pollLoop() {
|
||||
deathsBuf, ok1 := readMemory(handle, gameDataMan+0x94, 4)
|
||||
bossBuf, ok2 := readMemory(handle, gameDataMan+0xC0, 1)
|
||||
if !ok1 {
|
||||
st.setDisconnected("se perdio la lectura de memoria (el juego se cerro o reinicio)")
|
||||
st.setDisconnected("lost the memory reading (the game closed or restarted)")
|
||||
closeHandle()
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
@@ -760,21 +767,21 @@ func pollLoop() {
|
||||
lastBossRead = boss
|
||||
}
|
||||
if raw < 0 || raw > 1_000_000 {
|
||||
log.Printf("lectura imposible descartada (raw %d) - re-escaneando firmas", raw)
|
||||
log.Printf("discarding an impossible reading (raw %d) - rescanning signatures", raw)
|
||||
resolved = false
|
||||
haveLastRaw = false
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// Dentro de una misma partida el contador no baja ni pega saltos:
|
||||
// si pasa, es memoria que ya no es GameDataMan. Cruzando una carga
|
||||
// en cambio puede cambiar a cualquier cosa, porque puede ser otro
|
||||
// personaje, y de eso se encarga setCharacter.
|
||||
// Within the same save, the counter never goes down or jumps: if
|
||||
// it does, it's memory that's no longer GameDataMan. Crossing a
|
||||
// load, on the other hand, can change to anything, since it might
|
||||
// be a different character — and setCharacter handles that case.
|
||||
if haveLastRaw && !sawUnloaded {
|
||||
delta := int64(raw) - int64(lastRaw)
|
||||
if delta < 0 || delta > maxPlausibleDeltaPerTick {
|
||||
log.Printf("lectura sospechosa descartada (raw %d, anterior %d) - re-escaneando firmas", raw, lastRaw)
|
||||
log.Printf("discarding a suspicious reading (raw %d, previous %d) - rescanning signatures", raw, lastRaw)
|
||||
resolved = false
|
||||
haveLastRaw = false
|
||||
time.Sleep(1 * time.Second)
|
||||
@@ -782,9 +789,9 @@ func pollLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// Quien es este personaje se resuelve ANTES de registrar la
|
||||
// lectura: si cambiaste de personaje, el total salta al suyo en
|
||||
// esta misma vuelta y no hay que esperar a que alguien muera.
|
||||
// Which character this is gets resolved BEFORE recording the
|
||||
// reading: if you switched characters, the total jumps to theirs
|
||||
// on this very pass, with no need to wait for a death.
|
||||
name, _ := resolveCharName(handle, gameDataMan, &gp)
|
||||
st.setCharacter(readSaveSlot(handle, gp), name, raw)
|
||||
|
||||
@@ -800,9 +807,9 @@ func pollLoop() {
|
||||
|
||||
// ---------------------------------- HTTP ----------------------------------
|
||||
|
||||
// puertoDe saca el puerto de una direccion tipo "0.0.0.0:47822", para
|
||||
// poder decirle al compañero exactamente que escribir en su config.
|
||||
func puertoDe(addr string) string {
|
||||
// portOf pulls the port out of an address like "0.0.0.0:47822", so your
|
||||
// partner can be told exactly what to put in their config.
|
||||
func portOf(addr string) string {
|
||||
if _, port, ok := strings.Cut(addr, ":"); ok {
|
||||
return port
|
||||
}
|
||||
@@ -816,15 +823,15 @@ func withCORS(w http.ResponseWriter) {
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.Ltime)
|
||||
log.Println("=== Elden Ring Death Counter (lectura local, solo lectura) ===")
|
||||
log.Println("=== Elden Ring Death Counter (local, read-only) ===")
|
||||
|
||||
loadLocales()
|
||||
cfg := loadConfig()
|
||||
writeSampleConfig()
|
||||
esPeer := cfg.Mode == "peer"
|
||||
isPeer := cfg.Mode == "peer"
|
||||
|
||||
lang := resolveLang(cfg.Language)
|
||||
log.Printf("version: %s | PID %d | modo %s | idioma %s (disponibles: %s)",
|
||||
log.Printf("version: %s | PID %d | mode %s | language %s (available: %s)",
|
||||
buildTag, os.Getpid(), cfg.Mode, lang, strings.Join(availableLangs(), ", "))
|
||||
|
||||
totals = newTotalsStore()
|
||||
@@ -832,47 +839,77 @@ func main() {
|
||||
|
||||
go pollLoop()
|
||||
|
||||
registro := newPeerRegistry()
|
||||
registry := newPeerRegistry()
|
||||
|
||||
// El token es obligatorio en las dos puntas: sin el, cualquiera que
|
||||
// alcance el puerto podria inyectar datos en el overlay.
|
||||
// The token is required on both ends: without it, anyone who can
|
||||
// reach the port could inject data into the overlay. The hub also
|
||||
// generates its own TLS certificate the first time: the peer pins it
|
||||
// by fingerprint (pin.go), not by certificate-authority trust, which
|
||||
// doesn't exist for a Tailscale or LAN address anyway.
|
||||
var token string
|
||||
if !esPeer {
|
||||
t, generado, err := resolveToken(cfg)
|
||||
var hubCert tls.Certificate
|
||||
var certFingerprint string
|
||||
if !isPeer {
|
||||
t, generated, err := resolveToken(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("no pude preparar el token: %v", err)
|
||||
log.Fatalf("couldn't prepare the token: %v", err)
|
||||
}
|
||||
token = t
|
||||
logTokenBanner(token, generado)
|
||||
logTokenBanner(token, generated)
|
||||
|
||||
cert, fp, certGenerated, err := loadOrCreateHubCert()
|
||||
if err != nil {
|
||||
log.Fatalf("couldn't prepare the TLS certificate for the peer link: %v", err)
|
||||
}
|
||||
if certGenerated {
|
||||
log.Println("generated a new TLS certificate for the peer link")
|
||||
}
|
||||
hubCert, certFingerprint = cert, fp
|
||||
|
||||
candidates := candidateIPv4s()
|
||||
host := pickBestHost(candidates)
|
||||
if len(candidates) > 0 {
|
||||
log.Printf("detected network addresses: %s", strings.Join(candidates, ", "))
|
||||
}
|
||||
peerPort := portOf(cfg.PeerListen)
|
||||
if host != "" {
|
||||
invite := encodeInvite(inviteCode{Host: host, Port: peerPort, Fingerprint: certFingerprint, Token: token})
|
||||
log.Println("invite code for your co-op partner — paste it as invite = \"...\" in their config.toml:")
|
||||
log.Println(invite)
|
||||
log.Printf("(wrong address? they can override just the host with hub = \"<the right IP>:%s\")", peerPort)
|
||||
} else {
|
||||
log.Println("couldn't auto-detect a network address to build an invite code with.")
|
||||
log.Printf("have your partner set these by hand in their config.toml: hub = \"<your IP>:%s\", token = \"%s\", fingerprint = \"%s\"", peerPort, token, certFingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
if esPeer {
|
||||
if cfg.Hub == "" {
|
||||
log.Println("¡ojo! modo peer sin 'hub' en el config.toml: no tengo a donde mandar el contador")
|
||||
} else if strings.TrimSpace(cfg.Token) == "" {
|
||||
log.Println("¡ojo! modo peer sin 'token' en el config.toml: el hub te va a rechazar. Pedíle el token a quien lo corre.")
|
||||
var peerHub string
|
||||
if isPeer {
|
||||
hub, tok, fp, ok, err := resolvePeerConn(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("peer config problem: %v", err)
|
||||
}
|
||||
if ok {
|
||||
peerHub = hub
|
||||
go peerLoop(cfg, hub, tok, fp)
|
||||
} else {
|
||||
go peerLoop(cfg)
|
||||
log.Println("peer mode with nothing configured yet (no 'invite', no 'hub'+'token'+'fingerprint'): running as a local-only overlay for now")
|
||||
}
|
||||
} else {
|
||||
registro.declare(cfg.Partner)
|
||||
registry.declare(cfg.Partner)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
if !esPeer {
|
||||
mux.HandleFunc("/ws", registro.wsHandler(token))
|
||||
}
|
||||
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("X-Build", buildTag)
|
||||
w.Write(overlayHTML)
|
||||
})
|
||||
|
||||
// Los textos de la interfaz: la pagina los pide una vez al cargar.
|
||||
// ?lang= permite forzar un idioma sin tocar el config, comodo para
|
||||
// tener el overlay en un idioma y el panel en otro.
|
||||
// The UI's text: the page requests it once on load. ?lang= lets you
|
||||
// force a language without touching the config, handy for having the
|
||||
// overlay in one language and the panel in another.
|
||||
mux.HandleFunc("/strings.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
withCORS(w)
|
||||
want := lang
|
||||
@@ -887,18 +924,18 @@ func main() {
|
||||
withCORS(w)
|
||||
snap := st.snapshot()
|
||||
|
||||
// Este jugador primero, despues los compañeros en orden de aparicion.
|
||||
// This player first, then partners in order of appearance.
|
||||
players := []playerView{{
|
||||
Name: nombreMostrado(cfg),
|
||||
Name: displayName(cfg),
|
||||
Deaths: snap.Total,
|
||||
BossFight: snap.BossFight,
|
||||
PlayerLoaded: snap.PlayerLoaded,
|
||||
Connected: snap.Connected,
|
||||
Self: true,
|
||||
}}
|
||||
// Modo coop solo cuando hay (o hubo recien) un compañero conectado.
|
||||
if registro.coopMode() {
|
||||
players = append(players, registro.views()...)
|
||||
// Co-op mode only when a partner is (or was just recently) connected.
|
||||
if registry.coopMode() {
|
||||
players = append(players, registry.views()...)
|
||||
}
|
||||
|
||||
var combined int64
|
||||
@@ -910,8 +947,8 @@ func main() {
|
||||
"players": players,
|
||||
"combined": combined,
|
||||
"build": buildTag,
|
||||
// Campos de la version de un solo jugador: los dejamos para no
|
||||
// romper nada que ya este apuntando aca.
|
||||
// Single-player-version fields: kept so nothing already
|
||||
// pointing at them breaks.
|
||||
"deaths": snap.Total,
|
||||
"rawDeaths": snap.RawDeaths,
|
||||
"character": snap.CharName,
|
||||
@@ -925,15 +962,37 @@ func main() {
|
||||
})
|
||||
|
||||
log.Printf("Panel: http://%s/", cfg.Listen)
|
||||
if esPeer {
|
||||
log.Printf("Mandando el contador al hub %s. Esta ventana tiene que quedar abierta mientras jugás.", cfg.Hub)
|
||||
} else {
|
||||
log.Printf("OBS URL: http://%s/?view=overlay", cfg.Listen)
|
||||
log.Printf("Tu compañero tiene que poner en su config.toml: hub = \"<tu IP>:%s\" y el token de arriba", puertoDe(cfg.Listen))
|
||||
log.Println("Dejá esta ventana abierta mientras streameás. Ctrl+C para cerrar.")
|
||||
if isPeer {
|
||||
if peerHub != "" {
|
||||
log.Printf("Pushing the counter to the hub %s. This window needs to stay open while you play.", peerHub)
|
||||
}
|
||||
if err := http.ListenAndServe(cfg.Listen, mux); err != nil {
|
||||
log.Fatalf("couldn't start the local server: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := http.ListenAndServe(cfg.Listen, mux); err != nil {
|
||||
log.Fatalf("no se pudo iniciar el servidor local: %v", err)
|
||||
log.Printf("OBS URL: http://%s/?view=overlay", cfg.Listen)
|
||||
log.Println("Keep this window open while you stream. Ctrl+C to close.")
|
||||
|
||||
// Panel/overlay in plain HTTP, in the background; the peer server
|
||||
// (TLS, with the certificate from above) blocks in the foreground as
|
||||
// the process's main server.
|
||||
go func() {
|
||||
if err := http.ListenAndServe(cfg.Listen, mux); err != nil {
|
||||
log.Fatalf("couldn't start the local server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
peerMux := http.NewServeMux()
|
||||
peerMux.HandleFunc("/ws", registry.wsHandler(token))
|
||||
peerSrv := &http.Server{
|
||||
Addr: cfg.PeerListen,
|
||||
Handler: peerMux,
|
||||
TLSConfig: hubServerTLSConfig(hubCert),
|
||||
}
|
||||
log.Printf("Peer link: %s (TLS, certificate pinned)", cfg.PeerListen)
|
||||
if err := peerSrv.ListenAndServeTLS("", ""); err != nil {
|
||||
log.Fatalf("couldn't start the peer server: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user