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

+74 -28
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)
}
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/<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
}