Compare commits
5
Commits
2ba312a833
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b73becb579 | ||
|
|
6029850d08 | ||
|
|
8de11e15a6 | ||
|
|
356df6cbd5 | ||
|
|
66aba2fff2 |
No files matched your search
+23
-14
@@ -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.
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -47,6 +48,11 @@ func tokenPath() string {
|
||||
return "token.txt"
|
||||
}
|
||||
|
||||
// tokenFile is token.txt's on-disk shape.
|
||||
type tokenFile struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func randomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
@@ -68,15 +74,22 @@ func resolveToken(cfg config) (token string, generated bool, err error) {
|
||||
}
|
||||
path := tokenPath()
|
||||
if data, rerr := os.ReadFile(path); rerr == nil {
|
||||
if t := strings.TrimSpace(string(data)); t != "" {
|
||||
var tf tokenFile
|
||||
if jerr := json.Unmarshal(data, &tf); jerr == nil {
|
||||
if t := strings.TrimSpace(tf.Token); t != "" {
|
||||
return t, false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
t, gerr := randomHex(tokenBytes)
|
||||
if gerr != nil {
|
||||
return "", false, fmt.Errorf("couldn't generate a token: %w", gerr)
|
||||
}
|
||||
if werr := os.WriteFile(path, []byte(t), 0600); werr != nil {
|
||||
blob, merr := json.MarshalIndent(tokenFile{Token: t}, "", " ")
|
||||
if merr != nil {
|
||||
return "", false, fmt.Errorf("couldn't encode token.txt: %w", merr)
|
||||
}
|
||||
if werr := os.WriteFile(path, blob, 0600); werr != nil {
|
||||
return "", false, fmt.Errorf("couldn't save token.txt: %w", werr)
|
||||
}
|
||||
return t, true, nil
|
||||
|
||||
@@ -16,6 +16,7 @@ package main
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -316,16 +317,24 @@ partner = ""
|
||||
// load a different save. With its own id, the hub keeps seeing the same
|
||||
// partner even if they switch characters, reconnect, or restart the
|
||||
// program.
|
||||
// clientIDFile is client-id.txt's on-disk shape.
|
||||
type clientIDFile struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func clientID() string {
|
||||
path := "client-id.txt"
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
path = filepath.Join(filepath.Dir(exe), "client-id.txt")
|
||||
}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if id := strings.TrimSpace(string(data)); id != "" {
|
||||
var f clientIDFile
|
||||
if jerr := json.Unmarshal(data, &f); jerr == nil {
|
||||
if id := strings.TrimSpace(f.ID); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// No randomness available: still return something stable for
|
||||
@@ -333,7 +342,12 @@ func clientID() string {
|
||||
return fmt.Sprintf("pid-%d", os.Getpid())
|
||||
}
|
||||
id := hex.EncodeToString(b[:])
|
||||
if err := os.WriteFile(path, []byte(id), 0644); err != nil {
|
||||
blob, merr := json.MarshalIndent(clientIDFile{ID: id}, "", " ")
|
||||
if merr != nil {
|
||||
log.Printf("couldn't encode client-id.txt (%v): using a fresh id every startup", merr)
|
||||
return id
|
||||
}
|
||||
if err := os.WriteFile(path, blob, 0644); err != nil {
|
||||
log.Printf("couldn't save client-id.txt (%v): using a fresh id every startup", err)
|
||||
}
|
||||
return id
|
||||
@@ -345,7 +359,7 @@ func writeSampleConfig() {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(sampleConfig), 0644); err != nil {
|
||||
if err := os.WriteFile(path, []byte(sampleConfig), 0600); err != nil {
|
||||
log.Printf("couldn't write the sample config.toml: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
+28
-15
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user