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>
This commit is contained in:
emmatherockandClaude Sonnet 5 committed 2026-09-18 01:54:52 -03:00
1 parent 66aba2fff2
commit 356df6cbd5
3 files changed
+167 -32

No files matched your search

+13 -4
View File
@@ -89,10 +89,19 @@ Windows, mismas firmas y offsets):
(`findProcessID`/`scanMaps`): Proton levanta varios procesos: se (`findProcessID`/`scanMaps`): Proton levanta varios procesos: se
recorre `/proc/*/maps` y se toma el pid que tenga mapeado un archivo 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 terminado en `eldenring.exe`. Los mismos mapeos dan la base y el
tamaño del módulo para `findModuleBase`: puede venir partido en varios tamaño del módulo para `findModuleBase` (`moduleSpan`, la parte pura
tramos (`.text`/`.rdata`/`.data`), así que se toma el span completo de `scanMaps`, testeada en `process_linux_test.go`) — pero el tamaño
(mínimo inicio, máximo final) — alcanza, porque el escáner ya lee por real **no** sale de sumar los tramos con ese nombre. Confirmado
chunks y saltea los que no puede leer. 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 - **Leer memoria vía `/proc/<pid>/mem`** (`ReadAt`, sin dependencias
externas), no `process_vm_readv(2)` crudo: mismo resultado, sin tener 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`. que hacer un syscall a mano con structs `iovec` sin `golang.org/x/sys`.
+74 -28
View File
@@ -42,37 +42,32 @@ func findProcessID(name string) (uint32, error) {
return 0, fmt.Errorf("process not found: %s", name) return 0, fmt.Errorf("process not found: %s", name)
} }
// scanMaps walks /proc/<pid>/maps looking for lines whose mapped file's // mapLine is one parsed line of /proc/<pid>/maps.
// base name matches name (case-insensitively), and returns the full span type mapLine struct {
// across every matching line: the module can be split into several start, end uintptr
// segments (.text/.rdata/.data with different permissions), and the path string // empty for an anonymous mapping
// 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) { // 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)) f, err := os.Open(fmt.Sprintf("/proc/%d/maps", pid))
if err != nil { if err != nil {
return 0, 0, "", false return nil, err
} }
defer f.Close() defer f.Close()
var lines []mapLine
sc := bufio.NewScanner(f) sc := bufio.NewScanner(f)
for sc.Scan() { 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()) fields := strings.Fields(sc.Text())
if len(fields) < 6 { if len(fields) < 5 {
continue continue
} }
mapPath := strings.Join(fields[5:], " ")
if !strings.EqualFold(filepath.Base(mapPath), name) {
continue
}
startStr, endStr, cut := strings.Cut(fields[0], "-") startStr, endStr, cut := strings.Cut(fields[0], "-")
if !cut { if !cut {
continue continue
@@ -82,15 +77,66 @@ func scanMaps(pid uint32, name string) (base, end uintptr, path string, ok bool)
if err1 != nil || err2 != nil { if err1 != nil || err2 != nil {
continue continue
} }
if !ok || uintptr(start) < base { var path string
base = uintptr(start) if len(fields) >= 6 {
path = strings.Join(fields[5:], " ")
} }
if uintptr(stop) > end { lines = append(lines, mapLine{start: uintptr(start), end: uintptr(stop), path: path})
end = uintptr(stop)
}
path = mapPath
ok = true
} }
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 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")
}
}