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>
284 lines
9.2 KiB
Go
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")
|
|
}
|
|
}
|