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:
1 parent
66aba2fff2
commit
356df6cbd5
3 files changed
+167
-32
No files matched your search
@@ -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`.
|
||||
|
||||
+74
-28
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user