Compare commits

...
2 Commits
Author SHA1 Message Date
emmatherockandClaude Sonnet 5 356df6cbd5 Fix Linux module-bounds detection: extend through Wine's anonymous PE mapping
Running the Linux build against a live game showed it stuck forever on
"scanning signatures": scanMaps only collected mappings whose file matched
eldenring.exe, but this Proton build backs just the 4 KB PE header with the
real path and maps the rest of the module (~94 MB of .text/.rdata/.data,
where every AOB signature lives) as one anonymous mapping with no path.
findModuleBase was handing the scanner the header alone.

moduleSpan (the matching logic, now pulled out of scanMaps as a pure
function for process_linux_test.go) extends the span through contiguous
anonymous mappings that follow the last named one, and stops at the first
mapping with its own path so it can't merge in an unrelated module.
Confirmed against the live process: all three signatures resolve, PlayerIns
confirms, and /deaths reports real numbers end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 01:54:52 -03:00
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
5 changed files with 486 additions and 55 deletions

No files matched your search

+23 -14
View File
@@ -89,10 +89,19 @@ Windows, mismas firmas y offsets):
(`findProcessID`/`scanMaps`): Proton levanta varios procesos: se
recorre `/proc/*/maps` y se toma el pid que tenga mapeado un archivo
terminado en `eldenring.exe`. Los mismos mapeos dan la base y el
tamaño del módulo para `findModuleBase`: puede venir partido en varios
tramos (`.text`/`.rdata`/`.data`), así que se toma el span completo
(mínimo inicio, máximo final) — alcanza, porque el escáner ya lee por
chunks y saltea los que no puede leer.
tamaño del módulo para `findModuleBase` (`moduleSpan`, la parte pura
de `scanMaps`, testeada en `process_linux_test.go`) — pero el tamaño
real **no** sale de sumar los tramos con ese nombre. Confirmado
corriendo contra un Proton real: el loader de PE de Wine sólo mapea
con el archivo real la página del header (unos pocos KB); el resto
del módulo — `.text`/`.rdata`/`.data`, donde viven las firmas — es UN
mapeo anónimo enorme (acá, ~94 MB) pegado justo después, sin ruta.
Quedarse con el span de los tramos nombrados solos deja al escáner con
el header y nada más: todas las firmas fallan y `resolvePointers` da
vueltas para siempre. `moduleSpan` extiende el span a través de
mapeos anónimos contiguos que sigan al último tramo nombrado —
contiguos y sin ruta nada más, para no comerse por accidente un
módulo distinto que justo cargue pegado.
- **Leer memoria vía `/proc/<pid>/mem`** (`ReadAt`, sin dependencias
externas), no `process_vm_readv(2)` crudo: mismo resultado, sin tener
que hacer un syscall a mano con structs `iovec` sin `golang.org/x/sys`.
@@ -122,11 +131,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 +286,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()
+72 -26
View File
@@ -42,37 +42,32 @@ func findProcessID(name string) (uint32, error) {
return 0, fmt.Errorf("process not found: %s", name)
}
// scanMaps walks /proc/<pid>/maps looking for lines whose mapped file's
// base name matches name (case-insensitively), and returns the full span
// across every matching line: the module can be split into several
// segments (.text/.rdata/.data with different permissions), and the
// scanner in process.go already reads in chunks and tolerates unreadable
// ones, so the min-start/max-end span across all of them is enough.
func scanMaps(pid uint32, name string) (base, end uintptr, path string, ok bool) {
// mapLine is one parsed line of /proc/<pid>/maps.
type mapLine struct {
start, end uintptr
path string // empty for an anonymous mapping
}
// readMaps parses every line of /proc/<pid>/maps. Format: "start-end perms
// offset dev inode [pathname]" — the pathname (anonymous mappings don't
// have one) is everything after the first 5 fields, rejoined with single
// spaces. A pathname with unusual internal spacing could theoretically
// come out collapsed, but that's a cosmetic edge case that doesn't affect
// matching against a base filename like "eldenring.exe".
func readMaps(pid uint32) ([]mapLine, error) {
f, err := os.Open(fmt.Sprintf("/proc/%d/maps", pid))
if err != nil {
return 0, 0, "", false
return nil, err
}
defer f.Close()
var lines []mapLine
sc := bufio.NewScanner(f)
for sc.Scan() {
// Format: "start-end perms offset dev inode [pathname]". The
// pathname (anonymous mappings don't have one) is everything
// after the first 5 fields, rejoined with single spaces — a
// pathname with unusual internal spacing could theoretically
// come out collapsed, but that's a cosmetic edge case that
// doesn't affect matching against a base filename like
// "eldenring.exe".
fields := strings.Fields(sc.Text())
if len(fields) < 6 {
if len(fields) < 5 {
continue
}
mapPath := strings.Join(fields[5:], " ")
if !strings.EqualFold(filepath.Base(mapPath), name) {
continue
}
startStr, endStr, cut := strings.Cut(fields[0], "-")
if !cut {
continue
@@ -82,15 +77,66 @@ func scanMaps(pid uint32, name string) (base, end uintptr, path string, ok bool)
if err1 != nil || err2 != nil {
continue
}
if !ok || uintptr(start) < base {
base = uintptr(start)
var path string
if len(fields) >= 6 {
path = strings.Join(fields[5:], " ")
}
if uintptr(stop) > end {
end = uintptr(stop)
lines = append(lines, mapLine{start: uintptr(start), end: uintptr(stop), path: path})
}
path = mapPath
return lines, sc.Err()
}
// scanMaps looks for mappings whose file's base name matches name
// (case-insensitively) and returns the full span across every matching
// line, then — this is the part that matters in practice — extends that
// span through any anonymous mappings that follow it with no gap.
//
// Confirmed live against a running Proton build: Wine's PE loader maps
// only the PE header (a handful of KB) as a real file-backed mapping;
// the rest of the module — .text/.rdata/.data, everything the AOB
// signatures actually live in — comes right after as ONE large anonymous
// mapping with no path at all. Stopping at the last named line, like an
// ELF/native loader's split-by-section layout would suggest, leaves the
// scanner holding a few KB of PE header and nothing else: every signature
// scan fails and resolvePointers loops forever ("GameDataMan's pattern
// wasn't found") without ever reading real code. The extension is
// restricted to path=="" so it can't wander into a genuinely different,
// unrelated module that just happens to load right after this one.
func scanMaps(pid uint32, name string) (base, end uintptr, path string, ok bool) {
lines, err := readMaps(pid)
if err != nil {
return 0, 0, "", false
}
return moduleSpan(lines, name)
}
// moduleSpan is scanMaps' matching/extension logic, pulled out as a pure
// function of an already-parsed maps listing so it's testable (see
// process_linux_test.go) without a real /proc/<pid>/maps to read.
func moduleSpan(lines []mapLine, name string) (base, end uintptr, path string, ok bool) {
lastMatch := -1
for i, l := range lines {
if !strings.EqualFold(filepath.Base(l.path), name) {
continue
}
if !ok || l.start < base {
base = l.start
}
if l.end > end {
end = l.end
}
path = l.path
ok = true
lastMatch = i
}
if !ok {
return 0, 0, "", false
}
for i := lastMatch + 1; i < len(lines) && lines[i].path == "" && lines[i].start == end; i++ {
end = lines[i].end
}
return base, end, path, ok
}
+80
View File
@@ -0,0 +1,80 @@
//go:build linux
package main
import "testing"
// TestModuleSpanExtendsThroughAnonymousWineMapping is a regression test for
// what running against a real Proton build surfaced: Wine's PE loader maps
// only the PE header as a real file-backed mapping, and the rest of the
// module — .text/.rdata/.data, everything the AOB signatures live in —
// comes right after as one large anonymous mapping with no path. Without
// the extension, findModuleBase would hand the scanner a few KB of PE
// header and nothing else, and every signature scan would fail forever.
func TestModuleSpanExtendsThroughAnonymousWineMapping(t *testing.T) {
lines := []mapLine{
{start: 0x10000, end: 0x12000, path: ""}, // unrelated anonymous mapping before it
{start: 0x140000000, end: 0x140001000, path: "/games/ELDEN RING/Game/eldenring.exe"}, // PE header, file-backed
{start: 0x140001000, end: 0x145e0e000, path: ""}, // the actual module body, anonymous
{start: 0x555591e6c000, end: 0x5555975f4000, path: "[heap]"},
}
base, end, path, ok := moduleSpan(lines, "eldenring.exe")
if !ok {
t.Fatal("expected a match")
}
if base != 0x140000000 {
t.Errorf("base = 0x%X, want 0x140000000", base)
}
if end != 0x145e0e000 {
t.Errorf("end = 0x%X, want 0x145e0e000 (the header alone would give 0x140001000)", end)
}
if path != "/games/ELDEN RING/Game/eldenring.exe" {
t.Errorf("path = %q, want the header's own path", path)
}
}
// TestModuleSpanDoesNotSwallowAFollowingNamedMapping guards the boundary
// of the fix above: the extension must stop at the first mapping that has
// its own path, even if it's perfectly contiguous, so it can never merge
// a genuinely different module into the span.
func TestModuleSpanDoesNotSwallowAFollowingNamedMapping(t *testing.T) {
lines := []mapLine{
{start: 0x140000000, end: 0x140001000, path: "/games/ELDEN RING/Game/eldenring.exe"},
{start: 0x140001000, end: 0x140003000, path: ""}, // module body, anonymous
{start: 0x140003000, end: 0x140010000, path: "/games/ELDEN RING/Game/d3d12.dll"}, // a different, unrelated module
}
_, end, _, ok := moduleSpan(lines, "eldenring.exe")
if !ok {
t.Fatal("expected a match")
}
if end != 0x140003000 {
t.Errorf("end = 0x%X, want 0x140003000 (must stop before d3d12.dll)", end)
}
}
// TestModuleSpanCoversMultipleNamedSegments keeps the ELF-style layout
// (several file-backed segments sharing the module's own name) working
// too, in case a future Wine/Proton build maps it that way instead.
func TestModuleSpanCoversMultipleNamedSegments(t *testing.T) {
lines := []mapLine{
{start: 0x140000000, end: 0x140001000, path: "/games/ELDEN RING/Game/eldenring.exe"},
{start: 0x140001000, end: 0x140002000, path: "/games/ELDEN RING/Game/eldenring.exe"},
{start: 0x140002000, end: 0x140003000, path: "/games/ELDEN RING/Game/eldenring.exe"},
}
base, end, _, ok := moduleSpan(lines, "eldenring.exe")
if !ok || base != 0x140000000 || end != 0x140003000 {
t.Fatalf("got base=0x%X end=0x%X ok=%v, want 0x140000000-0x140003000", base, end, ok)
}
}
func TestModuleSpanNotFound(t *testing.T) {
lines := []mapLine{
{start: 0x1000, end: 0x2000, path: "/games/somethingelse.exe"},
}
if _, _, _, ok := moduleSpan(lines, "eldenring.exe"); ok {
t.Fatal("expected no match")
}
}
+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")
}
}