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") } }