Files
deathwatch/main.go
T
emmatherock 2ba312a833 Add Linux/Proton support
Elden Ring under Proton on Linux is the same Windows binary, so every
AOB signature and memory offset is unchanged — only how the process
gets found and read differs. Split main.go/i18n.go (previously
Windows-only) into a portable process.go (signature scanning, pointer
resolution, the poll loop) plus process_windows.go/process_linux.go
behind a small boundary: findProcessID, openProcess,
closeProcessHandle, readMemory, findModuleBase, productVersion,
systemLang.

Linux side: finds the process by walking /proc/*/maps for a mapping
ending in eldenring.exe (Proton runs several helper processes, so
matching by name alone isn't reliable), reads memory via
/proc/<pid>/mem (stdlib only, no external deps), and has no
productVersion equivalent (returns ok=false — this was always just a
hint for which PlayerIns offset to try first; the real one is
confirmed by a live memory read regardless). openProcess probes
/proc/<pid>/mem up front so a ptrace_scope permission failure surfaces
immediately with the exact `sudo setcap cap_sys_ptrace+ep <path>` fix,
never suggesting the system-wide ptrace_scope=0 weakening or running
as root.

main.go and i18n.go are fully portable now, no build tags. Verified:
Windows build/vet/test plus a real run (no regression from moving
~500 lines). Linux is cross-compile build/vet only in this session —
not yet run against a real Proton process.
2026-09-18 01:25:44 -03:00

218 lines
6.8 KiB
Go

// 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). Writes
// nothing to the game's memory. Runs on Windows natively and on Linux
// against the game running under Proton — same signatures and offsets
// either way, since it's the same Windows binary in memory; see
// process.go/process_windows.go/process_linux.go for the split.
//
// Exposes:
//
// 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/json"
"log"
"net/http"
"os"
"strings"
)
//go:embed overlay.html
var overlayHTML []byte
// 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
}
return addr
}
func withCORS(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Cache-Control", "no-store")
}
func main() {
log.SetFlags(log.Ltime)
log.Println("=== Elden Ring Death Counter (local, read-only) ===")
loadLocales()
cfg := loadConfig()
writeSampleConfig()
isPeer := cfg.Mode == "peer"
lang := resolveLang(cfg.Language)
log.Printf("version: %s | PID %d | mode %s | language %s (available: %s)",
buildTag, os.Getpid(), cfg.Mode, lang, strings.Join(availableLangs(), ", "))
totals = newTotalsStore()
st.init(totals)
go pollLoop()
registry := newPeerRegistry()
// 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
var hubCert tls.Certificate
var certFingerprint string
if !isPeer {
t, generated, err := resolveToken(cfg)
if err != nil {
log.Fatalf("couldn't prepare the token: %v", err)
}
token = t
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)
}
}
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 {
log.Println("peer mode with nothing configured yet (no 'invite', no 'hub'+'token'+'fingerprint'): running as a local-only overlay for now")
}
} else {
registry.declare(cfg.Partner)
}
mux := http.NewServeMux()
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)
})
// 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
if q := r.URL.Query().Get("lang"); q != "" {
want = resolveLang(q)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(stringsFor(want))
})
mux.HandleFunc("/deaths", func(w http.ResponseWriter, r *http.Request) {
withCORS(w)
snap := st.snapshot()
// This player first, then partners in order of appearance.
players := []playerView{{
Name: displayName(cfg),
Deaths: snap.Total,
BossFight: snap.BossFight,
PlayerLoaded: snap.PlayerLoaded,
Connected: snap.Connected,
Self: true,
}}
// Co-op mode only when a partner is (or was just recently) connected.
if registry.coopMode() {
players = append(players, registry.views()...)
}
var combined int64
for _, p := range players {
combined += p.Deaths
}
resp := map[string]interface{}{
"players": players,
"combined": combined,
"build": buildTag,
// Single-player-version fields: kept so nothing already
// pointing at them breaks.
"deaths": snap.Total,
"rawDeaths": snap.RawDeaths,
"character": snap.CharName,
"slot": snap.Slot,
"bossFight": snap.BossFight,
"connected": snap.Connected,
"playerLoaded": snap.PlayerLoaded,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
})
log.Printf("Panel: http://%s/", cfg.Listen)
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
}
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)
}
}