Files
deathwatch/process_test.go
emmatherockandClaude Sonnet 5 66aba2fff2 Add fake-memory-reader tests for the scanner/poller
process.go's boundary functions (readMemory, findModuleBase,
productVersion, findProcessID, openProcess, closeProcessHandle) become
swappable package variables, same pattern as testExeDir in totals.go.
process_test.go builds a fakeProcess (an in-memory buffer addressed
like real process memory) to exercise signature scanning, scanModule's
chunk-overlap logic, PlayerIns confirmation by memory read (the path
Linux always takes), the save-slot read, the double-read name
confirmation rule, and rejection of raw-pointer-as-UTF16 garbage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 01:48:38 -03:00

284 lines
9.2 KiB
Go

package main
import (
"encoding/binary"
"testing"
"unicode/utf16"
)
// fakeProcess is an in-memory stand-in for eldenring.exe's address space: a
// single flat buffer addressed exactly like real process memory, letting
// the scanner/poller logic in process.go run without a PC with the game
// open. It's wired in through readMemoryFn/findModuleBaseFn/productVersionFn
// (see the var block at the top of process.go) — the same swappable-package-
// variable pattern testExeDir uses in totals.go, not a new interface.
type fakeProcess struct {
base uintptr
mem []byte
}
func newFakeProcess(base uintptr, size int) *fakeProcess {
return &fakeProcess{base: base, mem: make([]byte, size)}
}
// read mirrors readMemory's contract: a read that falls even partially
// outside mapped memory fails, same as an unmapped page would on a real
// process.
func (f *fakeProcess) read(_ procHandle, addr uintptr, size int) ([]byte, bool) {
if addr < f.base {
return nil, false
}
off := int(addr - f.base)
if off+size > len(f.mem) {
return nil, false
}
out := make([]byte, size)
copy(out, f.mem[off:off+size])
return out, true
}
func (f *fakeProcess) putBytes(addr uintptr, b []byte) {
off := int(addr - f.base)
copy(f.mem[off:], b)
}
func (f *fakeProcess) putUint64(addr uintptr, v uint64) {
var b [8]byte
binary.LittleEndian.PutUint64(b[:], v)
f.putBytes(addr, b[:])
}
func (f *fakeProcess) putUint32(addr uintptr, v uint32) {
var b [4]byte
binary.LittleEndian.PutUint32(b[:], v)
f.putBytes(addr, b[:])
}
func (f *fakeProcess) putByte(addr uintptr, v byte) {
f.mem[int(addr-f.base)] = v
}
func (f *fakeProcess) putUTF16(addr uintptr, s string) {
for i, u := range utf16.Encode([]rune(s)) {
var b [2]byte
binary.LittleEndian.PutUint16(b[:], u)
f.putBytes(addr+uintptr(i*2), b[:])
}
}
// writePattern lays pat's fixed bytes down at addr (wildcard bytes left as
// zero, since matchAt never looks at them).
func writePattern(f *fakeProcess, addr uintptr, pat []patByte) {
buf := make([]byte, len(pat))
for i, p := range pat {
if !p.wildcard {
buf[i] = p.val
}
}
f.putBytes(addr, buf)
}
// pointRipSlot fills in the disp32 of a "mov reg,[rip+disp32]" instruction
// at addr so it resolves (via ripSlot) to slotAddr — the same encoding a
// real compiled game binary uses.
func pointRipSlot(f *fakeProcess, addr, slotAddr uintptr) {
disp := int32(int64(slotAddr) - int64(addr+7))
f.putUint32(addr+3, uint32(disp))
}
// TestScanModuleFindsPatternStraddlingChunkBoundary is a regression test for
// scanModule's chunk/overlap logic: a pattern whose bytes straddle the 1 MiB
// chunk boundary must still be found. Without the overlap, half the pattern
// would land in one chunk read and half in the next, and matchAt would never
// see it whole.
func TestScanModuleFindsPatternStraddlingChunkBoundary(t *testing.T) {
const chunk = 1 << 20
base := uintptr(0x1_4000_0000)
size := chunk + 4096
fp := newFakeProcess(base, size)
patAddr := base + chunk - 5 // starts 5 bytes before the boundary, ends well after it
writePattern(fp, patAddr, gameDataManPattern)
pointRipSlot(fp, patAddr, base+uintptr(size-16))
orig := readMemoryFn
readMemoryFn = fp.read
defer func() { readMemoryFn = orig }()
found := scanModule(procHandle(1), base, uint32(size), [][]patByte{gameDataManPattern})
if found[0] != patAddr {
t.Fatalf("pattern straddling the chunk boundary not found: got 0x%X, want 0x%X", found[0], patAddr)
}
}
// eldenRingLayout builds a fake process with all three signatures and the
// object graph resolvePointers/pollLoop walk to reach the death counter,
// the loaded-character check, the save slot, and the character name. It
// mirrors CLAUDE.md's "Offsets de memoria" table.
type eldenRingLayout struct {
fp *fakeProcess
gameDataManSlot uintptr
gameDataMan uintptr
worldChrManSlot uintptr
worldChrMan uintptr
gameManSlot uintptr
gameMan uintptr
playerIns uintptr
playerGameData uintptr
}
func newEldenRingLayout() *eldenRingLayout {
base := uintptr(0x1_4000_0000)
fp := newFakeProcess(base, 0x40000)
l := &eldenRingLayout{
fp: fp,
gameDataManSlot: base + 0x2000,
gameDataMan: base + 0x3000,
worldChrManSlot: base + 0x6000,
worldChrMan: base + 0x7000,
gameManSlot: base + 0x9000,
gameMan: base + 0xA000,
playerIns: base + 0x30000,
playerGameData: base + 0x4000,
}
writePattern(fp, base+0x1000, gameDataManPattern)
pointRipSlot(fp, base+0x1000, l.gameDataManSlot)
fp.putUint64(l.gameDataManSlot, uint64(l.gameDataMan))
fp.putUint64(l.gameDataMan+0x08, uint64(l.playerGameData)) // PlayerGameData, first name candidate
fp.putUTF16(l.playerGameData+0x9C, "Aria")
writePattern(fp, base+0x5000, worldChrManPattern)
pointRipSlot(fp, base+0x5000, l.worldChrManSlot)
fp.putUint64(l.worldChrManSlot, uint64(l.worldChrMan))
fp.putUint64(l.worldChrMan+0x1E508, uint64(l.playerIns)) // current PlayerIns offset
writePattern(fp, base+0x8000, gameManPattern)
pointRipSlot(fp, base+0x8000, l.gameManSlot)
fp.putUint64(l.gameManSlot, uint64(l.gameMan))
fp.putByte(l.gameMan+saveSlotOffset, 3)
return l
}
// withFakeGame swaps the boundary functions the poller uses to a fake
// process built from an eldenRingLayout, as if productVersion couldn't
// report anything (like on Linux, see process_linux.go), forcing PlayerIns
// resolution down the in-memory-confirmation path instead of the version
// hunch.
func withFakeGame(t *testing.T) *eldenRingLayout {
t.Helper()
l := newEldenRingLayout()
origRead, origBase, origVersion := readMemoryFn, findModuleBaseFn, productVersionFn
readMemoryFn = l.fp.read
findModuleBaseFn = func(pid uint32, name string) (uintptr, uint32, string, error) {
return l.fp.base, uint32(len(l.fp.mem)), "Z:\\fake\\eldenring.exe", nil
}
productVersionFn = func(path string) (uint16, uint16, string, bool) {
return 0, 0, "", false
}
t.Cleanup(func() {
readMemoryFn = origRead
findModuleBaseFn = origBase
productVersionFn = origVersion
})
return l
}
func TestResolvePointersFindsAllThreeSignatures(t *testing.T) {
l := withFakeGame(t)
gp, err := resolvePointers(procHandle(1), 1234)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gp.gameDataManSlot != l.gameDataManSlot {
t.Errorf("GameDataMan slot = 0x%X, want 0x%X", gp.gameDataManSlot, l.gameDataManSlot)
}
if gp.worldChrManSlot != l.worldChrManSlot {
t.Errorf("WorldChrMan slot = 0x%X, want 0x%X", gp.worldChrManSlot, l.worldChrManSlot)
}
if gp.gameManSlot != l.gameManSlot {
t.Errorf("GameMan slot = 0x%X, want 0x%X", gp.gameManSlot, l.gameManSlot)
}
}
// TestIsPlayerLoadedConfirmsOffsetByReadingMemory exercises the Linux path
// (productVersion always ok=false, see process_linux.go): with no version
// hunch, isPlayerLoaded must fall back to trying each known PlayerIns
// offset and trust whichever one points at genuinely readable memory.
func TestIsPlayerLoadedConfirmsOffsetByReadingMemory(t *testing.T) {
withFakeGame(t)
gp, err := resolvePointers(procHandle(1), 1234)
if err != nil {
t.Fatalf("resolvePointers: %v", err)
}
loaded, known := isPlayerLoaded(procHandle(1), &gp)
if !known || !loaded {
t.Fatalf("isPlayerLoaded = (%v, %v), want (true, true)", loaded, known)
}
if !gp.offsetConfirmed || gp.playerInsOffset != 0x1E508 {
t.Fatalf("PlayerIns offset not confirmed at 0x1E508: confirmed=%v offset=0x%X", gp.offsetConfirmed, gp.playerInsOffset)
}
}
func TestReadSaveSlotReadsGameManByte(t *testing.T) {
withFakeGame(t)
gp, err := resolvePointers(procHandle(1), 1234)
if err != nil {
t.Fatalf("resolvePointers: %v", err)
}
if got := readSaveSlot(procHandle(1), gp); got != 3 {
t.Fatalf("readSaveSlot = %d, want 3", got)
}
}
// TestResolveCharNameRequiresTwoMatchingReadsBeforeConfirming locks in the
// double-read confirmation rule (process.go, resolveCharName): a candidate
// only gets trusted once it reads the SAME text twice in a row.
func TestResolveCharNameRequiresTwoMatchingReadsBeforeConfirming(t *testing.T) {
l := withFakeGame(t)
gp, err := resolvePointers(procHandle(1), 1234)
if err != nil {
t.Fatalf("resolvePointers: %v", err)
}
name, ok := resolveCharName(procHandle(1), l.gameDataMan, &gp)
if ok {
t.Fatalf("first read shouldn't confirm yet, got (%q, true)", name)
}
if gp.nameConfirmed {
t.Fatal("nameConfirmed set after just one read")
}
name, ok = resolveCharName(procHandle(1), l.gameDataMan, &gp)
if !ok || name != "Aria" {
t.Fatalf("second matching read should confirm \"Aria\", got (%q, %v)", name, ok)
}
if !gp.nameConfirmed {
t.Fatal("nameConfirmed should be true after two matching reads")
}
}
// TestReadCharNameRejectsRawPointerBytes is the scenario CLAUDE.md's
// "Offsets de memoria" section calls out by name: decoding a raw 64-bit
// pointer as if it were UTF-16 text produces garbage that mixes unrelated
// script families (here Greek-ish + Han-ish code points) and must be
// rejected, not shown as a character name.
func TestReadCharNameRejectsRawPointerBytes(t *testing.T) {
l := withFakeGame(t)
// Overwrite the name text with what a raw pointer looks like reinterpreted
// as UTF-16 code units instead of a real name.
l.fp.putUint64(l.playerGameData+0x9C, 0x00007FF6_ABCDEF12)
if _, ok := readCharName(procHandle(1), l.gameDataMan, nameCandidates[0]); ok {
t.Fatal("readCharName accepted raw pointer bytes decoded as UTF-16 as a name")
}
}