Files
deathwatch/main.go
T
emmatherock e9fe10f0f7 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).
2026-09-17 21:51:22 -03:00

999 lines
32 KiB
Go

//go:build windows
// 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.
//
// 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/binary"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"syscall"
"time"
"unicode/utf16"
"unsafe"
)
//go:embed overlay.html
var overlayHTML []byte
const (
processName = "eldenring.exe"
processQueryInformation = 0x0400
processVMRead = 0x0010
th32csSnapProcess = 0x00000002
th32csSnapModule = 0x00000008
th32csSnapModule32 = 0x00000010
maxPath = 260
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procOpenProcess = kernel32.NewProc("OpenProcess")
procCloseHandle = kernel32.NewProc("CloseHandle")
procReadProcessMemory = kernel32.NewProc("ReadProcessMemory")
procCreateToolhelp32Snapshot = kernel32.NewProc("CreateToolhelp32Snapshot")
procModule32FirstW = kernel32.NewProc("Module32FirstW")
procModule32NextW = kernel32.NewProc("Module32NextW")
procProcess32FirstW = kernel32.NewProc("Process32FirstW")
procProcess32NextW = kernel32.NewProc("Process32NextW")
versionDLL = syscall.NewLazyDLL("version.dll")
procGetFileVersionInfoSizeW = versionDLL.NewProc("GetFileVersionInfoSizeW")
procGetFileVersionInfoW = versionDLL.NewProc("GetFileVersionInfoW")
procVerQueryValueW = versionDLL.NewProc("VerQueryValueW")
)
// 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
FileVersionMS uint32
FileVersionLS uint32
ProductVersionMS uint32
ProductVersionLS uint32
FileFlagsMask uint32
FileFlags uint32
FileOS uint32
FileType uint32
FileSubtype uint32
FileDateMS uint32
FileDateLS uint32
}
type processEntry32 struct {
Size uint32
CntUsage uint32
ProcessID uint32
DefaultHeapID uintptr
ModuleID uint32
CntThreads uint32
ParentProcessID uint32
PriorityClassBase int32
Flags uint32
ExeFile [maxPath]uint16
}
type moduleEntry32 struct {
Size uint32
ModuleID uint32
ProcessID uint32
GlblcntUsage uint32
ProccntUsage uint32
ModBaseAddr uintptr
ModBaseSize uint32
HModule syscall.Handle
ModuleName [256]uint16
ExePath [maxPath]uint16
}
// ---------------------------- Windows API helpers ----------------------------
func findProcessID(name string) (uint32, error) {
snap, _, _ := procCreateToolhelp32Snapshot.Call(uintptr(th32csSnapProcess), 0)
if snap == 0 || snap == uintptr(^uintptr(0)) {
return 0, fmt.Errorf("couldn't take a process snapshot")
}
defer procCloseHandle.Call(snap)
var pe processEntry32
pe.Size = uint32(unsafe.Sizeof(pe))
r, _, _ := procProcess32FirstW.Call(snap, uintptr(unsafe.Pointer(&pe)))
if r == 0 {
return 0, fmt.Errorf("Process32First failed")
}
for {
exe := syscall.UTF16ToString(pe.ExeFile[:])
if strings.EqualFold(exe, name) {
return pe.ProcessID, nil
}
r, _, _ := procProcess32NextW.Call(snap, uintptr(unsafe.Pointer(&pe)))
if r == 0 {
break
}
}
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("couldn't take a module snapshot")
}
defer procCloseHandle.Call(snap)
var me moduleEntry32
me.Size = uint32(unsafe.Sizeof(me))
r, _, _ := procModule32FirstW.Call(snap, uintptr(unsafe.Pointer(&me)))
if r == 0 {
return 0, 0, "", fmt.Errorf("Module32First failed")
}
for {
mname := syscall.UTF16ToString(me.ModuleName[:])
if strings.EqualFold(mname, name) {
return me.ModBaseAddr, me.ModBaseSize, syscall.UTF16ToString(me.ExePath[:]), nil
}
r, _, _ := procModule32NextW.Call(snap, uintptr(unsafe.Pointer(&me)))
if r == 0 {
break
}
}
return 0, 0, "", fmt.Errorf("module not found: %s", name)
}
// 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
}
p, err := syscall.UTF16PtrFromString(path)
if err != nil {
return 0, 0, "", false
}
size, _, _ := procGetFileVersionInfoSizeW.Call(uintptr(unsafe.Pointer(p)), 0)
if size == 0 {
return 0, 0, "", false
}
buf := make([]byte, size)
r, _, _ := procGetFileVersionInfoW.Call(uintptr(unsafe.Pointer(p)), 0, size, uintptr(unsafe.Pointer(&buf[0])))
if r == 0 {
return 0, 0, "", false
}
sub, err := syscall.UTF16PtrFromString(`\`)
if err != nil {
return 0, 0, "", false
}
var info *vsFixedFileInfo
var infoLen uint32
r, _, _ = procVerQueryValueW.Call(
uintptr(unsafe.Pointer(&buf[0])),
uintptr(unsafe.Pointer(sub)),
uintptr(unsafe.Pointer(&info)),
uintptr(unsafe.Pointer(&infoLen)),
)
if r == 0 || info == nil || infoLen == 0 {
return 0, 0, "", false
}
quad := func(ms, ls uint32) string {
return fmt.Sprintf("%d.%d.%d.%d", ms>>16, ms&0xFFFF, ls>>16, ls&0xFFFF)
}
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 mirrors SoulMemory's table (InitializeOffsets):
// up to 1.06 the PlayerIns offset inside WorldChrMan is 0x18468, from
// 1.07 onward it's 0x1E508.
//
// 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
}
return 0x1E508
}
// 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}
}
return []uintptr{0x1E508, 0x18468}
}
func openProcessHandle(pid uint32) (syscall.Handle, error) {
h, _, err := procOpenProcess.Call(uintptr(processQueryInformation|processVMRead), 0, uintptr(pid))
if h == 0 {
return 0, err
}
return syscall.Handle(h), nil
}
func readMemory(h syscall.Handle, addr uintptr, size int) ([]byte, bool) {
if addr == 0 {
return nil, false
}
buf := make([]byte, size)
var n uintptr
r, _, _ := procReadProcessMemory.Call(uintptr(h), addr, uintptr(unsafe.Pointer(&buf[0])), uintptr(size), uintptr(unsafe.Pointer(&n)))
if r == 0 || int(n) != size {
return nil, false
}
return buf, true
}
// ------------------------- signature scans -------------------------
//
// 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 -> 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 accepts "48 8B 05 ?? ?? ?? ??" (?? = wildcard).
func parsePattern(s string) []patByte {
var out []patByte
for _, tok := range strings.Fields(s) {
if strings.HasPrefix(tok, "?") {
out = append(out, patByte{wildcard: true})
continue
}
v, err := strconv.ParseUint(tok, 16, 8)
if err != nil {
panic("invalid pattern: " + tok)
}
out = append(out, patByte{val: byte(v)})
}
return out
}
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, 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: +0xAC0 holds the loaded character's save slot)
gameManPattern = parsePattern("48 8B 05 ?? ?? ?? ?? 80 B8 ?? ?? ?? ?? 0D 0F 94 C0 C3")
)
// 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 {
if i+len(pattern) > len(buf) {
return false
}
for j, p := range pattern {
if !p.wildcard && buf[i+j] != p.val {
return false
}
}
return true
}
// 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
found := make([]uintptr, len(patterns))
remaining := len(patterns)
var pos uint32
for pos < size && remaining > 0 {
readSize := chunk
if rem := int(size - pos); readSize > rem {
readSize = rem
}
buf, ok := readMemory(h, base+uintptr(pos), readSize)
if ok {
for i := 0; i < len(buf); i++ {
for p := range patterns {
if found[p] != 0 {
continue
}
if matchAt(buf, i, patterns[p]) {
found[p] = base + uintptr(pos) + uintptr(i)
remaining--
}
}
}
}
if uint32(readSize) <= overlap {
break
}
pos += uint32(readSize) - overlap
}
return found
}
// 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("pattern not found")
}
codeLocation := matchAddr + 3 // the first 3 bytes are the opcode
dispBytes, ok := readMemory(h, codeLocation, 4)
if !ok {
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 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 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 once verified by actually reading memory
versionLabel string
nameChain nameCandidate // how we got to the character's name
nameConfirmed bool
namePending string // candidate waiting to repeat (see resolveCharName)
namePendingOf nameCandidate
}
// 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
base, size, exePath, err := findModuleBase(pid, processName)
if err != nil {
return gp, err
}
major, minor, label, okVer := productVersion(exePath)
gp.playerInsTried = playerInsCandidates(major, minor, okVer)
gp.playerInsOffset = gp.playerInsTried[0]
gp.versionLabel = label
if !okVer {
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("GameDataMan's pattern wasn't found (did the game update?)")
}
// 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 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 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
}
buf, ok := readMemory(h, slot, 8)
if !ok {
return 0, false
}
return uintptr(binary.LittleEndian.Uint64(buf)), true
}
// ------------------------- character name -------------------------
//
// 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 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 and 0x9C added together)"},
}
// 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
if c.ptrOffset != 0 {
p, ok := derefPointer(h, gameDataMan+c.ptrOffset)
if !ok || p == 0 {
return "", false
}
base = p
}
buf, ok := readMemory(h, base+c.nameOffset, charNameMaxChars*2)
if !ok {
return "", false
}
u16 := make([]uint16, 0, charNameMaxChars)
for i := 0; i+1 < len(buf); i += 2 {
ch := binary.LittleEndian.Uint16(buf[i : i+2])
if ch == 0 {
break
}
u16 = append(u16, ch)
}
s := strings.TrimSpace(string(utf16.Decode(u16)))
if !looksLikeName(s) {
return "", false
}
return s, true
}
// 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)
}
for _, c := range nameCandidates {
s, ok := readCharName(h, gameDataMan, c)
if !ok {
continue
}
if gp.namePending == s && gp.namePendingOf == c {
gp.nameChain = c
gp.nameConfirmed = true
gp.namePending = ""
log.Printf("character name: \"%s\" (read via %s)", s, c.label)
return s, true
}
gp.namePending = s
gp.namePendingOf = c
return "", false
}
gp.namePending = ""
return "", false
}
// 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
}
gameMan, ok := derefPointer(h, gp.gameManSlot)
if !ok || gameMan == 0 {
return -1
}
buf, ok := readMemory(h, gameMan+saveSlotOffset, 1)
if !ok {
return -1
}
slot := int(buf[0])
if slot < 0 || slot > 9 {
return -1
}
return slot
}
// 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.
//
// 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
}
worldChrMan, ok := derefPointer(h, gp.worldChrManSlot)
if !ok {
return true, false
}
if worldChrMan == 0 {
return false, true
}
if gp.offsetConfirmed {
playerIns, ok := derefPointer(h, worldChrMan+gp.playerInsOffset)
if !ok {
return true, false
}
return playerIns != 0, true
}
for _, cand := range gp.playerInsTried {
playerIns, ok := derefPointer(h, worldChrMan+cand)
if !ok || playerIns == 0 {
continue
}
// 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 confirmed at +0x%X (verified by reading the object, not by version number)", cand)
return true, true
}
return false, true
}
// -------------------------------- poller loop --------------------------------
// 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() {
var (
handle syscall.Handle
pid uint32
gp gamePointers
resolved bool
lastRaw int32
haveLastRaw bool
sawUnloaded bool
warnedNoWCM bool
lastBossRead bool
// 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
)
closeHandle := func() {
if handle != 0 {
procCloseHandle.Call(uintptr(handle))
handle = 0
}
pid = 0
gp = gamePointers{}
resolved = false
haveLastRaw = false
sawUnloaded = false
haveUnloadedRaw = false
}
for {
if handle == 0 {
newPid, err := findProcessID(processName)
if err != nil {
st.setDisconnected("waiting for eldenring.exe")
time.Sleep(3 * time.Second)
continue
}
h, err := openProcessHandle(newPid)
if err != nil {
st.setDisconnected("couldn't open the process (permissions?): " + err.Error())
time.Sleep(3 * time.Second)
continue
}
pid = newPid
handle = h
log.Printf("eldenring.exe found (PID %d), scanning signatures...", pid)
}
if !resolved {
p, err := resolvePointers(handle, pid)
if err != nil {
st.setDisconnected(err.Error())
time.Sleep(2 * time.Second)
// if the process died, release the handle to retry from scratch
if _, ferr := findProcessID(processName); ferr != nil {
closeHandle()
}
continue
}
gp = p
resolved = true
haveLastRaw = false
log.Printf("game version: %s | GameDataMan slot 0x%X", gp.versionLabel, gp.gameDataManSlot)
if gp.gameManSlot != 0 {
log.Printf("GameMan slot 0x%X (identifying characters by their save slot)", gp.gameManSlot)
} else {
log.Printf("warning: GameMan's pattern wasn't found; identifying characters by name")
}
if gp.worldChrManSlot != 0 {
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("warning: WorldChrMan's pattern wasn't found; still counting deaths but without menu/loading-screen detection")
}
}
// 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 {
// 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.
//
// 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("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
// 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
haveUnloadedRaw = false
continue
}
} else {
unloadedRawFirst = raw
}
unloadedRaw = raw
haveUnloadedRaw = true
}
}
}
// 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-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("lost the memory reading (the game closed or restarted)")
closeHandle()
time.Sleep(2 * time.Second)
continue
}
if gameDataMan == 0 {
st.setPlayerUnloaded("no game loaded")
sawUnloaded = true
time.Sleep(1 * time.Second)
continue
}
deathsBuf, ok1 := readMemory(handle, gameDataMan+0x94, 4)
bossBuf, ok2 := readMemory(handle, gameDataMan+0xC0, 1)
if !ok1 {
st.setDisconnected("lost the memory reading (the game closed or restarted)")
closeHandle()
time.Sleep(2 * time.Second)
continue
}
raw := int32(binary.LittleEndian.Uint32(deathsBuf))
boss := lastBossRead
if ok2 {
boss = bossBuf[0] != 0
lastBossRead = boss
}
if raw < 0 || raw > 1_000_000 {
log.Printf("discarding an impossible reading (raw %d) - rescanning signatures", raw)
resolved = false
haveLastRaw = false
time.Sleep(1 * time.Second)
continue
}
// 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("discarding a suspicious reading (raw %d, previous %d) - rescanning signatures", raw, lastRaw)
resolved = false
haveLastRaw = false
time.Sleep(1 * time.Second)
continue
}
}
// 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)
st.setReading(raw, boss)
lastRaw = raw
haveLastRaw = true
sawUnloaded = false
time.Sleep(1 * time.Second)
}
}
// ---------------------------------- HTTP ----------------------------------
// 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)
}
}