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.
218 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|