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.
This commit is contained in:
1 parent
5a2c3272e9
commit
2ba312a833
7 files changed
+1128
-883
No files matched your search
+616
@@ -0,0 +1,616 @@
|
||||
// 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
|
||||
|
||||
// ------------------------- 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 := 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 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 := 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 procHandle, 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 procHandle, 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 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 := 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 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 := 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, 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 := 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
closeProcessHandle(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 := openProcess(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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user