// process.go: everything about finding the death counter in the game's // memory that does NOT depend on which OS is doing the reading — AOB // signature scanning, pointer resolution, character-name reading, and the // poll loop that ties it all together. // // The actual reading is behind procHandle and a handful of functions // (findProcessID, openProcess, closeProcessHandle, readMemory, // findModuleBase, productVersion, systemLang) implemented once per // platform in process_windows.go/process_linux.go. Elden Ring under // Proton on Linux is the exact same Windows binary Wine is running, so // every signature and offset below is identical on both platforms — only // how the process gets found and read differs. package main import ( "encoding/binary" "fmt" "log" "strconv" "strings" "time" "unicode/utf16" ) const processName = "eldenring.exe" // procHandle is an opaque reference to an open process, produced by // openProcess and consumed by readMemory/closeProcessHandle. What it // actually holds is platform-specific: a real Windows HANDLE value, or // just a pid on Linux (which needs no persistent OS resource — see // process_linux.go). type procHandle uintptr // Boundary functions as package variables, defaulting to the platform // implementation compiled into process_windows.go/process_linux.go. // Tests (process_test.go) swap these for a fake in-memory reader — same // pattern as testExeDir in totals.go, not a new interface. var ( findProcessIDFn = findProcessID openProcessFn = openProcess closeProcessHandleFn = closeProcessHandle readMemoryFn = readMemory findModuleBaseFn = findModuleBase productVersionFn = productVersion ) // ------------------------- 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 procHandle, 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 := readMemoryFn(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 procHandle, 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 := readMemoryFn(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 procHandle, pid uint32) (gamePointers, error) { var gp gamePointers base, size, exePath, err := findModuleBaseFn(pid, processName) if err != nil { return gp, err } major, minor, label, okVer := productVersionFn(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 procHandle, slot uintptr) (uintptr, bool) { if slot == 0 { return 0, false } buf, ok := readMemoryFn(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 the OS at all, let alone Windows vs. Linux, so they're kept // out of this file to be testable without a PC with the game open. func readCharName(h procHandle, 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 := readMemoryFn(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 procHandle, 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 procHandle, gp gamePointers) int { if gp.gameManSlot == 0 { return -1 } gameMan, ok := derefPointer(h, gp.gameManSlot) if !ok || gameMan == 0 { return -1 } buf, ok := readMemoryFn(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, and on // Linux isn't available at all — see productVersion), 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 // or an OS that can't report a version at all. func isPlayerLoaded(h procHandle, 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 := readMemoryFn(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 } // 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 in // isPlayerLoaded. On Linux, where productVersion always reports ok=false, // this hunch is simply skipped — the in-memory verification still nails // it down. 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} } // -------------------------------- 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 procHandle 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 { closeProcessHandleFn(handle) handle = 0 } pid = 0 gp = gamePointers{} resolved = false haveLastRaw = false sawUnloaded = false haveUnloadedRaw = false } for { if handle == 0 { newPid, err := findProcessIDFn(processName) if err != nil { st.setDisconnected("waiting for eldenring.exe") time.Sleep(3 * time.Second) continue } h, err := openProcessFn(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 := findProcessIDFn(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 := readMemoryFn(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 := readMemoryFn(handle, gameDataMan+0x94, 4) bossBuf, ok2 := readMemoryFn(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) } }