diff --git a/CLAUDE.md b/CLAUDE.md index 5c23955..8911aab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/process.go b/process.go index d8a7363..fcbd100 100644 --- a/process.go +++ b/process.go @@ -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() diff --git a/process_test.go b/process_test.go new file mode 100644 index 0000000..fef2a04 --- /dev/null +++ b/process_test.go @@ -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") + } +}