diff --git a/CLAUDE.md b/CLAUDE.md index 8911aab..a1300e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//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`. diff --git a/process_linux.go b/process_linux.go index 14ecd1f..3171b4c 100644 --- a/process_linux.go +++ b/process_linux.go @@ -42,37 +42,32 @@ func findProcessID(name string) (uint32, error) { return 0, fmt.Errorf("process not found: %s", name) } -// scanMaps walks /proc//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//maps. +type mapLine struct { + start, end uintptr + path string // empty for an anonymous mapping +} + +// readMaps parses every line of /proc//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) - } - path = mapPath - ok = true + lines = append(lines, mapLine{start: uintptr(start), end: uintptr(stop), path: path}) } + 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//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 } diff --git a/process_linux_test.go b/process_linux_test.go new file mode 100644 index 0000000..7bc1db2 --- /dev/null +++ b/process_linux_test.go @@ -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") + } +}