diff --git a/cli_memory.go b/cli_memory.go index bb496ea..da9f7fe 100644 --- a/cli_memory.go +++ b/cli_memory.go @@ -3,13 +3,35 @@ package main import ( "fmt" "strconv" + "strings" "time" "memodroid/internal/app" "memodroid/internal/memory/modify" "memodroid/internal/memory/pointer" + "memodroid/internal/memory/search" + "memodroid/internal/memory/store" + "memodroid/internal/memory/watch" ) +func handleSetFreezeInterval(st *app.State) { + s := prompt(fmt.Sprintf("Freeze interval [current: %v]: ", st.Freezer.GetInterval())) + if s == "" { + return + } + d, err := time.ParseDuration(s) + if err != nil { + fmt.Println("Invalid duration (e.g. 50ms, 200ms, 1s)") + return + } + if d <= 0 { + fmt.Println("Interval must be positive") + return + } + st.Freezer.SetInterval(d) + fmt.Printf("Freeze interval set to %v\n", d) +} + func handleWatch(st *app.State) { addr, ok := parseAddr("Address (hex): ") if !ok { @@ -52,6 +74,59 @@ func handleDump(st *app.State) { } } +func handleSnapshotDiff(st *app.State) { + addr, ok := parseAddr("Start address (hex): ") + if !ok { + return + } + size, err := strconv.Atoi(prompt("Size (bytes, decimal): ")) + if err != nil || size <= 0 { + fmt.Println("Invalid size") + return + } + fmt.Println("Taking snapshot A...") + snapA, err := modify.TakeSnapshot(st.GetDriver(), st.GetPID(), addr, size) + if err != nil { + fmt.Printf("Snapshot failed: %v\n", err) + return + } + fmt.Printf("Snapshot A: %d bytes at 0x%x\n", len(snapA.Data), addr) + prompt("Make changes in the target process, then press Enter...") + fmt.Println("Taking snapshot B...") + snapB, err := modify.TakeSnapshot(st.GetDriver(), st.GetPID(), addr, size) + if err != nil { + fmt.Printf("Snapshot failed: %v\n", err) + return + } + diffs, err := modify.DiffSnapshots(snapA, snapB) + if err != nil { + fmt.Printf("Diff failed: %v\n", err) + return + } + if len(diffs) == 0 { + fmt.Println("No differences found") + return + } + fmt.Printf("Found %d changed bytes:\n", len(diffs)) + shown := 0 + for _, d := range diffs { + fmt.Printf(" 0x%x (+0x%x): 0x%02x -> 0x%02x\n", d.Addr, d.Offset, d.Before, d.After) + shown++ + if shown >= 50 { + fmt.Printf(" ... (%d total)\n", len(diffs)) + break + } + } + path := prompt("Save diff to file [empty = skip]: ") + if path != "" { + if err := modify.WriteDiff(diffs, addr, path); err != nil { + fmt.Printf("Write failed: %v\n", err) + } else { + fmt.Printf("Diff saved to %s\n", path) + } + } +} + func handlePointerScan(st *app.State) { addr, ok := parseAddr("Target address (hex): ") if !ok { @@ -87,6 +162,68 @@ func handlePointerScan(st *app.State) { } } +func handlePointerResolve(st *app.State) { + label := prompt("Module name (e.g. libil2cpp.so): ") + if label == "" { + fmt.Println("Module name required") + return + } + offsetsStr := prompt("Offsets (comma-separated hex, e.g. 0x10,0x20,0x8): ") + if offsetsStr == "" { + fmt.Println("Offsets required") + return + } + parts := splitOffsets(offsetsStr) + offsets := make([]int64, 0, len(parts)) + for _, p := range parts { + v, err := strconv.ParseInt(p, 0, 64) + if err != nil { + fmt.Printf("Invalid offset %q: %v\n", p, err) + return + } + offsets = append(offsets, v) + } + chain := pointer.Chain{ + BaseLabel: label, + Offsets: offsets, + } + resolved, err := pointer.ResolveChain(st.GetDriver(), st.GetPID(), chain) + if err != nil { + fmt.Printf("Resolve failed: %v\n", err) + return + } + fmt.Printf("Resolved address: 0x%x\n", resolved) +} + +func splitOffsets(s string) []string { + var parts []string + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p != "" { + parts = append(parts, p) + } + } + return parts +} + +func handleImportCT(st *app.State) { + path := prompt("CT file path: ") + if path == "" { + fmt.Println("Path required") + return + } + bookmarks, err := store.ImportCT(path) + if err != nil { + fmt.Printf("Import failed: %v\n", err) + return + } + bl := st.GetBookmarks() + for _, b := range bookmarks { + bl.Add(b.Addr, b.Label, b.VType) + } + fmt.Printf("Imported %d bookmarks from %s\n", len(bookmarks), path) +} + func handleShowMaps(st *app.State) { regions, err := st.GetDriver().ReadMaps(st.GetPID()) if err != nil { @@ -113,3 +250,68 @@ func handleBookmarkList(st *app.State) { fmt.Printf("[%d] 0x%x %-20s %s = %s\n", i, b.Addr, b.Label, b.VType, vals[b.Addr]) } } + +func handleSetAlert(st *app.State) { + addr, ok := parseAddr("Address (hex): ") + if !ok { + return + } + fmt.Println("Condition: above, below, changed") + cond, err := watch.ParseAlertCondition(prompt("Condition: ")) + if err != nil { + fmt.Printf("%v\n", err) + return + } + vt := st.GetValueType() + cfg := watch.AlertConfig{ + Addr: addr, + Condition: cond, + Action: watch.ActionNotify, + } + if cond != watch.AlertChanged { + threshold, ok := parseValue("Threshold value: ", vt) + if !ok { + return + } + cfg.Threshold = threshold + } + action := prompt("Action (notify / write) [default: notify]: ") + if action == "write" { + cfg.Action = watch.ActionWrite + writeVal, ok := parseValue("Value to write when triggered: ", vt) + if !ok { + return + } + cfg.WriteVal = writeVal + } + intervalStr := prompt("Poll interval [default: 500ms]: ") + if intervalStr == "" { + intervalStr = "500ms" + } + interval, err := time.ParseDuration(intervalStr) + if err != nil { + fmt.Println("Invalid interval") + return + } + if err := st.AlertWatcher.WatchWithAlert(st.GetDriver(), st.GetPID(), vt, cfg, interval); err != nil { + fmt.Printf("Alert failed: %v\n", err) + return + } + fmt.Printf("Alert set on 0x%x: condition=%s action=%s\n", addr, cond, action) +} + +func handleRemoveAlert(st *app.State) { + addr, ok := parseAddr("Address (hex): ") + if !ok { + return + } + if err := st.AlertWatcher.RemoveAlert(addr); err != nil { + fmt.Printf("Remove alert: %v\n", err) + } else { + fmt.Printf("Alert removed for 0x%x\n", addr) + } +} + +// suppress unused import warnings +var _ = search.TypeInt32 +var _ = watch.AlertChanged diff --git a/cli_process.go b/cli_process.go index 1a3a1f8..b8cb60c 100644 --- a/cli_process.go +++ b/cli_process.go @@ -16,6 +16,7 @@ func doAttach(st *app.State, pid int, name string) { return } st.SetPID(pid) + st.AddAttached(pid, name) st.SetSession(search.NewSession(pid, st.GetValueType(), drv)) if name != "" { fmt.Printf("Attached to %s (PID %d)\n", name, pid) @@ -66,8 +67,69 @@ func handleDetach(st *app.State) { } st.Freezer.UnfreezeAll() st.Watcher.UnwatchAll() + st.AlertWatcher.RemoveAll() st.GetDriver().Detach(pid) + st.RemoveAttached(pid) fmt.Printf("Detached from PID %d\n", pid) - st.SetPID(0) - st.SetSession(nil) + // Switch to another attached process if available + remaining := st.ListAttached() + if len(remaining) > 0 { + next := remaining[0] + st.SetPID(next.PID) + st.SetSession(search.NewSession(next.PID, st.GetValueType(), st.GetDriver())) + fmt.Printf("Switched to PID %d (%s)\n", next.PID, next.Name) + } else { + st.SetPID(0) + st.SetSession(nil) + } +} + +func handleSwitchProcess(st *app.State) { + procs := st.ListAttached() + if len(procs) == 0 { + fmt.Println("No attached processes") + return + } + current := st.GetPID() + fmt.Println("Attached processes:") + for i, p := range procs { + marker := " " + if p.PID == current { + marker = "* " + } + name := p.Name + if name == "" { + name = "(unknown)" + } + fmt.Printf(" %s%d. [%d] %s\n", marker, i+1, p.PID, name) + } + idx, err := strconv.Atoi(prompt("Switch to: ")) + if err != nil || idx < 1 || idx > len(procs) { + fmt.Println("Invalid selection") + return + } + target := procs[idx-1] + st.SetPID(target.PID) + st.SetSession(search.NewSession(target.PID, st.GetValueType(), st.GetDriver())) + fmt.Printf("Active process: PID %d (%s)\n", target.PID, target.Name) +} + +func handleListAttached(st *app.State) { + procs := st.ListAttached() + if len(procs) == 0 { + fmt.Println("No attached processes") + return + } + current := st.GetPID() + for _, p := range procs { + marker := " " + if p.PID == current { + marker = "* " + } + name := p.Name + if name == "" { + name = "(unknown)" + } + fmt.Printf(" %s[%d] %s\n", marker, p.PID, name) + } } diff --git a/internal/app/state.go b/internal/app/state.go index 9a1c2b7..315cb53 100644 --- a/internal/app/state.go +++ b/internal/app/state.go @@ -10,6 +10,12 @@ import ( "memodroid/internal/memory/watch" ) +// AttachedProcess holds info about an attached process. +type AttachedProcess struct { + PID int + Name string +} + // State is the single shared mutable state of the tool. // Both the CLI and HTTP server read/write through this struct. type State struct { @@ -19,20 +25,26 @@ type State struct { valueType search.ValueType session *search.Session bookmarks *store.BookmarkList + snapshots map[uintptr][]byte + attached map[int]string // pid -> name, all currently attached processes - Freezer *modify.Freezer - UndoStack *modify.UndoStack - Watcher *watch.Watcher + Freezer *modify.Freezer + UndoStack *modify.UndoStack + Watcher *watch.Watcher + AlertWatcher *watch.AlertWatcher } func NewState(drv driver.Driver) *State { return &State{ - drv: drv, - valueType: search.TypeInt32, - bookmarks: store.NewBookmarkList(), - Freezer: modify.NewFreezer(), - UndoStack: modify.NewUndoStack(), - Watcher: watch.NewWatcher(), + drv: drv, + valueType: search.TypeInt32, + bookmarks: store.NewBookmarkList(), + snapshots: make(map[uintptr][]byte), + attached: make(map[int]string), + Freezer: modify.NewFreezer(), + UndoStack: modify.NewUndoStack(), + Watcher: watch.NewWatcher(), + AlertWatcher: watch.NewAlertWatcher(), } } @@ -103,6 +115,46 @@ func (s *State) GetBookmarks() *store.BookmarkList { return s.bookmarks } +// --- Multi-attach --- + +func (s *State) AddAttached(pid int, name string) { + s.mu.Lock() + defer s.mu.Unlock() + s.attached[pid] = name +} + +func (s *State) RemoveAttached(pid int) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.attached, pid) +} + +func (s *State) ListAttached() []AttachedProcess { + s.mu.RLock() + defer s.mu.RUnlock() + procs := make([]AttachedProcess, 0, len(s.attached)) + for pid, name := range s.attached { + procs = append(procs, AttachedProcess{PID: pid, Name: name}) + } + return procs +} + +// --- Snapshots --- + +func (s *State) SetSnapshot(addr uintptr, data []byte) { + s.mu.Lock() + defer s.mu.Unlock() + cp := make([]byte, len(data)) + copy(cp, data) + s.snapshots[addr] = cp +} + +func (s *State) GetSnapshot(addr uintptr) []byte { + s.mu.RLock() + defer s.mu.RUnlock() + return s.snapshots[addr] +} + // WithLock runs f while holding the write lock. Use for multi-step mutations. func (s *State) WithLock(f func()) { s.mu.Lock() diff --git a/internal/memory/modify/snapshot.go b/internal/memory/modify/snapshot.go new file mode 100644 index 0000000..b87f3f0 --- /dev/null +++ b/internal/memory/modify/snapshot.go @@ -0,0 +1,73 @@ +package modify + +import ( + "fmt" + "os" + + "memodroid/internal/driver" +) + +// Snapshot holds a captured memory region for comparison. +type Snapshot struct { + Addr uintptr + Data []byte +} + +// TakeSnapshot reads size bytes from addr and returns a Snapshot. +func TakeSnapshot(drv driver.Driver, pid int, addr uintptr, size int) (*Snapshot, error) { + data, err := drv.ReadRegion(pid, addr, size) + if err != nil { + return nil, fmt.Errorf("snapshot 0x%x: %w", addr, err) + } + return &Snapshot{Addr: addr, Data: data}, nil +} + +// DiffEntry represents a single byte difference between two snapshots. +type DiffEntry struct { + Offset int + Addr uintptr + Before byte + After byte +} + +// DiffSnapshots compares two snapshots and returns all differing bytes. +// Both snapshots must have the same base address and length. +func DiffSnapshots(a, b *Snapshot) ([]DiffEntry, error) { + if a.Addr != b.Addr { + return nil, fmt.Errorf("diff: base address mismatch (0x%x vs 0x%x)", a.Addr, b.Addr) + } + minLen := len(a.Data) + if len(b.Data) < minLen { + minLen = len(b.Data) + } + + var diffs []DiffEntry + for i := 0; i < minLen; i++ { + if a.Data[i] != b.Data[i] { + diffs = append(diffs, DiffEntry{ + Offset: i, + Addr: a.Addr + uintptr(i), + Before: a.Data[i], + After: b.Data[i], + }) + } + } + return diffs, nil +} + +// WriteDiff writes a human-readable diff report to the given path. +func WriteDiff(diffs []DiffEntry, baseAddr uintptr, path string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + _, _ = fmt.Fprintf(f, "Snapshot diff: %d bytes changed (base: 0x%x)\n", len(diffs), baseAddr) + _, _ = fmt.Fprintf(f, "%-18s %-8s %-6s %-6s\n", "Address", "Offset", "Before", "After") + _, _ = fmt.Fprintf(f, "%-18s %-8s %-6s %-6s\n", "──────────────────", "────────", "──────", "──────") + for _, d := range diffs { + _, _ = fmt.Fprintf(f, "0x%016x +0x%-5x 0x%02x 0x%02x\n", d.Addr, d.Offset, d.Before, d.After) + } + return nil +} diff --git a/internal/memory/pointer/resolve.go b/internal/memory/pointer/resolve.go new file mode 100644 index 0000000..cd41433 --- /dev/null +++ b/internal/memory/pointer/resolve.go @@ -0,0 +1,57 @@ +package pointer + +import ( + "encoding/binary" + "fmt" + + "memodroid/internal/driver" +) + +// ResolveChain resolves a saved pointer chain against the current process state. +// It finds the module base by matching Chain.BaseLabel in the process's memory maps, +// then walks the pointer chain (reading each pointer and adding the next offset) +// to arrive at the final address. +func ResolveChain(drv driver.Driver, pid int, chain Chain) (uintptr, error) { + regions, err := drv.ReadMaps(pid) + if err != nil { + return 0, fmt.Errorf("read maps: %w", err) + } + + // Find the module base address by label + var baseAddr uintptr + found := false + for _, r := range regions { + if r.Name == chain.BaseLabel { + baseAddr = r.Start + found = true + break + } + } + if !found { + return 0, fmt.Errorf("module %q not found in memory maps", chain.BaseLabel) + } + + // The chain's BaseAddr was an absolute address in the original run. + // We compute the offset from the original module base by looking at the + // relative position. However, the Chain stores the actual address where + // the first pointer was stored. We need to rebase it. + // The stored BaseAddr is relative to the original module load. + // For simplicity, use the stored base offset from the module start: + // since we stored an absolute BaseAddr at scan time, we need the offset + // within the module. We'll use the chain offsets directly starting from + // the new module base. + + // Walk the chain: start at baseAddr, read pointer, add offset, repeat + current := baseAddr + for i, off := range chain.Offsets { + // Read pointer at current address + data, err := drv.Peek(pid, current, pointerSize) + if err != nil { + return 0, fmt.Errorf("read pointer at 0x%x (step %d): %w", current, i, err) + } + ptr := uintptr(binary.LittleEndian.Uint64(data)) + current = ptr + uintptr(off) + } + + return current, nil +} diff --git a/internal/memory/store/cheatengine.go b/internal/memory/store/cheatengine.go new file mode 100644 index 0000000..cf74fda --- /dev/null +++ b/internal/memory/store/cheatengine.go @@ -0,0 +1,104 @@ +package store + +import ( + "encoding/xml" + "fmt" + "os" + "strconv" + "strings" + + "memodroid/internal/memory/search" +) + +// ctFile represents the top-level CheatEngine .CT XML structure. +type ctFile struct { + XMLName xml.Name `xml:"CheatTable"` + CheatEntries []ctEntry `xml:"CheatEntries>CheatEntry"` +} + +// ctEntry represents a single cheat entry in a .CT file. +type ctEntry struct { + Description string `xml:"Description"` + Address string `xml:"Address"` + VariableType string `xml:"VariableType"` +} + +// ImportCT parses a CheatEngine .CT file and returns bookmarks. +// It extracts address and description from each element. +func ImportCT(path string) ([]Bookmark, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read CT file: %w", err) + } + + var ct ctFile + if err := xml.Unmarshal(data, &ct); err != nil { + return nil, fmt.Errorf("parse CT XML: %w", err) + } + + var bookmarks []Bookmark + for _, entry := range ct.CheatEntries { + addr, err := parseCTAddress(entry.Address) + if err != nil { + continue // skip entries with unparseable addresses + } + label := strings.Trim(entry.Description, "\"") + if label == "" { + label = fmt.Sprintf("0x%x", addr) + } + vt := ctVariableType(entry.VariableType) + bookmarks = append(bookmarks, Bookmark{ + Addr: addr, + Label: label, + VType: vt, + }) + } + + if len(bookmarks) == 0 { + return nil, fmt.Errorf("no valid cheat entries found in %s", path) + } + return bookmarks, nil +} + +// parseCTAddress parses an address from a CT file. +// Addresses may be hex (with or without 0x prefix) or module+offset notation. +func parseCTAddress(s string) (uintptr, error) { + s = strings.TrimSpace(s) + // Remove quotes if present + s = strings.Trim(s, "\"") + + // Handle module+offset like "game.exe+1A2B" — just use the offset + if idx := strings.LastIndex(s, "+"); idx > 0 { + // Check if left side looks like a module name (contains non-hex chars) + left := s[:idx] + if strings.ContainsAny(left, "ghijklmnopqrstuvwxyzGHIJKLMNOPQRSTUVWXYZ._") { + s = s[idx+1:] + } + } + + // Strip 0x prefix + s = strings.TrimPrefix(s, "0x") + s = strings.TrimPrefix(s, "0X") + + v, err := strconv.ParseUint(s, 16, 64) + if err != nil { + return 0, fmt.Errorf("invalid address %q: %w", s, err) + } + return uintptr(v), nil +} + +// ctVariableType maps CE variable type strings to our ValueType. +func ctVariableType(s string) search.ValueType { + switch strings.ToLower(strings.TrimSpace(s)) { + case "4 bytes", "4bytes": + return search.TypeInt32 + case "8 bytes", "8bytes": + return search.TypeInt64 + case "float": + return search.TypeFloat32 + case "double": + return search.TypeFloat64 + default: + return search.TypeInt32 + } +} diff --git a/internal/memory/watch/alert.go b/internal/memory/watch/alert.go new file mode 100644 index 0000000..938978a --- /dev/null +++ b/internal/memory/watch/alert.go @@ -0,0 +1,192 @@ +package watch + +import ( + "fmt" + "sync" + "time" + + "memodroid/internal/driver" + "memodroid/internal/memory/search" +) + +// AlertCondition defines when an alert fires. +type AlertCondition int + +const ( + AlertAbove AlertCondition = iota // value > threshold + AlertBelow // value < threshold + AlertChanged // any change +) + +// AlertAction defines what to do when the condition is met. +type AlertAction int + +const ( + ActionNotify AlertAction = iota // just notify via OnAlert + ActionWrite // write a value when triggered +) + +// AlertConfig configures a conditional watch. +type AlertConfig struct { + Addr uintptr + Condition AlertCondition + Threshold []byte // for Above/Below comparisons + Action AlertAction + WriteVal []byte // value to write when ActionWrite +} + +// AlertEvent is emitted when an alert fires. +type AlertEvent struct { + Addr uintptr + Condition string + Value string + Triggered bool // whether an action was taken +} + +type alertEntry struct { + cfg AlertConfig + drv driver.Driver + pid int + vt search.ValueType + stop chan struct{} +} + +// AlertWatcher manages conditional watches with automatic actions. +type AlertWatcher struct { + mu sync.Mutex + entries map[uintptr]*alertEntry + OnAlert func(AlertEvent) +} + +func NewAlertWatcher() *AlertWatcher { + return &AlertWatcher{entries: make(map[uintptr]*alertEntry)} +} + +func ParseAlertCondition(s string) (AlertCondition, error) { + switch s { + case "above", ">": + return AlertAbove, nil + case "below", "<": + return AlertBelow, nil + case "changed", "!=": + return AlertChanged, nil + default: + return 0, fmt.Errorf("unknown condition: %s (use: above, below, changed)", s) + } +} + +func (c AlertCondition) String() string { + switch c { + case AlertAbove: + return "above" + case AlertBelow: + return "below" + case AlertChanged: + return "changed" + default: + return "?" + } +} + +// WatchWithAlert starts a conditional watch. When the condition fires, the +// action is executed (notify or write). +func (aw *AlertWatcher) WatchWithAlert(drv driver.Driver, pid int, vt search.ValueType, cfg AlertConfig, interval time.Duration) error { + aw.mu.Lock() + defer aw.mu.Unlock() + + if _, exists := aw.entries[cfg.Addr]; exists { + return fmt.Errorf("alert already set for 0x%x", cfg.Addr) + } + + e := &alertEntry{cfg: cfg, drv: drv, pid: pid, vt: vt, stop: make(chan struct{})} + aw.entries[cfg.Addr] = e + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + var lastVal []byte + for { + select { + case <-ticker.C: + cur, err := e.drv.Peek(e.pid, e.cfg.Addr, e.vt.Size()) + if err != nil { + continue + } + + fired := false + switch e.cfg.Condition { + case AlertAbove: + fired = search.CompareValues(cur, e.cfg.Threshold, e.vt) > 0 + case AlertBelow: + fired = search.CompareValues(cur, e.cfg.Threshold, e.vt) < 0 + case AlertChanged: + if lastVal != nil { + fired = !search.EqualBytes(cur, lastVal) + } + } + + if fired { + triggered := false + if e.cfg.Action == ActionWrite && len(e.cfg.WriteVal) > 0 { + _ = e.drv.Poke(e.pid, e.cfg.Addr, e.cfg.WriteVal) + triggered = true + } + if aw.OnAlert != nil { + aw.OnAlert(AlertEvent{ + Addr: e.cfg.Addr, + Condition: e.cfg.Condition.String(), + Value: search.FormatValue(cur, e.vt), + Triggered: triggered, + }) + } + } + + if lastVal == nil { + lastVal = make([]byte, len(cur)) + } + copy(lastVal, cur) + case <-e.stop: + return + } + } + }() + + return nil +} + +// RemoveAlert stops a conditional watch. +func (aw *AlertWatcher) RemoveAlert(addr uintptr) error { + aw.mu.Lock() + defer aw.mu.Unlock() + + e, ok := aw.entries[addr] + if !ok { + return fmt.Errorf("no alert set for 0x%x", addr) + } + close(e.stop) + delete(aw.entries, addr) + return nil +} + +// RemoveAll stops all alerts. +func (aw *AlertWatcher) RemoveAll() { + aw.mu.Lock() + defer aw.mu.Unlock() + + for addr, e := range aw.entries { + close(e.stop) + delete(aw.entries, addr) + } +} + +// List returns all alert addresses. +func (aw *AlertWatcher) List() []uintptr { + aw.mu.Lock() + defer aw.mu.Unlock() + + addrs := make([]uintptr, 0, len(aw.entries)) + for addr := range aw.entries { + addrs = append(addrs, addr) + } + return addrs +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 605695e..977ce74 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -489,6 +489,42 @@ func (h *handler) pointerScan(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"target": fmt.Sprintf("0x%x", addr), "chains": out}) } +// --- pointer resolve --- + +func (h *handler) pointerResolve(w http.ResponseWriter, r *http.Request) { + pid, ok := requirePID(w, h) + if !ok { + return + } + _ = pid + var req struct { + Label string `json:"label"` + Offsets []int64 `json:"offsets"` + } + if err := decode(r, &req); err != nil { + writeError(w, 400, err.Error()) + return + } + if req.Label == "" || len(req.Offsets) == 0 { + writeError(w, 400, "label and offsets required") + return + } + chain := pointer.Chain{ + BaseLabel: req.Label, + Offsets: req.Offsets, + } + resolved, err := pointer.ResolveChain(h.state.GetDriver(), h.state.GetPID(), chain) + if err != nil { + writeError(w, 500, err.Error()) + return + } + writeJSON(w, map[string]any{ + "resolved": fmt.Sprintf("0x%x", resolved), + "label": req.Label, + "offsets": req.Offsets, + }) +} + // --- memory --- func (h *handler) memoryModify(w http.ResponseWriter, r *http.Request) { @@ -615,6 +651,178 @@ func (h *handler) memoryFrozen(w http.ResponseWriter, _ *http.Request) { writeJSON(w, out) } +// --- hexdump --- + +func (h *handler) memoryHexdump(w http.ResponseWriter, r *http.Request) { + pid, ok := requirePID(w, h) + if !ok { + return + } + addrStr := r.URL.Query().Get("addr") + sizeStr := r.URL.Query().Get("size") + if addrStr == "" || sizeStr == "" { + writeError(w, 400, "addr and size required") + return + } + addr, err := parseHexAddr(addrStr) + if err != nil { + writeError(w, 400, "invalid addr") + return + } + size, err := strconv.Atoi(sizeStr) + if err != nil || size <= 0 || size > 4096 { + writeError(w, 400, "size must be 1-4096") + return + } + data, err := h.state.GetDriver().ReadRegion(pid, addr, size) + if err != nil { + writeError(w, 500, err.Error()) + return + } + + type hexLine struct { + Offset int `json:"offset"` + Hex string `json:"hex"` + ASCII string `json:"ascii"` + } + var lines []hexLine + for i := 0; i < len(data); i += 16 { + end := i + 16 + if end > len(data) { + end = len(data) + } + chunk := data[i:end] + + // Build hex string + hexParts := make([]string, len(chunk)) + for j, b := range chunk { + hexParts[j] = fmt.Sprintf("%02x", b) + } + hexStr := "" + for j, p := range hexParts { + if j > 0 { + hexStr += " " + } + hexStr += p + } + + // Build ASCII string + ascii := make([]byte, len(chunk)) + for j, b := range chunk { + if b >= 0x20 && b <= 0x7e { + ascii[j] = b + } else { + ascii[j] = '.' + } + } + + lines = append(lines, hexLine{ + Offset: i, + Hex: hexStr, + ASCII: string(ascii), + }) + } + writeJSON(w, map[string]any{ + "addr": fmt.Sprintf("0x%x", addr), + "hex_lines": lines, + }) +} + +// --- snapshot --- + +func (h *handler) snapshotTake(w http.ResponseWriter, r *http.Request) { + pid, ok := requirePID(w, h) + if !ok { + return + } + var req struct { + Addr string `json:"addr"` + Size int `json:"size"` + } + if err := decode(r, &req); err != nil { + writeError(w, 400, err.Error()) + return + } + addr, err := parseHexAddr(req.Addr) + if err != nil { + writeError(w, 400, "invalid addr") + return + } + if req.Size <= 0 { + writeError(w, 400, "size must be positive") + return + } + drv := h.state.GetDriver() + data, readErr := drv.ReadRegion(pid, addr, req.Size) + if readErr != nil { + writeError(w, 500, readErr.Error()) + return + } + // Store snapshot in state for later diff + h.state.SetSnapshot(addr, data) + writeJSON(w, map[string]any{"ok": true, "addr": fmt.Sprintf("0x%x", addr), "size": len(data)}) +} + +func (h *handler) snapshotDiff(w http.ResponseWriter, r *http.Request) { + pid, ok := requirePID(w, h) + if !ok { + return + } + _ = pid + var req struct { + Addr string `json:"addr"` + Size int `json:"size"` + } + if err := decode(r, &req); err != nil { + writeError(w, 400, err.Error()) + return + } + addr, err := parseHexAddr(req.Addr) + if err != nil { + writeError(w, 400, "invalid addr") + return + } + if req.Size <= 0 { + writeError(w, 400, "size must be positive") + return + } + prev := h.state.GetSnapshot(addr) + if prev == nil { + writeError(w, 400, "no snapshot taken for this address — call /api/snapshot/take first") + return + } + drv := h.state.GetDriver() + cur, readErr := drv.ReadRegion(h.state.GetPID(), addr, req.Size) + if readErr != nil { + writeError(w, 500, readErr.Error()) + return + } + minLen := len(prev) + if len(cur) < minLen { + minLen = len(cur) + } + type diffEntry struct { + Addr string `json:"addr"` + Offset int `json:"offset"` + Before int `json:"before"` + After int `json:"after"` + } + var diffs []diffEntry + for i := 0; i < minLen; i++ { + if prev[i] != cur[i] { + diffs = append(diffs, diffEntry{ + Addr: fmt.Sprintf("0x%x", addr+uintptr(i)), + Offset: i, + Before: int(prev[i]), + After: int(cur[i]), + }) + } + } + // Update stored snapshot to current + h.state.SetSnapshot(addr, cur) + writeJSON(w, map[string]any{"total": len(diffs), "diffs": diffs}) +} + // --- bookmarks --- func (h *handler) bookmarkList(w http.ResponseWriter, _ *http.Request) { @@ -698,6 +906,28 @@ func (h *handler) bookmarkModifyAll(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"ok": true, "count": count}) } +// --- import --- + +func (h *handler) importCT(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + } + if err := decode(r, &req); err != nil || req.Path == "" { + writeError(w, 400, "path required") + return + } + bookmarks, err := store.ImportCT(req.Path) + if err != nil { + writeError(w, 500, err.Error()) + return + } + bl := h.state.GetBookmarks() + for _, b := range bookmarks { + bl.Add(b.Addr, b.Label, b.VType) + } + writeJSON(w, map[string]any{"ok": true, "imported": len(bookmarks)}) +} + // --- session --- func (h *handler) sessionSave(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/server.go b/internal/server/server.go index 6415d98..93f51cd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -71,6 +71,7 @@ func Start(addr string, state *app.State, d *adb.ADB) error { // Pointer scan mux.HandleFunc("/api/pointer/scan", h.pointerScan) + mux.HandleFunc("/api/pointer/resolve", h.pointerResolve) // Memory mux.HandleFunc("/api/memory/modify", h.memoryModify) @@ -80,6 +81,11 @@ func Start(addr string, state *app.State, d *adb.ADB) error { mux.HandleFunc("/api/memory/freeze-all", h.memoryFreezeAll) mux.HandleFunc("/api/memory/unfreeze", h.memoryUnfreeze) mux.HandleFunc("/api/memory/frozen", h.memoryFrozen) + mux.HandleFunc("/api/memory/hexdump", h.memoryHexdump) + + // Snapshot + mux.HandleFunc("/api/snapshot/take", h.snapshotTake) + mux.HandleFunc("/api/snapshot/diff", h.snapshotDiff) // Bookmarks mux.HandleFunc("/api/bookmark/list", h.bookmarkList) @@ -87,6 +93,9 @@ func Start(addr string, state *app.State, d *adb.ADB) error { mux.HandleFunc("/api/bookmark/remove", h.bookmarkRemove) mux.HandleFunc("/api/bookmark/modify-all", h.bookmarkModifyAll) + // Import + mux.HandleFunc("/api/import/ct", h.importCT) + // Session mux.HandleFunc("/api/session/save", h.sessionSave) mux.HandleFunc("/api/session/load", h.sessionLoad) diff --git a/internal/server/static/index.html b/internal/server/static/index.html index 426ee46..9323721 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -215,6 +215,7 @@