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>
This commit is contained in:
emmatherockandClaude Sonnet 5 committed 2026-09-18 01:48:38 -03:00
1 parent 2ba312a833
commit 66aba2fff2
3 files changed
+321 -25

No files matched your search

+10 -10
View File
@@ -122,11 +122,16 @@ local (el caso de una PC con el OBS en Linux mientras se juega en otra),
3s sin nunca llegar a `openProcess` — `setcap`/`ptrace_scope` no entran
en juego para nada en ese caso.
**Pendiente, no parte de este cambio:** testear el escáner/poller con un
lector de memoria falso. El split ya lo habilita, pero escribir esos
tests (siguiendo el patrón de variable de paquete intercambiable que ya
usa `testExeDir` en `totals.go`, no una interfaz nueva) queda para
después — ver Pendientes.
El split habilita testear el escáner/poller sin una PC con el juego
abierto: `readMemoryFn`, `findModuleBaseFn` y `productVersionFn`
(`process.go`) son variables de paquete que por defecto apuntan a las
implementaciones de plataforma, mismo patrón que `testExeDir` en
`totals.go`. `process_test.go` las pisa con un `fakeProcess` — un buffer
en memoria direccionado como memoria de proceso real — y ejercita el
escaneo de las tres firmas, el overlap de `scanModule` entre chunks de 1
MiB, la confirmación de `PlayerIns` por lectura (el camino que toma
Linux siempre, al no haber versión), el slot de guardado, y la regla de
doble lectura para confirmar el nombre del personaje.
## Offsets de memoria
@@ -272,10 +277,5 @@ No son preferencias de estilo. Cada una costó un bug en producción.
está en inglés de punta a punta, ver "Idioma del código" arriba.
- La identificación por nombre (respaldo cuando no se lee el slot) mezcla
personajes homónimos. Documentado, no resuelto.
- Testear el escáner/poller (`process.go`) con un lector de memoria
falso. El split multiplataforma (ver "Multiplataforma" arriba) ya lo
habilita — falta escribir los tests en sí, con una variable de paquete
intercambiable por función de frontera (mismo patrón que `testExeDir`
en `totals.go`), no una interfaz nueva.
- El README debería mencionar que meter todo adentro de una VPN sigue
siendo una opción perfectamente válida, TLS+pinning aparte.
+28 -15
View File
@@ -31,6 +31,19 @@ const processName = "eldenring.exe"
// 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]"
@@ -111,7 +124,7 @@ func scanModule(h procHandle, base uintptr, size uint32, patterns [][]patByte) [
if rem := int(size - pos); readSize > rem {
readSize = rem
}
buf, ok := readMemory(h, base+uintptr(pos), readSize)
buf, ok := readMemoryFn(h, base+uintptr(pos), readSize)
if ok {
for i := 0; i < len(buf); i++ {
for p := range patterns {
@@ -140,7 +153,7 @@ func ripSlot(h procHandle, matchAddr uintptr) (uintptr, error) {
return 0, fmt.Errorf("pattern not found")
}
codeLocation := matchAddr + 3 // the first 3 bytes are the opcode
dispBytes, ok := readMemory(h, codeLocation, 4)
dispBytes, ok := readMemoryFn(h, codeLocation, 4)
if !ok {
return 0, fmt.Errorf("couldn't read the RIP-relative displacement")
}
@@ -178,12 +191,12 @@ type gamePointers struct {
func resolvePointers(h procHandle, pid uint32) (gamePointers, error) {
var gp gamePointers
base, size, exePath, err := findModuleBase(pid, processName)
base, size, exePath, err := findModuleBaseFn(pid, processName)
if err != nil {
return gp, err
}
major, minor, label, okVer := productVersion(exePath)
major, minor, label, okVer := productVersionFn(exePath)
gp.playerInsTried = playerInsCandidates(major, minor, okVer)
gp.playerInsOffset = gp.playerInsTried[0]
gp.versionLabel = label
@@ -216,7 +229,7 @@ func derefPointer(h procHandle, slot uintptr) (uintptr, bool) {
if slot == 0 {
return 0, false
}
buf, ok := readMemory(h, slot, 8)
buf, ok := readMemoryFn(h, slot, 8)
if !ok {
return 0, false
}
@@ -257,7 +270,7 @@ func readCharName(h procHandle, gameDataMan uintptr, c nameCandidate) (string, b
}
base = p
}
buf, ok := readMemory(h, base+c.nameOffset, charNameMaxChars*2)
buf, ok := readMemoryFn(h, base+c.nameOffset, charNameMaxChars*2)
if !ok {
return "", false
}
@@ -316,7 +329,7 @@ func readSaveSlot(h procHandle, gp gamePointers) int {
if !ok || gameMan == 0 {
return -1
}
buf, ok := readMemory(h, gameMan+saveSlotOffset, 1)
buf, ok := readMemoryFn(h, gameMan+saveSlotOffset, 1)
if !ok {
return -1
}
@@ -365,7 +378,7 @@ func isPlayerLoaded(h procHandle, gp *gamePointers) (loaded bool, known bool) {
}
// A real pointer points at mapped memory; a garbage one almost
// never survives this read.
if _, ok := readMemory(h, playerIns, 8); !ok {
if _, ok := readMemoryFn(h, playerIns, 8); !ok {
continue
}
gp.playerInsOffset = cand
@@ -436,7 +449,7 @@ func pollLoop() {
closeHandle := func() {
if handle != 0 {
closeProcessHandle(handle)
closeProcessHandleFn(handle)
handle = 0
}
pid = 0
@@ -449,13 +462,13 @@ func pollLoop() {
for {
if handle == 0 {
newPid, err := findProcessID(processName)
newPid, err := findProcessIDFn(processName)
if err != nil {
st.setDisconnected("waiting for eldenring.exe")
time.Sleep(3 * time.Second)
continue
}
h, err := openProcess(newPid)
h, err := openProcessFn(newPid)
if err != nil {
st.setDisconnected("couldn't open the process (permissions?): " + err.Error())
time.Sleep(3 * time.Second)
@@ -472,7 +485,7 @@ func pollLoop() {
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 {
if _, ferr := findProcessIDFn(processName); ferr != nil {
closeHandle()
}
continue
@@ -510,7 +523,7 @@ func pollLoop() {
// 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 {
if buf, ok := readMemoryFn(handle, gdm+0x94, 4); ok {
raw := int32(binary.LittleEndian.Uint32(buf))
if raw >= 0 && raw < 1_000_000 {
if haveUnloadedRaw {
@@ -562,8 +575,8 @@ func pollLoop() {
continue
}
deathsBuf, ok1 := readMemory(handle, gameDataMan+0x94, 4)
bossBuf, ok2 := readMemory(handle, gameDataMan+0xC0, 1)
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()
+283
View File
@@ -0,0 +1,283 @@
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")
}
}