From 9c9d37aeef34508825766852c4e5145a3840c81b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Tue, 27 Oct 2026 01:11:38 +0200 Subject: [PATCH 01/10] feat: add atomic CLI analysis primitives (search, data, xref, struct, alias, abi, dump) and indirect/short-abs resolution --- MANUAL.md | 54 +- cmd/hunks/agent_cmds.go | 1974 ++++++++++++++++++++++++++ cmd/hunks/agent_cmds_test.go | 145 ++ cmd/hunks/main.go | 204 ++- internal/analyzer/analyzer.go | 24 +- internal/analyzer/analyzer_test.go | 144 ++ internal/analyzer/baseregs.go | 138 +- internal/analyzer/classify.go | 39 +- internal/analyzer/format.go | 48 +- internal/analyzer/stage2_code.go | 60 +- internal/analyzer/stage4_funcmeta.go | 74 + internal/analyzer/trace.go | 87 +- internal/callgraph/callgraph.go | 517 ++++++- internal/callgraph/callgraph_test.go | 126 ++ internal/db/controller.go | 118 +- internal/emitter/emitter.go | 89 +- internal/emitter/emitter_test.go | 78 + internal/m68k/decode.go | 12 +- internal/project/project.go | 14 + 19 files changed, 3772 insertions(+), 173 deletions(-) create mode 100644 cmd/hunks/agent_cmds.go create mode 100644 cmd/hunks/agent_cmds_test.go diff --git a/MANUAL.md b/MANUAL.md index 06c56fa..9d89f90 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -62,13 +62,20 @@ cmp -l assets/disk.1 build/disk_1.bin # 0 differences | `-m analyze [--all \| -s, --segment NAME]` | Run unified 3-stage analysis across all module segments (whole hunk set for relocatable executables; standalone per-segment for flat binaries), or analyze a single segment with `-s/--segment NAME` | | `-m analyze --trace [--budget N]` | Trace-driven analysis: run the binary from its entry point in the built-in sandboxed M68000 interpreter (`m68kemu`) + minimal Amiga chipset, then resolve indirect dispatch (trampolines, `d16(An)`/indexed computed calls), self-installed interrupt/trap handlers, and hardware-register accesses **by observation** and seed them into recursive descent. Opt-in, for **flat self-booting binaries** (games/demos); default `analyze` is unchanged. `--budget` caps the instruction count (default 20,000,000). All facts are non-byte-affecting (labels/edges/comments) — parity is preserved. | | `-m emit [--amiga] [--all] [--relocatable] [--force]` | Emit modular Amiga assembly (`--amiga`: `src//hunk*.asm` + `disasm/.asm` + shared `src/equates.i`) or single-file assembly (`disasm/.asm`). Refuses to overwrite existing files unless `--force` is passed. | -| `-m callgraph` / `cg [TARGET] [FLAGS]` | Inspect multi-hunk call graphs, bottom-up worklists, string/HW anchors, and function disassembly | +| `-m callgraph` / `cg [TARGET...] [FLAGS]` | Inspect multi-hunk call graphs, bottom-up worklists, string/HW anchors, indirect dispatch sites (`--indirect`), and function disassembly | +| `-m search QUERY [-s SEG] [-n LIMIT] [--dense] [-f text\|json]` | Search instructions, operands, strings, labels, comments, bytes, hardware/math clusters (`cluster:`), or self-modifying code (`smc`) | +| `-m data` / `inspect TARGET [--len N] [--copper] [--struct NAME [--count N]] [-f text\|json]` | Inspect non-code data, pointer tables, strings, Amiga Copper lists (`--copper`), or structured record arrays (`--struct`) | +| `-m xref TARGET_OR_RANGE [-f text\|json]` | Query all incoming cross-references to an address/label (`xref $1178`) or external references into an address/offset range (`xref $1000..$1FFF`, `xref +3DDEE..+3EFFF`) | | `-m hint code TARGET` | Mark target as code entry (`confidence = 100`) and run incremental analysis | | `-m hint data TARGET [COUNT] [--string] [--comment TEXT]` | Mark byte/data range or null-terminated string (`--string`) at `confidence = 100` | -| `-m hint pointer TARGET [COUNT] [--follow] [--comment TEXT]` | Mark 32-bit pointer(s) (`confidence = 100`), optionally tracing pointed-to code (`--follow`) | +| `-m hint pointer TARGET [COUNT] [--follow] [--comment TEXT]` | Mark 32-bit pointer(s) (`confidence = 100`), create target labels + `DATA_ADDR` xrefs, and seed code targets when `--follow` is passed | | `-m hint base-reg REG BASE_HEX [--segment NAME] [--function LABEL]` | Declare a base-register invariant (`REG` = `a4`/`a5`/`a6`, e.g. `hint base-reg a6 0xDFF000`) so `d16(An)` accesses resolve to hardware names / global `DATA`/`BSS` xrefs. Scope is global, or limited to one `--segment` or `--function`; re-runs analysis | | `-m rename OLD_NAME NEW_NAME` | Rename a symbol (or multiple `OLD=NEW` pairs, or `--file ` batch file) across all segments in a single transaction | -| `-m comment TARGET [TEXT] [--block]` | Set or clear inline comment or function/block header comment (`--block` / `-b`) | +| `-m comment TARGET [TEXT] [--block] [--batch FILE]` | Set or clear inline comment or function/block header comment (`--block` / `-b`), with strict cell-boundary & function-bounds validation and batch file support (`--batch` / `--file`) | +| `-m abi [TARGET [SIGNATURE]] [--verify] [--batch FILE] [-f text\|json]` | Set, inspect, or statically verify (`--verify`) a function's register call signature (`(a0=Model*) -> d0 \| clobbers: d1/a1`) | +| `-m struct add NAME [--size BYTES] OFFSET=FIELD[.b\|.w\|.l\|.p]...` | Define or update a named struct and its field offsets (`struct list [-f text\|json]`, `struct remove NAME`); emitted as ` equ ` and `_SIZEOF equ ` | +| `-m alias [REG STRUCT [--fn FUNC]]` | Bind an address register (`a0`..`a6`) globally or per-function (`--fn `) to a struct so `d16(An)` renders symbolically (`alias list [-f text\|json]`, `alias remove REG [--fn FUNC]`) | +| `-m dump [-f text\|json] [--section SEC]` | Export all durable reverse-engineering state (`entry_points`, `memory_regions`, `renames`, non-auto `comments`, `structs`, `register_aliases`, `carves`) | | `-m operand TARGET FORMAT` | Set operand numeric format (`HEX`, `DEC`, `BIN`) and reformat the instruction | | `-m segment add --file F --base HEX --name N` | Add a binary segment to the module | | `-m segment split PARENT --at OFF (--len N \| --to OFF) --base HEX --name N` | Slice an extent out of a segment into a new static-base part (`.bin` + shared DB), filed into the active space | @@ -119,12 +126,53 @@ Every CLI command that takes a `TARGET` (`callgraph`, `hint`, `comment`, `operan | `--anchors` | List functions that reference ASCII `STRING` cells or Amiga hardware registers (`$DFFxxx` / CIA) | | `--ready` | List **bottom-up ready** unrenamed functions (`LAB_*`) whose outgoing callees are 100% renamed | | `--suspect` | List functions that fall through into `DATA_*` / `STRING` cells without a terminator (`rts`/`jmp`/`bra`) | +| `--indirect` | List all indirect/trampoline dispatch sites (`jsr (An)`, `jmp d16(An)`, `jmp d8(PC,Xn)`) and their resolved or `[UNRESOLVED]` status | | `-u, --unrenamed` | Filter worklists to show only unrenamed (`LAB_*`) functions | | `-n, --limit ` | Maximum functions to display in worklist modes (`0` = unlimited, default: `50`) | | `-f, --format ` | Output format: `text` (default), `json`, `dot` (Graphviz), or `mermaid` | --- +## Atomic Analysis & Annotation Primitives (`search`, `data`, `xref`, `struct`, `alias`, `abi`, `dump`) + +### 1. Code, Pattern & Cluster Search (`hunks search`) +```bash +hunks -m search m:muls.w # mnemonic prefix match +hunks -m search 'o:$1178' # operand substring match +hunks -m search cluster:3dmath --dense # dense pockets (>=3 hits in 24B) of muls/mulu/divs/divu +hunks -m search cluster:blitter # blitter, copper, paula, bitplane, sprite, cia, dma +hunks -m search smc # self-modifying code writes into CODE cells +``` + +### 2. Data, Copper & Struct Array Inspection (`hunks data` / `inspect`) +```bash +hunks -m data +001A4 --len 64 # formatted dc.b/dc.w/dc.l/pointer/string cells +hunks -m data CopperList_Main --copper # decode Amiga Copper MOVE/WAIT/SKIP words +hunks -m data 0x6B6E --struct Model --count 8 # decode N records using a defined struct stride +``` + +### 3. Cross-Reference & Region Rollup (`hunks xref`) +```bash +hunks -m xref 0x1178 # all CALL/BRANCH/READ/WRITE/ADDR/PTR refs to $1178 +hunks -m xref '$1000..$1FFF' # rollup of all external refs into low-RAM range +hunks -m xref '+3DDEE..+3EFFF' # rollup of all external entries into a segment range +``` + +### 4. Structs, Scoped Register Aliases & Call ABI (`struct`, `alias`, `abi`) +```bash +hunks -m struct add Model --size 18 0=Model_Radius.w 2=Model_Verts.p +hunks -m alias a0 Model --fn Poly_RenderObject # scope a0 -> Model to a single function +hunks -m abi Poly_RenderObject "(a0=Model*, d0.w=id) -> d0.l | clobbers: d1-d7/a1-a3" +hunks -m abi --verify # check all ABI strings against static ReadRegs/WriteRegs +``` + +### 5. Structured State Export (`hunks dump`) +```bash +hunks -m dump -f json > findings_state.json # export entries, regions, renames, comments, structs, aliases, carves +``` + +--- + ## Comprehensive Usage Examples & Walkthroughs ### Walkthrough 1: Bootstrapping a Multi-Hunk Game (*Frontier: Elite II*) diff --git a/cmd/hunks/agent_cmds.go b/cmd/hunks/agent_cmds.go new file mode 100644 index 0000000..01a0b4e --- /dev/null +++ b/cmd/hunks/agent_cmds.go @@ -0,0 +1,1974 @@ +package main + +import ( + "bufio" + "cmp" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "maps" + "os" + "slices" + "strconv" + "strings" + + "github.com/mikolas/hunks/internal/amiga" + "github.com/mikolas/hunks/internal/analyzer" + "github.com/mikolas/hunks/internal/callgraph" + "github.com/mikolas/hunks/internal/db" + "github.com/mikolas/hunks/internal/project" + "github.com/spf13/cobra" +) + +func registerAgentCommands( + rootCmd *cobra.Command, + getWorkspaceRoot func() string, + moduleName *string, + resolveCallerPath func(string) string, +) { + // --- 1. search --- + var searchSegment, searchFormat string + var searchLimit int + var searchDense bool + searchCmd := &cobra.Command{ + Use: "search ", + Short: "Search instructions, operands, strings, comments, labels, bytes, hardware clusters, or SMC writes", + Args: cobra.MinimumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + query := strings.TrimSpace(strings.Join(args, " ")) + proj, err := loadTargetProject(getWorkspaceRoot(), *moduleName) + if err != nil { + return err + } + cg, err := callgraph.Build(proj) + if err != nil { + return err + } + return runSearchCommand(proj, cg, query, searchSegment, searchLimit, searchDense, searchFormat) + }, + } + searchCmd.Flags().StringVarP(&searchSegment, "segment", "s", "", "Filter results to a specific segment/hunk") + searchCmd.Flags().IntVarP(&searchLimit, "limit", "n", 100, "Maximum number of results to return (0 = unlimited)") + searchCmd.Flags().BoolVar(&searchDense, "dense", false, "For cluster:, only return dense pockets (>=3 hits in 24 bytes)") + searchCmd.Flags().StringVarP(&searchFormat, "format", "f", "text", "Output format: text or json") + rootCmd.AddCommand(searchCmd) + + // --- 2. data (alias: inspect) --- + var dataLen, dataCount int + var dataCopper bool + var dataStruct, dataFormat string + dataCmd := &cobra.Command{ + Use: "data ", + Aliases: []string{"inspect"}, + Short: "Inspect non-code data, pointer tables, copper lists, strings, or struct arrays", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + proj, err := loadTargetProject(getWorkspaceRoot(), *moduleName) + if err != nil { + return err + } + seg, err := requireSegment(proj) + if err != nil { + return err + } + ctrl, err := db.New(seg.DB) + if err != nil { + return err + } + defer ctrl.Close() + proj.SyncDBScope(ctrl) + + addr, err := resolveAddrOrLabel(proj, ctrl, args[0]) + if err != nil { + return fmt.Errorf("cannot resolve address or label %q: %w", args[0], err) + } + cg, err := callgraph.Build(proj) + if err != nil { + return err + } + out, err := renderDataInspection(proj, ctrl, cg, addr, dataLen, dataCopper, dataStruct, dataCount, dataFormat) + if err != nil { + return err + } + fmt.Print(out) + return nil + }, + } + dataCmd.Flags().IntVar(&dataLen, "len", 64, "Number of bytes to inspect when target is not a single bounded table/string") + dataCmd.Flags().BoolVar(&dataCopper, "copper", false, "Decode words as Amiga Copper MOVE/WAIT/SKIP instructions") + dataCmd.Flags().StringVar(&dataStruct, "struct", "", "Decode bytes as records of the named struct") + dataCmd.Flags().IntVar(&dataCount, "count", 1, "Number of struct records to decode with --struct") + dataCmd.Flags().StringVarP(&dataFormat, "format", "f", "text", "Output format: text or json") + rootCmd.AddCommand(dataCmd) + + // --- 3. xref --- + var xrefFormat string + xrefCmd := &cobra.Command{ + Use: "xref ", + Short: "Query cross-references to an address, label, hardware register, or address range (a..b)", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + proj, err := loadTargetProject(getWorkspaceRoot(), *moduleName) + if err != nil { + return err + } + seg, err := requireSegment(proj) + if err != nil { + return err + } + ctrl, err := db.New(seg.DB) + if err != nil { + return err + } + defer ctrl.Close() + proj.SyncDBScope(ctrl) + + cg, err := callgraph.Build(proj) + if err != nil { + return err + } + out, err := runXrefCommand(proj, ctrl, cg, args[0], xrefFormat) + if err != nil { + return err + } + fmt.Print(out) + return nil + }, + } + xrefCmd.Flags().StringVarP(&xrefFormat, "format", "f", "text", "Output format: text or json") + rootCmd.AddCommand(xrefCmd) + + // --- 5a. struct --- + var structFormat string + structCmd := &cobra.Command{ + Use: "struct", + Short: "Define, list, or remove struct definitions for register-displacement symbolic substitution", + } + var structSize int + structAddCmd := &cobra.Command{ + Use: "add [=...]", + Short: "Add or update fields on a named struct", + Args: cobra.MinimumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + structName := strings.TrimSpace(args[0]) + if structName == "" { + return fmt.Errorf("struct name cannot be empty") + } + proj, err := loadTargetProject(getWorkspaceRoot(), *moduleName) + if err != nil { + return err + } + if proj.Data.Structs == nil { + proj.Data.Structs = make(map[string]map[string]string) + } + sdef, ok := proj.Data.Structs[structName] + if !ok || sdef == nil { + sdef = make(map[string]string) + proj.Data.Structs[structName] = sdef + } + for _, spec := range args[1:] { + offPart, fieldPart, hasEq := strings.Cut(spec, "=") + if !hasEq { + return fmt.Errorf("invalid field spec %q (expected =)", spec) + } + offVal, err := parseSignedOffset(offPart) + if err != nil { + rhsBase, rhsSuffix := project.StripFieldType(strings.TrimSpace(fieldPart)) + if altOff, altErr := parseSignedOffset(rhsBase); altErr == nil { + offVal = altOff + err = nil + fName, fSuf := project.StripFieldType(strings.TrimSpace(offPart)) + if fSuf == "" && rhsSuffix != "" { + fSuf = rhsSuffix + } + if fSuf != "" { + fieldPart = fName + "." + fSuf + } else { + fieldPart = fName + } + } + } + if err != nil { + return fmt.Errorf("invalid offset %q in %q: %w", offPart, spec, err) + } + fieldPart = strings.TrimSpace(fieldPart) + if fieldPart == "" { + delete(sdef, strconv.FormatInt(offVal, 10)) + } else { + sdef[strconv.FormatInt(offVal, 10)] = fieldPart + } + } + if structSize > 0 { + sdef["_size"] = strconv.Itoa(structSize) + } + if err := reformatAllSegmentsForStructs(proj); err != nil { + return err + } + if err := proj.Save(); err != nil { + return err + } + fmt.Printf("Updated struct '%s' (%d field entries)\n", structName, len(sdef)) + return nil + }, + } + structAddCmd.Flags().IntVar(&structSize, "size", 0, "Total byte stride/size of the struct (for array decoding and _SIZEOF)") + + structListCmd := &cobra.Command{ + Use: "list", + Short: "List all defined structs and their fields", + RunE: func(_ *cobra.Command, _ []string) error { + proj, err := loadTargetProject(getWorkspaceRoot(), *moduleName) + if err != nil { + return err + } + return renderStructList(proj, structFormat) + }, + } + structListCmd.Flags().StringVarP(&structFormat, "format", "f", "text", "Output format: text or json") + + structRemoveCmd := &cobra.Command{ + Use: "remove ", + Short: "Remove a struct definition", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + name := strings.TrimSpace(args[0]) + proj, err := loadTargetProject(getWorkspaceRoot(), *moduleName) + if err != nil { + return err + } + delete(proj.Data.Structs, name) + for k, v := range proj.Data.RegisterAliases { + if v == name { + delete(proj.Data.RegisterAliases, k) + } + } + if err := reformatAllSegmentsForStructs(proj); err != nil { + return err + } + if err := proj.Save(); err != nil { + return err + } + fmt.Printf("Removed struct '%s'\n", name) + return nil + }, + } + structCmd.AddCommand(structAddCmd, structListCmd, structRemoveCmd) + rootCmd.AddCommand(structCmd) + + // --- 5b. alias --- + var aliasFunc, aliasFormat string + aliasCmd := &cobra.Command{ + Use: "alias [a0..a6] [StructName]", + Short: "Bind an address register to a struct (globally or scoped with --fn )", + Args: cobra.RangeArgs(0, 2), + RunE: func(_ *cobra.Command, args []string) error { + proj, err := loadTargetProject(getWorkspaceRoot(), *moduleName) + if err != nil { + return err + } + if len(args) == 0 { + return renderAliasList(proj, aliasFormat) + } + if len(args) < 2 { + return fmt.Errorf("expected `hunks alias [--fn ]` or `hunks alias list`") + } + reg := strings.ToLower(strings.TrimSpace(args[0])) + if len(reg) != 2 || reg[0] != 'a' || reg[1] < '0' || reg[1] > '6' { + return fmt.Errorf("register must be a0..a6 (got %q)", args[0]) + } + structName := strings.TrimSpace(args[1]) + if _, exists := proj.Data.Structs[structName]; !exists { + return fmt.Errorf("struct %q is not defined; define it first with `hunks struct add %s ...`", structName, structName) + } + key := reg + if strings.TrimSpace(aliasFunc) != "" { + key = strings.TrimSpace(aliasFunc) + ":" + reg + } + if proj.Data.RegisterAliases == nil { + proj.Data.RegisterAliases = make(map[string]string) + } + proj.Data.RegisterAliases[key] = structName + if err := reformatAllSegmentsForStructs(proj); err != nil { + return err + } + if err := proj.Save(); err != nil { + return err + } + fmt.Printf("Bound alias %s -> %s\n", key, structName) + return nil + }, + } + aliasCmd.Flags().StringVar(&aliasFunc, "fn", "", "Scope the register alias to a specific function name or label") + aliasCmd.Flags().StringVarP(&aliasFormat, "format", "f", "text", "Output format when listing aliases: text or json") + + var aliasListFormat string + aliasListCmd := &cobra.Command{ + Use: "list", + Short: "List all active register aliases", + RunE: func(_ *cobra.Command, _ []string) error { + proj, err := loadTargetProject(getWorkspaceRoot(), *moduleName) + if err != nil { + return err + } + return renderAliasList(proj, aliasListFormat) + }, + } + aliasListCmd.Flags().StringVarP(&aliasListFormat, "format", "f", "text", "Output format: text or json") + + var aliasRemoveFunc string + aliasRemoveCmd := &cobra.Command{ + Use: "remove ", + Short: "Remove a register alias", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + reg := strings.ToLower(strings.TrimSpace(args[0])) + key := reg + if strings.TrimSpace(aliasRemoveFunc) != "" { + key = strings.TrimSpace(aliasRemoveFunc) + ":" + reg + } + proj, err := loadTargetProject(getWorkspaceRoot(), *moduleName) + if err != nil { + return err + } + delete(proj.Data.RegisterAliases, key) + if err := reformatAllSegmentsForStructs(proj); err != nil { + return err + } + if err := proj.Save(); err != nil { + return err + } + fmt.Printf("Removed alias %s\n", key) + return nil + }, + } + aliasRemoveCmd.Flags().StringVar(&aliasRemoveFunc, "fn", "", "Function scope of the alias to remove") + aliasCmd.AddCommand(aliasListCmd, aliasRemoveCmd) + rootCmd.AddCommand(aliasCmd) + + // --- 7. abi --- + var abiBatchFile, abiFormat string + var abiVerify bool + abiCmd := &cobra.Command{ + Use: "abi [func] [signature]", + Short: "Set, inspect, or verify function register ABI / calling conventions", + Args: cobra.RangeArgs(0, 2), + RunE: func(_ *cobra.Command, args []string) error { + proj, err := loadTargetProject(getWorkspaceRoot(), *moduleName) + if err != nil { + return err + } + return runABICommand(proj, args, abiBatchFile, abiVerify, abiFormat, resolveCallerPath) + }, + } + abiCmd.Flags().StringVar(&abiBatchFile, "batch", "", "Batch file containing ' = ' per line (use '-' for stdin)") + abiCmd.Flags().StringVar(&abiBatchFile, "file", "", "Alias for --batch") + abiCmd.Flags().BoolVar(&abiVerify, "verify", false, "Verify user-defined ABI strings against statically inferred ReadRegs/WriteRegs") + abiCmd.Flags().StringVarP(&abiFormat, "format", "f", "text", "Output format: text or json") + rootCmd.AddCommand(abiCmd) + + // --- 8. dump --- + var dumpFormat, dumpSection string + dumpCmd := &cobra.Command{ + Use: "dump", + Short: "Export all durable reverse-engineering state (renames, comments, regions, structs, aliases, carves)", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + proj, err := loadTargetProject(getWorkspaceRoot(), *moduleName) + if err != nil { + return err + } + out, err := runDumpCommand(proj, dumpSection, dumpFormat) + if err != nil { + return err + } + fmt.Print(out) + return nil + }, + } + dumpCmd.Flags().StringVar(&dumpSection, "section", "", "Export only one section: renames, comments, regions, structs, aliases, carves, entries") + dumpCmd.Flags().StringVarP(&dumpFormat, "format", "f", "text", "Output format: text or json") + rootCmd.AddCommand(dumpCmd) +} + +func parseSignedOffset(s string) (int64, error) { + clean := strings.TrimSpace(s) + neg := false + if after, ok := strings.CutPrefix(clean, "-"); ok { + neg = true + clean = strings.TrimSpace(after) + } else if after, ok := strings.CutPrefix(clean, "+"); ok { + clean = strings.TrimSpace(after) + } + u, err := project.ParseUint32(clean) + if err != nil { + return 0, err + } + val := int64(u) + if neg { + val = -val + } + return val, nil +} + +func reformatAllSegmentsForStructs(proj *project.Project) error { + seenDBs := make(map[string]bool) + for _, seg := range proj.Data.Segments { + if seenDBs[seg.DB] { + continue + } + seenDBs[seg.DB] = true + ctrl, err := db.New(seg.DB) + if err != nil { + return err + } + proj.SyncDBScope(ctrl) + an, err := analyzer.New(proj, ctrl) + if err != nil { + _ = ctrl.Close() + return err + } + an.Stage3FormatOperands() + _ = ctrl.Close() + } + return nil +} + +// --- §1: hunks search --- + +type searchHitJSON struct { + Addr string `json:"addr"` + Segment string `json:"segment"` + Offset string `json:"offset"` + Func string `json:"func,omitempty"` + FnOffset string `json:"fn_offset,omitempty"` + Type string `json:"type"` + Instruction string `json:"instruction"` + Comment string `json:"comment,omitempty"` +} + +func matchesClusterInstruction(clusterName string, mnem, opStr, comment string) bool { + c := strings.ToLower(strings.TrimSpace(clusterName)) + mLower := strings.ToLower(mnem) + baseMnem, _, _ := strings.Cut(mLower, ".") + combined := strings.ToUpper(opStr + " " + comment) + + switch c { + case "3dmath", "math": + switch baseMnem { + case "muls", "mulu", "divs", "divu": + return true + } + return false + case "blitter": + for _, tok := range []string{"BLT", "DMACON"} { + if strings.Contains(combined, tok) { + return true + } + } + return false + case "copper": + for _, tok := range []string{"COP1LC", "COP2LC", "COPJMP", "COPCON", "COPINS"} { + if strings.Contains(combined, tok) { + return true + } + } + return false + case "paula", "audio": + for _, tok := range []string{"AUD0", "AUD1", "AUD2", "AUD3", "ADKCON"} { + if strings.Contains(combined, tok) { + return true + } + } + return false + case "bitplane", "display", "video": + for _, tok := range []string{"BPL", "DIWSTRT", "DIWSTOP", "DDFSTRT", "DDFSTOP", "COLOR"} { + if strings.Contains(combined, tok) { + return true + } + } + return false + case "sprite", "sprites": + return strings.Contains(combined, "SPR") + case "cia", "timer": + return strings.Contains(combined, "CIA") + case "dma", "irq", "interrupt": + for _, tok := range []string{"DMACON", "INTENA", "INTREQ"} { + if strings.Contains(combined, tok) { + return true + } + } + return false + } + return false +} + +func dispatchControllerSearch(proj *project.Project, ctrl *db.Controller, cg *callgraph.CallGraph, query string) []uint32 { + const maxDBLimit = 10000 + q := strings.TrimSpace(query) + qLower := strings.ToLower(q) + var out []uint32 + add := func(items []db.AddrText) { + for _, it := range items { + out = append(out, it.Addr) + } + } + + switch { + case strings.HasPrefix(qLower, "m:"): + add(ctrl.SearchCode("m", strings.TrimSpace(q[2:]), nil, maxDBLimit)) + case strings.HasPrefix(qLower, "o:"): + needle := strings.TrimSpace(q[2:]) + add(ctrl.SearchCode("o", needle, nil, maxDBLimit)) + // Also match auto/inline comments (e.g. resolved hardware or base-reg targets). + add(ctrl.SearchComments(needle, nil, maxDBLimit)) + case strings.HasPrefix(qLower, "s:"): + add(ctrl.SearchStrings(strings.TrimSpace(q[2:]), nil, maxDBLimit)) + case strings.HasPrefix(qLower, "c:"): + add(ctrl.SearchComments(strings.TrimSpace(q[2:]), nil, maxDBLimit)) + case strings.HasPrefix(qLower, "l:"): + add(ctrl.SearchLabels(strings.TrimSpace(q[2:]), nil, maxDBLimit)) + case strings.HasPrefix(qLower, "b:"): + hexClean := strings.ReplaceAll(strings.TrimSpace(q[2:]), " ", "") + var pat []byte + for i := 0; i+2 <= len(hexClean); i += 2 { + b, err := strconv.ParseUint(hexClean[i:i+2], 16, 8) + if err != nil { + return nil + } + pat = append(pat, byte(b)) + } + if len(pat) > 0 { + for _, seg := range proj.ActiveSegments() { + data := seg.LoadBinaryData() + for i := 0; i+len(pat) <= len(data); i++ { + if slices.Equal(data[i:i+len(pat)], pat) { + rawAddr := seg.Base + uint32(i) + snapped := rawAddr + cg.CellsTree.DescendLessOrEqual(db.Cell{Addr: rawAddr}, func(c db.Cell) bool { + if rawAddr >= c.Addr && rawAddr < c.Addr+c.Size { + snapped = c.Addr + } + return false + }) + out = append(out, snapped) + } + } + } + } + default: + add(ctrl.SearchLabels(q, nil, maxDBLimit)) + add(ctrl.SearchCode("m", q, nil, maxDBLimit)) + add(ctrl.SearchCode("o", q, nil, maxDBLimit)) + add(ctrl.SearchComments(q, nil, maxDBLimit)) + add(ctrl.SearchStrings(q, nil, maxDBLimit)) + } + return out +} + +func runSearchCommand( + proj *project.Project, + cg *callgraph.CallGraph, + query string, + segFilter string, + limit int, + denseOnly bool, + format string, +) error { + normSeg := callgraph.NormalizeSegmentFilter(segFilter) + qLower := strings.ToLower(strings.TrimSpace(query)) + + var matchedAddrs []uint32 + + seenDBs := make(map[string]bool) + for _, seg := range proj.ActiveSegments() { + if seenDBs[seg.DB] { + continue + } + seenDBs[seg.DB] = true + ctrl, err := db.New(seg.DB) + if err != nil { + return err + } + proj.SyncDBScope(ctrl) + + switch { + case qLower == "smc": + // Match all CODE cells with [SMC] comment or XrefDataWrite targeting a CODE cell. + for _, addr := range slices.Sorted(maps.Keys(cg.Cells)) { + cell := cg.Cells[addr] + if cell.Type != db.CellCode { + continue + } + if strings.Contains(cg.Comments[addr].EffectiveComment(), "[SMC]") { + matchedAddrs = append(matchedAddrs, addr) + } + } + for _, x := range ctrl.AllXrefs() { + if x.Kind == db.XrefDataWrite { + if srcCell, ok := cg.Cells[x.Source]; ok && srcCell.Type == db.CellCode { + if tgtCell, ok := cg.Cells[x.Target]; ok && tgtCell.Type == db.CellCode { + if !slices.Contains(matchedAddrs, x.Source) { + matchedAddrs = append(matchedAddrs, x.Source) + } + } + } + } + } + + case strings.HasPrefix(qLower, "cluster:"): + clusterName := strings.TrimSpace(query[len("cluster:"):]) + var clusterAddrs []uint32 + for _, addr := range slices.Sorted(maps.Keys(cg.Cells)) { + cell := cg.Cells[addr] + if cell.Type != db.CellCode { + continue + } + cmt := cg.Comments[addr].EffectiveComment() + if matchesClusterInstruction(clusterName, cell.MnemonicStr(), cell.OpStrVal(), cmt) { + clusterAddrs = append(clusterAddrs, addr) + } + } + if denseOnly { + for i := range clusterAddrs { + j := i + for j < len(clusterAddrs) && clusterAddrs[j]-clusterAddrs[i] <= 24 { + j++ + } + if j-i >= 3 { + for k := i; k < j; k++ { + matchedAddrs = append(matchedAddrs, clusterAddrs[k]) + } + } + } + } else { + matchedAddrs = append(matchedAddrs, clusterAddrs...) + } + + default: + matchedAddrs = append(matchedAddrs, dispatchControllerSearch(proj, ctrl, cg, query)...) + } + _ = ctrl.Close() + } + + slices.Sort(matchedAddrs) + matchedAddrs = slices.Compact(matchedAddrs) + + var hits []searchHitJSON + for _, addr := range matchedAddrs { + seg := proj.SegmentAtAddr(addr) + segName := "?" + var segOff uint32 + if seg != nil { + segName = seg.Name + if addr >= seg.Base { + segOff = addr - seg.Base + } + } + if normSeg != "" && !strings.EqualFold(segName, normSeg) { + continue + } + cell, ok := cg.Cells[addr] + cellType := "DATA" + var insnText string + if ok { + cellType = cell.Type.AsStr() + mnem := cell.MnemonicStr() + ops := cell.OpStrVal() + if mnem != "" { + insnText = strings.TrimSpace(fmt.Sprintf("%-8s %s", mnem, ops)) + } else if ops != "" { + insnText = fmt.Sprintf("%-8s %s", cellType, ops) + } else { + insnText = cellType + } + } else if lbl := cg.Labels[addr]; lbl != "" { + cellType = "LABEL" + insnText = lbl + } + + var fnName, fnOffStr string + if fAddr, ok := cg.InsnToFunc[addr]; ok { + if f, ok := cg.Functions[fAddr]; ok { + fnName = f.Name + fnOffStr = fmt.Sprintf("+%X", addr-f.EntryAddr) + } + } + cmt := cg.Comments[addr].EffectiveComment() + hits = append(hits, searchHitJSON{ + Addr: fmt.Sprintf("0x%X", addr), + Segment: segName, + Offset: fmt.Sprintf("+%05X", segOff), + Func: fnName, + FnOffset: fnOffStr, + Type: cellType, + Instruction: insnText, + Comment: cmt, + }) + if limit > 0 && len(hits) >= limit { + break + } + } + + if format == "json" { + payload := map[string]any{ + "query": query, + "count": len(hits), + "hits": hits, + } + b, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + } + + fmt.Printf("=== Search %q (%d hits) ===\n", query, len(hits)) + for _, h := range hits { + ctx := fmt.Sprintf("[%s]", h.Type) + if h.Func != "" { + ctx = fmt.Sprintf("[%s%s]", h.Func, h.FnOffset) + } + cmt := "" + if h.Comment != "" { + cmt = " ; " + h.Comment + } + fmt.Printf(" %s %-26s %s%s\n", h.Offset, ctx, h.Instruction, cmt) + } + return nil +} + +// --- §2: hunks data --- + +func renderDataInspection( + proj *project.Project, + ctrl *db.Controller, + cg *callgraph.CallGraph, + addr uint32, + byteLen int, + copperMode bool, + structName string, + structCount int, + format string, +) (string, error) { + seg := proj.SegmentAtAddr(addr) + if seg == nil { + // Unmapped / MemoryRegion / hardware address: fall back to XREFs + return runXrefCommand(proj, ctrl, cg, fmt.Sprintf("0x%X", addr), format) + } + segBytes := seg.LoadBinaryData() + if addr < seg.Base || int(addr-seg.Base) >= len(segBytes) { + return runXrefCommand(proj, ctrl, cg, fmt.Sprintf("0x%X", addr), format) + } + relOff := int(addr - seg.Base) + if byteLen <= 0 { + byteLen = 64 + } + // If a single non-code cell at addr is larger than default 64 bytes (e.g. a string or pointer table), use its size. + if c, ok := cg.Cells[addr]; ok && c.Type != db.CellCode && int(c.Size) > byteLen && byteLen == 64 { + byteLen = int(c.Size) + } + endOff := min(relOff+byteLen, len(segBytes)) + rawSlice := segBytes[relOff:endOff] + endAddr := seg.Base + uint32(endOff) + + lbl := cg.Labels[addr] + if lbl == "" { + lbl = proj.AutoLabel(addr) + } + cellType := "RAW" + if c, ok := cg.Cells[addr]; ok { + cellType = c.Type.AsStr() + } + allRelocs, _ := ctrl.GetAllRelocs() + + resolvePtrLabel := func(slotAddr, val uint32, isPointerCell bool) (string, bool) { + if rTgt, ok := allRelocs[slotAddr]; ok { + val = rTgt + isPointerCell = true + } + if c, ok := cg.Cells[slotAddr]; ok && c.Type == db.CellPointer { + isPointerCell = true + } + if !isPointerCell { + return "", false + } + if l := cg.Labels[val]; l != "" { + return l, true + } + if hw, ok := amiga.GetAmigaHWLabel(val); ok { + return hw, true + } + if proj.SegmentAtAddr(val) != nil { + return proj.AutoLabel(val), true + } + return "", false + } + + if structName != "" { + sdef, ok := proj.Data.Structs[structName] + if !ok { + return "", fmt.Errorf("struct %q is not defined", structName) + } + return renderStructRecords(proj, cg, seg, addr, lbl, structName, sdef, structCount, segBytes, resolvePtrLabel, format) + } + + if copperMode { + return renderCopperDecode(seg, addr, lbl, rawSlice, format) + } + + type dataItemJSON struct { + Offset string `json:"offset"` + Addr string `json:"addr"` + Type string `json:"type"` + Asm string `json:"asm"` + Target string `json:"target,omitempty"` + Comment string `json:"comment,omitempty"` + } + var items []dataItemJSON + cur := addr + for cur < endAddr { + c, hasCell := cg.Cells[cur] + off := cur - seg.Base + bIdx := int(off) + if hasCell && c.Addr == cur && c.Size > 0 && cur+c.Size <= endAddr { + switch c.Type { + case db.CellPointer, db.CellDataL: + if bIdx+4 <= len(segBytes) { + val := binary.BigEndian.Uint32(segBytes[bIdx : bIdx+4]) + isPtr := c.Type == db.CellPointer + if _, hasR := allRelocs[cur]; hasR { + isPtr = true + } + tLbl, resolved := resolvePtrLabel(cur, val, isPtr) + asm := fmt.Sprintf("dc.l $%08X", val) + var tgtStr string + if resolved { + asm = fmt.Sprintf("dc.l %s", tLbl) + tgtStr = fmt.Sprintf("$%08X (%s)", val, tLbl) + } + items = append(items, dataItemJSON{ + Offset: fmt.Sprintf("+%05X", off), + Addr: fmt.Sprintf("0x%X", cur), + Type: c.Type.AsStr(), + Asm: asm, + Target: tgtStr, + Comment: cg.Comments[cur].EffectiveComment(), + }) + cur += 4 + continue + } + case db.CellStr: + endC := min(bIdx+int(c.Size), len(segBytes)) + strPreview := string(segBytes[bIdx:endC]) + strPreview = strings.TrimRight(strPreview, "\x00") + items = append(items, dataItemJSON{ + Offset: fmt.Sprintf("+%05X", off), + Addr: fmt.Sprintf("0x%X", cur), + Type: "STRING", + Asm: fmt.Sprintf("dc.b %q,0", strPreview), + Comment: cg.Comments[cur].EffectiveComment(), + }) + cur += c.Size + continue + case db.CellDataW: + if bIdx+2 <= len(segBytes) { + val := binary.BigEndian.Uint16(segBytes[bIdx : bIdx+2]) + items = append(items, dataItemJSON{ + Offset: fmt.Sprintf("+%05X", off), + Addr: fmt.Sprintf("0x%X", cur), + Type: "DATA_W", + Asm: fmt.Sprintf("dc.w $%04X (%d)", val, int16(val)), + Comment: cg.Comments[cur].EffectiveComment(), + }) + cur += 2 + continue + } + } + } + + // Fallback hex/word dump row (16 bytes per row) + rowLen := min(16, int(endAddr-cur)) + rowBytes := segBytes[bIdx : bIdx+rowLen] + var words []string + for i := 0; i < len(rowBytes); i += 2 { + if i+2 <= len(rowBytes) { + words = append(words, fmt.Sprintf("$%04X", binary.BigEndian.Uint16(rowBytes[i:i+2]))) + } else { + words = append(words, fmt.Sprintf("$%02X", rowBytes[i])) + } + } + var ascii strings.Builder + for _, b := range rowBytes { + if b >= 0x20 && b <= 0x7E { + ascii.WriteByte(b) + } else { + ascii.WriteByte('.') + } + } + items = append(items, dataItemJSON{ + Offset: fmt.Sprintf("+%05X", off), + Addr: fmt.Sprintf("0x%X", cur), + Type: cellType, + Asm: strings.Join(words, " "), + Comment: ascii.String(), + }) + cur += uint32(rowLen) + } + + xrefOut, _ := runXrefCommand(proj, ctrl, cg, fmt.Sprintf("0x%X", addr), "text") + + if format == "json" { + payload := map[string]any{ + "addr": fmt.Sprintf("0x%X", addr), + "segment": seg.Name, + "offset": fmt.Sprintf("+%05X", addr-seg.Base), + "label": lbl, + "type": cellType, + "length": len(rawSlice), + "items": items, + } + b, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return "", err + } + return string(b) + "\n", nil + } + + var out strings.Builder + fmt.Fprintf(&out, "=== Data @ %s [+%05X / $%X] (%s, %d bytes) ===\n", lbl, addr-seg.Base, addr, cellType, len(rawSlice)) + for _, it := range items { + tgt := "" + if it.Target != "" { + tgt = " -> " + it.Target + } + cmt := "" + if it.Comment != "" { + cmt = " ; " + it.Comment + } + fmt.Fprintf(&out, " %s %-38s%s%s\n", it.Offset, it.Asm, tgt, cmt) + } + out.WriteByte('\n') + out.WriteString(xrefOut) + return out.String(), nil +} + +func renderCopperDecode(seg *project.Segment, addr uint32, lbl string, raw []byte, format string) (string, error) { + type copEntry struct { + Offset string `json:"offset"` + Word0 string `json:"word0"` + Word1 string `json:"word1"` + Kind string `json:"kind"` + Summary string `json:"summary"` + } + var entries []copEntry + for i := 0; i+4 <= len(raw); i += 4 { + w0 := binary.BigEndian.Uint16(raw[i : i+2]) + w1 := binary.BigEndian.Uint16(raw[i+2 : i+4]) + off := (addr - seg.Base) + uint32(i) + if (w0 & 1) == 0 { + regOff := uint32(w0 & 0x01FE) + regName, ok := amiga.GetAmigaHWLabel(amiga.CustomBase + regOff) + if !ok { + regName = fmt.Sprintf("$DFF%03X", regOff) + } + bf := amiga.DecodeCustomRegWrite(regOff, w1) + sum := fmt.Sprintf("MOVE $%04X -> %s", w1, regName) + if bf != "" { + sum += " (" + bf + ")" + } + entries = append(entries, copEntry{ + Offset: fmt.Sprintf("+%05X", off), + Word0: fmt.Sprintf("$%04X", w0), + Word1: fmt.Sprintf("$%04X", w1), + Kind: "MOVE", + Summary: sum, + }) + } else if w0 == 0xFFFF && w1 == 0xFFFE { + entries = append(entries, copEntry{ + Offset: fmt.Sprintf("+%05X", off), + Word0: "$FFFF", + Word1: "$FFFE", + Kind: "END", + Summary: "WAIT ($FFFF,$FFFE) — End of Copperlist", + }) + break + } else { + vp := (w0 >> 8) & 0xFF + hp := w0 & 0xFE + kind := "WAIT" + if (w1 & 1) != 0 { + kind = "SKIP" + } + entries = append(entries, copEntry{ + Offset: fmt.Sprintf("+%05X", off), + Word0: fmt.Sprintf("$%04X", w0), + Word1: fmt.Sprintf("$%04X", w1), + Kind: kind, + Summary: fmt.Sprintf("%s vpos=%d ($%02X), hpos=%d ($%02X), mask=$%04X", kind, vp, vp, hp, hp, w1), + }) + } + } + if format == "json" { + b, err := json.MarshalIndent(map[string]any{ + "addr": fmt.Sprintf("0x%X", addr), + "label": lbl, + "entries": entries, + }, "", " ") + if err != nil { + return "", err + } + return string(b) + "\n", nil + } + var out strings.Builder + fmt.Fprintf(&out, "=== Copperlist @ %s [+%05X / $%X] ===\n", lbl, addr-seg.Base, addr) + for _, e := range entries { + fmt.Fprintf(&out, " %s %s %s %s\n", e.Offset, e.Word0, e.Word1, e.Summary) + } + return out.String(), nil +} + +func renderStructRecords( + proj *project.Project, + cg *callgraph.CallGraph, + seg *project.Segment, + addr uint32, + lbl string, + structName string, + sdef map[string]string, + count int, + segBytes []byte, + resolvePtrLabel func(uint32, uint32, bool) (string, bool), + format string, +) (string, error) { + if count <= 0 { + count = 1 + } + type fSpec struct { + off int + name string + fType string + size int + } + var fields []fSpec + for k, rawF := range sdef { + if k == "_size" { + continue + } + off, err := strconv.Atoi(k) + if err != nil || off < 0 { + continue + } + fname, suf := project.StripFieldType(rawF) + fields = append(fields, fSpec{off: off, name: fname, fType: suf}) + } + slices.SortFunc(fields, func(a, b fSpec) int { + return cmp.Compare(a.off, b.off) + }) + stride := 0 + if szStr, ok := sdef["_size"]; ok { + stride, _ = strconv.Atoi(szStr) + } + for i := range fields { + switch fields[i].fType { + case "b": + fields[i].size = 1 + case "w": + fields[i].size = 2 + case "l", "p": + fields[i].size = 4 + default: + if i+1 < len(fields) { + diff := fields[i+1].off - fields[i].off + if diff == 1 || diff == 2 || diff == 4 { + fields[i].size = diff + } else { + fields[i].size = 2 + } + } else if stride > fields[i].off { + diff := stride - fields[i].off + if diff == 1 || diff == 2 || diff == 4 { + fields[i].size = diff + } else { + fields[i].size = 2 + } + } else { + fields[i].size = 2 + } + } + if fields[i].off+fields[i].size > stride { + stride = fields[i].off + fields[i].size + } + } + if stride <= 0 { + stride = 2 + } + + type fieldValJSON struct { + Offset int `json:"offset"` + Field string `json:"field"` + Size int `json:"size"` + Hex string `json:"hex"` + Dec int64 `json:"dec"` + Target string `json:"target,omitempty"` + } + type recJSON struct { + Index int `json:"index"` + Addr string `json:"addr"` + Offset string `json:"offset"` + Fields []fieldValJSON `json:"fields"` + } + var records []recJSON + for idx := 0; idx < count; idx++ { + recAddr := addr + uint32(idx*stride) + if int(recAddr-seg.Base)+stride > len(segBytes) { + break + } + var fvals []fieldValJSON + for _, f := range fields { + fAddr := recAddr + uint32(f.off) + bOff := int(fAddr - seg.Base) + if bOff+f.size > len(segBytes) { + continue + } + var hexStr, tgtStr string + var decVal int64 + switch f.size { + case 1: + v := segBytes[bOff] + hexStr = fmt.Sprintf("$%02X", v) + decVal = int64(int8(v)) + case 2: + v := binary.BigEndian.Uint16(segBytes[bOff : bOff+2]) + hexStr = fmt.Sprintf("$%04X", v) + decVal = int64(int16(v)) + default: + v := binary.BigEndian.Uint32(segBytes[bOff : bOff+4]) + hexStr = fmt.Sprintf("$%08X", v) + decVal = int64(int32(v)) + isPtr := f.fType == "p" || (proj.SegmentAtAddr(v) != nil && cg.Labels[v] != "") + if tLbl, ok := resolvePtrLabel(fAddr, v, isPtr); ok { + tgtStr = tLbl + } + } + fvals = append(fvals, fieldValJSON{ + Offset: f.off, + Field: f.name, + Size: f.size, + Hex: hexStr, + Dec: decVal, + Target: tgtStr, + }) + } + records = append(records, recJSON{ + Index: idx, + Addr: fmt.Sprintf("0x%X", recAddr), + Offset: fmt.Sprintf("+%05X", recAddr-seg.Base), + Fields: fvals, + }) + } + + if format == "json" { + b, err := json.MarshalIndent(map[string]any{ + "struct": structName, + "stride": stride, + "count": len(records), + "records": records, + }, "", " ") + if err != nil { + return "", err + } + return string(b) + "\n", nil + } + + var out strings.Builder + fmt.Fprintf(&out, "=== Struct %s @ %s [+%05X / $%X] (stride %d bytes, %d record(s)) ===\n", + structName, lbl, addr-seg.Base, addr, stride, len(records)) + for _, r := range records { + fmt.Fprintf(&out, " [%d] %s (%s):\n", r.Index, r.Offset, r.Addr) + for _, fv := range r.Fields { + tgt := "" + if fv.Target != "" { + tgt = " -> " + fv.Target + } + fmt.Fprintf(&out, " +%-3d %-22s = %-10s (%d)%s\n", fv.Offset, fv.Field, fv.Hex, fv.Dec, tgt) + } + } + return out.String(), nil +} + +// --- §3: hunks xref --- + +type xrefHitJSON struct { + SourceAddr string `json:"source_addr"` + Segment string `json:"segment"` + Offset string `json:"offset"` + Func string `json:"func,omitempty"` + FnOffset string `json:"fn_offset,omitempty"` + Kind string `json:"kind"` + Insn string `json:"insn"` + TargetAddr string `json:"target_addr"` +} + +func runXrefCommand( + proj *project.Project, + ctrl *db.Controller, + cg *callgraph.CallGraph, + spec string, + format string, +) (string, error) { + trimmed := strings.TrimSpace(spec) + if startPart, endPart, isRange := strings.Cut(trimmed, ".."); isRange { + startAddr, err := resolveAddrOrLabel(proj, ctrl, strings.TrimSpace(startPart)) + if err != nil { + return "", fmt.Errorf("invalid range start %q: %w", startPart, err) + } + endAddr, err := resolveAddrOrLabel(proj, ctrl, strings.TrimSpace(endPart)) + if err != nil { + return "", fmt.Errorf("invalid range end %q: %w", endPart, err) + } + if endAddr < startAddr { + startAddr, endAddr = endAddr, startAddr + } + return renderXrefRange(proj, ctrl, cg, startAddr, endAddr, format) + } + + var targetAddr uint32 + if hwEqs := amiga.GetAllEquates(); len(hwEqs) > 0 { + for k, v := range hwEqs { + if strings.EqualFold(k, trimmed) { + targetAddr = v + break + } + } + } + if targetAddr == 0 { + var err error + targetAddr, err = resolveAddrOrLabel(proj, ctrl, trimmed) + if err != nil { + return "", fmt.Errorf("cannot resolve address or label %q: %w", trimmed, err) + } + } + + rawXrefs := ctrl.GetXrefsToRange(targetAddr, targetAddr, false) + hits := formatXrefHits(proj, cg, rawXrefs, 0, 0, false) + + lbl := cg.Labels[targetAddr] + if lbl == "" { + if hw, ok := amiga.GetAmigaHWLabel(targetAddr); ok { + lbl = hw + } else { + lbl = fmt.Sprintf("$%X", targetAddr) + } + } + + if format == "json" { + b, err := json.MarshalIndent(map[string]any{ + "target_addr": fmt.Sprintf("0x%X", targetAddr), + "target_label": lbl, + "count": len(hits), + "xrefs": hits, + }, "", " ") + if err != nil { + return "", err + } + return string(b) + "\n", nil + } + + var out strings.Builder + fmt.Fprintf(&out, "=== XREFs to %s ($%X) — %d reference(s) ===\n", lbl, targetAddr, len(hits)) + if len(hits) == 0 { + out.WriteString(" (none)\n") + return out.String(), nil + } + for _, h := range hits { + ctx := "[DATA]" + if h.Func != "" { + ctx = fmt.Sprintf("[%s%s]", h.Func, h.FnOffset) + } + fmt.Fprintf(&out, " %-10s %s %-26s %s\n", h.Kind, h.Offset, ctx, h.Insn) + } + return out.String(), nil +} + +func renderXrefRange( + proj *project.Project, + ctrl *db.Controller, + cg *callgraph.CallGraph, + startAddr, endAddr uint32, + format string, +) (string, error) { + rawXrefs := ctrl.GetXrefsToRange(startAddr, endAddr, true) + // Exclude internal references where source is also inside [startAddr, endAddr]. + hits := formatXrefHits(proj, cg, rawXrefs, startAddr, endAddr, true) + + type targetSummary struct { + TargetAddr string `json:"target_addr"` + TargetLabel string `json:"target_label,omitempty"` + Reads int `json:"reads"` + Writes int `json:"writes"` + Calls int `json:"calls"` + Branches int `json:"branches"` + Addrs int `json:"addrs"` + Ptrs int `json:"ptrs"` + Accessors []string `json:"accessors"` + } + byTarget := make(map[uint32]*targetSummary) + for _, h := range hits { + tAddr, _ := project.ParseHex(h.TargetAddr) + ts, ok := byTarget[tAddr] + if !ok { + lbl := cg.Labels[tAddr] + if lbl == "" { + if hw, ok := amiga.GetAmigaHWLabel(tAddr); ok { + lbl = hw + } + } + ts = &targetSummary{ + TargetAddr: fmt.Sprintf("0x%X", tAddr), + TargetLabel: lbl, + } + byTarget[tAddr] = ts + } + switch h.Kind { + case "READ": + ts.Reads++ + case "WRITE": + ts.Writes++ + case "CALL": + ts.Calls++ + case "BRANCH": + ts.Branches++ + case "PTR": + ts.Ptrs++ + default: + ts.Addrs++ + } + acc := h.Offset + if h.Func != "" { + acc = fmt.Sprintf("%s%s (%s)", h.Func, h.FnOffset, h.Kind) + } + if len(ts.Accessors) < 6 && !slices.Contains(ts.Accessors, acc) { + ts.Accessors = append(ts.Accessors, acc) + } + } + + sortedTargets := slices.Sorted(maps.Keys(byTarget)) + summaries := make([]*targetSummary, len(sortedTargets)) + for i, k := range sortedTargets { + summaries[i] = byTarget[k] + } + + if format == "json" { + b, err := json.MarshalIndent(map[string]any{ + "start": fmt.Sprintf("0x%X", startAddr), + "end": fmt.Sprintf("0x%X", endAddr), + "targets": summaries, + "xrefs": hits, + }, "", " ") + if err != nil { + return "", err + } + return string(b) + "\n", nil + } + + var out strings.Builder + fmt.Fprintf(&out, "=== External XREF Rollup for [$%X..$%X] (%d target(s), %d external ref(s)) ===\n", + startAddr, endAddr, len(summaries), len(hits)) + for _, ts := range summaries { + lbl := "" + if ts.TargetLabel != "" { + lbl = " (" + ts.TargetLabel + ")" + } + fmt.Fprintf(&out, " %-10s%-20s R:%-2d W:%-2d C:%-2d B:%-2d P:%-2d | %s\n", + ts.TargetAddr, lbl, ts.Reads, ts.Writes, ts.Calls, ts.Branches, ts.Ptrs, + strings.Join(ts.Accessors, ", ")) + } + return out.String(), nil +} + +func formatXrefHits( + proj *project.Project, + cg *callgraph.CallGraph, + rawXrefs []db.Xref, + excludeStart, excludeEnd uint32, + excludeInternal bool, +) []xrefHitJSON { + var hits []xrefHitJSON + for _, x := range rawXrefs { + if excludeInternal && x.Source >= excludeStart && x.Source <= excludeEnd { + continue + } + seg := proj.SegmentAtAddr(x.Source) + segName := "?" + var segOff uint32 + if seg != nil { + segName = seg.Name + if x.Source >= seg.Base { + segOff = x.Source - seg.Base + } + } + var fnName, fnOff string + if fAddr, ok := cg.InsnToFunc[x.Source]; ok { + if f, ok := cg.Functions[fAddr]; ok { + fnName = f.Name + fnOff = fmt.Sprintf("+%X", x.Source-f.EntryAddr) + } + } + kindStr := "ADDR" + switch x.Kind { + case db.XrefDataRead: + kindStr = "READ" + case db.XrefDataWrite: + kindStr = "WRITE" + case db.XrefCodeCall: + kindStr = "CALL" + case db.XrefCodeBranch: + kindStr = "BRANCH" + } + insnStr := "" + if c, ok := cg.Cells[x.Source]; ok { + if c.Type == db.CellPointer { + kindStr = "PTR" + insnStr = "dc.l " + c.OpStrVal() + } else { + mnem := c.MnemonicStr() + ops := c.OpStrVal() + insnStr = strings.TrimSpace(fmt.Sprintf("%-8s %s", mnem, ops)) + } + } + hits = append(hits, xrefHitJSON{ + SourceAddr: fmt.Sprintf("0x%X", x.Source), + Segment: segName, + Offset: fmt.Sprintf("+%05X", segOff), + Func: fnName, + FnOffset: fnOff, + Kind: kindStr, + Insn: insnStr, + TargetAddr: fmt.Sprintf("0x%X", x.Target), + }) + } + return hits +} + +// --- §5: struct / alias rendering --- + +func renderStructList(proj *project.Project, format string) error { + if format == "json" { + b, err := json.MarshalIndent(proj.Data.Structs, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + } + if len(proj.Data.Structs) == 0 { + fmt.Println("No structs defined.") + return nil + } + for _, sname := range slices.Sorted(maps.Keys(proj.Data.Structs)) { + sdef := proj.Data.Structs[sname] + sz := sdef["_size"] + if sz != "" { + fmt.Printf(" %s (size %s bytes):\n", sname, sz) + } else { + fmt.Printf(" %s:\n", sname) + } + var offs []int + for k := range sdef { + if k == "_size" { + continue + } + if v, err := strconv.Atoi(k); err == nil { + offs = append(offs, v) + } + } + slices.Sort(offs) + for _, off := range offs { + fmt.Printf(" +%-4d ($%02X) %s\n", off, uint32(off), sdef[strconv.Itoa(off)]) + } + } + return nil +} + +func renderAliasList(proj *project.Project, format string) error { + if format == "json" { + b, err := json.MarshalIndent(proj.Data.RegisterAliases, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + } + if len(proj.Data.RegisterAliases) == 0 { + fmt.Println("No register aliases defined.") + return nil + } + for _, k := range slices.Sorted(maps.Keys(proj.Data.RegisterAliases)) { + fmt.Printf(" %-28s -> %s\n", k, proj.Data.RegisterAliases[k]) + } + return nil +} + +// --- §6: strict comment offset validation --- + +func resolveStrictCommentAddr(proj *project.Project, ctrl *db.Controller, cg *callgraph.CallGraph, addrStr string) (uint32, string, error) { + s := strings.TrimSpace(addrStr) + if basePart, dispPart, hasPlus := strings.Cut(s, "+"); hasPlus && strings.TrimSpace(basePart) != "" { + baseTrim := strings.TrimSpace(basePart) + disp, err := project.ParseHex(strings.TrimSpace(dispPart)) + if err == nil { + if fnEntry, ok := cg.ResolveTarget(proj, baseTrim, ""); ok { + if f, isF := cg.Functions[fnEntry]; isF { + cand := fnEntry + disp + fnEnd := fnEntry + f.ByteSize + if len(f.InsnAddrs) > 0 { + lastIA := f.InsnAddrs[len(f.InsnAddrs)-1] + if lastC, ok := cg.Cells[lastIA]; ok && lastIA+lastC.Size > fnEnd { + fnEnd = lastIA + lastC.Size + } + } + var note string + if cand >= fnEnd { + // Check if disp was actually a segment offset (+051CC instead of +1CC). + segBase := f.EntryAddr - f.OffsetInSeg + segCand := segBase + disp + if segCand >= fnEntry && segCand < fnEnd { + if c, ok := cg.Cells[segCand]; ok && c.Type == db.CellCode { + fnRel := segCand - fnEntry + note = fmt.Sprintf("Note: resolved %s as segment offset +%05X (function-relative: %s+%X)", + s, disp, f.Name, fnRel) + return segCand, note, nil + } + } + return 0, "", fmt.Errorf("offset +%X lands at $%X, which is outside function %s ($%X..$%X, size $%X bytes)", + disp, cand, f.Name, fnEntry, fnEnd, fnEnd-fnEntry) + } + if c, ok := cg.Cells[cand]; ok && c.Addr == cand { + return cand, note, nil + } + // Misaligned inside an instruction! Find nearest valid instruction offsets. + var prevOff, nextOff uint32 + for _, ia := range f.InsnAddrs { + if ia < cand { + prevOff = ia - fnEntry + } else if ia > cand { + nextOff = ia - fnEntry + break + } + } + return 0, "", fmt.Errorf("offset %s+%X ($%X) lands mid-instruction; nearest valid instruction offsets in %s are +%X and +%X", + f.Name, disp, cand, f.Name, prevOff, nextOff) + } + } + } + } + + addr, err := resolveAddrOrLabel(proj, ctrl, s) + if err != nil { + return 0, "", err + } + // Check if addr lands mid-instruction in any CODE cell. + var midCell *db.Cell + cg.CellsTree.DescendLessOrEqual(db.Cell{Addr: addr}, func(c db.Cell) bool { + if c.Type == db.CellCode && addr > c.Addr && addr < c.Addr+c.Size { + cp := c + midCell = &cp + } + return false + }) + if midCell != nil { + return 0, "", fmt.Errorf("address $%X lands mid-instruction inside %d-byte instruction %q at $%X (next instruction at $%X)", + addr, midCell.Size, midCell.MnemonicStr()+" "+midCell.OpStrVal(), midCell.Addr, midCell.Addr+midCell.Size) + } + return addr, "", nil +} + +// --- §7: hunks abi --- + +func runABICommand( + proj *project.Project, + args []string, + batchFile string, + verify bool, + format string, + resolveCallerPath func(string) string, +) error { + seg, err := requireSegment(proj) + if err != nil { + return err + } + ctrl, err := db.New(seg.DB) + if err != nil { + return err + } + defer ctrl.Close() + proj.SyncDBScope(ctrl) + + cg, err := callgraph.Build(proj) + if err != nil { + return err + } + + if batchFile != "" { + var r io.Reader + if batchFile == "-" { + r = os.Stdin + } else { + f, err := os.Open(resolveCallerPath(batchFile)) + if err != nil { + return err + } + defer f.Close() + r = f + } + scanner := bufio.NewScanner(r) + count := 0 + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + fnPart, sigPart, ok := strings.Cut(line, "=") + if !ok { + continue + } + fnAddr, ok := cg.ResolveTarget(proj, strings.TrimSpace(fnPart), "") + if !ok { + return fmt.Errorf("unknown function %q in ABI batch file", strings.TrimSpace(fnPart)) + } + sig := strings.TrimSpace(sigPart) + fnName := proj.AutoLabel(fnAddr) + if f, ok := cg.Functions[fnAddr]; ok { + fnName = f.Name + } + if err := ctrl.SetFunctionABI(fnAddr, fnName, sig); err != nil { + return err + } + if f, ok := cg.Functions[fnAddr]; ok { + f.ABI = sig + for _, w := range callgraph.VerifyABI(sig, f.InRegs, f.ModRegs, f.SavedRegs) { + fmt.Fprintf(os.Stderr, "Warning (%s): %s\n", f.Name, w) + } + } + count++ + } + if err := scanner.Err(); err != nil { + return err + } + fmt.Printf("Updated %d function ABI signature(s)\n", count) + return nil + } + + if len(args) == 2 { + fnAddr, ok := cg.ResolveTarget(proj, args[0], "") + if !ok { + return fmt.Errorf("function %q not found", args[0]) + } + sig := strings.TrimSpace(args[1]) + f := cg.Functions[fnAddr] + if err := ctrl.SetFunctionABI(fnAddr, f.Name, sig); err != nil { + return err + } + f.ABI = sig + warnings := callgraph.VerifyABI(sig, f.InRegs, f.ModRegs, f.SavedRegs) + for _, w := range warnings { + fmt.Fprintf(os.Stderr, "Warning (%s): %s\n", f.Name, w) + } + fmt.Printf("Set ABI for %s (+%05X): %s\n", f.Name, f.OffsetInSeg, sig) + return nil + } + + if len(args) == 1 { + fnAddr, ok := cg.ResolveTarget(proj, args[0], "") + if !ok { + return fmt.Errorf("function %q not found", args[0]) + } + f := cg.Functions[fnAddr] + warnings := callgraph.VerifyABI(f.ABI, f.InRegs, f.ModRegs, f.SavedRegs) + if format == "json" { + b, err := json.MarshalIndent(map[string]any{ + "function": f.Name, + "addr": fmt.Sprintf("0x%X", f.EntryAddr), + "offset": fmt.Sprintf("+%05X", f.OffsetInSeg), + "abi": f.ABI, + "in_regs": f.InRegs, + "mod_regs": f.ModRegs, + "saved_regs": f.SavedRegs, + "warnings": warnings, + }, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + } + fmt.Printf(" %s [+%05X]:\n", f.Name, f.OffsetInSeg) + if f.ABI != "" { + fmt.Printf(" ABI: %s\n", f.ABI) + } + fmt.Printf(" Regs (static): %s\n", f.StaticABISummary()) + for _, w := range warnings { + fmt.Printf(" [!] %s\n", w) + } + return nil + } + + // List all functions with user-defined ABI (or all functions if --verify). + type abiRow struct { + Func string `json:"func"` + Addr string `json:"addr"` + Offset string `json:"offset"` + ABI string `json:"abi"` + InRegs []string `json:"in_regs"` + ModRegs []string `json:"mod_regs"` + SavedRegs []string `json:"saved_regs,omitempty"` + Warnings []string `json:"warnings,omitempty"` + } + var rows []abiRow + for _, addr := range cg.FuncAddrs { + f := cg.Functions[addr] + if f.ABI == "" && !verify { + continue + } + var warnings []string + if f.ABI != "" { + warnings = callgraph.VerifyABI(f.ABI, f.InRegs, f.ModRegs, f.SavedRegs) + } + if verify && f.ABI != "" && len(warnings) == 0 { + continue + } + if verify && f.ABI == "" { + continue + } + rows = append(rows, abiRow{ + Func: f.Name, + Addr: fmt.Sprintf("0x%X", f.EntryAddr), + Offset: fmt.Sprintf("+%05X", f.OffsetInSeg), + ABI: f.ABI, + InRegs: f.InRegs, + ModRegs: f.ModRegs, + SavedRegs: f.SavedRegs, + Warnings: warnings, + }) + } + if format == "json" { + b, err := json.MarshalIndent(rows, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + } + if len(rows) == 0 { + if verify { + fmt.Println("All user-defined function ABI signatures are consistent with static register usage.") + } else { + fmt.Println("No user-defined ABI signatures set.") + } + return nil + } + for _, r := range rows { + fmt.Printf(" %-28s [%s] ABI: %s\n", r.Func, r.Offset, r.ABI) + for _, w := range r.Warnings { + fmt.Printf(" [!] %s\n", w) + } + } + return nil +} + +// --- §8: hunks dump --- + +func runDumpCommand(proj *project.Project, section string, format string) (string, error) { + cg, err := callgraph.Build(proj) + if err != nil { + return "", err + } + + type renameEntry struct { + Addr string `json:"addr"` + Segment string `json:"segment"` + Offset string `json:"offset"` + OldName string `json:"old_name"` + NewName string `json:"new_name"` + ABI string `json:"abi,omitempty"` + } + type commentEntry struct { + Addr string `json:"addr"` + Segment string `json:"segment"` + Offset string `json:"offset"` + Func string `json:"func,omitempty"` + FnOffset string `json:"fn_offset,omitempty"` + Comment string `json:"comment,omitempty"` + BlockComment string `json:"block_comment,omitempty"` + } + type carveEntry struct { + Addr string `json:"addr"` + Segment string `json:"segment"` + Size uint32 `json:"size"` + File string `json:"file"` + } + + renames := []renameEntry{} + for _, addr := range slices.Sorted(maps.Keys(cg.Labels)) { + newName := cg.Labels[addr] + rawName := cg.RawLabels[addr] + if rawName == "" { + rawName = proj.AutoLabel(addr) + } + if newName == rawName && strings.HasPrefix(newName, "LAB_") { + continue + } + seg := proj.SegmentAtAddr(addr) + segName := "" + var off uint32 + if seg != nil { + segName = seg.Name + if addr >= seg.Base { + off = addr - seg.Base + } + } + var abi string + if f, ok := cg.Functions[addr]; ok { + abi = f.ABI + } + renames = append(renames, renameEntry{ + Addr: fmt.Sprintf("0x%X", addr), + Segment: segName, + Offset: fmt.Sprintf("+%05X", off), + OldName: rawName, + NewName: newName, + ABI: abi, + }) + } + + userComments := []commentEntry{} + carves := []carveEntry{} + seenDBs := make(map[string]bool) + for _, seg := range proj.ActiveSegments() { + if seenDBs[seg.DB] { + continue + } + seenDBs[seg.DB] = true + ctrl, err := db.New(seg.DB) + if err != nil { + return "", err + } + proj.SyncDBScope(ctrl) + for _, ac := range ctrl.GetUserComments() { + addr := ac.Addr + s := proj.SegmentAtAddr(addr) + segName := "" + var off uint32 + if s != nil { + segName = s.Name + if addr >= s.Base { + off = addr - s.Base + } + } + var fnName, fnOff string + if fAddr, ok := cg.InsnToFunc[addr]; ok { + if f, ok := cg.Functions[fAddr]; ok { + fnName = f.Name + fnOff = fmt.Sprintf("+%X", addr-f.EntryAddr) + } + } + userComments = append(userComments, commentEntry{ + Addr: fmt.Sprintf("0x%X", addr), + Segment: segName, + Offset: fmt.Sprintf("+%05X", off), + Func: fnName, + FnOffset: fnOff, + Comment: ac.Comment, + BlockComment: ac.BlockComment, + }) + } + _ = ctrl.Close() + } + + for _, addr := range slices.Sorted(maps.Keys(cg.Cells)) { + c := cg.Cells[addr] + if c.Type == db.CellIncbin { + carves = append(carves, carveEntry{ + Addr: fmt.Sprintf("0x%X", c.Addr), + Segment: c.Seg, + Size: c.Size, + File: c.OpStrVal(), + }) + } + } + + payload := map[string]any{ + "target_name": proj.Data.TargetName, + "entry_points": proj.Data.EntryPoints, + "memory_regions": proj.Data.MemoryRegions, + "renames": renames, + "comments": userComments, + "structs": proj.Data.Structs, + "register_aliases": proj.Data.RegisterAliases, + "carves": carves, + } + if section != "" { + secKey := strings.ToLower(strings.TrimSpace(section)) + switch secKey { + case "entries", "entry_points": + payload = map[string]any{"entry_points": proj.Data.EntryPoints} + case "regions", "memory_regions": + payload = map[string]any{"memory_regions": proj.Data.MemoryRegions} + case "renames": + payload = map[string]any{"renames": renames} + case "comments": + payload = map[string]any{"comments": userComments} + case "structs": + payload = map[string]any{"structs": proj.Data.Structs} + case "aliases", "register_aliases": + payload = map[string]any{"register_aliases": proj.Data.RegisterAliases} + case "carves": + payload = map[string]any{"carves": carves} + default: + return "", fmt.Errorf("unknown dump section %q", section) + } + } + + if format == "json" { + b, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return "", err + } + return string(b) + "\n", nil + } + + var out strings.Builder + fmt.Fprintf(&out, "=== Project Dump (%s) ===\n", proj.Data.TargetName) + fmt.Fprintf(&out, " Renames: %d | User Comments: %d | Regions: %d | Structs: %d | Aliases: %d | Carves: %d\n\n", + len(renames), len(userComments), len(proj.Data.MemoryRegions), len(proj.Data.Structs), len(proj.Data.RegisterAliases), len(carves)) + if len(renames) > 0 && (section == "" || section == "renames") { + out.WriteString("=== Renames ===\n") + for _, r := range renames { + abi := "" + if r.ABI != "" { + abi = " [ABI: " + r.ABI + "]" + } + fmt.Fprintf(&out, " %s (%s) %s -> %s%s\n", r.Offset, r.Addr, r.OldName, r.NewName, abi) + } + out.WriteByte('\n') + } + if len(userComments) > 0 && (section == "" || section == "comments") { + out.WriteString("=== User Comments ===\n") + for _, c := range userComments { + ctx := c.Offset + if c.Func != "" { + ctx = fmt.Sprintf("%s (%s%s)", c.Offset, c.Func, c.FnOffset) + } + if c.BlockComment != "" { + fmt.Fprintf(&out, " %s [block]: %s\n", ctx, strings.ReplaceAll(c.BlockComment, "\n", " // ")) + } + if c.Comment != "" { + fmt.Fprintf(&out, " %s: %s\n", ctx, c.Comment) + } + } + out.WriteByte('\n') + } + return out.String(), nil +} diff --git a/cmd/hunks/agent_cmds_test.go b/cmd/hunks/agent_cmds_test.go new file mode 100644 index 0000000..4c127b7 --- /dev/null +++ b/cmd/hunks/agent_cmds_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mikolas/hunks/internal/analyzer" + "github.com/mikolas/hunks/internal/callgraph" + "github.com/mikolas/hunks/internal/db" + "github.com/mikolas/hunks/internal/hunk" + "github.com/mikolas/hunks/internal/project" +) + +func TestAgentWorkflowCommands(t *testing.T) { + tmpDir := t.TempDir() + // 0x10000: move.w $1034.w,d0 30 38 10 34 + // 0x10004: muls.w d1,d0 C1 C1 + // 0x10006: divs.w d2,d0 81 C2 + // 0x10008: muls.w d3,d0 C1 C3 + // 0x1000A: rts 4E 75 + // 0x1000C: Copper list: + // 0x0180, 0x0000 ->dc.w $0180,$0000 (MOVE #$0000 -> COLOR00) + // 0xFFDF, 0xFFFE ->dc.w $FFDF,$FFFE (WAIT v=255, h=222) + // 0xFFFF, 0xFFFE ->dc.w $FFFF,$FFFE (END) + raw := []byte{ + 0x30, 0x38, 0x10, 0x34, + 0xC1, 0xC1, + 0x81, 0xC2, + 0xC1, 0xC3, + 0x4E, 0x75, + 0x01, 0x80, 0x00, 0x00, + 0xFF, 0xDF, 0xFF, 0xFE, + 0xFF, 0xFF, 0xFF, 0xFE, + } + binPath := filepath.Join(tmpDir, "seg.bin") + if err := os.WriteFile(binPath, raw, 0o644); err != nil { + t.Fatal(err) + } + dbPath := filepath.Join(tmpDir, "seg.db") + ctrl, err := db.New(dbPath) + if err != nil { + t.Fatal(err) + } + defer ctrl.Close() + + proj := project.New(filepath.Join(tmpDir, "project.json")) + proj.Data.TargetName = "test_mod" + proj.Data.Segments = []project.Segment{ + { + Name: "main", + HunkIndex: 0, + HunkType: hunk.HunkTypeCode, + MemoryType: hunk.MemoryPublic, + AllocSize: uint32(len(raw)), + File: &binPath, + Base: 0x10000, + DB: dbPath, + Relocatable: false, + }, + } + proj.Data.Spaces = []project.Space{{Name: "default", Segments: []string{"main"}}} + proj.Data.ActiveSpace = "default" + proj.Data.EntryPoints = []project.EntryPoint{{Addr: 0x10000, Name: "MathKernel", Segment: "main"}} + proj.Data.Structs = map[string]map[string]string{ + "CopMove": { + "0": "reg.w", + "2": "val.w", + "_size": "4", + }, + } + + an, err := analyzer.New(proj, ctrl) + if err != nil { + t.Fatal(err) + } + an.Analyze([]uint32{0x10000}) + _ = ctrl.SetLabel(0x10000, "MathKernel", db.LabelLab, false) + _ = ctrl.UpdateComment(0x10004, "scale X") + _ = ctrl.UpdateBlockComment(0x10000, "3D math kernel") + + cg, err := callgraph.Build(proj) + if err != nil { + t.Fatal(err) + } + + // 1. Test `xref` single address & range + xrefSingle, err := runXrefCommand(proj, ctrl, cg, "0x1034", "text") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(xrefSingle, "[MathKernel+0]") || !strings.Contains(xrefSingle, "READ") { + t.Fatalf("unexpected xref single output:\n%s", xrefSingle) + } + xrefRange, err := runXrefCommand(proj, ctrl, cg, "$1000..$1100", "json") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(xrefRange, "\"0x1034\"") { + t.Fatalf("unexpected xref range json output:\n%s", xrefRange) + } + + // 2. Test `data --copper` and `data --struct` + copOut, err := renderDataInspection(proj, ctrl, cg, 0x1000C, 12, true, "", 1, "text") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(copOut, "MOVE $0000 -> COLOR00") || !strings.Contains(copOut, "End of Copperlist") { + t.Fatalf("unexpected copper decode output:\n%s", copOut) + } + structOut, err := renderDataInspection(proj, ctrl, cg, 0x1000C, 12, false, "CopMove", 2, "text") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(structOut, "reg") || !strings.Contains(structOut, "$0180") { + t.Fatalf("unexpected struct decode output:\n%s", structOut) + } + + // 3. Test `resolveStrictCommentAddr` + resolvedAddr, note, err := resolveStrictCommentAddr(proj, ctrl, cg, "MathKernel+4") + if err != nil || resolvedAddr != 0x10004 || note != "" { + t.Fatalf("expected MathKernel+4 -> (0x10004, \"\"), got (%#x, %q, %v)", resolvedAddr, note, err) + } + if _, _, err := resolveStrictCommentAddr(proj, ctrl, cg, "MathKernel+2"); err == nil { + t.Fatalf("expected misaligned offset MathKernel+2 (inside 4-byte move.w) to return an error") + } + + // 4. Test `dump` JSON + dumpJSON, err := runDumpCommand(proj, "", "json") + if err != nil { + t.Fatal(err) + } + var parsed map[string]any + if err := json.Unmarshal([]byte(dumpJSON), &parsed); err != nil { + t.Fatalf("invalid dump JSON: %v\n%s", err, dumpJSON) + } + if parsed["target_name"] != "test_mod" { + t.Fatalf("unexpected target_name in dump: %v", parsed["target_name"]) + } + if !strings.Contains(dumpJSON, "\"scale X\"") || !strings.Contains(dumpJSON, "\"3D math kernel\"") { + t.Fatalf("expected user comments in dump JSON:\n%s", dumpJSON) + } +} diff --git a/cmd/hunks/main.go b/cmd/hunks/main.go index 7fe790a..460a6cf 100644 --- a/cmd/hunks/main.go +++ b/cmd/hunks/main.go @@ -906,11 +906,35 @@ func main() { return err } } + an, err := analyzer.New(proj, ctrl) + if err != nil { + return err + } + var codeSeeds []uint32 if err := ctrl.WithTransaction(func() error { for i := uint32(0); i < cnt; i++ { - if err := ctrl.MarkData(addrU32+(i*4), 4, db.CellPointer); err != nil { + slotAddr := addrU32 + (i * 4) + if err := ctrl.MarkData(slotAddr, 4, db.CellPointer); err != nil { return err } + var tgt uint32 + hasTgt := false + if rTgt, ok := an.Relocs[slotAddr]; ok { + tgt = rTgt + hasTgt = true + } else if u32Tgt, ok := an.ReadU32(slotAddr); ok { + tgt = u32Tgt + hasTgt = true + } + if hasTgt && (an.IsInAnySegment(tgt) || an.IsInMemoryRegion(tgt)) { + an.AddLabel(&slotAddr, tgt) + _ = ctrl.InsertXref(slotAddr, tgt, db.XrefDataAddr) + lbl := an.GetOrDefaultLabel(tgt) + _ = ctrl.SetCellOp(slotAddr, lbl) + if hintFollow && tgt%2 == 0 && an.IsInCodeSegment(tgt) && !slices.Contains(codeSeeds, tgt) { + codeSeeds = append(codeSeeds, tgt) + } + } } if hintComment != "" { return ctrl.UpdateComment(addrU32, hintComment) @@ -919,7 +943,12 @@ func main() { }); err != nil { return err } - fmt.Printf("Added pointer hint at 0x%X with count %d\n", addrU32, cnt) + for _, seed := range codeSeeds { + if _, err := analyzer.ApplyCodeHint(proj, ctrl, seed); err != nil { + return err + } + } + fmt.Printf("Added pointer hint at 0x%X with count %d (%d code target(s) followed)\n", addrU32, cnt, len(codeSeeds)) case "data": size := uint32(1) if countStr != "" { @@ -1130,16 +1159,12 @@ func main() { // --- comment --- var commentBlock bool + var commentBatchFile string commentCmd := &cobra.Command{ - Use: "comment [text]", - Short: "Set or remove an inline or block comment at an address", - Args: cobra.RangeArgs(1, 2), + Use: "comment [addr] [text]", + Short: "Set or remove an inline or block comment at an address (or batch via --batch/--file)", + Args: cobra.RangeArgs(0, 2), RunE: func(_ *cobra.Command, args []string) error { - addrStr := args[0] - var val string - if len(args) >= 2 { - val = args[1] - } proj, err := loadTargetProject(getWorkspaceRoot(), moduleName) if err != nil { return err @@ -1154,10 +1179,83 @@ func main() { } defer ctrl.Close() proj.SyncDBScope(ctrl) - addrU32, err := resolveAddrOrLabel(proj, ctrl, addrStr) + cg, err := callgraph.Build(proj) if err != nil { return err } + + applyOne := func(addrStr, val string) error { + addrU32, note, err := resolveStrictCommentAddr(proj, ctrl, cg, addrStr) + if err != nil { + return err + } + if note != "" { + fmt.Fprintln(os.Stderr, note) + } + if commentBlock { + return ctrl.UpdateBlockComment(addrU32, val) + } + return ctrl.UpdateComment(addrU32, val) + } + + if commentBatchFile != "" { + var r io.Reader + if commentBatchFile == "-" { + r = os.Stdin + } else { + f, err := os.Open(resolveCallerPath(commentBatchFile)) + if err != nil { + return err + } + defer f.Close() + r = f + } + scanner := bufio.NewScanner(r) + count := 0 + if err := ctrl.WithTransaction(func() error { + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + var targetPart, textPart string + if b, a, ok := strings.Cut(line, "="); ok { + targetPart = strings.TrimSpace(b) + textPart = strings.TrimSpace(a) + } else if fields := strings.Fields(line); len(fields) >= 2 { + targetPart = fields[0] + textPart = strings.TrimSpace(line[len(fields[0]):]) + } else { + continue + } + if err := applyOne(targetPart, textPart); err != nil { + return err + } + count++ + } + return scanner.Err() + }); err != nil { + return err + } + fmt.Printf("Applied %d comment(s)\n", count) + return nil + } + + if len(args) == 0 { + return fmt.Errorf("provide [text] or --batch ") + } + addrStr := args[0] + var val string + if len(args) >= 2 { + val = args[1] + } + addrU32, note, err := resolveStrictCommentAddr(proj, ctrl, cg, addrStr) + if err != nil { + return err + } + if note != "" { + fmt.Fprintln(os.Stderr, note) + } if commentBlock { if err := ctrl.UpdateBlockComment(addrU32, val); err != nil { return err @@ -1181,6 +1279,8 @@ func main() { }, } commentCmd.Flags().BoolVarP(&commentBlock, "block", "b", false, "Set block comment (above function/instruction) instead of inline comment") + commentCmd.Flags().StringVar(&commentBatchFile, "batch", "", "Batch file containing ' = ' per line (use '-' for stdin)") + commentCmd.Flags().StringVar(&commentBatchFile, "file", "", "Alias for --batch") rootCmd.AddCommand(commentCmd) // --- operand --- @@ -1241,17 +1341,13 @@ func main() { // --- callgraph / cg --- var cgSegment, cgFormat string var cgDepth, cgUpDepth, cgLimit int - var cgCode, cgLeaves, cgAnchors, cgReady, cgSuspect, cgUnrenamed bool + var cgCode, cgLeaves, cgAnchors, cgReady, cgSuspect, cgIndirect, cgUnrenamed bool cgCmd := &cobra.Command{ - Use: "callgraph [target]", + Use: "callgraph [target...]", Aliases: []string{"cg"}, Short: "Query the static call graph", - Args: cobra.MaximumNArgs(1), + Args: cobra.ArbitraryArgs, RunE: func(_ *cobra.Command, args []string) error { - var target string - if len(args) > 0 { - target = args[0] - } proj, err := loadTargetProject(getWorkspaceRoot(), moduleName) if err != nil { return err @@ -1260,25 +1356,59 @@ func main() { if err != nil { return err } - opts := &callgraph.Options{ - Target: target, - Segment: cgSegment, - Depth: cgDepth, - UpDepth: cgUpDepth, - Code: cgCode, - Leaves: cgLeaves, - Anchors: cgAnchors, - Ready: cgReady, - Suspect: cgSuspect, - Unrenamed: cgUnrenamed, - Limit: cgLimit, - Format: cgFormat, - } - output, err := cg.Render(proj, opts) - if err != nil { - return err + renderOne := func(target string) (string, error) { + opts := &callgraph.Options{ + Target: target, + Segment: cgSegment, + Depth: cgDepth, + UpDepth: cgUpDepth, + Code: cgCode, + Leaves: cgLeaves, + Anchors: cgAnchors, + Ready: cgReady, + Suspect: cgSuspect, + Indirect: cgIndirect, + Unrenamed: cgUnrenamed, + Limit: cgLimit, + Format: cgFormat, + } + out, err := cg.Render(proj, opts) + if err != nil && target != "" { + // Auto-fallback to non-code data + XREF inspection if target resolves to data/global. + if seg, sErr := requireSegment(proj); sErr == nil { + if ctrl, cErr := db.New(seg.DB); cErr == nil { + defer ctrl.Close() + proj.SyncDBScope(ctrl) + if addr, aErr := resolveAddrOrLabel(proj, ctrl, target); aErr == nil { + return renderDataInspection(proj, ctrl, cg, addr, 64, false, "", 1, cgFormat) + } + } + } + } + return out, err + } + if len(args) <= 1 { + var target string + if len(args) == 1 { + target = args[0] + } + output, err := renderOne(target) + if err != nil { + return err + } + fmt.Print(output) + return nil + } + for i, t := range args { + if i > 0 { + fmt.Println() + } + output, err := renderOne(t) + if err != nil { + return err + } + fmt.Print(output) } - fmt.Print(output) return nil }, } @@ -1291,6 +1421,7 @@ func main() { cgCmd.Flags().BoolVar(&cgAnchors, "anchors", false, "List functions anchored by STRING references or Amiga hardware registers") cgCmd.Flags().BoolVar(&cgReady, "ready", false, "List unrenamed functions whose callees are all renamed (bottom-up frontier)") cgCmd.Flags().BoolVar(&cgSuspect, "suspect", false, "List functions with suspect fallthrough into DATA/STRING without rts/jmp/bra") + cgCmd.Flags().BoolVar(&cgIndirect, "indirect", false, "List all indirect and trampoline call/jump dispatch sites (resolved and unresolved)") cgCmd.Flags().BoolVarP(&cgUnrenamed, "unrenamed", "u", false, "Only show unrenamed functions (LAB_*) in worklist modes") cgCmd.Flags().IntVarP(&cgLimit, "limit", "n", 50, "Maximum number of functions to list in worklist modes (0 = all)") cgCmd.Flags().StringVarP(&cgFormat, "format", "f", "text", "Output format: text, json, dot, mermaid") @@ -1901,6 +2032,7 @@ func main() { }, } rootCmd.AddCommand(tuiCmd) + registerAgentCommands(rootCmd, getWorkspaceRoot, &moduleName, resolveCallerPath) if err := rootCmd.Execute(); err != nil { os.Exit(1) diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 0d591a3..90847dd 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -62,6 +62,7 @@ type Analyzer struct { ExportedCache map[uint32]struct{} OperandFormatsCache map[uint32]string CustomEA map[CustomEAKey]string + funcEntries []uint32 cellRanges []db.AddrRange cellRangesLoaded bool insnCache map[uint32]m68k.Insn @@ -209,6 +210,7 @@ func (a *Analyzer) RefreshCaches() { } else { a.OperandFormatsCache = make(map[uint32]string) } + a.funcEntries = a.DB.FunctionEntries() a.cellRanges = nil a.cellRangesLoaded = false } @@ -319,6 +321,15 @@ func (a *Analyzer) GetOrDefaultLabel(addr uint32) string { return a.Proj.AutoLabel(addr) } +func (a *Analyzer) IsInMemoryRegion(addr uint32) bool { + for _, r := range a.Proj.Data.MemoryRegions { + if addr >= r.Start && addr < r.End { + return true + } + } + return false +} + // AddLabel ensures a symbolic label exists at targetAddr if it falls within an active // segment or declared memory region (and is not a dedicated Amiga hardware register). // When srcAddr belongs to a different segment than targetAddr, the label is marked @@ -327,13 +338,7 @@ func (a *Analyzer) AddLabel(srcAddr *uint32, targetAddr uint32) { if _, ok := amiga.GetAmigaHWLabel(targetAddr); ok { return } - inMemRegion := false - for _, r := range a.Proj.Data.MemoryRegions { - if targetAddr >= r.Start && targetAddr < r.End { - inMemRegion = true - break - } - } + inMemRegion := a.IsInMemoryRegion(targetAddr) if !a.IsInAnySegment(targetAddr) && !inMemRegion { return } @@ -473,7 +478,10 @@ func (a *Analyzer) runPipeline(entries []uint32) { } a.stage2DiscoverCode(entries, jumpTable) - a.propagateBaseRegs(jumpTable) + if newSeeds := a.propagateBaseRegs(jumpTable); len(newSeeds) > 0 { + a.stage2DiscoverCode(newSeeds, jumpTable) + _ = a.propagateBaseRegs(jumpTable) + } a.stage3ClassifyData() a.Stage3FormatOperands() a.stage4FunctionMeta() diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go index c02c6a6..3107be5 100644 --- a/internal/analyzer/analyzer_test.go +++ b/internal/analyzer/analyzer_test.go @@ -1208,3 +1208,147 @@ func TestCustomRegBitfieldAutoComments(t *testing.T) { t.Fatalf("expected BPLCON0 bitfield auto-comment at 0x10018, got %q", cBplcon) } } + +func TestAbsShortXrefsAndRangeRollup(t *testing.T) { + const base uint32 = 0x10000 + // 0x10000: move.w $1034.w,d0 30 38 10 34 + // 0x10004: move.w d0,$1038.w 31 C0 10 38 + // 0x10008: lea.l $1178.w,a0 41 F8 11 78 + // 0x1000C: pea.l $0020.w 48 78 00 20 (small constant push — must not record xref) + // 0x10010: rts 4E 75 + code := []byte{ + 0x30, 0x38, 0x10, 0x34, + 0x31, 0xC0, 0x10, 0x38, + 0x41, 0xF8, 0x11, 0x78, + 0x48, 0x78, 0x00, 0x20, + 0x4E, 0x75, + } + a, ctrl := newTestTraceAnalyzer(t, code, base) + a.Analyze([]uint32{base}) + + if xrefs := ctrl.GetXrefsToRange(0x1034, 0x1035, false); len(xrefs) != 1 || xrefs[0].Source != 0x10000 || xrefs[0].Kind != db.XrefDataRead { + t.Fatalf("expected 1 XrefDataRead to 0x1034 from 0x10000, got %+v", xrefs) + } + if xrefs := ctrl.GetXrefsToRange(0x1038, 0x1039, false); len(xrefs) != 1 || xrefs[0].Source != 0x10004 || xrefs[0].Kind != db.XrefDataWrite { + t.Fatalf("expected 1 XrefDataWrite to 0x1038 from 0x10004, got %+v", xrefs) + } + if xrefs := ctrl.GetXrefsToRange(0x1178, 0x1179, false); len(xrefs) != 1 || xrefs[0].Source != 0x10008 || xrefs[0].Kind != db.XrefDataAddr { + t.Fatalf("expected 1 XrefDataAddr to 0x1178 from 0x10008, got %+v", xrefs) + } + if xrefs := ctrl.GetXrefsToRange(0x0020, 0x0021, false); len(xrefs) != 0 { + t.Fatalf("expected pea $0020.w not to record xref, got %+v", xrefs) + } + rangeXrefs := ctrl.GetXrefsToRange(0x1000, 0x1200, true) + if len(rangeXrefs) != 3 { + t.Fatalf("expected 3 xrefs in range 0x1000..0x1200, got %+v", rangeXrefs) + } +} + +func TestStaticMemorySlotTrampolineResolution(t *testing.T) { + const base uint32 = 0x10000 + // 0x10000: move.w #$4EF9,$1032.w 31 FC 4E F9 10 32 + // 0x10006: move.l #$00010014,$1034.w 21 FC 00 01 00 14 10 34 + // 0x1000E: jsr $1032.w 4E B8 10 32 + // 0x10012: rts 4E 75 + // 0x10014: moveq #$2A,d0 70 2A + // 0x10016: rts 4E 75 + code := []byte{ + 0x31, 0xFC, 0x4E, 0xF9, 0x10, 0x32, + 0x21, 0xFC, 0x00, 0x01, 0x00, 0x14, 0x10, 0x34, + 0x4E, 0xB8, 0x10, 0x32, + 0x4E, 0x75, + 0x70, 0x2A, + 0x4E, 0x75, + } + a, ctrl := newTestTraceAnalyzer(t, code, base) + a.Analyze([]uint32{base}) + + cTarget, err := ctrl.GetCell(0x10014) + if err != nil || cTarget == nil || cTarget.Type != db.CellCode { + t.Fatalf("expected target 0x10014 to be discovered as CODE via static trampoline slot, got %+v", cTarget) + } + if tgt, ok := ctrl.FirstControlEdgeTarget(0x1000E); !ok || tgt != 0x10014 { + t.Fatalf("expected jsr $1032.w at 0x1000E to have EdgeCall to 0x10014, got (%#x, %v)", tgt, ok) + } + if autoCmt := ctrl.GetAddressComment(0x1000E).AutoComment; !strings.Contains(autoCmt, "-> LAB_0_00014 (via $1032)") { + t.Fatalf("expected auto-comment at 0x1000E to contain '-> LAB_0_00014 (via $1032)', got %q", autoCmt) + } +} + +func TestTraceLowRAMTrampolineBouncing(t *testing.T) { + const base uint32 = 0x10000 + code := []byte{ + 0x31, 0xFC, 0x4E, 0xF9, 0x10, 0x32, + 0x21, 0xFC, 0x00, 0x01, 0x00, 0x14, 0x10, 0x34, + 0x4E, 0xB8, 0x10, 0x32, + 0x4E, 0x75, + 0x70, 0x2A, + 0x4E, 0x75, + } + res := RunTrace(code, base, base, TraceOptions{Budget: 100, StuckN: 50}) + var found bool + for _, site := range res.Sites { + if site.Addr == 0x1000E && site.Targets[0x10014] > 0 { + found = true + break + } + } + if !found { + t.Fatalf("expected RunTrace to bounce through low-RAM trampoline $1032.w and resolve 0x10014, got %+v", res.Sites) + } +} + +func TestSMCAutoCommentAnnotation(t *testing.T) { + const base uint32 = 0x10000 + // 0x10000: move.w #$1234,$0001000A.l 33 FC 12 34 00 01 00 0A (patches immediate word inside 0x10008!) + // 0x10008: move.w #$0000,d0 30 3C 00 00 + // 0x1000C: rts 4E 75 + code := []byte{ + 0x33, 0xFC, 0x12, 0x34, 0x00, 0x01, 0x00, 0x0A, + 0x30, 0x3C, 0x00, 0x00, + 0x4E, 0x75, + } + a, ctrl := newTestTraceAnalyzer(t, code, base) + a.Analyze([]uint32{base}) + + autoCmt := ctrl.GetAddressComment(0x10000).AutoComment + if !strings.Contains(autoCmt, "[SMC] writes code at") { + t.Fatalf("expected SMC auto-comment at 0x10000, got %q", autoCmt) + } +} + +func TestScopedRegisterAliasAndFieldTypeStripping(t *testing.T) { + const base uint32 = 0x10000 + // 0x10000: bsr.s $10008 61 06 + // 0x10002: move.w 2(a0),d0 30 28 00 02 + // 0x10006: rts 4E 75 + // 0x10008: move.w 2(a0),d0 30 28 00 02 + // 0x1000C: rts 4E 75 + code := []byte{ + 0x61, 0x06, + 0x30, 0x28, 0x00, 0x02, + 0x4E, 0x75, + 0x30, 0x28, 0x00, 0x02, + 0x4E, 0x75, + } + a, ctrl := newTestTraceAnalyzer(t, code, base) + a.Proj.Data.Structs = map[string]map[string]string{ + "Model": { + "2": "vx.w", + "_size": "18", + }, + } + a.Proj.Data.RegisterAliases = map[string]string{ + "LAB_10008:a0": "Model", + } + a.Analyze([]uint32{base}) + + cMain, _ := ctrl.GetCell(0x10002) + if cMain == nil || cMain.OpStrVal() != "2(a0),d0" { + t.Fatalf("expected un-aliased 2(a0),d0 at 0x10002, got %+v", cMain) + } + cSub, _ := ctrl.GetCell(0x10008) + if cSub == nil || cSub.OpStrVal() != "vx(a0),d0" { + t.Fatalf("expected function-scoped alias vx(a0),d0 at 0x10008, got %+v", cSub) + } +} diff --git a/internal/analyzer/baseregs.go b/internal/analyzer/baseregs.go index 4cf99b3..4bc598b 100644 --- a/internal/analyzer/baseregs.go +++ b/internal/analyzer/baseregs.go @@ -2,6 +2,7 @@ package analyzer import ( "fmt" + "slices" "github.com/mikolas/hunks/internal/amiga" "github.com/mikolas/hunks/internal/db" @@ -13,6 +14,7 @@ import ( type regKnown struct { known bool val uint32 // meaningful iff known + slot uint32 // non-zero when loaded from a static memory pointer slot } type regFile struct { @@ -231,14 +233,14 @@ func (a *Analyzer) resolveSlotOrRelocTarget(slotAddr uint32, jumpTable map[uint3 // A register `aN` (`a0..a6`) is auto-promoted to a global base-register invariant iff // every constant load into `aN` across the binary agrees on the same target address and // `aN` has zero non-constant writes (`!sawDynamicWrite[n]`) anywhere in the program. -func (a *Analyzer) propagateBaseRegs(jumpTable map[uint32]uint32) { +func (a *Analyzer) propagateBaseRegs(jumpTable map[uint32]uint32) []uint32 { _ = a.DB.ClearAutoBaseRegs() globalBase, hinted := a.hintedBaseRegs() slots := a.DB.SpaceCellSlots() if len(slots) == 0 { - return + return nil } if a.insnCache != nil && len(a.insnCache) == 0 { a.insnCache = make(map[uint32]m68k.Insn, len(slots)) @@ -247,6 +249,7 @@ func (a *Analyzer) propagateBaseRegs(jumpTable map[uint32]uint32) { var firstLoad [8]uint32 var hasLoad [8]bool var sawDynamicWrite [8]bool + memSlotTargets := make(map[uint32][]uint32) for _, slot := range slots { if !slot.IsCode { continue @@ -259,6 +262,27 @@ func (a *Analyzer) propagateBaseRegs(jumpTable map[uint32]uint32) { if !ok { continue } + if insn.Op == m68k.OpMOVE && insn.Suffix == m68k.SizeL && insn.NumOps == 2 && insn.Ops[0].Mode == m68k.EAImm { + dst := insn.Ops[1] + if dst.Mode == m68k.EAAbsShort || dst.Mode == m68k.EAAbsLong { + var immTgt uint32 + hasImmTgt := false + if len(a.Relocs) > 0 { + if rTgt, ok := a.Relocs[slot.Addr+2]; ok { + immTgt = rTgt + hasImmTgt = true + } + } else { + immTgt = insn.Ops[0].UVal + hasImmTgt = true + } + if hasImmTgt && immTgt%2 == 0 && a.IsInCodeSegment(immTgt) { + if !slices.Contains(memSlotTargets[dst.UVal], immTgt) { + memSlotTargets[dst.UVal] = append(memSlotTargets[dst.UVal], immTgt) + } + } + } + } constReg, val, isConst := a.decodeConstAregLoad(slot.Addr, &insn) if isConst { if !hasLoad[constReg] { @@ -288,7 +312,7 @@ func (a *Analyzer) propagateBaseRegs(jumpTable map[uint32]uint32) { _ = a.DB.SetBaseReg("global", "", m68k.RegAName(r), first, "auto") } - a.resolveBaseRelativeAccesses(slots, globalBase, jumpTable) + return a.resolveBaseRelativeAccesses(slots, globalBase, jumpTable, memSlotTargets) } func (a *Analyzer) decodeConstAregLoad(addr uint32, insn *m68k.Insn) (uint8, uint32, bool) { @@ -339,7 +363,7 @@ func (a *Analyzer) decodeConstAregLoad(addr uint32, insn *m68k.Insn) (uint8, uin // would shift the assembled displacement by `rv.val - $DFF000` and break binary parity. // 3. Small-data globals (`base + disp` landing in a `DATA` or `BSS` segment): seeds // a label at the target and records a `DATA_READ` or `DATA_WRITE` XREF. -func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase regFile, jumpTable map[uint32]uint32) { +func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase regFile, jumpTable map[uint32]uint32, memSlotTargets map[uint32][]uint32) []uint32 { state := globalBase type addrComment struct { @@ -347,9 +371,10 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r frag string } type globalRef struct { - site uint32 - target uint32 - isWrite bool + site uint32 + target uint32 + isWrite bool + addLabel bool } type jtEdge struct { site uint32 @@ -365,6 +390,18 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r var globals []globalRef var jtEdges []jtEdge var customEAs []customEAEntry + var newSeeds []uint32 + + lookupSlotTargets := func(slotAddr uint32) []uint32 { + if len(memSlotTargets) == 0 { + return nil + } + if tgts := memSlotTargets[slotAddr]; len(tgts) > 0 { + return tgts + } + // Check 6-byte JMP xxx.L trampoline where $4EF9 is at slotAddr and target is at slotAddr+2. + return memSlotTargets[slotAddr+2] + } for _, slot := range slots { if !slot.IsCode { @@ -380,6 +417,24 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r continue } + // Direct call/jump through a non-code memory slot / RAM trampoline (e.g. jsr $1034.w). + if (insn.Op == m68k.OpJSR || insn.Op == m68k.OpJMP) && insn.NumOps >= 1 { + op0 := insn.Ops[0] + if op0.Mode == m68k.EAAbsShort || op0.Mode == m68k.EAAbsLong { + if tgts := lookupSlotTargets(op0.UVal); len(tgts) > 0 && !a.isCodeCell(op0.UVal) { + isCall := insn.Op == m68k.OpJSR + if !slices.Contains(a.IndirectSites, slot.Addr) { + a.IndirectSites = append(a.IndirectSites, slot.Addr) + } + for _, tgt := range tgts { + jtEdges = append(jtEdges, jtEdge{site: slot.Addr, target: tgt, isCall: isCall}) + tname := a.GetOrDefaultLabel(tgt) + comments = append(comments, addrComment{addr: slot.Addr, frag: fmt.Sprintf("-> %s (via $%X)", tname, op0.UVal)}) + } + } + } + } + // Step 1: Use / resolve operands against pre-instruction register state. for opIdx := uint8(0); opIdx < insn.NumOps; opIdx++ { op := insn.Ops[opIdx] @@ -392,11 +447,41 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r } if op.Mode == m68k.EAAddrInd && (insn.Op == m68k.OpJSR || insn.Op == m68k.OpJMP) { + isCall := insn.Op == m68k.OpJSR + if rv.slot != 0 { + if tgts := lookupSlotTargets(rv.slot); len(tgts) > 0 { + for _, tgt := range tgts { + jtEdges = append(jtEdges, jtEdge{site: slot.Addr, target: tgt, isCall: isCall}) + tname := a.GetOrDefaultLabel(tgt) + comments = append(comments, addrComment{addr: slot.Addr, frag: fmt.Sprintf("-> %s (via $%X)", tname, rv.slot)}) + } + continue + } + } if finalTgt, ok := a.resolveDirectOrSlotTarget(rv.val, jumpTable); ok { - isCall := insn.Op == m68k.OpJSR jtEdges = append(jtEdges, jtEdge{site: slot.Addr, target: finalTgt, isCall: isCall}) tname := a.GetOrDefaultLabel(finalTgt) comments = append(comments, addrComment{addr: slot.Addr, frag: fmt.Sprintf("-> %s", tname)}) + } else if tgts := lookupSlotTargets(rv.val); len(tgts) > 0 { + for _, tgt := range tgts { + jtEdges = append(jtEdges, jtEdge{site: slot.Addr, target: tgt, isCall: isCall}) + tname := a.GetOrDefaultLabel(tgt) + comments = append(comments, addrComment{addr: slot.Addr, frag: fmt.Sprintf("-> %s (via $%X)", tname, rv.val)}) + } + } + continue + } + + if op.Mode == m68k.EAAddrInd && (op.Access&(m68k.AccessRead|m68k.AccessWrite)) != 0 { + target := rv.val + if _, isHW := amiga.GetAmigaHWLabelSub(target); !isHW { + if a.isInDataOrBss(target) || a.IsInMemoryRegion(target) { + isWrite := (op.Access & m68k.AccessWrite) != 0 + globals = append(globals, globalRef{site: slot.Addr, target: target, isWrite: isWrite, addLabel: true}) + } else if a.IsInAnySegment(target) { + isWrite := (op.Access & m68k.AccessWrite) != 0 + globals = append(globals, globalRef{site: slot.Addr, target: target, isWrite: isWrite, addLabel: false}) + } } continue } @@ -416,6 +501,15 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r comments = append(comments, addrComment{addr: slot.Addr, frag: fmt.Sprintf("-> %s", tname)}) continue } + if tgts := lookupSlotTargets(target); len(tgts) > 0 { + isCall := insn.Op == m68k.OpJSR + for _, tgt := range tgts { + jtEdges = append(jtEdges, jtEdge{site: slot.Addr, target: tgt, isCall: isCall}) + tname := a.GetOrDefaultLabel(tgt) + comments = append(comments, addrComment{addr: slot.Addr, frag: fmt.Sprintf("-> %s (via $%X)", tname, target)}) + } + continue + } } if hw, ok := amiga.GetAmigaHWLabelSub(target); ok { @@ -445,14 +539,26 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r comments = append(comments, addrComment{addr: slot.Addr, frag: bf}) } } - } else if a.isInDataOrBss(target) { + } else if a.isInDataOrBss(target) || a.IsInMemoryRegion(target) { isWrite := (op.Access & m68k.AccessWrite) != 0 - globals = append(globals, globalRef{site: slot.Addr, target: target, isWrite: isWrite}) + globals = append(globals, globalRef{site: slot.Addr, target: target, isWrite: isWrite, addLabel: true}) + } else if a.IsInAnySegment(target) { + isWrite := (op.Access & m68k.AccessWrite) != 0 + globals = append(globals, globalRef{site: slot.Addr, target: target, isWrite: isWrite, addLabel: false}) } } // Steps 2-6: Compute definition, clobber WriteRegs, commit, call-clobber, terminator reset. a.stepInsn(slot.Addr, &insn, &state, &globalBase) + if insn.Op == m68k.OpMOVEA && insn.Suffix == m68k.SizeL && insn.NumOps == 2 { + op0 := insn.Ops[0] + dstOp := insn.Ops[1] + if (op0.Mode == m68k.EAAbsShort || op0.Mode == m68k.EAAbsLong) && dstOp.Mode == m68k.EAAddrReg && dstOp.Reg < 7 { + if tgts := lookupSlotTargets(op0.UVal); len(tgts) > 0 { + state.a[dstOp.Reg] = regKnown{known: true, val: tgts[0], slot: op0.UVal} + } + } + } } for _, ce := range customEAs { @@ -464,8 +570,10 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r a.appendCellComment(c.addr, c.frag) } for _, g := range globals { - site := g.site - a.AddLabel(&site, g.target) + if g.addLabel { + site := g.site + a.AddLabel(&site, g.target) + } xrefKind := db.XrefDataRead if g.isWrite { xrefKind = db.XrefDataWrite @@ -475,17 +583,23 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r for _, je := range jtEdges { site := je.site a.AddLabel(&site, je.target) + if !a.isCodeCell(je.target) && a.IsInCodeSegment(je.target) && !slices.Contains(newSeeds, je.target) { + newSeeds = append(newSeeds, je.target) + } edgeKind := db.EdgeUncond xrefKind := db.XrefCodeBranch if je.isCall { edgeKind = db.EdgeCall xrefKind = db.XrefCodeCall + labelName := a.GetOrDefaultLabel(je.target) + _ = a.DB.InsertFunction(je.target, labelName, ConfHigh) } _ = a.DB.InsertEdge(je.site, je.target, edgeKind) _ = a.DB.InsertXref(je.site, je.target, xrefKind) } a.DB.Commit() } + return newSeeds } func (a *Analyzer) isInDataOrBss(addr uint32) bool { diff --git a/internal/analyzer/classify.go b/internal/analyzer/classify.go index c615a10..f03e72d 100644 --- a/internal/analyzer/classify.go +++ b/internal/analyzer/classify.go @@ -64,7 +64,7 @@ func AbsLongExtAddr(addr uint32, insn *m68k.Insn, opIdx int) uint32 { } func (a *Analyzer) isValidTarget(addr uint32) bool { - if a.IsInAnyProjectSegment(addr) { + if a.IsInAnyProjectSegment(addr) || a.IsInMemoryRegion(addr) { return true } _, ok := amiga.GetAmigaHWLabel(addr) @@ -154,6 +154,18 @@ func (a *Analyzer) ClassifyReference(addr uint32, insn *m68k.Insn, op m68k.Opera } target := op.UVal if !a.isValidTarget(target) { + // In flat-binary mode, still record data read/write/addr XREFs to unmapped + // 24-bit Amiga memory addresses (e.g. $10xx, $6xxx) so `hunks xref` can + // query them even before a MemoryRegion is declared. + if !isCall && !isBranch && target > 0 && (target&0xFF000000) == 0 { + if isAddrCalc { + return RefType{Kind: RefDataAddr, Target: target}, true + } + if isWrite { + return RefType{Kind: RefDataWrite, Target: target}, true + } + return RefType{Kind: RefDataRead, Target: target}, true + } return RefType{}, false } if isCall { @@ -171,7 +183,30 @@ func (a *Analyzer) ClassifyReference(addr uint32, insn *m68k.Insn, op m68k.Opera return RefType{Kind: RefDataRead, Target: target}, true case m68k.EAAbsShort: - return RefType{}, false + // `pea ($xxxx).w` is the standard 68000 compiler idiom for pushing a 16-bit + // integer constant onto the stack, not a memory reference. + if insn.Op == m68k.OpPEA { + return RefType{}, false + } + target := uint32(int32(int16(uint16(op.UVal)))) & 0x00FFFFFF + if isCall { + return RefType{Kind: RefCodeCall, Target: target}, true + } + if isBranch { + return RefType{Kind: RefCodeBranch, Target: target, IsConditional: false}, true + } + if isAddrCalc { + // `lea ($xxxx).w,An` with tiny values or negative constants (-1).w is often a constant load, + // but still useful to record in xrefs when non-negative or in a valid target range. + if int16(uint16(op.UVal)) < 0 && !a.isValidTarget(target) { + return RefType{}, false + } + return RefType{Kind: RefDataAddr, Target: target}, true + } + if isWrite { + return RefType{Kind: RefDataWrite, Target: target}, true + } + return RefType{Kind: RefDataRead, Target: target}, true case m68k.EAAddrInd, m68k.EADisp16, m68k.EAIndex: if isCall || isBranch { diff --git a/internal/analyzer/format.go b/internal/analyzer/format.go index 00c65e5..b5962e8 100644 --- a/internal/analyzer/format.go +++ b/internal/analyzer/format.go @@ -2,10 +2,12 @@ package analyzer import ( "fmt" + "strings" "github.com/mikolas/hunks/internal/amiga" "github.com/mikolas/hunks/internal/db" "github.com/mikolas/hunks/internal/m68k" + "github.com/mikolas/hunks/internal/project" ) // Stage3FormatOperands formats all CODE cells' mnemonics and operands into vasmm68k_mot syntax. @@ -181,9 +183,10 @@ func (a *Analyzer) formatOperand(addr uint32, insn *m68k.Insn, op m68k.Operand, } if len(a.Proj.Data.RegisterAliases) > 0 { baseStr := m68k.RegAName(op.Reg) - if structName, ok := a.Proj.Data.RegisterAliases[baseStr]; ok { + if structName, ok := a.resolveRegisterAlias(addr, baseStr); ok { if structDef, ok := a.Proj.Data.Structs[structName]; ok { - if field, ok := structDef[fmt.Sprintf("%d", op.Disp)]; ok { + if rawField, ok := structDef[fmt.Sprintf("%d", op.Disp)]; ok && rawField != "" { + field, _ := project.StripFieldType(rawField) if op.Mode == m68k.EAIndex { return field + "(" + baseStr + "," + op.IndexRegName() + op.IndexSize.DotSuffix() + ")" } @@ -198,6 +201,47 @@ func (a *Analyzer) formatOperand(addr uint32, insn *m68k.Insn, op m68k.Operand, return op.FormatASM(insn.Op) } +func (a *Analyzer) resolveRegisterAlias(addr uint32, baseStr string) (string, bool) { + aliases := a.Proj.Data.RegisterAliases + if len(aliases) == 0 { + return "", false + } + if len(a.funcEntries) == 0 { + a.funcEntries = a.DB.FunctionEntries() + } + lo, hi := 0, len(a.funcEntries) + for lo < hi { + mid := int(uint(lo+hi) >> 1) + if a.funcEntries[mid] <= addr { + lo = mid + 1 + } else { + hi = mid + } + } + if lo > 0 && a.IsSameSegment(a.funcEntries[lo-1], addr) { + fnEntry := a.funcEntries[lo-1] + var candKeys [4]string + candKeys[0] = a.GetOrDefaultLabel(fnEntry) + ":" + baseStr + candKeys[1] = a.Proj.AutoLabel(fnEntry) + ":" + baseStr + candKeys[2] = fmt.Sprintf("LAB_%05X:%s", fnEntry, baseStr) + candKeys[3] = fmt.Sprintf("0x%X:%s", fnEntry, baseStr) + for _, k := range candKeys { + if s, ok := aliases[k]; ok && s != "" { + return s, true + } + for ak, av := range aliases { + if strings.EqualFold(ak, k) && av != "" { + return av, true + } + } + } + } + if s, ok := aliases[baseStr]; ok && s != "" { + return s, true + } + return "", false +} + func (a *Analyzer) formatImmOperand(addr uint32, insn *m68k.Insn, op m68k.Operand, hunkMode bool) string { val := op.UVal if fmtKind, ok := a.OperandFormatsCache[addr]; ok { diff --git a/internal/analyzer/stage2_code.go b/internal/analyzer/stage2_code.go index 654914b..d1ccdfd 100644 --- a/internal/analyzer/stage2_code.go +++ b/internal/analyzer/stage2_code.go @@ -186,8 +186,12 @@ func (a *Analyzer) stage2DiscoverCode(initialEntries []uint32, jumpTable map[uin case RefCodeCall: target := ref.Target _ = a.DB.InsertXref(addr, target, db.XrefCodeCall) - _ = a.DB.InsertEdge(addr, target, db.EdgeCall) - a.AddLabel(&src, target) + if op.Mode != m68k.EAAbsShort || a.IsInAnyProjectSegment(target) { + _ = a.DB.InsertEdge(addr, target, db.EdgeCall) + a.AddLabel(&src, target) + } else if a.IsInMemoryRegion(target) { + a.AddLabel(&src, target) + } if a.IsInCodeSegment(target) { labelName := a.GetOrDefaultLabel(target) _ = a.DB.InsertFunction(target, labelName, confidence) @@ -199,13 +203,17 @@ func (a *Analyzer) stage2DiscoverCode(initialEntries []uint32, jumpTable map[uin case RefCodeBranch: target := ref.Target _ = a.DB.InsertXref(addr, target, db.XrefCodeBranch) - if ref.IsConditional { - _ = a.DB.InsertEdge(addr, target, db.EdgeCondTrue) - _ = a.DB.InsertEdge(addr, addr+size, db.EdgeCondFalse) - } else { - _ = a.DB.InsertEdge(addr, target, db.EdgeUncond) + if op.Mode != m68k.EAAbsShort || a.IsInAnyProjectSegment(target) { + if ref.IsConditional { + _ = a.DB.InsertEdge(addr, target, db.EdgeCondTrue) + _ = a.DB.InsertEdge(addr, addr+size, db.EdgeCondFalse) + } else { + _ = a.DB.InsertEdge(addr, target, db.EdgeUncond) + } + a.AddLabel(&src, target) + } else if a.IsInMemoryRegion(target) { + a.AddLabel(&src, target) } - a.AddLabel(&src, target) if a.IsInCodeSegment(target) { if _, seen := visited[target]; !seen { worklist = append(worklist, workItem{addr: target, confidence: confidence}) @@ -215,22 +223,32 @@ func (a *Analyzer) stage2DiscoverCode(initialEntries []uint32, jumpTable map[uin case RefDataAddr: target := ref.Target _ = a.DB.InsertXref(addr, target, db.XrefDataAddr) - a.AddLabel(&src, target) - if a.IsInCodeSegment(target) && target%2 == 0 { - candidateCode = append(candidateCode, target) + if (op.Mode != m68k.EAAbsShort && a.IsInAnyProjectSegment(target)) || a.IsInMemoryRegion(target) { + a.AddLabel(&src, target) + if op.Mode != m68k.EAAbsShort && a.IsInCodeSegment(target) && target%2 == 0 { + candidateCode = append(candidateCode, target) + } } case RefDataRead: target := ref.Target _ = a.DB.InsertXref(addr, target, db.XrefDataRead) - a.AddLabel(&src, target) - dataReadTargets[target] = struct{}{} + if (op.Mode != m68k.EAAbsShort && a.IsInAnyProjectSegment(target)) || a.IsInMemoryRegion(target) { + a.AddLabel(&src, target) + if op.Mode != m68k.EAAbsShort && a.IsInAnyProjectSegment(target) { + dataReadTargets[target] = struct{}{} + } + } case RefDataWrite: target := ref.Target _ = a.DB.InsertXref(addr, target, db.XrefDataWrite) - a.AddLabel(&src, target) - dataReadTargets[target] = struct{}{} + if (op.Mode != m68k.EAAbsShort && a.IsInAnyProjectSegment(target)) || a.IsInMemoryRegion(target) { + a.AddLabel(&src, target) + if op.Mode != m68k.EAAbsShort && a.IsInAnyProjectSegment(target) { + dataReadTargets[target] = struct{}{} + } + } case RefIndirect: if _, proc := processedIndirect[addr]; !proc { @@ -363,14 +381,26 @@ func (a *Analyzer) stage2DiscoverCode(initialEntries []uint32, jumpTable map[uin // 6. Verify no CODE cell swallows an interior label in `labels` allLabelAddrs := a.DB.LabelAddrs(nil) var invalidCodeAddrs []uint32 + var bogusInteriorLabels []uint32 codeCells.Ascend(func(cell db.Cell) bool { cEnd := cell.Addr + cell.Size idx, _ := slices.BinarySearch(allLabelAddrs, cell.Addr+1) if idx < len(allLabelAddrs) && allLabelAddrs[idx] < cEnd { + lblAddr := allLabelAddrs[idx] + if _, isReloc := a.RelocTargets[lblAddr]; !isReloc && cell.Confidence <= ConfHigh { + if lblObj, err := a.DB.GetLabelFull(lblAddr); err == nil && lblObj != nil && lblObj.IsAuto { + bogusInteriorLabels = append(bogusInteriorLabels, lblAddr) + return true + } + } invalidCodeAddrs = append(invalidCodeAddrs, cell.Addr) } return true }) + for _, lAddr := range bogusInteriorLabels { + _ = a.DB.DeleteLabelAt(lAddr) + delete(a.LabelsCache, lAddr) + } for _, cAddr := range invalidCodeAddrs { codeCells.Delete(db.Cell{Addr: cAddr}) } diff --git a/internal/analyzer/stage4_funcmeta.go b/internal/analyzer/stage4_funcmeta.go index 10e5994..1887046 100644 --- a/internal/analyzer/stage4_funcmeta.go +++ b/internal/analyzer/stage4_funcmeta.go @@ -2,6 +2,7 @@ package analyzer import ( "cmp" + "fmt" "slices" "github.com/mikolas/hunks/internal/db" @@ -199,6 +200,79 @@ func (a *Analyzer) stage4FunctionMeta() { } a.pruneNoreturnFallthroughs(allEdges, cellByAddr, noreturn) + a.annotateSMC(cells, cellByAddr, entries) +} + +func (a *Analyzer) annotateSMC(codeCells []db.Cell, cellByAddr map[uint32]*db.Cell, entries []uint32) { + if len(codeCells) == 0 { + return + } + slices.SortFunc(codeCells, func(x, y db.Cell) int { + return cmp.Compare(x.Addr, y.Addr) + }) + findCodeCell := func(addr uint32) *db.Cell { + lo, hi := 0, len(codeCells) + for lo < hi { + mid := int(uint(lo+hi) >> 1) + if codeCells[mid].Addr <= addr { + lo = mid + 1 + } else { + hi = mid + } + } + if lo > 0 && addr < codeCells[lo-1].Addr+codeCells[lo-1].Size { + return &codeCells[lo-1] + } + return nil + } + findEnclosingFunc := func(addr uint32) (uint32, bool) { + lo, hi := 0, len(entries) + for lo < hi { + mid := int(uint(lo+hi) >> 1) + if entries[mid] <= addr { + lo = mid + 1 + } else { + hi = mid + } + } + if lo > 0 && a.IsSameSegment(entries[lo-1], addr) { + return entries[lo-1], true + } + return 0, false + } + + for _, x := range a.DB.AllXrefs() { + if x.Kind != db.XrefDataWrite { + continue + } + if cellByAddr[x.Source] == nil { + continue + } + tgtCell := findCodeCell(x.Target) + if tgtCell == nil { + continue + } + var frag string + if lbl, ok := a.LabelsCache[tgtCell.Addr]; ok && lbl != "" { + off := x.Target - tgtCell.Addr + if off > 0 { + frag = fmt.Sprintf("[SMC] writes code at %s+%X", lbl, off) + } else { + frag = fmt.Sprintf("[SMC] writes code at %s", lbl) + } + } else if fnEntry, ok := findEnclosingFunc(x.Target); ok { + fnLbl := a.GetOrDefaultLabel(fnEntry) + off := x.Target - fnEntry + if off > 0 { + frag = fmt.Sprintf("[SMC] writes code at %s+%X", fnLbl, off) + } else { + frag = fmt.Sprintf("[SMC] writes code at %s", fnLbl) + } + } else { + frag = fmt.Sprintf("[SMC] writes code at $%X", x.Target) + } + a.appendCellComment(x.Source, frag) + } } // pruneNoreturnFallthroughs removes the FALLTHROUGH edge inserted after a `jsr`/`bsr` diff --git a/internal/analyzer/trace.go b/internal/analyzer/trace.go index eace912..5ff898f 100644 --- a/internal/analyzer/trace.go +++ b/internal/analyzer/trace.go @@ -381,6 +381,12 @@ func RunTrace(image []byte, base, entry uint32, opts TraceOptions) TraceReport { var lastBranchDAR [16]uint32 var frozenReps uint64 + var outsideSteps int + var hasTrampOrigin bool + var trampOriginPC uint32 + var trampFirstOutPC uint32 + var trampOriginDisp Dispatch + var stop StopReason for { if steps >= opts.Budget { @@ -389,19 +395,32 @@ func RunTrace(image []byte, base, entry uint32, opts TraceOptions) TraceReport { } curRegs := cpu.Registers() pc := curRegs.PC & 0x00FFFFFF - if uint64(pc) < uint64(base) || uint64(pc) >= imgEnd { - stop = StopReason{Kind: StopLeftImage, To: pc, AtPC: pc, Step: steps} - break + inImage := uint64(pc) >= uint64(base) && uint64(pc) < imgEnd + if !inImage { + if outsideSteps == 0 { + trampFirstOutPC = pc + } + outsideSteps++ + if outsideSteps > 8 || pc%2 != 0 || uint64(pc) >= uint64(traceRAMLen) || dev.readWord(pc) == 0 { + stop = StopReason{Kind: StopLeftImage, To: trampFirstOutPC, AtPC: trampFirstOutPC, Step: steps} + break + } + } else { + outsideSteps = 0 + pcLo = min(pcLo, pc) + pcHi = max(pcHi, pc) } - pcLo = min(pcLo, pc) - pcHi = max(pcHi, pc) op := dev.readWord(pc) - dispatch, hasDispatch := indirectDispatch(op) + var dispatch Dispatch + var hasDispatch bool var darPre [16]uint32 - copy(darPre[0:8], curRegs.D[:]) - copy(darPre[8:16], curRegs.A[:]) - regs[pc] = darPre + if inImage { + dispatch, hasDispatch = indirectDispatch(op) + copy(darPre[0:8], curRegs.D[:]) + copy(darPre[8:16], curRegs.A[:]) + regs[pc] = darPre + } // Check self-installed vs unhandled TRAP / A-line / F-line before stepping. if (op & 0xF000) == 0xA000 { @@ -467,13 +486,47 @@ func RunTrace(image []byte, base, entry uint32, opts TraceOptions) TraceReport { } } - if hasDispatch { - sa, ok := sitesMap[pc] - if !ok { - sa = &siteAccum{disp: dispatch, targets: make(map[uint32]uint64)} - sitesMap[pc] = sa + newInImage := uint64(newPC) >= uint64(base) && uint64(newPC) < imgEnd + if inImage { + if newInImage { + hasTrampOrigin = false + if hasDispatch { + sa, ok := sitesMap[pc] + if !ok { + sa = &siteAccum{disp: dispatch, targets: make(map[uint32]uint64)} + sitesMap[pc] = sa + } + sa.targets[newPC]++ + } + } else { + trampOriginPC = pc + trampFirstOutPC = newPC + if hasDispatch { + hasTrampOrigin = true + trampOriginDisp = dispatch + } else if (op&0xFFC0) == 0x4E80 || (op&0xFFC0) == 0x4EC0 || (op&0xFF00) == 0x6000 || (op&0xFF00) == 0x6100 { + hasTrampOrigin = true + isCall := (op&0xFFC0) == 0x4E80 || (op&0xFF00) == 0x6100 + mode := m68k.EAAbsShort + if (op & 0x003F) == 0x0039 { + mode = m68k.EAAbsLong + } + trampOriginDisp = Dispatch{Mode: mode, IsCall: isCall, BaseAn: -1} + } else { + hasTrampOrigin = false + } + } + } else if newInImage && hasTrampOrigin { + // Bounced back into image from an out-of-image trampoline! + if op != 0x4E75 && op != 0x4E73 && op != 0x4E77 { + sa, ok := sitesMap[trampOriginPC] + if !ok { + sa = &siteAccum{disp: trampOriginDisp, targets: make(map[uint32]uint64)} + sitesMap[trampOriginPC] = sa + } + sa.targets[newPC]++ } - sa.targets[newPC]++ + hasTrampOrigin = false } if op == 0x4E72 { @@ -562,7 +615,7 @@ func RunTrace(image []byte, base, entry uint32, opts TraceOptions) TraceReport { func isResolvableDispatchMode(mode m68k.EAMode) bool { switch mode { - case m68k.EAAddrInd, m68k.EADisp16, m68k.EAIndex, m68k.EAPCIndex: + case m68k.EAAddrInd, m68k.EADisp16, m68k.EAIndex, m68k.EAPCIndex, m68k.EAAbsShort, m68k.EAAbsLong: return true default: return false @@ -609,6 +662,8 @@ func (a *Analyzer) ApplyTraceDispatches(sites []DispatchSite) { if s.IsCall { edge = db.EdgeCall xref = db.XrefCodeCall + labelName := a.GetOrDefaultLabel(target) + _ = a.DB.InsertFunction(target, labelName, ConfHigh) } _ = a.DB.InsertEdge(s.Addr, target, edge) _ = a.DB.InsertXref(s.Addr, target, xref) diff --git a/internal/callgraph/callgraph.go b/internal/callgraph/callgraph.go index 676dc44..aebef97 100644 --- a/internal/callgraph/callgraph.go +++ b/internal/callgraph/callgraph.go @@ -38,28 +38,126 @@ func (p AddrStringPair) MarshalJSON() ([]byte, error) { } type FunctionNode struct { - EntryAddr uint32 `json:"entry_addr"` - Name string `json:"name"` - RawName string `json:"raw_name"` - IsRenamed bool `json:"is_renamed"` - IsExported bool `json:"is_exported"` - Segment string `json:"segment"` - OffsetInSeg uint32 `json:"offset_in_seg"` - InsnAddrs []uint32 `json:"insn_addrs"` - ByteSize uint32 `json:"byte_size"` - SelfMinCycles uint32 `json:"self_min_cycles"` - SelfMaxCycles uint32 `json:"self_max_cycles"` - InclMinCycles uint32 `json:"incl_min_cycles"` - InclMaxCycles uint32 `json:"incl_max_cycles"` - HasLoop bool `json:"has_loop"` - HasIndirect bool `json:"has_indirect"` - BlockComment *string `json:"block_comment"` - Callers []CallSite `json:"callers"` - Callees []CallSite `json:"callees"` - Strings []AddrStringPair `json:"strings"` - HWRegs []string `json:"hw_regs"` - IndirectCalls []AddrStringPair `json:"indirect_calls"` - SuspectCutoff *AddrStringPair `json:"suspect_cutoff"` + EntryAddr uint32 `json:"entry_addr"` + Name string `json:"name"` + RawName string `json:"raw_name"` + IsRenamed bool `json:"is_renamed"` + IsExported bool `json:"is_exported"` + Segment string `json:"segment"` + OffsetInSeg uint32 `json:"offset_in_seg"` + InsnAddrs []uint32 `json:"insn_addrs"` + ByteSize uint32 `json:"byte_size"` + SelfMinCycles uint32 `json:"self_min_cycles"` + SelfMaxCycles uint32 `json:"self_max_cycles"` + InclMinCycles uint32 `json:"incl_min_cycles"` + InclMaxCycles uint32 `json:"incl_max_cycles"` + HasLoop bool `json:"has_loop"` + HasIndirect bool `json:"has_indirect"` + HasSMC bool `json:"has_smc,omitempty"` + ABI string `json:"abi,omitempty"` + InRegs []string `json:"in_regs,omitempty"` + ModRegs []string `json:"mod_regs,omitempty"` + SavedRegs []string `json:"saved_regs,omitempty"` + BlockComment *string `json:"block_comment"` + Callers []CallSite `json:"callers"` + Callees []CallSite `json:"callees"` + Strings []AddrStringPair `json:"strings"` + HWRegs []string `json:"hw_regs"` + GlobalsRead []AddrStringPair `json:"globals_read,omitempty"` + GlobalsWritten []AddrStringPair `json:"globals_written,omitempty"` + DataAddrs []AddrStringPair `json:"data_addrs,omitempty"` + IndirectCalls []AddrStringPair `json:"indirect_calls"` + SuspectCutoff *AddrStringPair `json:"suspect_cutoff"` +} + +func regMaskNames(mask m68k.RegMask) []string { + var out []string + for r := uint8(0); r < 8; r++ { + if (mask & m68k.RegD(r)) != 0 { + out = append(out, m68k.RegDName(r)) + } + } + for r := uint8(0); r < 7; r++ { + if (mask & m68k.RegA(r)) != 0 { + out = append(out, m68k.RegAName(r)) + } + } + return out +} + +func (f *FunctionNode) StaticABISummary() string { + var parts []string + if len(f.InRegs) > 0 { + parts = append(parts, "in: "+strings.Join(f.InRegs, ",")) + } else { + parts = append(parts, "in: none") + } + if len(f.ModRegs) > 0 { + parts = append(parts, "mod: "+strings.Join(f.ModRegs, ",")) + } else { + parts = append(parts, "mod: none") + } + if len(f.SavedRegs) > 0 { + parts = append(parts, "saved: "+strings.Join(f.SavedRegs, ",")) + } + return strings.Join(parts, " | ") +} + +// VerifyABI checks a free-form ABI string against statically inferred InRegs and ModRegs +// and returns human-readable contradiction warnings (or nil if consistent). +func VerifyABI(abiStr string, inRegs, modRegs, savedRegs []string) []string { + lower := strings.ToLower(abiStr) + inSection := lower + var modSection string + for _, sep := range []string{"clobbers:", "clobber:", "mod:", "modifies:"} { + if before, after, ok := strings.Cut(inSection, sep); ok { + inSection = before + modSection = after + break + } + } + if before, _, ok := strings.Cut(inSection, "->"); ok { + inSection = before + } + + extractRegs := func(s string) []string { + tokens := strings.FieldsFunc(s, func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) + var regs []string + for _, tok := range tokens { + if len(tok) == 2 && (tok[0] == 'd' || tok[0] == 'a') && tok[1] >= '0' && tok[1] <= '6' { + if !slices.Contains(regs, tok) { + regs = append(regs, tok) + } + } + } + return regs + } + + var warnings []string + for _, r := range extractRegs(inSection) { + if !slices.Contains(inRegs, r) && !slices.Contains(modRegs, r) && !slices.Contains(savedRegs, r) { + warnings = append(warnings, fmt.Sprintf("claimed input register %s is never read in function body", r)) + } + } + for _, r := range extractRegs(modSection) { + if !slices.Contains(modRegs, r) { + warnings = append(warnings, fmt.Sprintf("claimed clobbered register %s is never written (or is callee-saved)", r)) + } + } + return warnings +} + +type IndirectSiteInfo struct { + Addr uint32 `json:"addr"` + Segment string `json:"segment"` + OffsetInSeg uint32 `json:"offset_in_seg"` + FuncName string `json:"func_name"` + FnOffset uint32 `json:"fn_offset"` + InsnText string `json:"insn"` + Status string `json:"status"` + Targets []string `json:"targets"` } func formatCycleSpan(lo, hi uint32) string { @@ -106,14 +204,15 @@ func (f *FunctionNode) FormatLoc() string { } type CallGraph struct { - Functions map[uint32]*FunctionNode - FuncAddrs []uint32 - InsnToFunc map[uint32]uint32 - Cells map[uint32]db.Cell - Comments map[uint32]db.AddressComment - CellsTree *btree.BTreeG[db.Cell] - Labels map[uint32]string - RawLabels map[uint32]string + Functions map[uint32]*FunctionNode + FuncAddrs []uint32 + InsnToFunc map[uint32]uint32 + Cells map[uint32]db.Cell + Comments map[uint32]db.AddressComment + CellsTree *btree.BTreeG[db.Cell] + Labels map[uint32]string + RawLabels map[uint32]string + IndirectSites []IndirectSiteInfo } type Options struct { @@ -126,6 +225,7 @@ type Options struct { Anchors bool Ready bool Suspect bool + Indirect bool Unrenamed bool Limit int Format string @@ -159,6 +259,7 @@ func Build(proj *project.Project) (*CallGraph, error) { xrefsBySrc := make(map[uint32][]AddrStringPair) pointerTargets := make(map[uint32]bool) jmpTableBases := make(map[string]uint32) + abis := make(map[uint32]string) seenDBs := make(map[string]bool) for _, seg := range proj.ActiveSegments() { @@ -215,6 +316,7 @@ func Build(proj *project.Project) (*CallGraph, error) { }) } maps.Copy(jmpTableBases, ctrl.DetectJumpVectorBases()) + maps.Copy(abis, ctrl.GetAllFunctionABIs()) _ = ctrl.Close() } @@ -391,11 +493,16 @@ func Build(proj *project.Project) (*CallGraph, error) { stringMap := make(map[uint32]string) hwSet := make(map[string]bool) + globalsReadMap := make(map[uint32]string) + globalsWriteMap := make(map[uint32]string) + dataAddrMap := make(map[uint32]string) var indirectCalls []AddrStringPair var selfMinCyc, selfMaxCyc uint32 + var inMask, modMask, savedMask m68k.RegMask hasLoop := false + hasSMC := false - for _, ia := range insnAddrs { + for insnIdx, ia := range insnAddrs { cell, ok := cells[ia] if !ok { continue @@ -417,6 +524,20 @@ func Build(proj *project.Project) (*CallGraph, error) { } } if insn, ok := m68k.Decode(raw, ia); ok { + isEntrySave := insnIdx <= 2 && insn.Op == m68k.OpMOVEM && insn.NumOps == 2 && insn.Ops[1].Mode == m68k.EAPreDec && insn.Ops[1].Reg == 7 + isExitRestore := insn.Op == m68k.OpMOVEM && insn.NumOps == 2 && insn.Ops[0].Mode == m68k.EAPostInc && insn.Ops[0].Reg == 7 + if isEntrySave { + savedMask |= insn.ReadRegs &^ m68k.RegA(7) + } else if !isExitRestore { + rMask := insn.ReadRegs &^ m68k.RegA(7) + if (insn.Op == m68k.OpSUB || insn.Op == m68k.OpEOR) && insn.NumOps == 2 && + insn.Ops[0].Mode == insn.Ops[1].Mode && insn.Ops[0].Reg == insn.Ops[1].Reg { + rMask = 0 + } + inMask |= rMask &^ modMask + modMask |= insn.WriteRegs &^ m68k.RegA(7) + } + t := insn.Timing() switch t.Kind { case m68k.TimingBranch, m68k.TimingRange: @@ -469,18 +590,41 @@ func Build(proj *project.Project) (*CallGraph, error) { hwSet[tok] = true } } + if ac, ok := comments[ia]; ok && strings.Contains(ac.EffectiveComment(), "[SMC]") { + hasSMC = true + } for subAddr := ia; subAddr < ia+cell.Size; subAddr++ { for _, xr := range xrefsBySrc[subAddr] { + if _, isHW := amiga.GetAmigaHWLabel(xr.Addr); isHW { + continue + } + if xr.Addr >= 0xDFF000 && xr.Addr <= 0xDFF1FF { + continue + } if tgtCell, ok := cells[xr.Addr]; ok && tgtCell.Type == db.CellStr { sVal := extractStringPreview(proj, xr.Addr, &tgtCell, segDataCache) if sVal != "" { stringMap[xr.Addr] = sVal + continue + } + } + lbl := labels[xr.Addr] + switch xr.Text { + case "DATA_READ": + globalsReadMap[xr.Addr] = lbl + case "DATA_WRITE": + globalsWriteMap[xr.Addr] = lbl + if tgtCell, ok := cells[xr.Addr]; ok && tgtCell.Type == db.CellCode { + hasSMC = true } + case "DATA_ADDR": + dataAddrMap[xr.Addr] = lbl } } } } + modMask &^= savedMask strAddrs := slices.Sorted(maps.Keys(stringMap)) stringsList := make([]AddrStringPair, len(strAddrs)) @@ -488,6 +632,18 @@ func Build(proj *project.Project) (*CallGraph, error) { stringsList[i] = AddrStringPair{Addr: a, Text: stringMap[a]} } + toSortedPairs := func(m map[uint32]string) []AddrStringPair { + if len(m) == 0 { + return nil + } + keys := slices.Sorted(maps.Keys(m)) + res := make([]AddrStringPair, len(keys)) + for i, a := range keys { + res[i] = AddrStringPair{Addr: a, Text: m[a]} + } + return res + } + hwRegs := slices.Sorted(maps.Keys(hwSet)) if indirectCalls == nil { @@ -500,28 +656,36 @@ func Build(proj *project.Project) (*CallGraph, error) { } return &FunctionNode{ - EntryAddr: entryAddr, - Name: name, - RawName: rawName, - IsRenamed: isRenamed, - IsExported: isExported, - Segment: segName, - OffsetInSeg: offsetInSeg, - InsnAddrs: insnAddrs, - ByteSize: byteSize, - SelfMinCycles: selfMinCyc, - SelfMaxCycles: selfMaxCyc, - InclMinCycles: selfMinCyc, - InclMaxCycles: selfMaxCyc, - HasLoop: hasLoop, - HasIndirect: len(indirectCalls) > 0, - BlockComment: blockComment, - Callers: []CallSite{}, - Callees: []CallSite{}, - Strings: stringsList, - HWRegs: hwRegs, - IndirectCalls: indirectCalls, - SuspectCutoff: suspectCutoff, + EntryAddr: entryAddr, + Name: name, + RawName: rawName, + IsRenamed: isRenamed, + IsExported: isExported, + Segment: segName, + OffsetInSeg: offsetInSeg, + InsnAddrs: insnAddrs, + ByteSize: byteSize, + SelfMinCycles: selfMinCyc, + SelfMaxCycles: selfMaxCyc, + InclMinCycles: selfMinCyc, + InclMaxCycles: selfMaxCyc, + HasLoop: hasLoop, + HasIndirect: len(indirectCalls) > 0, + HasSMC: hasSMC, + ABI: abis[entryAddr], + InRegs: regMaskNames(inMask), + ModRegs: regMaskNames(modMask), + SavedRegs: regMaskNames(savedMask), + BlockComment: blockComment, + Callers: []CallSite{}, + Callees: []CallSite{}, + Strings: stringsList, + HWRegs: hwRegs, + GlobalsRead: toSortedPairs(globalsReadMap), + GlobalsWritten: toSortedPairs(globalsWriteMap), + DataAddrs: toSortedPairs(dataAddrMap), + IndirectCalls: indirectCalls, + SuspectCutoff: suspectCutoff, } } @@ -705,15 +869,116 @@ func Build(proj *project.Project) (*CallGraph, error) { f.HasIndirect = res.hasIndirect } + // Collect all indirect/trampoline dispatch sites across active functions. + indirectSiteSet := make(map[uint32]bool) + for _, s := range proj.Data.Analysis.IndirectAddressing { + if a, err := project.ParseHex(s); err == nil { + indirectSiteSet[a] = true + } + } + for _, fAddr := range funcAddrs { + f := functions[fAddr] + for _, ic := range f.IndirectCalls { + indirectSiteSet[ic.Addr] = true + } + for _, ia := range f.InsnAddrs { + if c, ok := cells[ia]; ok { + mnem := c.MnemonicStr() + opStr := c.OpStrVal() + if mnem == "jsr" || mnem == "jmp" { + if strings.Contains(opStr, "(") { + indirectSiteSet[ia] = true + } + } + } + } + } + var indirectSites []IndirectSiteInfo + for _, ia := range slices.Sorted(maps.Keys(indirectSiteSet)) { + c, ok := cells[ia] + if !ok || c.Type != db.CellCode { + continue + } + seg := proj.SegmentAtAddr(ia) + segName := "?" + var offInSeg uint32 + if seg != nil { + segName = seg.Name + if ia >= seg.Base { + offInSeg = ia - seg.Base + } + } + var fnName string + var fnOff uint32 + if fAddr, ok := insnToFunc[ia]; ok { + if f, ok := functions[fAddr]; ok { + if ia >= f.EntryAddr { + fnName = f.QualifiedName() + fnOff = ia - f.EntryAddr + } else if lbl, hasLbl := labels[ia]; hasLbl && lbl != "" { + fnName = segName + ":" + lbl + fnOff = 0 + } else if len(f.InsnAddrs) > 0 && ia >= f.InsnAddrs[0] { + startLbl := labels[f.InsnAddrs[0]] + if startLbl == "" { + startLbl = proj.AutoLabel(f.InsnAddrs[0]) + } + fnName = segName + ":" + startLbl + fnOff = ia - f.InsnAddrs[0] + } else { + fnName = f.QualifiedName() + } + } + } + mnem := c.MnemonicStr() + opStr := c.OpStrVal() + var targets []string + for _, e := range edgesBySrc[ia] { + if e.kind == db.EdgeCall || e.kind == db.EdgeUncond { + lbl := labels[e.target] + if lbl == "" { + lbl = proj.AutoLabel(e.target) + } + if !slices.Contains(targets, lbl) { + targets = append(targets, lbl) + } + } + } + if len(targets) == 0 { + if _, _, realTgt, ok := resolveVectoredCall(opStr); ok { + lbl := labels[realTgt] + if lbl == "" { + lbl = proj.AutoLabel(realTgt) + } + targets = append(targets, lbl) + } + } + status := "unresolved" + if len(targets) > 0 { + status = "resolved" + } + indirectSites = append(indirectSites, IndirectSiteInfo{ + Addr: ia, + Segment: segName, + OffsetInSeg: offInSeg, + FuncName: fnName, + FnOffset: fnOff, + InsnText: strings.TrimSpace(mnem + " " + opStr), + Status: status, + Targets: targets, + }) + } + return &CallGraph{ - Functions: functions, - FuncAddrs: funcAddrs, - InsnToFunc: insnToFunc, - Cells: cells, - Comments: comments, - CellsTree: cellsTree, - Labels: labels, - RawLabels: rawLabels, + Functions: functions, + FuncAddrs: funcAddrs, + InsnToFunc: insnToFunc, + Cells: cells, + Comments: comments, + CellsTree: cellsTree, + Labels: labels, + RawLabels: rawLabels, + IndirectSites: indirectSites, }, nil } @@ -907,6 +1172,9 @@ func (cg *CallGraph) ResolveTarget(proj *project.Project, target string, segFilt func (cg *CallGraph) Render(proj *project.Project, opts *Options) (string, error) { normSeg := NormalizeSegmentFilter(opts.Segment) + if opts.Indirect { + return cg.renderIndirect(opts, normSeg) + } if opts.Target != "" { funcAddr, ok := cg.ResolveTarget(proj, opts.Target, normSeg) if !ok { @@ -917,6 +1185,49 @@ func (cg *CallGraph) Render(proj *project.Project, opts *Options) (string, error return cg.renderWorklist(opts, normSeg) } +func (cg *CallGraph) renderIndirect(opts *Options, normSeg string) (string, error) { + var sites []IndirectSiteInfo + var resolvedCnt, unresolvedCnt int + for _, s := range cg.IndirectSites { + if normSeg != "" && !strings.EqualFold(s.Segment, normSeg) { + continue + } + if s.Status == "resolved" { + resolvedCnt++ + } else { + unresolvedCnt++ + } + sites = append(sites, s) + } + if opts.Format == "json" { + payload := map[string]any{ + "total": len(sites), + "resolved": resolvedCnt, + "unresolved": unresolvedCnt, + "sites": sites, + } + b, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return "", err + } + return string(b), nil + } + var out strings.Builder + fmt.Fprintf(&out, "=== Indirect Dispatch Sites (%d total: %d resolved, %d unresolved) ===\n", len(sites), resolvedCnt, unresolvedCnt) + for _, s := range sites { + fnCtx := "" + if s.FuncName != "" { + fnCtx = fmt.Sprintf("[%s+%X]", s.FuncName, s.FnOffset) + } + if s.Status == "resolved" { + fmt.Fprintf(&out, " +%05X %-24s %-22s -> %s (resolved)\n", s.OffsetInSeg, fnCtx, s.InsnText, strings.Join(s.Targets, ", ")) + } else { + fmt.Fprintf(&out, " +%05X %-24s %-22s -> [UNRESOLVED]\n", s.OffsetInSeg, fnCtx, s.InsnText) + } + } + return out.String(), nil +} + func (cg *CallGraph) renderTarget(proj *project.Project, funcAddr uint32, opts *Options) (string, error) { f := cg.Functions[funcAddr] segBase := f.EntryAddr - f.OffsetInSeg @@ -924,6 +1235,7 @@ func (cg *CallGraph) renderTarget(proj *project.Project, funcAddr uint32, opts * if opts.Format == "json" { type codeLine struct { Offset string `json:"offset"` + FnOffset string `json:"fn_offset"` Label string `json:"label"` Mnemonic string `json:"mnemonic"` Operands string `json:"operands"` @@ -933,12 +1245,16 @@ func (cg *CallGraph) renderTarget(proj *project.Project, funcAddr uint32, opts * if opts.Code { for _, ia := range f.InsnAddrs { if c, ok := cg.Cells[ia]; ok { - var off uint32 + var off, fnOff uint32 if ia > segBase { off = ia - segBase } + if ia > f.EntryAddr { + fnOff = ia - f.EntryAddr + } codeLines = append(codeLines, codeLine{ Offset: fmt.Sprintf("+%05X", off), + FnOffset: fmt.Sprintf("+%03X", fnOff), Label: cg.Labels[ia], Mnemonic: c.MnemonicStr(), Operands: c.OpStrVal(), @@ -1004,6 +1320,12 @@ func (cg *CallGraph) renderTarget(proj *project.Project, funcAddr uint32, opts * fmt.Fprintf(&out, " ; %s\n", line) } } + if f.ABI != "" { + fmt.Fprintf(&out, " ABI: %s\n", f.ABI) + } + if staticABI := f.StaticABISummary(); staticABI != "" { + fmt.Fprintf(&out, " Regs (static): %s\n", staticABI) + } if len(f.Strings) > 0 { strs := make([]string, len(f.Strings)) for i, sp := range f.Strings { @@ -1053,6 +1375,35 @@ func (cg *CallGraph) renderTarget(proj *project.Project, funcAddr uint32, opts * cg.fmtCalleesTree(&out, funcAddr, 1, opts.Depth, visited) } + if len(f.GlobalsRead) > 0 || len(f.GlobalsWritten) > 0 || len(f.DataAddrs) > 0 { + out.WriteString("\n=== Globals & Memory Flow ===\n") + fmtGlobals := func(items []AddrStringPair) string { + limit := min(12, len(items)) + parts := make([]string, limit) + for i, p := range items[:limit] { + if p.Text != "" { + parts[i] = fmt.Sprintf("$%X (%s)", p.Addr, p.Text) + } else { + parts[i] = fmt.Sprintf("$%X", p.Addr) + } + } + s := strings.Join(parts, ", ") + if len(items) > limit { + s += fmt.Sprintf(" (+%d more)", len(items)-limit) + } + return s + } + if len(f.GlobalsRead) > 0 { + fmt.Fprintf(&out, " Reads: %s\n", fmtGlobals(f.GlobalsRead)) + } + if len(f.GlobalsWritten) > 0 { + fmt.Fprintf(&out, " Writes: %s\n", fmtGlobals(f.GlobalsWritten)) + } + if len(f.DataAddrs) > 0 { + fmt.Fprintf(&out, " Addresses: %s\n", fmtGlobals(f.DataAddrs)) + } + } + // 4. Optional Disassembly & Caller Context (--code) if opts.Code { out.WriteString("\n=== Disassembly ===\n") @@ -1070,23 +1421,31 @@ func (cg *CallGraph) renderTarget(proj *project.Project, funcAddr uint32, opts * if eff := cg.Comments[ia].EffectiveComment(); eff != "" { cmt = fmt.Sprintf(" ; %s", eff) } - if cmt == "" { - for _, site := range f.Callees { - if site.CallInsn == ia { - if tf, ok := cg.Functions[site.TargetFunc]; ok { - if tf.Segment != f.Segment || strings.Contains(ops, "(") { - cmt = fmt.Sprintf(" ; -> %s", tf.QualifiedName()) + for _, site := range f.Callees { + if site.CallInsn == ia { + if tf, ok := cg.Functions[site.TargetFunc]; ok { + if cmt == "" && (tf.Segment != f.Segment || strings.Contains(ops, "(")) { + cmt = fmt.Sprintf(" ; -> %s", tf.QualifiedName()) + } + if tf.ABI != "" && !strings.Contains(cmt, "ABI:") { + if cmt == "" { + cmt = fmt.Sprintf(" ; [ABI: %s]", tf.ABI) + } else { + cmt += fmt.Sprintf(" [ABI: %s]", tf.ABI) } } - break } + break } } - var off uint32 + var off, fnOff uint32 if ia > segBase { off = ia - segBase } - fmt.Fprintf(&out, " +%05X %-8s %s%s\n", off, mnem, ops, cmt) + if ia > f.EntryAddr { + fnOff = ia - f.EntryAddr + } + fmt.Fprintf(&out, " +%05X (+%03X) %-8s %s%s\n", off, fnOff, mnem, ops, cmt) } } if f.SuspectCutoff != nil { @@ -1137,13 +1496,18 @@ func (cg *CallGraph) renderTarget(proj *project.Project, funcAddr uint32, opts * if ca == site.CallInsn { marker = ">>" } + var cmt string + if eff := cg.Comments[ca].EffectiveComment(); eff != "" { + cmt = fmt.Sprintf(" ; %s", eff) + } fmt.Fprintf( &out, - " %s +%05X: %-8s %s\n", + " %s +%05X: %-8s %s%s\n", marker, ca-cfSegBase, cell.MnemonicStr(), cell.OpStrVal(), + cmt, ) } return true @@ -1228,6 +1592,12 @@ func (cg *CallGraph) fmtCalleesTree( n := min(4, len(tf.HWRegs)) hwTag = fmt.Sprintf(" | HW: %s", strings.Join(tf.HWRegs[:n], ",")) } + abiTag := "" + if tf.ABI != "" { + abiTag = fmt.Sprintf(" | ABI: %s", tf.ABI) + } else if len(tf.InRegs) > 0 { + abiTag = fmt.Sprintf(" | in:%s", strings.Join(tf.InRegs, ",")) + } cmtTag := "" if tf.BlockComment != nil && *tf.BlockComment != "" { firstLine, _, _ := strings.Cut(*tf.BlockComment, "\n") @@ -1240,13 +1610,14 @@ func (cg *CallGraph) fmtCalleesTree( } fmt.Fprintf( out, - "%s├──► %s (%s | %d insts%s%s%s)%s\n", + "%s├──► %s (%s | %d insts%s%s%s%s)%s\n", indent, tf.QualifiedName(), site.Kind, len(tf.InsnAddrs), leafTag, hwTag, + abiTag, cyc, cmtTag, ) diff --git a/internal/callgraph/callgraph_test.go b/internal/callgraph/callgraph_test.go index a25436c..7133e19 100644 --- a/internal/callgraph/callgraph_test.go +++ b/internal/callgraph/callgraph_test.go @@ -1,6 +1,7 @@ package callgraph import ( + "os" "path/filepath" "slices" "strings" @@ -247,3 +248,128 @@ func TestCallGraphInferenceAndModes(t *testing.T) { t.Fatalf("overview missing callers: %s", overview) } } + +func TestCallGraphStaticRegsABIAndDualOffsets(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "project.db") + + // 0x1000: movem.l d2-d3,-(a7) 48 E7 30 00 (4B) + // 0x1004: move.w 2(a0),d0 30 28 00 02 (4B) -> reads a0, writes d0 + // 0x1008: add.w $1034.w,d0 D0 78 10 34 (4B) -> reads $1034, rw d0 + // 0x100C: move.w d0,$1038.w 31 C0 10 38 (4B) -> reads d0, writes $1038 + // 0x1010: movem.l (a7)+,d2-d3 4C DF 00 0C (4B) + // 0x1014: rts 4E 75 (2B) + rawBytes := []byte{ + 0x48, 0xE7, 0x30, 0x00, + 0x30, 0x28, 0x00, 0x02, + 0xD0, 0x78, 0x10, 0x34, + 0x31, 0xC0, 0x10, 0x38, + 0x4C, 0xDF, 0x00, 0x0C, + 0x4E, 0x75, + } + binPath := filepath.Join(tmpDir, "seg.bin") + if err := os.WriteFile(binPath, rawBytes, 0o644); err != nil { + t.Fatal(err) + } + + proj := project.New(filepath.Join(tmpDir, "project.json")) + proj.Data.Segments = []project.Segment{ + { + Name: "main", + HunkIndex: 0, + HunkType: hunk.HunkTypeCode, + MemoryType: hunk.MemoryPublic, + AllocSize: uint32(len(rawBytes)), + File: &binPath, + Base: 0x1000, + DB: dbPath, + Relocatable: false, + }, + } + proj.Data.Spaces = []project.Space{{Name: "default", Segments: []string{"main"}}} + proj.Data.ActiveSpace = "default" + proj.Data.EntryPoints = []project.EntryPoint{{Addr: 0x1000, Name: "CalcStep", Segment: "main"}} + + ctrl, err := db.New(dbPath) + if err != nil { + t.Fatal(err) + } + defer ctrl.Close() + proj.SyncDBScope(ctrl) + + _ = ctrl.SetLabel(0x1000, "CalcStep", db.LabelLab, false) + _ = ctrl.InsertFunction(0x1000, "CalcStep", 0) + _ = ctrl.SetFunctionABI(0x1000, "CalcStep", "(a0=obj, a1=unused) -> d0 | clobbers: d0, d4") + + insns := []struct { + addr uint32 + size uint32 + mnem string + op string + }{ + {0x1000, 4, "movem.l", "d2-d3,-(a7)"}, + {0x1004, 4, "move.w", "2(a0),d0"}, + {0x1008, 4, "add.w", "$1034.w,d0"}, + {0x100C, 4, "move.w", "d0,$1038.w"}, + {0x1010, 4, "movem.l", "(a7)+,d2-d3"}, + {0x1014, 2, "rts", ""}, + } + for _, in := range insns { + m := in.mnem + o := in.op + _ = ctrl.InsertCell(&db.Cell{ + Seg: "main", + Addr: in.addr, + Size: in.size, + Type: db.CellCode, + Mnemonic: &m, + OpStr: &o, + RawBytes: rawBytes[in.addr-0x1000 : in.addr-0x1000+in.size], + }) + if in.addr < 0x1014 { + _ = ctrl.InsertEdge(in.addr, in.addr+in.size, db.EdgeFallthrough) + } + } + _ = ctrl.InsertXref(0x1008, 0x1034, db.XrefDataRead) + _ = ctrl.InsertXref(0x100C, 0x1038, db.XrefDataWrite) + + cg, err := Build(proj) + if err != nil { + t.Fatal(err) + } + fn := cg.Functions[0x1000] + if fn == nil { + t.Fatalf("missing function at 0x1000") + } + if !slices.Equal(fn.InRegs, []string{"a0"}) { + t.Fatalf("expected InRegs=[a0], got %v", fn.InRegs) + } + if !slices.Equal(fn.ModRegs, []string{"d0"}) { + t.Fatalf("expected ModRegs=[d0], got %v", fn.ModRegs) + } + if !slices.Equal(fn.SavedRegs, []string{"d2", "d3"}) { + t.Fatalf("expected SavedRegs=[d2 d3], got %v", fn.SavedRegs) + } + warnings := VerifyABI(fn.ABI, fn.InRegs, fn.ModRegs, fn.SavedRegs) + if len(warnings) != 2 { + t.Fatalf("expected 2 ABI verification warnings (a1 never read, d4 never modified), got %v", warnings) + } + + rendered, err := cg.Render(proj, &Options{ + Target: "CalcStep", + Code: true, + Depth: 2, + UpDepth: 2, + Limit: 50, + Format: "text", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(rendered, "=== Globals & Memory Flow ===") { + t.Fatalf("missing Globals & Memory Flow section:\n%s", rendered) + } + if !strings.Contains(rendered, "+00004 (+004) move.w") { + t.Fatalf("missing dual offset +00004 (+004) in disassembly:\n%s", rendered) + } +} diff --git a/internal/db/controller.go b/internal/db/controller.go index 30d56e3..7e89f23 100644 --- a/internal/db/controller.go +++ b/internal/db/controller.go @@ -473,6 +473,7 @@ func (c *Controller) initSchema() error { is_noreturn BOOLEAN DEFAULT 0, stack_frame INTEGER, confidence INTEGER NOT NULL DEFAULT 0, + abi TEXT NOT NULL DEFAULT '', PRIMARY KEY (seg, entry_addr) ); @@ -509,8 +510,12 @@ func (c *Controller) initSchema() error { CREATE INDEX IF NOT EXISTS idx_edges_seg_target ON edges(seg, target_addr); CREATE INDEX IF NOT EXISTS idx_xrefs_seg_target ON xrefs(seg, target_addr); CREATE INDEX IF NOT EXISTS idx_cells_seg_type ON cells(seg, type);` - _, err := c.conn.Exec(schema) - return err + if _, err := c.conn.Exec(schema); err != nil { + return err + } + // Migrate pre-existing project.db files that were created before `functions.abi` existed. + _, _ = c.conn.Exec("ALTER TABLE functions ADD COLUMN abi TEXT NOT NULL DEFAULT ''") + return nil } func (c *Controller) InsertReloc(siteAddr, targetAddr uint32) error { @@ -1035,7 +1040,10 @@ func (c *Controller) FirstXrefTargetIn(start, end uint32) (uint32, bool) { seg := c.SegOf(start) var target uint32 err := c.conn.QueryRow( - "SELECT target_addr FROM xrefs WHERE seg = ?1 AND source_addr >= ?2 AND source_addr < ?3 LIMIT 1", + `SELECT target_addr FROM xrefs + WHERE seg = ?1 AND source_addr >= ?2 AND source_addr < ?3 + ORDER BY CASE type WHEN 'CODE_CALL' THEN 0 WHEN 'CODE_BRANCH' THEN 1 WHEN 'DATA_ADDR' THEN 2 ELSE 3 END, source_addr ASC + LIMIT 1`, seg, start, end, ).Scan(&target) if err != nil { @@ -2316,3 +2324,107 @@ func (c *Controller) GetSpaceCellSpans() ([]CellSpan, error) { } return out, rows.Err() } + +// SetFunctionABI sets (or clears when abi == "") the ABI signature on a function entry, +// creating the functions row if it does not yet exist. +func (c *Controller) SetFunctionABI(entryAddr uint32, name, abi string) error { + seg := c.SegOf(entryAddr) + _, err := c.conn.Exec( + `INSERT INTO functions (seg, entry_addr, name, confidence, abi) + VALUES (?1, ?2, ?3, 0, ?4) + ON CONFLICT(seg, entry_addr) DO UPDATE SET abi = excluded.abi`, + seg, entryAddr, name, abi, + ) + return err +} + +// GetFunctionABI returns the manual ABI string recorded for entryAddr in the active space. +func (c *Controller) GetFunctionABI(entryAddr uint32) string { + seg := c.SegOf(entryAddr) + var abi sql.NullString + _ = c.conn.QueryRow( + "SELECT abi FROM functions WHERE seg = ?1 AND entry_addr = ?2", + seg, entryAddr, + ).Scan(&abi) + return abi.String +} + +// GetAllFunctionABIs returns a map of entry_addr -> non-empty manual ABI strings across the active space. +func (c *Controller) GetAllFunctionABIs() map[uint32]string { + out := make(map[uint32]string) + inClause := c.spaceSegInClause(false) + query := fmt.Sprintf( + "SELECT entry_addr, abi FROM functions WHERE seg IN (%s) AND abi != ''", + inClause, + ) + rows, err := c.conn.Query(query) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var addr uint32 + var abi sql.NullString + if rows.Scan(&addr, &abi) == nil && abi.String != "" { + out[addr] = abi.String + } + } + return out +} + +// GetXrefsToRange returns all cross-references targeting [startAddr, endAddr] in the active space. +// When externalOnly is true, references whose source_addr lies inside [startAddr, endAddr] are excluded. +func (c *Controller) GetXrefsToRange(startAddr, endAddr uint32, externalOnly bool) []Xref { + var out []Xref + inEmpty := c.spaceSegInClause(true) + extClause := "" + if externalOnly { + extClause = " AND (source_addr < ?1 OR source_addr > ?2)" + } + query := fmt.Sprintf( + "SELECT seg, source_addr, target_addr, type FROM xrefs WHERE seg IN (%s) AND target_addr >= ?1 AND target_addr <= ?2%s ORDER BY target_addr ASC, source_addr ASC", + inEmpty, extClause, + ) + rows, err := c.conn.Query(query, startAddr, endAddr) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + if x, err := scanXref(rows); err == nil { + out = append(out, x) + } + } + return out +} + +// UserCommentRow holds a non-auto inline and/or block comment created by the user or agent. +type UserCommentRow struct { + Seg string + Addr uint32 + Comment string + BlockComment string +} + +// GetUserComments returns all rows in `comments` in the active space that have a non-empty +// manual `comment` or `block_comment` (excluding auto_comment-only rows). +func (c *Controller) GetUserComments() []UserCommentRow { + var out []UserCommentRow + inEmpty := c.spaceSegInClause(true) + query := fmt.Sprintf( + "SELECT seg, addr, comment, block_comment FROM comments WHERE seg IN (%s) AND (comment != '' OR block_comment != '') ORDER BY addr ASC", + inEmpty, + ) + rows, err := c.conn.Query(query) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var r UserCommentRow + if rows.Scan(&r.Seg, &r.Addr, &r.Comment, &r.BlockComment) == nil { + out = append(out, r) + } + } + return out +} diff --git a/internal/emitter/emitter.go b/internal/emitter/emitter.go index c4c654c..bde6cb9 100644 --- a/internal/emitter/emitter.go +++ b/internal/emitter/emitter.go @@ -295,6 +295,71 @@ type equateKV struct { addr uint32 } +type structEquateKV struct { + structName string + name string + offset int64 +} + +func formatSignedEquHex(v int64) string { + if v < 0 { + return fmt.Sprintf("-$%X", -v) + } + return fmt.Sprintf("$%X", v) +} + +func sortedStructEquates(structs map[string]map[string]string) []structEquateKV { + if len(structs) == 0 { + return nil + } + var out []structEquateKV + seenNames := make(map[string]bool) + for _, sname := range slices.Sorted(maps.Keys(structs)) { + sdef := structs[sname] + var fields []structEquateKV + for k, rawField := range sdef { + if k == "_size" { + continue + } + off, err := strconv.ParseInt(k, 10, 64) + if err != nil { + continue + } + fname, _ := project.StripFieldType(rawField) + if fname == "" || seenNames[fname] { + continue + } + seenNames[fname] = true + fields = append(fields, structEquateKV{ + structName: sname, + name: fname, + offset: off, + }) + } + slices.SortFunc(fields, func(a, b structEquateKV) int { + if c := cmp.Compare(a.offset, b.offset); c != 0 { + return c + } + return cmp.Compare(a.name, b.name) + }) + out = append(out, fields...) + if szStr, ok := sdef["_size"]; ok { + if sz, err := strconv.ParseInt(szStr, 10, 64); err == nil && sz > 0 { + szName := sname + "_SIZEOF" + if !seenNames[szName] { + seenNames[szName] = true + out = append(out, structEquateKV{ + structName: sname, + name: szName, + offset: sz, + }) + } + } + } + } + return out +} + func sortedEquates(equates map[string]uint32) []equateKV { vec := make([]equateKV, 0, len(equates)) for name, addr := range equates { @@ -323,6 +388,14 @@ func (e *Emitter) GenerateItems(relocatable bool) error { e.addItem(eq.name, "equ", fmt.Sprintf("$%X", eq.addr), "") } + if structEqs := sortedStructEquates(e.Proj.Data.Structs); len(structEqs) > 0 { + e.addItem("", "", "", "") + e.addItem("", "", "", "; --- Struct Field Equates ---") + for _, seq := range structEqs { + e.addItem(seq.name, "equ", formatSignedEquHex(seq.offset), "") + } + } + e.addItem("", "", "", "") e.addItem("", "", "", "; --- External/BSS Equates ---") @@ -556,6 +629,7 @@ func (e *Emitter) PopulateCellsItems() error { if err != nil { return err } + abisMap := e.DB.GetAllFunctionABIs() xrefsMap, err := e.buildXrefsMap(e.BaseAddr, endAddr, sc, allCells, true) if err != nil { @@ -714,6 +788,9 @@ func (e *Emitter) PopulateCellsItems() error { } ac := commentsMap[addr] + if abi := abisMap[addr]; abi != "" { + e.addItem("", "", "", "; ABI: "+abi) + } if ac.BlockComment != "" { for _, line := range strings.Split(ac.BlockComment, "\n") { line = strings.TrimSuffix(line, "\r") @@ -1139,7 +1216,11 @@ func (e *Emitter) GenerateModularHunkItems() error { secMem = "" } secDirective := fmt.Sprintf("%s,%s%s", secName, secType, secMem) - e.addItem("", "SECTION", secDirective, "") + if !seg.Relocatable && seg.Base != 0 { + e.addItem("", "org", fmt.Sprintf("$%X", seg.Base), "") + } else { + e.addItem("", "SECTION", secDirective, "") + } e.addItem("", "", "", "") segEnd := max(seg.EndAddr(), seg.Base+1) @@ -1278,6 +1359,12 @@ func EmitAllAmiga(proj *project.Project, outDir string, force bool) error { for _, eq := range sortedEquates(equates) { equatesLines = append(equatesLines, fmt.Sprintf("%s\tequ\t$%X", eq.name, eq.addr)) } + if structEqs := sortedStructEquates(proj.Data.Structs); len(structEqs) > 0 { + equatesLines = append(equatesLines, "", "; --- Struct Field Equates ---") + for _, seq := range structEqs { + equatesLines = append(equatesLines, fmt.Sprintf("%s\tequ\t%s", seq.name, formatSignedEquHex(seq.offset))) + } + } if err := os.WriteFile(filepath.Join(srcDir, "equates.i"), []byte(strings.Join(equatesLines, "\n")+"\n"), 0o644); err != nil { return err } diff --git a/internal/emitter/emitter_test.go b/internal/emitter/emitter_test.go index d1da66b..d272fa1 100644 --- a/internal/emitter/emitter_test.go +++ b/internal/emitter/emitter_test.go @@ -526,3 +526,81 @@ func BenchmarkEmitPipeline(b *testing.B) { _ = em.FormatLines() } } + +func TestStructEquatesAndABIEmission(t *testing.T) { + tmpDir := t.TempDir() + // 0x10000: move.w 2(a0),d0 30 28 00 02 + // 0x10004: rts 4E 75 + codeBytes := []byte{0x30, 0x28, 0x00, 0x02, 0x4E, 0x75} + binPath := filepath.Join(tmpDir, "main.bin") + if err := os.WriteFile(binPath, codeBytes, 0o644); err != nil { + t.Fatal(err) + } + dbPath := filepath.Join(tmpDir, "main.db") + ctrl, err := db.New(dbPath) + if err != nil { + t.Fatal(err) + } + defer ctrl.Close() + + proj := project.New(filepath.Join(tmpDir, "project.json")) + proj.Data.Segments = append(proj.Data.Segments, project.Segment{ + Name: "main", + HunkIndex: 0, + HunkType: hunk.HunkTypeCode, + MemoryType: hunk.MemoryPublic, + AllocSize: uint32(len(codeBytes)), + File: &binPath, + Base: 0x10000, + DB: dbPath, + Relocatable: false, + }) + proj.Data.Spaces = append(proj.Data.Spaces, project.Space{ + Name: "default", + Segments: []string{"main"}, + }) + proj.Data.ActiveSpace = "default" + proj.Data.Structs = map[string]map[string]string{ + "Model": { + "0": "Model_Radius.w", + "2": "Model_VX.w", + "_size": "18", + }, + } + proj.Data.RegisterAliases = map[string]string{ + "a0": "Model", + } + + an, err := analyzer.New(proj, ctrl) + if err != nil { + t.Fatal(err) + } + an.Analyze([]uint32{0x10000}) + if err := ctrl.SetFunctionABI(0x10000, "LAB_10000", "(a0=Model*) -> d0=vx"); err != nil { + t.Fatal(err) + } + + em, err := New(proj, ctrl) + if err != nil { + t.Fatal(err) + } + if err := em.GenerateItems(false); err != nil { + t.Fatal(err) + } + text := strings.Join(em.FormatLines(), "\n") + if !strings.Contains(text, "Model_Radius\tequ\t$0") { + t.Fatalf("expected Model_Radius equ $0 in emitted asm:\n%s", text) + } + if !strings.Contains(text, "Model_VX\tequ\t$2") { + t.Fatalf("expected Model_VX equ $2 in emitted asm:\n%s", text) + } + if !strings.Contains(text, "Model_SIZEOF\tequ\t$12") { + t.Fatalf("expected Model_SIZEOF equ $12 in emitted asm:\n%s", text) + } + if !strings.Contains(text, "; ABI: (a0=Model*) -> d0=vx") { + t.Fatalf("expected ABI comment in emitted asm:\n%s", text) + } + if !strings.Contains(text, "move.w\tModel_VX(a0),d0") { + t.Fatalf("expected Model_VX(a0),d0 in emitted asm:\n%s", text) + } +} diff --git a/internal/m68k/decode.go b/internal/m68k/decode.go index b79646e..2efa000 100644 --- a/internal/m68k/decode.go +++ b/internal/m68k/decode.go @@ -161,10 +161,18 @@ func decodeEA( switch mode { case 0: // 000: Dn (Data Register Direct) - return Operand{Mode: EADataReg, Access: access, Reg: reg}, pos, RegD(reg), 0, true + var rMask RegMask + if (access & AccessRead) != 0 { + rMask = RegD(reg) + } + return Operand{Mode: EADataReg, Access: access, Reg: reg}, pos, rMask, 0, true case 1: // 001: An (Address Register Direct) - return Operand{Mode: EAAddrReg, Access: access, Reg: reg}, pos, RegA(reg), 0, true + var rMask RegMask + if (access & AccessRead) != 0 { + rMask = RegA(reg) + } + return Operand{Mode: EAAddrReg, Access: access, Reg: reg}, pos, rMask, 0, true case 2: // 010: (An) (Address Register Indirect) return Operand{Mode: EAAddrInd, Access: access, Reg: reg}, pos, RegA(reg), 0, true diff --git a/internal/project/project.go b/internal/project/project.go index 8f96095..887e871 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -548,6 +548,20 @@ func (p *Project) QualifiedLabel(addr uint32, rawOrRenamed string) string { return rawOrRenamed } +// StripFieldType splits a struct field specification like "Ship_VX.w" or "Model_Vertices.p" +// into its clean identifier ("Ship_VX", "Model_Vertices") and lowercase type suffix ("w", "p"). +func StripFieldType(field string) (string, string) { + trimmed := strings.TrimSpace(field) + if idx := strings.LastIndexByte(trimmed, '.'); idx > 0 { + suf := strings.ToLower(trimmed[idx+1:]) + switch suf { + case "b", "w", "l", "p": + return trimmed[:idx], suf + } + } + return trimmed, "" +} + // ResolveTarget resolves a user/LLM target specifier into a synthetic project address. func (p *Project) ResolveTarget(ctrl *db.Controller, input string) (uint32, bool) { s := strings.TrimSpace(input) From 5a1fb98f0664734edb5def54ac919cf604ef79da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Tue, 27 Oct 2026 01:27:34 +0200 Subject: [PATCH 02/10] refactor: decouple ABI and struct/alias annotations from analyzer pipeline and DB schema --- cmd/hunks/agent_cmds.go | 49 ++-------- cmd/hunks/main.go | 2 + internal/analyzer/analyzer.go | 5 +- internal/analyzer/analyzer_test.go | 50 ---------- internal/analyzer/baseregs.go | 138 +++------------------------ internal/analyzer/stage2_code.go | 12 --- internal/analyzer/stage4_funcmeta.go | 74 -------------- internal/callgraph/callgraph.go | 8 +- internal/callgraph/callgraph_test.go | 2 +- internal/db/controller.go | 56 +---------- internal/emitter/emitter.go | 20 +++- internal/emitter/emitter_test.go | 4 +- internal/project/project.go | 131 +++++++++++++++++++++++++ 13 files changed, 179 insertions(+), 372 deletions(-) diff --git a/cmd/hunks/agent_cmds.go b/cmd/hunks/agent_cmds.go index 01a0b4e..53448a2 100644 --- a/cmd/hunks/agent_cmds.go +++ b/cmd/hunks/agent_cmds.go @@ -14,7 +14,6 @@ import ( "strings" "github.com/mikolas/hunks/internal/amiga" - "github.com/mikolas/hunks/internal/analyzer" "github.com/mikolas/hunks/internal/callgraph" "github.com/mikolas/hunks/internal/db" "github.com/mikolas/hunks/internal/project" @@ -202,9 +201,6 @@ func registerAgentCommands( if structSize > 0 { sdef["_size"] = strconv.Itoa(structSize) } - if err := reformatAllSegmentsForStructs(proj); err != nil { - return err - } if err := proj.Save(); err != nil { return err } @@ -243,9 +239,6 @@ func registerAgentCommands( delete(proj.Data.RegisterAliases, k) } } - if err := reformatAllSegmentsForStructs(proj); err != nil { - return err - } if err := proj.Save(); err != nil { return err } @@ -289,9 +282,6 @@ func registerAgentCommands( proj.Data.RegisterAliases = make(map[string]string) } proj.Data.RegisterAliases[key] = structName - if err := reformatAllSegmentsForStructs(proj); err != nil { - return err - } if err := proj.Save(); err != nil { return err } @@ -332,9 +322,6 @@ func registerAgentCommands( return err } delete(proj.Data.RegisterAliases, key) - if err := reformatAllSegmentsForStructs(proj); err != nil { - return err - } if err := proj.Save(); err != nil { return err } @@ -411,29 +398,6 @@ func parseSignedOffset(s string) (int64, error) { return val, nil } -func reformatAllSegmentsForStructs(proj *project.Project) error { - seenDBs := make(map[string]bool) - for _, seg := range proj.Data.Segments { - if seenDBs[seg.DB] { - continue - } - seenDBs[seg.DB] = true - ctrl, err := db.New(seg.DB) - if err != nil { - return err - } - proj.SyncDBScope(ctrl) - an, err := analyzer.New(proj, ctrl) - if err != nil { - _ = ctrl.Close() - return err - } - an.Stage3FormatOperands() - _ = ctrl.Close() - } - return nil -} - // --- §1: hunks search --- type searchHitJSON struct { @@ -1643,9 +1607,7 @@ func runABICommand( if f, ok := cg.Functions[fnAddr]; ok { fnName = f.Name } - if err := ctrl.SetFunctionABI(fnAddr, fnName, sig); err != nil { - return err - } + proj.SetFunctionABI(fnName, sig) if f, ok := cg.Functions[fnAddr]; ok { f.ABI = sig for _, w := range callgraph.VerifyABI(sig, f.InRegs, f.ModRegs, f.SavedRegs) { @@ -1657,6 +1619,9 @@ func runABICommand( if err := scanner.Err(); err != nil { return err } + if err := proj.Save(); err != nil { + return err + } fmt.Printf("Updated %d function ABI signature(s)\n", count) return nil } @@ -1668,7 +1633,8 @@ func runABICommand( } sig := strings.TrimSpace(args[1]) f := cg.Functions[fnAddr] - if err := ctrl.SetFunctionABI(fnAddr, f.Name, sig); err != nil { + proj.SetFunctionABI(f.Name, sig) + if err := proj.Save(); err != nil { return err } f.ABI = sig @@ -1907,6 +1873,7 @@ func runDumpCommand(proj *project.Project, section string, format string) (strin "comments": userComments, "structs": proj.Data.Structs, "register_aliases": proj.Data.RegisterAliases, + "function_abis": proj.Data.FunctionABIs, "carves": carves, } if section != "" { @@ -1924,6 +1891,8 @@ func runDumpCommand(proj *project.Project, section string, format string) (strin payload = map[string]any{"structs": proj.Data.Structs} case "aliases", "register_aliases": payload = map[string]any{"register_aliases": proj.Data.RegisterAliases} + case "abis", "function_abis": + payload = map[string]any{"function_abis": proj.Data.FunctionABIs} case "carves": payload = map[string]any{"carves": carves} default: diff --git a/cmd/hunks/main.go b/cmd/hunks/main.go index 460a6cf..272d905 100644 --- a/cmd/hunks/main.go +++ b/cmd/hunks/main.go @@ -1124,6 +1124,8 @@ func main() { ctrl.SetActiveSeg(prevSeg) return err } + proj.ReKeyFunctionABI(effectiveOld, p.newName) + proj.ReKeyFunctionABI(cleanOld, p.newName) if hasResolved { affectedAddrs = append(affectedAddrs, resolvedAddr) } else if targetAddr, ok := ctrl.LabelAddrByName(effectiveOld, false); ok { diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 90847dd..37522f3 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -478,10 +478,7 @@ func (a *Analyzer) runPipeline(entries []uint32) { } a.stage2DiscoverCode(entries, jumpTable) - if newSeeds := a.propagateBaseRegs(jumpTable); len(newSeeds) > 0 { - a.stage2DiscoverCode(newSeeds, jumpTable) - _ = a.propagateBaseRegs(jumpTable) - } + a.propagateBaseRegs(jumpTable) a.stage3ClassifyData() a.Stage3FormatOperands() a.stage4FunctionMeta() diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go index 3107be5..a9131dc 100644 --- a/internal/analyzer/analyzer_test.go +++ b/internal/analyzer/analyzer_test.go @@ -1244,37 +1244,6 @@ func TestAbsShortXrefsAndRangeRollup(t *testing.T) { } } -func TestStaticMemorySlotTrampolineResolution(t *testing.T) { - const base uint32 = 0x10000 - // 0x10000: move.w #$4EF9,$1032.w 31 FC 4E F9 10 32 - // 0x10006: move.l #$00010014,$1034.w 21 FC 00 01 00 14 10 34 - // 0x1000E: jsr $1032.w 4E B8 10 32 - // 0x10012: rts 4E 75 - // 0x10014: moveq #$2A,d0 70 2A - // 0x10016: rts 4E 75 - code := []byte{ - 0x31, 0xFC, 0x4E, 0xF9, 0x10, 0x32, - 0x21, 0xFC, 0x00, 0x01, 0x00, 0x14, 0x10, 0x34, - 0x4E, 0xB8, 0x10, 0x32, - 0x4E, 0x75, - 0x70, 0x2A, - 0x4E, 0x75, - } - a, ctrl := newTestTraceAnalyzer(t, code, base) - a.Analyze([]uint32{base}) - - cTarget, err := ctrl.GetCell(0x10014) - if err != nil || cTarget == nil || cTarget.Type != db.CellCode { - t.Fatalf("expected target 0x10014 to be discovered as CODE via static trampoline slot, got %+v", cTarget) - } - if tgt, ok := ctrl.FirstControlEdgeTarget(0x1000E); !ok || tgt != 0x10014 { - t.Fatalf("expected jsr $1032.w at 0x1000E to have EdgeCall to 0x10014, got (%#x, %v)", tgt, ok) - } - if autoCmt := ctrl.GetAddressComment(0x1000E).AutoComment; !strings.Contains(autoCmt, "-> LAB_0_00014 (via $1032)") { - t.Fatalf("expected auto-comment at 0x1000E to contain '-> LAB_0_00014 (via $1032)', got %q", autoCmt) - } -} - func TestTraceLowRAMTrampolineBouncing(t *testing.T) { const base uint32 = 0x10000 code := []byte{ @@ -1298,25 +1267,6 @@ func TestTraceLowRAMTrampolineBouncing(t *testing.T) { } } -func TestSMCAutoCommentAnnotation(t *testing.T) { - const base uint32 = 0x10000 - // 0x10000: move.w #$1234,$0001000A.l 33 FC 12 34 00 01 00 0A (patches immediate word inside 0x10008!) - // 0x10008: move.w #$0000,d0 30 3C 00 00 - // 0x1000C: rts 4E 75 - code := []byte{ - 0x33, 0xFC, 0x12, 0x34, 0x00, 0x01, 0x00, 0x0A, - 0x30, 0x3C, 0x00, 0x00, - 0x4E, 0x75, - } - a, ctrl := newTestTraceAnalyzer(t, code, base) - a.Analyze([]uint32{base}) - - autoCmt := ctrl.GetAddressComment(0x10000).AutoComment - if !strings.Contains(autoCmt, "[SMC] writes code at") { - t.Fatalf("expected SMC auto-comment at 0x10000, got %q", autoCmt) - } -} - func TestScopedRegisterAliasAndFieldTypeStripping(t *testing.T) { const base uint32 = 0x10000 // 0x10000: bsr.s $10008 61 06 diff --git a/internal/analyzer/baseregs.go b/internal/analyzer/baseregs.go index 4bc598b..4cf99b3 100644 --- a/internal/analyzer/baseregs.go +++ b/internal/analyzer/baseregs.go @@ -2,7 +2,6 @@ package analyzer import ( "fmt" - "slices" "github.com/mikolas/hunks/internal/amiga" "github.com/mikolas/hunks/internal/db" @@ -14,7 +13,6 @@ import ( type regKnown struct { known bool val uint32 // meaningful iff known - slot uint32 // non-zero when loaded from a static memory pointer slot } type regFile struct { @@ -233,14 +231,14 @@ func (a *Analyzer) resolveSlotOrRelocTarget(slotAddr uint32, jumpTable map[uint3 // A register `aN` (`a0..a6`) is auto-promoted to a global base-register invariant iff // every constant load into `aN` across the binary agrees on the same target address and // `aN` has zero non-constant writes (`!sawDynamicWrite[n]`) anywhere in the program. -func (a *Analyzer) propagateBaseRegs(jumpTable map[uint32]uint32) []uint32 { +func (a *Analyzer) propagateBaseRegs(jumpTable map[uint32]uint32) { _ = a.DB.ClearAutoBaseRegs() globalBase, hinted := a.hintedBaseRegs() slots := a.DB.SpaceCellSlots() if len(slots) == 0 { - return nil + return } if a.insnCache != nil && len(a.insnCache) == 0 { a.insnCache = make(map[uint32]m68k.Insn, len(slots)) @@ -249,7 +247,6 @@ func (a *Analyzer) propagateBaseRegs(jumpTable map[uint32]uint32) []uint32 { var firstLoad [8]uint32 var hasLoad [8]bool var sawDynamicWrite [8]bool - memSlotTargets := make(map[uint32][]uint32) for _, slot := range slots { if !slot.IsCode { continue @@ -262,27 +259,6 @@ func (a *Analyzer) propagateBaseRegs(jumpTable map[uint32]uint32) []uint32 { if !ok { continue } - if insn.Op == m68k.OpMOVE && insn.Suffix == m68k.SizeL && insn.NumOps == 2 && insn.Ops[0].Mode == m68k.EAImm { - dst := insn.Ops[1] - if dst.Mode == m68k.EAAbsShort || dst.Mode == m68k.EAAbsLong { - var immTgt uint32 - hasImmTgt := false - if len(a.Relocs) > 0 { - if rTgt, ok := a.Relocs[slot.Addr+2]; ok { - immTgt = rTgt - hasImmTgt = true - } - } else { - immTgt = insn.Ops[0].UVal - hasImmTgt = true - } - if hasImmTgt && immTgt%2 == 0 && a.IsInCodeSegment(immTgt) { - if !slices.Contains(memSlotTargets[dst.UVal], immTgt) { - memSlotTargets[dst.UVal] = append(memSlotTargets[dst.UVal], immTgt) - } - } - } - } constReg, val, isConst := a.decodeConstAregLoad(slot.Addr, &insn) if isConst { if !hasLoad[constReg] { @@ -312,7 +288,7 @@ func (a *Analyzer) propagateBaseRegs(jumpTable map[uint32]uint32) []uint32 { _ = a.DB.SetBaseReg("global", "", m68k.RegAName(r), first, "auto") } - return a.resolveBaseRelativeAccesses(slots, globalBase, jumpTable, memSlotTargets) + a.resolveBaseRelativeAccesses(slots, globalBase, jumpTable) } func (a *Analyzer) decodeConstAregLoad(addr uint32, insn *m68k.Insn) (uint8, uint32, bool) { @@ -363,7 +339,7 @@ func (a *Analyzer) decodeConstAregLoad(addr uint32, insn *m68k.Insn) (uint8, uin // would shift the assembled displacement by `rv.val - $DFF000` and break binary parity. // 3. Small-data globals (`base + disp` landing in a `DATA` or `BSS` segment): seeds // a label at the target and records a `DATA_READ` or `DATA_WRITE` XREF. -func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase regFile, jumpTable map[uint32]uint32, memSlotTargets map[uint32][]uint32) []uint32 { +func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase regFile, jumpTable map[uint32]uint32) { state := globalBase type addrComment struct { @@ -371,10 +347,9 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r frag string } type globalRef struct { - site uint32 - target uint32 - isWrite bool - addLabel bool + site uint32 + target uint32 + isWrite bool } type jtEdge struct { site uint32 @@ -390,18 +365,6 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r var globals []globalRef var jtEdges []jtEdge var customEAs []customEAEntry - var newSeeds []uint32 - - lookupSlotTargets := func(slotAddr uint32) []uint32 { - if len(memSlotTargets) == 0 { - return nil - } - if tgts := memSlotTargets[slotAddr]; len(tgts) > 0 { - return tgts - } - // Check 6-byte JMP xxx.L trampoline where $4EF9 is at slotAddr and target is at slotAddr+2. - return memSlotTargets[slotAddr+2] - } for _, slot := range slots { if !slot.IsCode { @@ -417,24 +380,6 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r continue } - // Direct call/jump through a non-code memory slot / RAM trampoline (e.g. jsr $1034.w). - if (insn.Op == m68k.OpJSR || insn.Op == m68k.OpJMP) && insn.NumOps >= 1 { - op0 := insn.Ops[0] - if op0.Mode == m68k.EAAbsShort || op0.Mode == m68k.EAAbsLong { - if tgts := lookupSlotTargets(op0.UVal); len(tgts) > 0 && !a.isCodeCell(op0.UVal) { - isCall := insn.Op == m68k.OpJSR - if !slices.Contains(a.IndirectSites, slot.Addr) { - a.IndirectSites = append(a.IndirectSites, slot.Addr) - } - for _, tgt := range tgts { - jtEdges = append(jtEdges, jtEdge{site: slot.Addr, target: tgt, isCall: isCall}) - tname := a.GetOrDefaultLabel(tgt) - comments = append(comments, addrComment{addr: slot.Addr, frag: fmt.Sprintf("-> %s (via $%X)", tname, op0.UVal)}) - } - } - } - } - // Step 1: Use / resolve operands against pre-instruction register state. for opIdx := uint8(0); opIdx < insn.NumOps; opIdx++ { op := insn.Ops[opIdx] @@ -447,41 +392,11 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r } if op.Mode == m68k.EAAddrInd && (insn.Op == m68k.OpJSR || insn.Op == m68k.OpJMP) { - isCall := insn.Op == m68k.OpJSR - if rv.slot != 0 { - if tgts := lookupSlotTargets(rv.slot); len(tgts) > 0 { - for _, tgt := range tgts { - jtEdges = append(jtEdges, jtEdge{site: slot.Addr, target: tgt, isCall: isCall}) - tname := a.GetOrDefaultLabel(tgt) - comments = append(comments, addrComment{addr: slot.Addr, frag: fmt.Sprintf("-> %s (via $%X)", tname, rv.slot)}) - } - continue - } - } if finalTgt, ok := a.resolveDirectOrSlotTarget(rv.val, jumpTable); ok { + isCall := insn.Op == m68k.OpJSR jtEdges = append(jtEdges, jtEdge{site: slot.Addr, target: finalTgt, isCall: isCall}) tname := a.GetOrDefaultLabel(finalTgt) comments = append(comments, addrComment{addr: slot.Addr, frag: fmt.Sprintf("-> %s", tname)}) - } else if tgts := lookupSlotTargets(rv.val); len(tgts) > 0 { - for _, tgt := range tgts { - jtEdges = append(jtEdges, jtEdge{site: slot.Addr, target: tgt, isCall: isCall}) - tname := a.GetOrDefaultLabel(tgt) - comments = append(comments, addrComment{addr: slot.Addr, frag: fmt.Sprintf("-> %s (via $%X)", tname, rv.val)}) - } - } - continue - } - - if op.Mode == m68k.EAAddrInd && (op.Access&(m68k.AccessRead|m68k.AccessWrite)) != 0 { - target := rv.val - if _, isHW := amiga.GetAmigaHWLabelSub(target); !isHW { - if a.isInDataOrBss(target) || a.IsInMemoryRegion(target) { - isWrite := (op.Access & m68k.AccessWrite) != 0 - globals = append(globals, globalRef{site: slot.Addr, target: target, isWrite: isWrite, addLabel: true}) - } else if a.IsInAnySegment(target) { - isWrite := (op.Access & m68k.AccessWrite) != 0 - globals = append(globals, globalRef{site: slot.Addr, target: target, isWrite: isWrite, addLabel: false}) - } } continue } @@ -501,15 +416,6 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r comments = append(comments, addrComment{addr: slot.Addr, frag: fmt.Sprintf("-> %s", tname)}) continue } - if tgts := lookupSlotTargets(target); len(tgts) > 0 { - isCall := insn.Op == m68k.OpJSR - for _, tgt := range tgts { - jtEdges = append(jtEdges, jtEdge{site: slot.Addr, target: tgt, isCall: isCall}) - tname := a.GetOrDefaultLabel(tgt) - comments = append(comments, addrComment{addr: slot.Addr, frag: fmt.Sprintf("-> %s (via $%X)", tname, target)}) - } - continue - } } if hw, ok := amiga.GetAmigaHWLabelSub(target); ok { @@ -539,26 +445,14 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r comments = append(comments, addrComment{addr: slot.Addr, frag: bf}) } } - } else if a.isInDataOrBss(target) || a.IsInMemoryRegion(target) { + } else if a.isInDataOrBss(target) { isWrite := (op.Access & m68k.AccessWrite) != 0 - globals = append(globals, globalRef{site: slot.Addr, target: target, isWrite: isWrite, addLabel: true}) - } else if a.IsInAnySegment(target) { - isWrite := (op.Access & m68k.AccessWrite) != 0 - globals = append(globals, globalRef{site: slot.Addr, target: target, isWrite: isWrite, addLabel: false}) + globals = append(globals, globalRef{site: slot.Addr, target: target, isWrite: isWrite}) } } // Steps 2-6: Compute definition, clobber WriteRegs, commit, call-clobber, terminator reset. a.stepInsn(slot.Addr, &insn, &state, &globalBase) - if insn.Op == m68k.OpMOVEA && insn.Suffix == m68k.SizeL && insn.NumOps == 2 { - op0 := insn.Ops[0] - dstOp := insn.Ops[1] - if (op0.Mode == m68k.EAAbsShort || op0.Mode == m68k.EAAbsLong) && dstOp.Mode == m68k.EAAddrReg && dstOp.Reg < 7 { - if tgts := lookupSlotTargets(op0.UVal); len(tgts) > 0 { - state.a[dstOp.Reg] = regKnown{known: true, val: tgts[0], slot: op0.UVal} - } - } - } } for _, ce := range customEAs { @@ -570,10 +464,8 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r a.appendCellComment(c.addr, c.frag) } for _, g := range globals { - if g.addLabel { - site := g.site - a.AddLabel(&site, g.target) - } + site := g.site + a.AddLabel(&site, g.target) xrefKind := db.XrefDataRead if g.isWrite { xrefKind = db.XrefDataWrite @@ -583,23 +475,17 @@ func (a *Analyzer) resolveBaseRelativeAccesses(slots []db.CellSlot, globalBase r for _, je := range jtEdges { site := je.site a.AddLabel(&site, je.target) - if !a.isCodeCell(je.target) && a.IsInCodeSegment(je.target) && !slices.Contains(newSeeds, je.target) { - newSeeds = append(newSeeds, je.target) - } edgeKind := db.EdgeUncond xrefKind := db.XrefCodeBranch if je.isCall { edgeKind = db.EdgeCall xrefKind = db.XrefCodeCall - labelName := a.GetOrDefaultLabel(je.target) - _ = a.DB.InsertFunction(je.target, labelName, ConfHigh) } _ = a.DB.InsertEdge(je.site, je.target, edgeKind) _ = a.DB.InsertXref(je.site, je.target, xrefKind) } a.DB.Commit() } - return newSeeds } func (a *Analyzer) isInDataOrBss(addr uint32) bool { diff --git a/internal/analyzer/stage2_code.go b/internal/analyzer/stage2_code.go index d1ccdfd..5481c8b 100644 --- a/internal/analyzer/stage2_code.go +++ b/internal/analyzer/stage2_code.go @@ -381,26 +381,14 @@ func (a *Analyzer) stage2DiscoverCode(initialEntries []uint32, jumpTable map[uin // 6. Verify no CODE cell swallows an interior label in `labels` allLabelAddrs := a.DB.LabelAddrs(nil) var invalidCodeAddrs []uint32 - var bogusInteriorLabels []uint32 codeCells.Ascend(func(cell db.Cell) bool { cEnd := cell.Addr + cell.Size idx, _ := slices.BinarySearch(allLabelAddrs, cell.Addr+1) if idx < len(allLabelAddrs) && allLabelAddrs[idx] < cEnd { - lblAddr := allLabelAddrs[idx] - if _, isReloc := a.RelocTargets[lblAddr]; !isReloc && cell.Confidence <= ConfHigh { - if lblObj, err := a.DB.GetLabelFull(lblAddr); err == nil && lblObj != nil && lblObj.IsAuto { - bogusInteriorLabels = append(bogusInteriorLabels, lblAddr) - return true - } - } invalidCodeAddrs = append(invalidCodeAddrs, cell.Addr) } return true }) - for _, lAddr := range bogusInteriorLabels { - _ = a.DB.DeleteLabelAt(lAddr) - delete(a.LabelsCache, lAddr) - } for _, cAddr := range invalidCodeAddrs { codeCells.Delete(db.Cell{Addr: cAddr}) } diff --git a/internal/analyzer/stage4_funcmeta.go b/internal/analyzer/stage4_funcmeta.go index 1887046..10e5994 100644 --- a/internal/analyzer/stage4_funcmeta.go +++ b/internal/analyzer/stage4_funcmeta.go @@ -2,7 +2,6 @@ package analyzer import ( "cmp" - "fmt" "slices" "github.com/mikolas/hunks/internal/db" @@ -200,79 +199,6 @@ func (a *Analyzer) stage4FunctionMeta() { } a.pruneNoreturnFallthroughs(allEdges, cellByAddr, noreturn) - a.annotateSMC(cells, cellByAddr, entries) -} - -func (a *Analyzer) annotateSMC(codeCells []db.Cell, cellByAddr map[uint32]*db.Cell, entries []uint32) { - if len(codeCells) == 0 { - return - } - slices.SortFunc(codeCells, func(x, y db.Cell) int { - return cmp.Compare(x.Addr, y.Addr) - }) - findCodeCell := func(addr uint32) *db.Cell { - lo, hi := 0, len(codeCells) - for lo < hi { - mid := int(uint(lo+hi) >> 1) - if codeCells[mid].Addr <= addr { - lo = mid + 1 - } else { - hi = mid - } - } - if lo > 0 && addr < codeCells[lo-1].Addr+codeCells[lo-1].Size { - return &codeCells[lo-1] - } - return nil - } - findEnclosingFunc := func(addr uint32) (uint32, bool) { - lo, hi := 0, len(entries) - for lo < hi { - mid := int(uint(lo+hi) >> 1) - if entries[mid] <= addr { - lo = mid + 1 - } else { - hi = mid - } - } - if lo > 0 && a.IsSameSegment(entries[lo-1], addr) { - return entries[lo-1], true - } - return 0, false - } - - for _, x := range a.DB.AllXrefs() { - if x.Kind != db.XrefDataWrite { - continue - } - if cellByAddr[x.Source] == nil { - continue - } - tgtCell := findCodeCell(x.Target) - if tgtCell == nil { - continue - } - var frag string - if lbl, ok := a.LabelsCache[tgtCell.Addr]; ok && lbl != "" { - off := x.Target - tgtCell.Addr - if off > 0 { - frag = fmt.Sprintf("[SMC] writes code at %s+%X", lbl, off) - } else { - frag = fmt.Sprintf("[SMC] writes code at %s", lbl) - } - } else if fnEntry, ok := findEnclosingFunc(x.Target); ok { - fnLbl := a.GetOrDefaultLabel(fnEntry) - off := x.Target - fnEntry - if off > 0 { - frag = fmt.Sprintf("[SMC] writes code at %s+%X", fnLbl, off) - } else { - frag = fmt.Sprintf("[SMC] writes code at %s", fnLbl) - } - } else { - frag = fmt.Sprintf("[SMC] writes code at $%X", x.Target) - } - a.appendCellComment(x.Source, frag) - } } // pruneNoreturnFallthroughs removes the FALLTHROUGH edge inserted after a `jsr`/`bsr` diff --git a/internal/callgraph/callgraph.go b/internal/callgraph/callgraph.go index aebef97..ff66b8a 100644 --- a/internal/callgraph/callgraph.go +++ b/internal/callgraph/callgraph.go @@ -259,7 +259,6 @@ func Build(proj *project.Project) (*CallGraph, error) { xrefsBySrc := make(map[uint32][]AddrStringPair) pointerTargets := make(map[uint32]bool) jmpTableBases := make(map[string]uint32) - abis := make(map[uint32]string) seenDBs := make(map[string]bool) for _, seg := range proj.ActiveSegments() { @@ -316,7 +315,6 @@ func Build(proj *project.Project) (*CallGraph, error) { }) } maps.Copy(jmpTableBases, ctrl.DetectJumpVectorBases()) - maps.Copy(abis, ctrl.GetAllFunctionABIs()) _ = ctrl.Close() } @@ -672,7 +670,7 @@ func Build(proj *project.Project) (*CallGraph, error) { HasLoop: hasLoop, HasIndirect: len(indirectCalls) > 0, HasSMC: hasSMC, - ABI: abis[entryAddr], + ABI: proj.GetFunctionABI(name, rawName, proj.AutoLabel(entryAddr)), InRegs: regMaskNames(inMask), ModRegs: regMaskNames(modMask), SavedRegs: regMaskNames(savedMask), @@ -1416,7 +1414,7 @@ func (cg *CallGraph) renderTarget(proj *project.Project, funcAddr uint32, opts * if mnem == "" { mnem = "?" } - ops := cell.OpStrVal() + ops := proj.ApplyStructAliasesToOpStr(cell.OpStrVal(), f.Name, f.RawName) var cmt string if eff := cg.Comments[ia].EffectiveComment(); eff != "" { cmt = fmt.Sprintf(" ; %s", eff) @@ -1459,7 +1457,7 @@ func (cg *CallGraph) renderTarget(proj *project.Project, funcAddr uint32, opts * if mnem == "" { mnem = cell.Type.AsStr() } - ops := cell.OpStrVal() + ops := proj.ApplyStructAliasesToOpStr(cell.OpStrVal(), f.Name, f.RawName) var off uint32 if cell.Addr > segBase { off = cell.Addr - segBase diff --git a/internal/callgraph/callgraph_test.go b/internal/callgraph/callgraph_test.go index 7133e19..e828af6 100644 --- a/internal/callgraph/callgraph_test.go +++ b/internal/callgraph/callgraph_test.go @@ -299,7 +299,7 @@ func TestCallGraphStaticRegsABIAndDualOffsets(t *testing.T) { _ = ctrl.SetLabel(0x1000, "CalcStep", db.LabelLab, false) _ = ctrl.InsertFunction(0x1000, "CalcStep", 0) - _ = ctrl.SetFunctionABI(0x1000, "CalcStep", "(a0=obj, a1=unused) -> d0 | clobbers: d0, d4") + proj.SetFunctionABI("CalcStep", "(a0=obj, a1=unused) -> d0 | clobbers: d0, d4") insns := []struct { addr uint32 diff --git a/internal/db/controller.go b/internal/db/controller.go index 7e89f23..fb19a1c 100644 --- a/internal/db/controller.go +++ b/internal/db/controller.go @@ -473,7 +473,6 @@ func (c *Controller) initSchema() error { is_noreturn BOOLEAN DEFAULT 0, stack_frame INTEGER, confidence INTEGER NOT NULL DEFAULT 0, - abi TEXT NOT NULL DEFAULT '', PRIMARY KEY (seg, entry_addr) ); @@ -510,12 +509,8 @@ func (c *Controller) initSchema() error { CREATE INDEX IF NOT EXISTS idx_edges_seg_target ON edges(seg, target_addr); CREATE INDEX IF NOT EXISTS idx_xrefs_seg_target ON xrefs(seg, target_addr); CREATE INDEX IF NOT EXISTS idx_cells_seg_type ON cells(seg, type);` - if _, err := c.conn.Exec(schema); err != nil { - return err - } - // Migrate pre-existing project.db files that were created before `functions.abi` existed. - _, _ = c.conn.Exec("ALTER TABLE functions ADD COLUMN abi TEXT NOT NULL DEFAULT ''") - return nil + _, err := c.conn.Exec(schema) + return err } func (c *Controller) InsertReloc(siteAddr, targetAddr uint32) error { @@ -2325,53 +2320,6 @@ func (c *Controller) GetSpaceCellSpans() ([]CellSpan, error) { return out, rows.Err() } -// SetFunctionABI sets (or clears when abi == "") the ABI signature on a function entry, -// creating the functions row if it does not yet exist. -func (c *Controller) SetFunctionABI(entryAddr uint32, name, abi string) error { - seg := c.SegOf(entryAddr) - _, err := c.conn.Exec( - `INSERT INTO functions (seg, entry_addr, name, confidence, abi) - VALUES (?1, ?2, ?3, 0, ?4) - ON CONFLICT(seg, entry_addr) DO UPDATE SET abi = excluded.abi`, - seg, entryAddr, name, abi, - ) - return err -} - -// GetFunctionABI returns the manual ABI string recorded for entryAddr in the active space. -func (c *Controller) GetFunctionABI(entryAddr uint32) string { - seg := c.SegOf(entryAddr) - var abi sql.NullString - _ = c.conn.QueryRow( - "SELECT abi FROM functions WHERE seg = ?1 AND entry_addr = ?2", - seg, entryAddr, - ).Scan(&abi) - return abi.String -} - -// GetAllFunctionABIs returns a map of entry_addr -> non-empty manual ABI strings across the active space. -func (c *Controller) GetAllFunctionABIs() map[uint32]string { - out := make(map[uint32]string) - inClause := c.spaceSegInClause(false) - query := fmt.Sprintf( - "SELECT entry_addr, abi FROM functions WHERE seg IN (%s) AND abi != ''", - inClause, - ) - rows, err := c.conn.Query(query) - if err != nil { - return out - } - defer rows.Close() - for rows.Next() { - var addr uint32 - var abi sql.NullString - if rows.Scan(&addr, &abi) == nil && abi.String != "" { - out[addr] = abi.String - } - } - return out -} - // GetXrefsToRange returns all cross-references targeting [startAddr, endAddr] in the active space. // When externalOnly is true, references whose source_addr lies inside [startAddr, endAddr] are excluded. func (c *Controller) GetXrefsToRange(startAddr, endAddr uint32, externalOnly bool) []Xref { diff --git a/internal/emitter/emitter.go b/internal/emitter/emitter.go index bde6cb9..f6dc716 100644 --- a/internal/emitter/emitter.go +++ b/internal/emitter/emitter.go @@ -629,7 +629,6 @@ func (e *Emitter) PopulateCellsItems() error { if err != nil { return err } - abisMap := e.DB.GetAllFunctionABIs() xrefsMap, err := e.buildXrefsMap(e.BaseAddr, endAddr, sc, allCells, true) if err != nil { @@ -674,6 +673,12 @@ func (e *Emitter) PopulateCellsItems() error { return "" } + fnEntrySet := make(map[uint32]bool) + for _, fnAddr := range e.DB.FunctionEntries() { + fnEntrySet[fnAddr] = true + } + var curFuncLabel, curFuncAutoLabel string + for addr < endAddr { nextCellAddr := endAddr if cellIdx < len(allCells) { @@ -786,10 +791,16 @@ func (e *Emitter) PopulateCellsItems() error { e.addItem("", "xdef", label, "") } } + if fnEntrySet[addr] || isEntry(addr) { + curFuncLabel = label + curFuncAutoLabel = e.Proj.AutoLabel(addr) + } ac := commentsMap[addr] - if abi := abisMap[addr]; abi != "" { - e.addItem("", "", "", "; ABI: "+abi) + if label != "" { + if abi := e.Proj.GetFunctionABI(label, e.Proj.AutoLabel(addr)); abi != "" { + e.addItem("", "", "", "; ABI: "+abi) + } } if ac.BlockComment != "" { for _, line := range strings.Split(ac.BlockComment, "\n") { @@ -812,6 +823,9 @@ func (e *Emitter) PopulateCellsItems() error { } if cell.OpStr != nil { opStr = *cell.OpStr + if cell.Type == db.CellCode { + opStr = e.Proj.ApplyStructAliasesToOpStr(opStr, curFuncLabel, curFuncAutoLabel) + } } if mnem == "" && (cell.Type.IsData() || cell.Type == db.CellPointer) { diff --git a/internal/emitter/emitter_test.go b/internal/emitter/emitter_test.go index d272fa1..3233315 100644 --- a/internal/emitter/emitter_test.go +++ b/internal/emitter/emitter_test.go @@ -576,9 +576,7 @@ func TestStructEquatesAndABIEmission(t *testing.T) { t.Fatal(err) } an.Analyze([]uint32{0x10000}) - if err := ctrl.SetFunctionABI(0x10000, "LAB_10000", "(a0=Model*) -> d0=vx"); err != nil { - t.Fatal(err) - } + proj.SetFunctionABI("LAB_10000", "(a0=Model*) -> d0=vx") em, err := New(proj, ctrl) if err != nil { diff --git a/internal/project/project.go b/internal/project/project.go index 887e871..44856c8 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -307,6 +307,7 @@ type ProjectData struct { MemoryRegions []MemoryRegion `json:"memory_regions"` Structs map[string]map[string]string `json:"structs"` RegisterAliases map[string]string `json:"register_aliases"` + FunctionABIs map[string]string `json:"function_abis,omitempty"` Analysis AnalysisData `json:"_analysis"` } @@ -328,6 +329,7 @@ func New(filepath string) *Project { MemoryRegions: []MemoryRegion{}, Structs: make(map[string]map[string]string), RegisterAliases: make(map[string]string), + FunctionABIs: make(map[string]string), Analysis: AnalysisData{ IndirectAddressing: []string{}, }, @@ -562,6 +564,135 @@ func StripFieldType(field string) (string, string) { return trimmed, "" } +// GetFunctionABI returns the user-defined ABI string associated with any of the candidate +// function label names (e.g. renamed label, segment-qualified label, or auto-label). +func (p *Project) GetFunctionABI(labels ...string) string { + if len(p.Data.FunctionABIs) == 0 { + return "" + } + for _, lbl := range labels { + if lbl == "" { + continue + } + if abi, ok := p.Data.FunctionABIs[lbl]; ok && abi != "" { + return abi + } + if _, short, hasColon := strings.Cut(lbl, ":"); hasColon { + if abi, ok := p.Data.FunctionABIs[short]; ok && abi != "" { + return abi + } + } + } + return "" +} + +// SetFunctionABI sets (or clears when abi == "") the user-defined ABI string keyed by label name. +func (p *Project) SetFunctionABI(label, abi string) { + lbl := strings.TrimSpace(label) + if lbl == "" { + return + } + if _, short, hasColon := strings.Cut(lbl, ":"); hasColon { + lbl = short + } + if abi == "" { + delete(p.Data.FunctionABIs, lbl) + return + } + if p.Data.FunctionABIs == nil { + p.Data.FunctionABIs = make(map[string]string) + } + p.Data.FunctionABIs[lbl] = abi +} + +// ReKeyFunctionABI migrates any ABI and function-scoped register aliases from oldLabel to newLabel on rename. +func (p *Project) ReKeyFunctionABI(oldLabel, newLabel string) { + oldClean := strings.TrimSpace(oldLabel) + newClean := strings.TrimSpace(newLabel) + if oldClean == "" || newClean == "" || oldClean == newClean { + return + } + if abi, ok := p.Data.FunctionABIs[oldClean]; ok { + delete(p.Data.FunctionABIs, oldClean) + p.Data.FunctionABIs[newClean] = abi + } + prefix := oldClean + ":" + for k, v := range p.Data.RegisterAliases { + if after, ok := strings.CutPrefix(k, prefix); ok { + delete(p.Data.RegisterAliases, k) + p.Data.RegisterAliases[newClean+":"+after] = v + } + } +} + +// ApplyStructAliasesToOpStr substitutes `(aN...)` with `(aN...)` for any +// register alias matching `fnLabels` or global `aN` so `struct` and `alias` updates +// take effect immediately in `cg`, `emit`, and `search` without re-running the analyzer. +func (p *Project) ApplyStructAliasesToOpStr(opStr string, fnLabels ...string) string { + if len(p.Data.RegisterAliases) == 0 || len(p.Data.Structs) == 0 || !strings.Contains(opStr, "(a") { + return opStr + } + parts := strings.Split(opStr, ",") + changed := false + for i, part := range parts { + openIdx := strings.Index(part, "(a") + if openIdx <= 0 || openIdx+2 >= len(part) { + continue + } + regChar := part[openIdx+2] + if regChar < '0' || regChar > '6' { + continue + } + afterReg := part[openIdx+3:] + if !strings.HasPrefix(afterReg, ")") && !strings.HasPrefix(afterReg, ",") { + continue + } + dispStr := strings.TrimSpace(part[:openIdx]) + dispVal, err := strconv.ParseInt(dispStr, 10, 32) + if err != nil { + continue + } + regName := "a" + string(regChar) + var structName string + for _, fl := range fnLabels { + if fl == "" { + continue + } + if s, ok := p.Data.RegisterAliases[fl+":"+regName]; ok && s != "" { + structName = s + break + } + if _, short, hasColon := strings.Cut(fl, ":"); hasColon { + if s, ok := p.Data.RegisterAliases[short+":"+regName]; ok && s != "" { + structName = s + break + } + } + } + if structName == "" { + structName = p.Data.RegisterAliases[regName] + } + if structName == "" { + continue + } + sdef, ok := p.Data.Structs[structName] + if !ok { + continue + } + rawField, ok := sdef[strconv.FormatInt(dispVal, 10)] + if !ok || rawField == "" { + continue + } + field, _ := StripFieldType(rawField) + parts[i] = field + part[openIdx:] + changed = true + } + if !changed { + return opStr + } + return strings.Join(parts, ",") +} + // ResolveTarget resolves a user/LLM target specifier into a synthetic project address. func (p *Project) ResolveTarget(ctrl *db.Controller, input string) (uint32, bool) { s := strings.TrimSpace(input) From caefae401633c9d52858d7c84ce202105c9d5620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Tue, 27 Oct 2026 01:33:03 +0200 Subject: [PATCH 03/10] docs(manual): add Agent Reverse-Engineering Playbook section --- MANUAL.md | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 175 insertions(+), 2 deletions(-) diff --git a/MANUAL.md b/MANUAL.md index 9d89f90..46677e9 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -168,11 +168,184 @@ hunks -m abi --verify # check all ABI strings ### 5. Structured State Export (`hunks dump`) ```bash -hunks -m dump -f json > findings_state.json # export entries, regions, renames, comments, structs, aliases, carves +hunks -m dump -f json > findings_state.json # export entries, regions, renames, comments, structs, aliases, abis, carves ``` --- +## Agent Reverse-Engineering Playbook + +This playbook defines the standard workflow for AI coding/reverse-engineering agents working on an Amiga binary with `hunks`. Every command is atomic and read-only (or a fast metadata mutation) — **do not write ad-hoc Python/shell scripts to grep raw bytes or scrape full assembly dumps when a `hunks` primitive answers the question directly**. + +### 1. Command Selection Matrix (What to Use and When) + +| Question / Goal | Command to Run | +|-----------------|----------------| +| Where are the entry points, high-fan-in leaves, or hardware/string anchors? | `hunks -m cg --anchors --unrenamed -n 30`
`hunks -m cg --leaves --unrenamed -n 30` | +| Which functions have all their callees already understood and are ready to name next? | `hunks -m cg --ready -n 30` | +| Are any functions cut off early by unreached branches or inline data tables? | `hunks -m cg --suspect -n 20` | +| What indirect calls (`jsr (An)`, `jmp d16(An)`, `jmp d8(PC,Xn)`) exist and which are unresolved? | `hunks -m cg --indirect [-f json]` | +| How does function `X` work, what registers/globals does it read/write, and how do callers invoke it? | `hunks -m cg --code` | +| Where are 3D math, Blitter, Copper, Paula audio, bitplane, sprite, CIA, or DMA routines? | `hunks -m search cluster:<3dmath\|blitter\|copper\|paula\|bitplane\|sprite\|cia\|dma> [--dense]` | +| Where does the program use a specific opcode (`muls.w`, `divs.w`, `movem.l`) or operand (`$1178`)? | `hunks -m search m:divs.w`
`hunks -m search 'o:$1178'` | +| Does this program use self-modifying code (SMC), and what code addresses are patched? | `hunks -m search smc` | +| What is inside a non-code table, string block, Copper list, or array of structs? | `hunks -m data [--len N]`
`hunks -m data --copper`
`hunks -m data --struct --count ` | +| What reads, writes, or calls a specific variable/function or a whole memory/code region? | `hunks -m xref `
`hunks -m xref '$1000..$1FFF'`
`hunks -m xref '+3DDEE..+3EFFF'` | +| A jump/pointer table in `DATA` points to unseeded subroutines — how do I mark & trace them? | `hunks -m hint pointer --follow` | +| How do I make `d16(An)` struct offsets readable in `cg --code` and emitted `.asm`? | `hunks -m struct add --size 0=field.w ...`
`hunks -m alias [--fn ]` | +| How do I record and statically verify a function's register calling convention? | `hunks -m abi "(a0=Model*, d0.w=id) -> d0.l \| clobbers: d1-d3/a1"`
`hunks -m abi --verify` | +| How do I reconcile all renames, comments, ABIs, structs, and regions with `FINDINGS.md`? | `hunks -m dump -f json` | + +--- + +### 2. Dissecting the Application Main Run Loop & Dispatch Architecture + +When starting on a newly initialized module, map the top-level control flow before diving into leaf math or rendering kernels: + +1. **Trace from the Entry Point (`main`):** + ```bash + hunks -m cg main --code + ``` + - If `main` is a small self-relocating stub or copies a jump trampoline to low RAM (e.g. `jmp $300.w` or `jsr $1034.w`) and stops, run trace-seeded analysis on flat self-booting binaries: + ```bash + hunks -m analyze --trace + ``` + `RunTrace` steps through low-RAM bounce stubs and hardware setup to seed the real entry points and interrupt vectors automatically. +2. **Locate OS Takeover & Interrupt Vectors:** + ```bash + hunks -m search cluster:dma + hunks -m xref '$64..$7C' + ``` + - Look for writes to `DMACON`/`INTENA` and the 68000 autovector slots (`$68` Level 2 CIA-A keyboard/ports, `$6C` Level 3 VBlank/Copper/Blitter, `$70` Level 4 Paula audio, `$78` Level 6 CIA-B). + - Every handler installed into `$68`/`$6C`/`$70`/`$78` is an asynchronous root of the call graph — rename those roots early (e.g. `Irq_Level3_VBlank`, `Irq_Level2_Keyboard`). +3. **Audit Indirect Dispatch & State Machines (`cg --indirect`):** + ```bash + hunks -m cg --indirect + ``` + - Many games drive their main loop or per-entity update via a state-machine jump table (`move.l Table(pc,d0.w),a0; jsr (a0)`) or a function pointer in low RAM (`jsr (a4)`). + - For any `[UNRESOLVED]` site in `cg --indirect`, inspect the enclosing function with `cg --code`, inspect the table address with `hunks -m data --len 64`, and mark pointer tables with `hunks -m hint pointer --follow` so all state handlers enter the call graph. +4. **Identify the Per-Frame Run Loop:** + - Inside `main` (or the primary state handler), locate the main loop (indicated by `+loop` in `cg` and a frame-sync call waiting on `VPOSR`/`VHPOSR` or a VBlank flag). + - The direct callees of that loop give you the spine of the application: **Input/AI Update $\rightarrow$ Camera/World Transform $\rightarrow$ Scene/Object Cull & Sort $\rightarrow$ Rasterize/Blit $\rightarrow$ Copper/Page Flip $\rightarrow$ Audio Tick**. + +--- + +### 3. Figuring Out Subsystems, Algorithms & Their Relationships + +Use cluster queries, mnemonic searches, and region XREF rollups to isolate each subsystem and trace how data flows between them: + +#### A. 3D / Fixed-Point Math, Transforms & Projection +1. **Find dense arithmetic pockets:** + ```bash + hunks -m search cluster:3dmath --dense + hunks -m search m:divs.w + ``` + - Functions with 6–9 `muls.w` instructions and `add.l` + shift/swap normalization (`lsl.l #2,dN; swap dN` for 2.14 fixed-point, or `asr.l #8,dN`) are **3×3 matrix × vector** or **matrix × matrix** routines. + - Functions pairing `muls.w` / `asl.l` with `divs.w` are **perspective projection** (`sx = (X * focal) / Z + centerX`) or **edge slope (`dx/dy`)** calculators. + - Functions computing `x1*y2 - y1*x2` (`muls.w` + `sub.l`) are **2D cross-product Winding / Backface Culling** tests. +2. **Trace the transformation matrix & scratch workspace:** + - Inspect a math routine with `cg --code` and check its **`=== Globals & Memory Flow ===`** block (e.g. `Reads: $1178, $117A...` / `Writes: $10E6...`). + - Run `hunks -m xref 0x1178` (or `hunks -m xref '$1060..$1210'`) to find every producer that writes the matrix/workspace and every consumer that reads it. + +#### B. Rendering, Blitter & Self-Modifying Fill Kernels +1. **Locate Blitter & CPU rasterizer kernels:** + ```bash + hunks -m search cluster:blitter + hunks -m search smc + ``` + - Amiga 3D and 2D engines frequently use **Self-Modifying Code (`search smc`)** for polygon span fillers, line drawers, and unrolled bitplane clearers (patching an instruction's immediate mask or computing a dynamic branch offset into an unrolled tower of `move.w d0,-(a0)` / `bfins` instructions). + - When you find a contiguous bank of unrolled fill kernels or edge-mask tables (e.g. `+3DDEE..+3EFFF`), run a **region XREF rollup** to see every dispatcher that enters the bank without querying dozens of individual addresses: + ```bash + hunks -m xref '+3DDEE..+3EFFF' + ``` + +#### C. Display, Copper Lists & Double/Triple Buffering +1. **Find Copper & Bitplane setup:** + ```bash + hunks -m search cluster:copper + hunks -m search cluster:bitplane + ``` +2. **Decode Copper lists directly:** + - Once you find the address loaded into `COP1LCH` (or a Copper buffer pointer), decode its `MOVE`/`WAIT`/`SKIP` stream: + ```bash + hunks -m data --copper --len 256 + ``` + +#### D. Mapping Data Structures & Connecting Producer $\leftrightarrow$ Consumer Subsystems +1. **Name low-RAM / BSS regions (`map region`):** + - When `xref '$1000..$1FFF'` reveals a cluster of shared globals, declare the region so `hunks` tracks it explicitly: + ```bash + hunks -m map region geo_workvars 0x1060 0x1210 + ``` +2. **Decode record arrays and bind register aliases (`struct` + `data --struct` + `alias`):** + - When a dispatcher indexes a table by `id * stride` (e.g. `mulu.w #18,d0; lea $6B6E(pc),a0; adda.l d0,a0`), define the struct with `--size` and field type suffixes (`.b`, `.w`, `.l`, `.p` for pointers): + ```bash + hunks -m struct add ModelRec --size 18 0=Model_Radius.w 2=Model_Verts.p 6=Model_Faces.p 10=Model_NumVerts.w + ``` + - Dump the entire table as decoded records to verify your field types against the actual bytes: + ```bash + hunks -m data 0x6B6E --struct ModelRec --count 16 + ``` + - Bind the base register **scoped to the consuming function** (`--fn`) so `2(a0)` renders as `Model_Verts(a0)` in `Poly_RenderObject` without polluting unrelated uses of `a0` elsewhere: + ```bash + hunks -m alias a0 ModelRec --fn Poly_RenderObject + ``` + +--- + +### 4. How and When to Rename, Comment, and Add ABI Envelopes + +Follow a strict **bottom-up verification discipline** so annotations remain accurate and never drift from the machine code: + +#### Rule 1: When and How to Rename (`hunks rename`) +- **Work bottom-up:** Start from `cg --anchors --unrenamed` and `cg --leaves --unrenamed`, then repeatedly query `cg --ready` (functions whose outgoing callees are 100% renamed). +- **Never rename from caller context alone:** Always run `hunks -m cg --code` and read the function body before renaming. A call that looks like "clear buffer" from one caller may actually be a general-purpose memory fill or Blitter wait. +- **Use `_` naming:** Group related routines by prefix (`Math_`, `Geo_`, `Poly_`, `Span_`, `Blit_`, `Cop_`, `Audio_`, `Input_`, `Obj_`, `Irq_`) so `cg` trees read like an architectural diagram. +- **Batch when appropriate:** Rename multiple verified symbols in one atomic transaction (`hunks -m rename LAB_47172=Geo_RotateVertices LAB_47204=Geo_ProjectPerspective`). + +#### Rule 2: How and When to Add an ABI Envelope (`hunks abi`) +- **Why ABI envelopes matter:** In M68000 assembly, arguments are passed in arbitrary registers (`d0`–`d7`, `a0`–`a6`) or scratch memory slots. Recording a function's ABI attaches `; [ABI: ...]` to every `jsr`/`bsr` call site in `cg --code` and `; ABI: ...` to emitted `.asm` function headers, eliminating the need to re-read callees when analyzing higher-level callers. +- **Always check `Regs (static)` first:** Every `cg ` output displays the statically inferred register envelope: + ```text + Regs (static): in: d0,a0 | mod: d0,d1,a0,a1 | saved: d2,d3,a2 + ``` + - `in:` lists registers read before being written in the function body. + - `mod:` lists registers modified and not restored via `movem.l (sp)+,...`. + - `saved:` lists registers preserved on the stack via `movem.l ...,−(sp)`. +- **Set the ABI as soon as you rename a function:** + ```bash + hunks -m abi Geo_RotateVertices "(in: $10E4.w=count, $10FA..$1102=srcXYZ, $1178=mat3x3) -> ($10E6..$10EE=dstXYZ) | clobbers: d0-d6/a1-a6" + # Or for a register-argument routine: + hunks -m abi Math_SinCos "(d0.w=angle_bam) -> d0.w=sin_2_14, d1.w=cos_2_14 | clobbers: d2/a0" + ``` +- **Verify against static register usage:** `hunks abi` automatically warns if a register you claim in the input `( ... )` section is never read or if a register in `clobbers:` is never written. Run `hunks -m abi --verify` after a batch of annotations to audit all ABI envelopes across the module. +- **Label-tied persistence:** ABI envelopes live in `project.json` keyed by **label name** (not raw address), and are automatically re-keyed if you later rename the function with `hunks rename`. + +#### Rule 3: How and When to Comment (`hunks comment`) +- **Function Header Block Comments (`--block`):** + Add a block comment on every non-trivial function entry explaining: + 1. What the algorithm does and its mathematical/hardware invariants (e.g. fixed-point scale `2.14`, screen coordinates, Blitter minterm). + 2. Any low-RAM globals read or written if passed via memory rather than registers. + ```bash + hunks -m comment Geo_RotateVertices --block "Transforms (count=$10E4) model vertices by 3x3 2.14 matrix at $1178..$1188 + translation $116C..$1170." + ``` +- **Instruction Inline Comments (`+`):** + - Comment **why**, not *what* (never comment `addq.w #1,d0` with `"increment d0"`). Comment fixed-point shifts (`"2.14 * 2.14 -> 4.28; lsl #2 + swap extracts signed 16-bit integer"`), bitmask tricks, hardware trigger writes, and loop termination conditions. + - **Always use the function-relative offset `(+fn_off)`** shown in parentheses in the second column of `cg --code`: + ```text + +3FB4A (+048) lsl.l #$2,d3 + +3FB4C (+04A) swap d3 + ``` + Target `+048` as `Geo_RotateVertices+48` (or segment offset `+3FB4A`): + ```bash + hunks -m comment Geo_RotateVertices+48 "2.14 fixed-point normalize: (sum << 2) >> 16" + ``` + - `hunks comment` enforces strict instruction-boundary and function-bounds validation: if an offset lands mid-instruction, it rejects the edit and prints the nearest valid instruction offsets. +- **Exporting & Reconciling State (`hunks dump`):** + When writing or updating a project findings document (`FINDINGS.md`), run `hunks -m dump -f json` to export all renames, user comments, ABI signatures, structs, aliases, and memory regions in one structured payload. + +--- + ## Comprehensive Usage Examples & Walkthroughs ### Walkthrough 1: Bootstrapping a Multi-Hunk Game (*Frontier: Elite II*) @@ -648,7 +821,7 @@ The TUI uses a built-in dark truecolor palette out of the box. You can override | File | Purpose | |------|---------| -| `work//project.json` | Target metadata (`target_name`, segments, entry points, spaces, structs, register aliases, memory regions) | +| `work//project.json` | Target metadata (`target_name`, segments, entry points, spaces, structs, register aliases, function ABIs, memory regions) | | `work//project.db` | Target SQLite database (`cells`, `labels`, `relocs`, `xrefs`, `edges`, `renames`, `operand_formats`) | | `work//hunk*.bin` | Extracted binary payloads for each `CODE`/`DATA` hunk of `` | | `disasm/.asm` | Master assembly file (`opt o-,a-` + `include "src/equates.i"` + `include "src//hunk*.asm"`, or flat binary output) | From 18ec9669c8e94f5c3a474bb4b255ade6cae1d225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Tue, 27 Oct 2026 08:59:02 +0200 Subject: [PATCH 04/10] fix(cli): resolve test-report bugs in xref, data, search, and abi --verify --- cmd/hunks/agent_cmds.go | 19 +++++++++++------ cmd/hunks/agent_cmds_test.go | 11 +++++++++- cmd/hunks/main.go | 5 ++++- internal/analyzer/classify.go | 5 ++++- internal/callgraph/callgraph.go | 36 +++++++++++++++++++++++---------- internal/project/project.go | 9 ++++++++- 6 files changed, 64 insertions(+), 21 deletions(-) diff --git a/cmd/hunks/agent_cmds.go b/cmd/hunks/agent_cmds.go index 53448a2..ad2581e 100644 --- a/cmd/hunks/agent_cmds.go +++ b/cmd/hunks/agent_cmds.go @@ -378,6 +378,13 @@ func registerAgentCommands( rootCmd.AddCommand(dumpCmd) } +func formatFnOffset(addr, entryAddr uint32) string { + if addr >= entryAddr { + return fmt.Sprintf("+%X", addr-entryAddr) + } + return fmt.Sprintf("-%X", entryAddr-addr) +} + func parseSignedOffset(s string) (int64, error) { clean := strings.TrimSpace(s) neg := false @@ -655,7 +662,7 @@ func runSearchCommand( if fAddr, ok := cg.InsnToFunc[addr]; ok { if f, ok := cg.Functions[fAddr]; ok { fnName = f.Name - fnOffStr = fmt.Sprintf("+%X", addr-f.EntryAddr) + fnOffStr = formatFnOffset(addr, f.EntryAddr) } } cmt := cg.Comments[addr].EffectiveComment() @@ -718,12 +725,12 @@ func renderDataInspection( ) (string, error) { seg := proj.SegmentAtAddr(addr) if seg == nil { - // Unmapped / MemoryRegion / hardware address: fall back to XREFs - return runXrefCommand(proj, ctrl, cg, fmt.Sprintf("0x%X", addr), format) + return "", fmt.Errorf("address $%X is not in any mapped binary segment (no file bytes to dump); use 'hunks xref $%X' to inspect references", addr, addr) } segBytes := seg.LoadBinaryData() if addr < seg.Base || int(addr-seg.Base) >= len(segBytes) { - return runXrefCommand(proj, ctrl, cg, fmt.Sprintf("0x%X", addr), format) + return "", fmt.Errorf("address $%X is outside segment '%s' file payload ($%X..$%X); use 'hunks xref $%X' to inspect references", + addr, seg.Name, seg.Base, seg.Base+uint32(len(segBytes)), addr) } relOff := int(addr - seg.Base) if byteLen <= 0 { @@ -1377,7 +1384,7 @@ func formatXrefHits( if fAddr, ok := cg.InsnToFunc[x.Source]; ok { if f, ok := cg.Functions[fAddr]; ok { fnName = f.Name - fnOff = fmt.Sprintf("+%X", x.Source-f.EntryAddr) + fnOff = formatFnOffset(x.Source, f.EntryAddr) } } kindStr := "ADDR" @@ -1837,7 +1844,7 @@ func runDumpCommand(proj *project.Project, section string, format string) (strin if fAddr, ok := cg.InsnToFunc[addr]; ok { if f, ok := cg.Functions[fAddr]; ok { fnName = f.Name - fnOff = fmt.Sprintf("+%X", addr-f.EntryAddr) + fnOff = formatFnOffset(addr, f.EntryAddr) } } userComments = append(userComments, commentEntry{ diff --git a/cmd/hunks/agent_cmds_test.go b/cmd/hunks/agent_cmds_test.go index 4c127b7..a62934c 100644 --- a/cmd/hunks/agent_cmds_test.go +++ b/cmd/hunks/agent_cmds_test.go @@ -102,7 +102,10 @@ func TestAgentWorkflowCommands(t *testing.T) { t.Fatalf("unexpected xref range json output:\n%s", xrefRange) } - // 2. Test `data --copper` and `data --struct` + // 2. Test `data --copper`, `data --struct`, and out-of-segment error + if _, err := renderDataInspection(proj, ctrl, cg, 0x6B6E, 16, false, "", 1, "text"); err == nil { + t.Fatalf("expected out-of-segment data 0x6B6E to return an error") + } copOut, err := renderDataInspection(proj, ctrl, cg, 0x1000C, 12, true, "", 1, "text") if err != nil { t.Fatal(err) @@ -117,6 +120,12 @@ func TestAgentWorkflowCommands(t *testing.T) { if !strings.Contains(structOut, "reg") || !strings.Contains(structOut, "$0180") { t.Fatalf("unexpected struct decode output:\n%s", structOut) } + if got := formatFnOffset(0x464A8, 0x465E6); got != "-13E" { + t.Fatalf("expected formatFnOffset(0x464A8, 0x465E6) == \"-13E\", got %q", got) + } + if warns := callgraph.VerifyABI("(d0=bogus) -> void", []string{"d1", "d2", "d3"}, []string{"d0"}, nil); len(warns) == 0 { + t.Fatalf("expected VerifyABI to warn when claimed input d0 is overwritten before being read") + } // 3. Test `resolveStrictCommentAddr` resolvedAddr, note, err := resolveStrictCommentAddr(proj, ctrl, cg, "MathKernel+4") diff --git a/cmd/hunks/main.go b/cmd/hunks/main.go index 272d905..ec9ccb4 100644 --- a/cmd/hunks/main.go +++ b/cmd/hunks/main.go @@ -1382,7 +1382,10 @@ func main() { defer ctrl.Close() proj.SyncDBScope(ctrl) if addr, aErr := resolveAddrOrLabel(proj, ctrl, target); aErr == nil { - return renderDataInspection(proj, ctrl, cg, addr, 64, false, "", 1, cgFormat) + if proj.SegmentAtAddr(addr) != nil { + return renderDataInspection(proj, ctrl, cg, addr, 64, false, "", 1, cgFormat) + } + return runXrefCommand(proj, ctrl, cg, fmt.Sprintf("0x%X", addr), cgFormat) } } } diff --git a/internal/analyzer/classify.go b/internal/analyzer/classify.go index f03e72d..f123260 100644 --- a/internal/analyzer/classify.go +++ b/internal/analyzer/classify.go @@ -230,7 +230,10 @@ func (a *Analyzer) classifyImmOperand(addr uint32, insn *m68k.Insn, op m68k.Oper return RefType{}, false } val := op.UVal - if a.IsInAnyProjectSegment(val) && val%2 == 0 { + if (a.IsInAnyProjectSegment(val) || a.IsInMemoryRegion(val)) && val%2 == 0 { + return RefType{Kind: RefDataAddr, Target: val}, true + } + if insn.Op == m68k.OpMOVEA && val > 0 && (val&0xFF000000) == 0 { return RefType{Kind: RefDataAddr, Target: val}, true } return RefType{}, false diff --git a/internal/callgraph/callgraph.go b/internal/callgraph/callgraph.go index ff66b8a..586f56d 100644 --- a/internal/callgraph/callgraph.go +++ b/internal/callgraph/callgraph.go @@ -137,8 +137,12 @@ func VerifyABI(abiStr string, inRegs, modRegs, savedRegs []string) []string { var warnings []string for _, r := range extractRegs(inSection) { - if !slices.Contains(inRegs, r) && !slices.Contains(modRegs, r) && !slices.Contains(savedRegs, r) { - warnings = append(warnings, fmt.Sprintf("claimed input register %s is never read in function body", r)) + if !slices.Contains(inRegs, r) { + if slices.Contains(modRegs, r) { + warnings = append(warnings, fmt.Sprintf("claimed input register %s is overwritten before being read in function body", r)) + } else { + warnings = append(warnings, fmt.Sprintf("claimed input register %s is never read in function body", r)) + } } } for _, r := range extractRegs(modSection) { @@ -359,6 +363,12 @@ func Build(proj *project.Project) (*CallGraph, error) { funcStartsSet[pt] = true } } + for lAddr, lName := range labels { + rawName := rawLabels[lAddr] + if (lName != rawName || !strings.HasPrefix(lName, "LAB_")) && !branchTargets[lAddr] && isCodeAt(lAddr) { + funcStartsSet[lAddr] = true + } + } hwEquates := make(map[string]bool) for k := range amiga.GetAllEquates() { @@ -418,7 +428,9 @@ func Build(proj *project.Project) (*CallGraph, error) { if !ok || cell.Type != db.CellCode { continue } - insnToFunc[addr] = entryAddr + if _, owned := insnToFunc[addr]; !owned { + insnToFunc[addr] = entryAddr + } mnem := cell.MnemonicStr() isTerm := isTerminator(mnem) @@ -1243,16 +1255,17 @@ func (cg *CallGraph) renderTarget(proj *project.Project, funcAddr uint32, opts * if opts.Code { for _, ia := range f.InsnAddrs { if c, ok := cg.Cells[ia]; ok { - var off, fnOff uint32 + var off uint32 if ia > segBase { off = ia - segBase } - if ia > f.EntryAddr { - fnOff = ia - f.EntryAddr + fnOffStr := fmt.Sprintf("+%03X", ia-f.EntryAddr) + if ia < f.EntryAddr { + fnOffStr = fmt.Sprintf("-%03X", f.EntryAddr-ia) } codeLines = append(codeLines, codeLine{ Offset: fmt.Sprintf("+%05X", off), - FnOffset: fmt.Sprintf("+%03X", fnOff), + FnOffset: fnOffStr, Label: cg.Labels[ia], Mnemonic: c.MnemonicStr(), Operands: c.OpStrVal(), @@ -1436,14 +1449,15 @@ func (cg *CallGraph) renderTarget(proj *project.Project, funcAddr uint32, opts * break } } - var off, fnOff uint32 + var off uint32 if ia > segBase { off = ia - segBase } - if ia > f.EntryAddr { - fnOff = ia - f.EntryAddr + fnOffStr := fmt.Sprintf("+%03X", ia-f.EntryAddr) + if ia < f.EntryAddr { + fnOffStr = fmt.Sprintf("-%03X", f.EntryAddr-ia) } - fmt.Fprintf(&out, " +%05X (+%03X) %-8s %s%s\n", off, fnOff, mnem, ops, cmt) + fmt.Fprintf(&out, " +%05X (%s) %-8s %s%s\n", off, fnOffStr, mnem, ops, cmt) } } if f.SuspectCutoff != nil { diff --git a/internal/project/project.go b/internal/project/project.go index 44856c8..1c8fef9 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -723,7 +723,7 @@ func (p *Project) ResolveTarget(ctrl *db.Controller, input string) (uint32, bool } } - // 2. Check `+` + // 2. Check `+` and `-` if basePart, dispPart, hasPlus := strings.Cut(s, "+"); hasPlus && strings.TrimSpace(basePart) != "" { if baseAddr, ok := p.ResolveTarget(ctrl, strings.TrimSpace(basePart)); ok { if disp, err := ParseHex(dispPart); err == nil { @@ -731,6 +731,13 @@ func (p *Project) ResolveTarget(ctrl *db.Controller, input string) (uint32, bool } } } + if basePart, dispPart, hasMinus := strings.Cut(s, "-"); hasMinus && strings.TrimSpace(basePart) != "" { + if baseAddr, ok := p.ResolveTarget(ctrl, strings.TrimSpace(basePart)); ok { + if disp, err := ParseHex(dispPart); err == nil && baseAddr >= disp { + return baseAddr - disp, true + } + } + } // 3. Check leading `+` relative to CurrentSegment if offStr, ok := strings.CutPrefix(s, "+"); ok { From 17aeaf4ce55c6a14d257094ce5b083f9046895ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Tue, 27 Oct 2026 09:12:17 +0200 Subject: [PATCH 05/10] fix(analyzer): re-walk code cells on full Analyze() so xrefs are populated on existing DBs --- internal/analyzer/analyzer.go | 8 +++++++ internal/analyzer/analyzer_test.go | 36 ++++++++++++++++++++++++++++++ internal/analyzer/stage2_code.go | 32 ++++++++++++-------------- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 37522f3..d10f9c3 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -452,6 +452,14 @@ func (a *Analyzer) Analyze(startAddrs []uint32) { entries = append(entries, seg.Base) } } + if startAddrs == nil { + for _, fnAddr := range a.DB.FunctionEntries() { + if a.IsInCodeSegment(fnAddr) && !slices.Contains(entries, fnAddr) { + entries = append(entries, fnAddr) + } + } + } + _ = a.DB.DeleteCodeCells() a.runPipeline(entries) a.updateCoverageStats() diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go index a9131dc..6dfbbdb 100644 --- a/internal/analyzer/analyzer_test.go +++ b/internal/analyzer/analyzer_test.go @@ -1017,6 +1017,15 @@ func TestFunctionMetaAndNoreturnPruning(t *testing.T) { 0x4A, 0xFC, } a, ctrl := newTestTraceAnalyzer(t, code, base) + // Pre-populate a CODE cell at base to simulate re-running `hunks analyze` on an existing DB. + _ = ctrl.InsertCell(&db.Cell{ + Addr: base, + Size: 4, + Type: db.CellCode, + Mnemonic: db.StrPtr("move.w"), + OpStr: db.StrPtr("$1034.w,d0"), + RawBytes: code[0:4], + }) a.Analyze([]uint32{base}) const normalFn = base + 0x0E // 0x1000E @@ -1097,6 +1106,15 @@ func TestApplyTraceRegDispatchesResolvesFromRegContext(t *testing.T) { // 0x10002: rts 4E 75 (the observed target) code := []byte{0x4E, 0xD0, 0x4E, 0x75} a, ctrl := newTestTraceAnalyzer(t, code, base) + // Pre-populate a CODE cell at base to simulate re-running `hunks analyze` on an existing DB. + _ = ctrl.InsertCell(&db.Cell{ + Addr: base, + Size: 4, + Type: db.CellCode, + Mnemonic: db.StrPtr("move.w"), + OpStr: db.StrPtr("$1034.w,d0"), + RawBytes: code[0:4], + }) a.Analyze([]uint32{base}) // Static analysis leaves the jmp (a0) site unresolved (no control edge). @@ -1191,6 +1209,15 @@ func TestCustomRegBitfieldAutoComments(t *testing.T) { 0x4E, 0x75, } a, ctrl := newTestTraceAnalyzer(t, code, base) + // Pre-populate a CODE cell at base to simulate re-running `hunks analyze` on an existing DB. + _ = ctrl.InsertCell(&db.Cell{ + Addr: base, + Size: 4, + Type: db.CellCode, + Mnemonic: db.StrPtr("move.w"), + OpStr: db.StrPtr("$1034.w,d0"), + RawBytes: code[0:4], + }) a.Analyze([]uint32{base}) cDmacon := ctrl.GetAddressComment(0x10000).AutoComment @@ -1224,6 +1251,15 @@ func TestAbsShortXrefsAndRangeRollup(t *testing.T) { 0x4E, 0x75, } a, ctrl := newTestTraceAnalyzer(t, code, base) + // Pre-populate a CODE cell at base to simulate re-running `hunks analyze` on an existing DB. + _ = ctrl.InsertCell(&db.Cell{ + Addr: base, + Size: 4, + Type: db.CellCode, + Mnemonic: db.StrPtr("move.w"), + OpStr: db.StrPtr("$1034.w,d0"), + RawBytes: code[0:4], + }) a.Analyze([]uint32{base}) if xrefs := ctrl.GetXrefsToRange(0x1034, 0x1035, false); len(xrefs) != 1 || xrefs[0].Source != 0x10000 || xrefs[0].Kind != db.XrefDataRead { diff --git a/internal/analyzer/stage2_code.go b/internal/analyzer/stage2_code.go index 5481c8b..c53bf40 100644 --- a/internal/analyzer/stage2_code.go +++ b/internal/analyzer/stage2_code.go @@ -182,17 +182,19 @@ func (a *Analyzer) stage2DiscoverCode(initialEntries []uint32, jumpTable map[uin continue } src := addr + shouldLabel := op.Mode != m68k.EAAbsShort && a.isValidTarget(ref.Target) + if op.Mode == m68k.EAImm && !a.IsInAnyProjectSegment(ref.Target) { + shouldLabel = false + } switch ref.Kind { case RefCodeCall: target := ref.Target _ = a.DB.InsertXref(addr, target, db.XrefCodeCall) - if op.Mode != m68k.EAAbsShort || a.IsInAnyProjectSegment(target) { + if shouldLabel { _ = a.DB.InsertEdge(addr, target, db.EdgeCall) a.AddLabel(&src, target) - } else if a.IsInMemoryRegion(target) { - a.AddLabel(&src, target) } - if a.IsInCodeSegment(target) { + if op.Mode != m68k.EAAbsShort && a.IsInCodeSegment(target) { labelName := a.GetOrDefaultLabel(target) _ = a.DB.InsertFunction(target, labelName, confidence) if _, seen := visited[target]; !seen { @@ -203,7 +205,7 @@ func (a *Analyzer) stage2DiscoverCode(initialEntries []uint32, jumpTable map[uin case RefCodeBranch: target := ref.Target _ = a.DB.InsertXref(addr, target, db.XrefCodeBranch) - if op.Mode != m68k.EAAbsShort || a.IsInAnyProjectSegment(target) { + if shouldLabel { if ref.IsConditional { _ = a.DB.InsertEdge(addr, target, db.EdgeCondTrue) _ = a.DB.InsertEdge(addr, addr+size, db.EdgeCondFalse) @@ -211,10 +213,8 @@ func (a *Analyzer) stage2DiscoverCode(initialEntries []uint32, jumpTable map[uin _ = a.DB.InsertEdge(addr, target, db.EdgeUncond) } a.AddLabel(&src, target) - } else if a.IsInMemoryRegion(target) { - a.AddLabel(&src, target) } - if a.IsInCodeSegment(target) { + if op.Mode != m68k.EAAbsShort && a.IsInCodeSegment(target) { if _, seen := visited[target]; !seen { worklist = append(worklist, workItem{addr: target, confidence: confidence}) } @@ -223,9 +223,9 @@ func (a *Analyzer) stage2DiscoverCode(initialEntries []uint32, jumpTable map[uin case RefDataAddr: target := ref.Target _ = a.DB.InsertXref(addr, target, db.XrefDataAddr) - if (op.Mode != m68k.EAAbsShort && a.IsInAnyProjectSegment(target)) || a.IsInMemoryRegion(target) { + if shouldLabel { a.AddLabel(&src, target) - if op.Mode != m68k.EAAbsShort && a.IsInCodeSegment(target) && target%2 == 0 { + if a.IsInCodeSegment(target) && target%2 == 0 { candidateCode = append(candidateCode, target) } } @@ -233,21 +233,17 @@ func (a *Analyzer) stage2DiscoverCode(initialEntries []uint32, jumpTable map[uin case RefDataRead: target := ref.Target _ = a.DB.InsertXref(addr, target, db.XrefDataRead) - if (op.Mode != m68k.EAAbsShort && a.IsInAnyProjectSegment(target)) || a.IsInMemoryRegion(target) { + if shouldLabel { a.AddLabel(&src, target) - if op.Mode != m68k.EAAbsShort && a.IsInAnyProjectSegment(target) { - dataReadTargets[target] = struct{}{} - } + dataReadTargets[target] = struct{}{} } case RefDataWrite: target := ref.Target _ = a.DB.InsertXref(addr, target, db.XrefDataWrite) - if (op.Mode != m68k.EAAbsShort && a.IsInAnyProjectSegment(target)) || a.IsInMemoryRegion(target) { + if shouldLabel { a.AddLabel(&src, target) - if op.Mode != m68k.EAAbsShort && a.IsInAnyProjectSegment(target) { - dataReadTargets[target] = struct{}{} - } + dataReadTargets[target] = struct{}{} } case RefIndirect: From 106148ab764ee7facfcf2a29ae24dc8f894b11ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Tue, 27 Oct 2026 09:56:19 +0200 Subject: [PATCH 06/10] feat(tui): support function ABIs, live struct aliases, and rename re-keying in TUI --- MANUAL.md | 64 ++++++++++++++++++++++++++++---- internal/tui/actions.go | 8 ++++ internal/tui/keys.go | 71 +++++++++++++++++++++++++++++++++++- internal/tui/listing.go | 43 +++++++++++++++++++++- internal/tui/listing_test.go | 20 ++++++++++ internal/tui/popups.go | 23 ++++++++---- internal/tui/render.go | 11 +++++- internal/tui/types.go | 3 ++ 8 files changed, 225 insertions(+), 18 deletions(-) diff --git a/MANUAL.md b/MANUAL.md index 46677e9..bcbe3dd 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -175,7 +175,35 @@ hunks -m dump -f json > findings_state.json # export entries, region ## Agent Reverse-Engineering Playbook -This playbook defines the standard workflow for AI coding/reverse-engineering agents working on an Amiga binary with `hunks`. Every command is atomic and read-only (or a fast metadata mutation) — **do not write ad-hoc Python/shell scripts to grep raw bytes or scrape full assembly dumps when a `hunks` primitive answers the question directly**. +This playbook defines the standard workflow for AI coding/reverse-engineering agents working on an Amiga binary with `hunks`. Every command is atomic and read-only (or a fast metadata mutation) — **do not write ad-hoc Python/shell scripts to grep raw bytes or scrape full assembly dumps when a `hunks` primitive answers the question directly**, and **never run `emit` just to text-search the listing** — `search`/`xref`/`data` answer those questions against `project.db`. + +### 0. Recommended Order (Optimized Cold-Start Path) + +For a freshly `init`'d binary, work top-down to the spine, then bottom-up to name it: + +``` +1. analyze --trace seed code/vectors/dispatch on a self-booting binary +2. xref '$64..$7C' find installed interrupt vectors (async call-graph roots) +3. cg --indirect map the dispatch spine (state machines, jump tables) +4. search cluster:<...> locate each subsystem (3dmath/blitter/copper/paula/...) +5. cg --code read a routine + its "Globals & Memory Flow" block + + xref map every producer/consumer of each global it touches +6. data --struct --count decode record arrays AND verify fields against real bytes +7. rename -> abi -> comment bottom-up, only after reading each body (Section 4) +8. dump -f json reconcile DB state with your FINDINGS.md +``` + +**Three principles that prevent wrong turns:** +- **`xref` is the data-flow oracle; `search o:` is only substring text.** To map a global use + `xref ` (returns typed `READ`/`WRITE`/`ADDR`/`CALL`/`BRANCH`); fall back to `search 'o:$addr'` + only for the textual operand form or when `xref` is empty. +- **Trace resolves only what the playthrough exercised.** `[UNRESOLVED]` sites in `cg --indirect` + after `analyze --trace` are **normal** for data-driven dispatch (e.g. per-song audio step routines) — + resolve them statically with `hint pointer --follow` when a table is obvious, otherwise document the + table and move on. Do not treat unresolved as failure. +- **Verify every structural guess against bytes.** After `struct add`, always + `data --struct --count N` and sanity-check: do `.p` fields resolve to labels, are counts plausible, + does the stride line up? A wrong `--size`/field type is caught here, not later. ### 1. Command Selection Matrix (What to Use and When) @@ -224,9 +252,11 @@ When starting on a newly initialized module, map the top-level control flow befo ``` - Many games drive their main loop or per-entity update via a state-machine jump table (`move.l Table(pc,d0.w),a0; jsr (a0)`) or a function pointer in low RAM (`jsr (a4)`). - For any `[UNRESOLVED]` site in `cg --indirect`, inspect the enclosing function with `cg --code`, inspect the table address with `hunks -m data --len 64`, and mark pointer tables with `hunks -m hint pointer --follow` so all state handlers enter the call graph. -4. **Identify the Per-Frame Run Loop:** - - Inside `main` (or the primary state handler), locate the main loop (indicated by `+loop` in `cg` and a frame-sync call waiting on `VPOSR`/`VHPOSR` or a VBlank flag). - - The direct callees of that loop give you the spine of the application: **Input/AI Update $\rightarrow$ Camera/World Transform $\rightarrow$ Scene/Object Cull & Sort $\rightarrow$ Rasterize/Blit $\rightarrow$ Copper/Page Flip $\rightarrow$ Audio Tick**. + - **Expect residual `[UNRESOLVED]` sites.** `analyze --trace` only resolves dispatch the playthrough actually took; data-driven callbacks (per-song audio step routines, rarely-hit menu branches) legitimately stay unresolved. Resolve the ones backed by an obvious pointer table, document the rest — unresolved is not a failure. +4. **Identify the Per-Frame Run Loop or Mode Dispatcher:** + - The frame driver is often **not** a `VPOSR`/VBlank-wait loop but a **mode-code state dispatcher**: a handler that reads a mode byte and branches per value (`move.w $mode,d0; cmp.b #'E',d0hi; beq ...; cmp.b #'_',d0hi; beq ...`). Each matched mode is a separate top-level pipeline (menu / flight / debrief / map); map and name each handler independently. + - Otherwise locate the loop by `+loop` in `cg` plus a frame-sync call (VBlank flag or `VPOSR`/`VHPOSR` wait). + - The direct callees of the active handler give the spine of the application: **Input/AI Update $\rightarrow$ Camera/World Transform $\rightarrow$ Scene/Object Cull & Sort $\rightarrow$ Rasterize/Blit $\rightarrow$ Copper/Page Flip $\rightarrow$ Audio Tick**. --- @@ -247,13 +277,19 @@ Use cluster queries, mnemonic searches, and region XREF rollups to isolate each - Inspect a math routine with `cg --code` and check its **`=== Globals & Memory Flow ===`** block (e.g. `Reads: $1178, $117A...` / `Writes: $10E6...`). - Run `hunks -m xref 0x1178` (or `hunks -m xref '$1060..$1210'`) to find every producer that writes the matrix/workspace and every consumer that reads it. -#### B. Rendering, Blitter & Self-Modifying Fill Kernels +#### B. Rendering, Blitter & CPU Fill Kernels 1. **Locate Blitter & CPU rasterizer kernels:** ```bash hunks -m search cluster:blitter hunks -m search smc ``` - - Amiga 3D and 2D engines frequently use **Self-Modifying Code (`search smc`)** for polygon span fillers, line drawers, and unrolled bitplane clearers (patching an instruction's immediate mask or computing a dynamic branch offset into an unrolled tower of `move.w d0,-(a0)` / `bfins` instructions). + - `search smc` finds **true opcode-patching** — instructions that write into `CODE` cells: boot + relocators/decompressors, `rts`-toggle self-enable tricks, and the rarer dynamically-patched + immediate/branch. Expect **few** hits; treat each as a genuine runtime code mutation. + - **Unrolled fill kernels are usually NOT self-modifying.** CPU span fillers, line drawers, and + bitplane clearers are typically a **bank of unrolled or jump-table-dispatched kernels** (one per + color/length/slope), reached by a computed `jmp (pc,d.w)` — find those with `cg --indirect` + (resolved jump tables) and `search cluster:blitter`, not `search smc`. - When you find a contiguous bank of unrolled fill kernels or edge-mask tables (e.g. `+3DDEE..+3EFFF`), run a **region XREF rollup** to see every dispatcher that enters the bank without querying dozens of individual addresses: ```bash hunks -m xref '+3DDEE..+3EFFF' @@ -282,14 +318,21 @@ Use cluster queries, mnemonic searches, and region XREF rollups to isolate each ```bash hunks -m struct add ModelRec --size 18 0=Model_Radius.w 2=Model_Verts.p 6=Model_Faces.p 10=Model_NumVerts.w ``` - - Dump the entire table as decoded records to verify your field types against the actual bytes: + - Dump the entire table as decoded records and **verify before trusting the layout** — this step catches a wrong `--size` stride or mis-typed field immediately (do `.p` fields resolve to labels? are counts plausible? does record[1] start where record[0] ends?): ```bash hunks -m data 0x6B6E --struct ModelRec --count 16 ``` + If a field looks wrong (a `.p` that doesn't resolve, a count of thousands), adjust `--size`/field + types and re-decode. Never document a struct you haven't byte-verified this way. - Bind the base register **scoped to the consuming function** (`--fn`) so `2(a0)` renders as `Model_Verts(a0)` in `Poly_RenderObject` without polluting unrelated uses of `a0` elsewhere: ```bash hunks -m alias a0 ModelRec --fn Poly_RenderObject ``` +3. **Use `cg --suspect` to find table boundaries, not just bugs:** + - A function flagged `SUSPECT:DATA_*@+off` (fallthrough into data) is frequently the exact boundary + between a routine and an adjacent **vertex/pointer/jump table**. Inspect the suspect address with + `data ` / `data --struct ...`; if it is a pointer table, `hint pointer N --follow`; + if a record array, define a struct. Suspects are structure-discovery leads, not only cutoff errors. --- @@ -305,6 +348,10 @@ Follow a strict **bottom-up verification discipline** so annotations remain accu #### Rule 2: How and When to Add an ABI Envelope (`hunks abi`) - **Why ABI envelopes matter:** In M68000 assembly, arguments are passed in arbitrary registers (`d0`–`d7`, `a0`–`a6`) or scratch memory slots. Recording a function's ABI attaches `; [ABI: ...]` to every `jsr`/`bsr` call site in `cg --code` and `; ABI: ...` to emitted `.asm` function headers, eliminating the need to re-read callees when analyzing higher-level callers. +- **Canonical signature grammar** (one form everywhere, so `--verify` can parse it): + `(in_reg=role, ...) -> out_reg=role | clobbers: regs`. Inputs/outputs may be registers + (`d0.w`, `a0`) or memory globals (`$10E4=count`); `clobbers:` lists registers trashed and not + restored. Omit any empty section (`(a0=desc) -> void`). Keep it one line. - **Always check `Regs (static)` first:** Every `cg ` output displays the statically inferred register envelope: ```text Regs (static): in: d0,a0 | mod: d0,d1,a0,a1 | saved: d2,d3,a2 @@ -710,9 +757,10 @@ Instructions that jump/call across hunks or through a register jump-vector table | Key | Action | |-----|--------| -| `n` | Rename label at cursor (propagates across all segments) | +| `n` | Rename label at cursor (propagates across all segments and re-keys function ABIs/aliases) | | `;` | Edit inline comment at cursor (on `STRING` rows, opens the block comment editor so comments appear cleanly above `dc.b "...",0`) | | `:` | Edit block comment (emitted on its own `; ...` line(s) above the instruction/label/string) | +| `A` | Set or edit function ABI signature for the enclosing function (displayed above the function label, in `f` Functions list, and on `JSR`/`BSR` call rows) | | `#` | Cycle operand format (`HEX` → `DEC` → `BIN`) | ### Project & Tool Keys diff --git a/internal/tui/actions.go b/internal/tui/actions.go index e2191ae..a1aa24d 100644 --- a/internal/tui/actions.go +++ b/internal/tui/actions.go @@ -168,11 +168,19 @@ func (a *App) Undo() { } _ = a.DB.UpdateBlockComment(entry.Addr, cmt) case UndoRename: + curRen, hasCur, _ := a.DB.GetRename(entry.Key) if entry.OldStr != nil { _ = a.DB.SetRename(entry.Key, *entry.OldStr) + if hasCur && curRen != "" { + a.Proj.ReKeyFunctionABI(curRen, *entry.OldStr) + } } else { _ = a.DB.DeleteRename(entry.Key) + if hasCur && curRen != "" { + a.Proj.ReKeyFunctionABI(curRen, entry.Key) + } } + _ = a.Proj.Save() } a.ReloadAtCursor() a.StatusMsg = "Undone" diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 5e9e997..d949f4f 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -31,6 +31,7 @@ func ContextMenuItems() []string { "Rename (n)", "Comment (;)", "Block Comment (:)", + "Function ABI (A)", "Cycle Format (#)", "───────────────────────", "Export ASM (e)", @@ -74,12 +75,43 @@ func (a *App) executeContextAction(idx int) { case 13: a.openCommentModal(true) case 14: + a.openABIModal() + case 15: a.CycleOperandFormat() - case 16: + case 17: a.ExportASM() } } +func (a *App) openABIModal() { + addr, ok := a.CursorAddr() + if !ok { + return + } + fnEntries := a.DB.FunctionEntries() + fnAddr := addr + if len(fnEntries) > 0 { + idx, found := slices.BinarySearch(fnEntries, addr) + if !found && idx > 0 { + fnAddr = fnEntries[idx-1] + } + } + orig, hasLbl, _ := a.DB.GetLabel(fnAddr) + if !hasLbl || orig == "" { + orig = a.Proj.AutoLabel(fnAddr) + } + current := orig + if ren, hasRen, _ := a.DB.GetRename(orig); hasRen && ren != "" { + current = ren + } + existing := a.Proj.GetFunctionABI(current, orig, a.Proj.AutoLabel(fnAddr)) + a.Modal = Modal{ + Kind: ModalABI, + Input: NewTextInput(existing), + ABIFuncLabel: current, + } +} + func (a *App) openCommentModal(block bool) { if !block && a.SelectedIdx >= 0 && a.SelectedIdx < len(a.DisasmRows) { if a.DisasmRows[a.SelectedIdx].Kind == SubRowString { @@ -176,6 +208,8 @@ func (a *App) HandleKey(key string) bool { a.openCommentModal(false) case ":": a.openCommentModal(true) + case "A": + a.openABIModal() case "#": a.CycleOperandFormat() case "c": @@ -280,7 +314,18 @@ func (a *App) handleModalKey(modal Modal, key string) bool { orig = a.Proj.AutoLabel(addr) _ = a.DB.SetLabel(addr, orig, db.LabelLab, true) } + oldRen, hasOldRen, _ := a.DB.GetRename(orig) + var oldPtr *string + if hasOldRen { + oldCopy := oldRen + oldPtr = &oldCopy + } + a.pushUndo(UndoEntry{Kind: UndoRename, Key: orig, OldStr: oldPtr}) _ = a.DB.SetRename(orig, val) + a.Proj.ReKeyFunctionABI(orig, val) + if hasOldRen && oldRen != "" { + a.Proj.ReKeyFunctionABI(oldRen, val) + } _ = a.Proj.Save() if an, err := analyzer.New(a.Proj, a.DB); err == nil { an.ReanalyzeXrefs(addr) @@ -290,6 +335,23 @@ func (a *App) handleModalKey(modal Modal, key string) bool { case ComponentContinue: a.Modal = modal } + case ModalABI: + status, val := modal.Input.HandleKey(key) + switch status { + case ComponentDone: + if modal.ABIFuncLabel != "" { + a.Proj.SetFunctionABI(modal.ABIFuncLabel, strings.TrimSpace(val)) + _ = a.Proj.Save() + a.ReloadAtCursor() + if strings.TrimSpace(val) == "" { + a.StatusMsg = fmt.Sprintf("Cleared ABI for %s", modal.ABIFuncLabel) + } else { + a.StatusMsg = fmt.Sprintf("Set ABI for %s: %s", modal.ABIFuncLabel, strings.TrimSpace(val)) + } + } + case ComponentContinue: + a.Modal = modal + } case ModalComment: status, val := modal.Input.HandleKey(key) switch status { @@ -722,12 +784,14 @@ func (a *App) handleModalKey(modal Modal, key string) bool { } st.Fields = append(st.Fields[:st.Selected], st.Fields[st.Selected+1:]...) _ = a.Proj.Save() + a.ReloadAtCursor() } } else if st.Selected >= 0 && st.Selected < len(st.Structs) { name := st.Structs[st.Selected] st.Structs = append(st.Structs[:st.Selected], st.Structs[st.Selected+1:]...) delete(a.Proj.Data.Structs, name) _ = a.Proj.Save() + a.ReloadAtCursor() } a.Modal = modal case "enter": @@ -737,6 +801,9 @@ func (a *App) handleModalKey(modal Modal, key string) bool { st.Fields = nil if sMap, ok := a.Proj.Data.Structs[name]; ok { for k, v := range sMap { + if k == "_size" { + continue + } st.Fields = append(st.Fields, StructField{Offset: k, Name: v}) } slices.SortFunc(st.Fields, func(x, y StructField) int { @@ -832,6 +899,7 @@ func (a *App) handleModalKey(modal Modal, key string) bool { return cmp.Compare(xi, yi) }) _ = a.Proj.Save() + a.ReloadAtCursor() } st.InputMode = StructInputNone st.Input = "" @@ -869,6 +937,7 @@ func (a *App) handleModalKey(modal Modal, key string) bool { if modal.ListSelect >= 0 && modal.ListSelect < len(keys) { delete(a.Proj.Data.RegisterAliases, keys[modal.ListSelect]) _ = a.Proj.Save() + a.ReloadAtCursor() } a.Modal = modal default: diff --git a/internal/tui/listing.go b/internal/tui/listing.go index 330a1cb..e14a8cc 100644 --- a/internal/tui/listing.go +++ b/internal/tui/listing.go @@ -479,6 +479,10 @@ func buildXrefBadgesMap( mnem = caller.overrideMnem } else if rawSrc >= startAddr && rawSrc < endAddr && len(localCells) > 0 { if c := findContainingCellInSlice(localCells, rawSrc); c != nil { + if allLabels[targetAddr] == "" && c.Type == db.CellCode && + (strings.HasSuffix(c.OpStrVal(), ".w") || strings.Contains(c.OpStrVal(), ".w,")) { + continue + } snappedAddr = c.Addr mnem = c.MnemonicStr() if c.Type == db.CellPointer || c.Type == db.CellDataL { @@ -655,6 +659,13 @@ func buildListingRowsWithData( entryAddrs[ep.Addr] = true } } + fnEntrySet := make(map[uint32]bool, len(entryAddrs)+16) + for _, fnAddr := range ctrl.FunctionEntries() { + fnEntrySet[fnAddr] = true + } + for a := range entryAddrs { + fnEntrySet[a] = true + } var rows []ListingRow appendSpacerIfNeeded := func() { @@ -677,6 +688,16 @@ func buildListingRowsWithData( } badges := xrefsByTarget[addr] lbl := getLabelStr(addr) + if fnEntrySet[addr] { + if abiStr := proj.GetFunctionABI(lbl, proj.AutoLabel(addr)); abiStr != "" && !strings.Contains(blockText, "ABI:") { + abiLine := "ABI: " + abiStr + if blockText != "" { + blockText = abiLine + "\n" + blockText + } else { + blockText = abiLine + } + } + } if blockText != "" || len(badges) > 0 || lbl != "" { appendSpacerIfNeeded() @@ -934,6 +955,7 @@ func buildListingRowsWithData( addr := baseAddr expIdx := 0 + var curFuncLabel, curFuncAutoLabel string for addr < dataEnd { nextExpAddr := dataEnd if expIdx < len(explicitCells) { @@ -965,12 +987,20 @@ func buildListingRowsWithData( isStringCell := cell.Type == db.CellStr emitPreSubRows(addr, cell.Size, cell.Type.AsStr(), isStringCell, false) + if fnEntrySet[addr] { + if l := getLabelStr(addr); l != "" { + curFuncLabel = l + } else { + curFuncLabel = proj.AutoLabel(addr) + } + curFuncAutoLabel = proj.AutoLabel(addr) + } addrCopy := addr switch cell.Type { case db.CellCode: mnem := cell.MnemonicStr() - opStr := cell.OpStrVal() + opStr := proj.ApplyStructAliasesToOpStr(cell.OpStrVal(), curFuncLabel, curFuncAutoLabel) var timing m68k.Timing var isTerm bool if insn, ok := m68k.Decode(raw, addr); ok { @@ -1023,6 +1053,17 @@ func buildListingRowsWithData( } } + if (op == m68k.OpJSR || op == m68k.OpBSR) && refTgt != nil { + if calleeABI := proj.GetFunctionABI(getLabelStr(*refTgt), proj.AutoLabel(*refTgt)); calleeABI != "" { + abiTag := fmt.Sprintf("[ABI: %s]", calleeABI) + if targetHint == "" { + targetHint = abiTag + } else if !strings.Contains(targetHint, "ABI:") { + targetHint = targetHint + " " + abiTag + } + } + } + ac := commentsMap[addr] eolText, eolStyle := resolveEOLComment(ac, refTgt, targetHint, allTargetComments) diff --git a/internal/tui/listing_test.go b/internal/tui/listing_test.go index a5c62f4..2b877b6 100644 --- a/internal/tui/listing_test.go +++ b/internal/tui/listing_test.go @@ -217,4 +217,24 @@ func TestBuildListingRowsSubRowOrderXrefsCoalescingAndPriority(t *testing.T) { len(strRow.RawBytes) != 0 || strRow.ASCIIBox != "" || strRow.Comment != "" { t.Fatalf("unexpected SubRowString at $1024: %+v", strRow) } + + // 5. Verify FunctionABI header row at $1006 and call-site [ABI: ...] hint at $1000 + _ = ctrl.InsertFunction(0x1006, "SubTarget", 0) + proj.SetFunctionABI("SubTarget", "(d0.l=val) -> void") + rowsABI, _, err := BuildListingRows(proj, ctrl, seg, 120) + if err != nil { + t.Fatalf("BuildListingRows with ABI failed: %v", err) + } + var foundABIHeader, foundCallABI bool + for _, r := range rowsABI { + if r.Addr != nil && *r.Addr == 0x1006 && r.Kind == SubRowBlockComment && strings.Contains(r.Comment, "ABI: (d0.l=val) -> void") { + foundABIHeader = true + } + if r.Addr != nil && *r.Addr == 0x1000 && r.IsPrimary && strings.Contains(r.Comment, "[ABI: (d0.l=val) -> void]") { + foundCallABI = true + } + } + if !foundABIHeader || !foundCallABI { + t.Fatalf("expected ABI header at $1006 (%v) and call-site ABI at $1000 (%v)", foundABIHeader, foundCallABI) + } } diff --git a/internal/tui/popups.go b/internal/tui/popups.go index c3f08c2..9c78d73 100644 --- a/internal/tui/popups.go +++ b/internal/tui/popups.go @@ -221,7 +221,12 @@ func (a *App) OpenFunctions() { if sc, ok := a.SubCycles[addr]; ok { cycStr = sc.FormatSummary() } - funcs[i] = FuncItem{Addr: addr, Name: name, Cycles: cycStr} + funcs[i] = FuncItem{ + Addr: addr, + Name: name, + Cycles: cycStr, + ABI: a.Proj.GetFunctionABI(name, a.Proj.AutoLabel(addr)), + } } a.Modal = Modal{ Kind: ModalFunctions, @@ -411,17 +416,21 @@ func (a *App) searchBytes(content string, targetSeg *project.Segment, rng *db.Ad func (a *App) searchCluster(preset string, rng *db.AddrRange) []SearchHit { var sig clusterSig - switch strings.TrimSpace(preset) { - case "3dmath": + switch strings.ToLower(strings.TrimSpace(preset)) { + case "3dmath", "math": sig = clusterSig{mnems: []string{"mul", "div"}} case "blitter": sig = clusterSig{offs: [][2]uint16{{0x040, 0x074}}} case "copper": sig = clusterSig{offs: [][2]uint16{{0x080, 0x08A}}} - case "bitplane": - sig = clusterSig{offs: [][2]uint16{{0x08E, 0x094}, {0x0E0, 0x0FE}, {0x100, 0x10A}}} - case "audio": - sig = clusterSig{offs: [][2]uint16{{0x0A0, 0x0DF}}} + case "bitplane", "display", "video": + sig = clusterSig{offs: [][2]uint16{{0x08E, 0x094}, {0x0E0, 0x0FE}, {0x100, 0x10A}, {0x180, 0x1BE}}} + case "audio", "paula": + sig = clusterSig{offs: [][2]uint16{{0x0A0, 0x0DF}, {0x09E, 0x09E}}} + case "sprite", "sprites": + sig = clusterSig{offs: [][2]uint16{{0x120, 0x17E}}} + case "dma", "irq", "interrupt": + sig = clusterSig{offs: [][2]uint16{{0x002, 0x002}, {0x01C, 0x01E}, {0x096, 0x09C}}} default: return nil } diff --git a/internal/tui/render.go b/internal/tui/render.go index 952957f..04f8e7b 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -866,6 +866,12 @@ func renderModal(buf *screenBuffer, a *App, screen rect) { renderInputModal(buf, screen, " Comment ", &m.Input, fgYellow) case ModalBlockComment: renderInputModal(buf, screen, " Block Comment ", &m.Input, fgYellow) + case ModalABI: + title := " Function ABI " + if m.ABIFuncLabel != "" { + title = fmt.Sprintf(" Function ABI (%s) ", m.ABIFuncLabel) + } + renderInputModal(buf, screen, title, &m.Input, fgCyan) case ModalGoto: title := " Goto (hex or label) " if a.IsRelocatable { @@ -957,6 +963,9 @@ func renderModal(buf *screenBuffer, a *App, screen rect) { if fn.Cycles != "" { spans = append(spans, styledSpan{text: fn.Cycles, style: cellStyle{fg: fgYellow}}) } + if fn.ABI != "" { + spans = append(spans, styledSpan{text: " " + fn.ABI, style: cellStyle{fg: fgCyan}}) + } rows[i] = spans } renderListModal(buf, area, title, fgGreen, rows, m.ListSelect) @@ -992,7 +1001,7 @@ func renderModal(buf *screenBuffer, a *App, screen rect) { " Ctrl-d/u Half page Home/End Top/Bottom", "", "Annotation:", - " n Rename ; Comment : Block comment", + " n Rename ; Comment : Block comment A ABI", " # Cycle format (HEX/DEC/BIN)", "", "Type Marking:", diff --git a/internal/tui/types.go b/internal/tui/types.go index 5db8bbc..6036676 100644 --- a/internal/tui/types.go +++ b/internal/tui/types.go @@ -169,6 +169,7 @@ const ( ModalBitplaneGeometry ModalBitplanePalette ModalMusicRipper + ModalABI ) // SearchHit represents a single search result row. @@ -189,6 +190,7 @@ type FuncItem struct { Addr uint32 Name string Cycles string + ABI string } // SubroutineCycles holds precomputed self and inclusive cycle bounds for a subroutine. @@ -306,6 +308,7 @@ type Modal struct { Bitplane *BitplaneState PixelProtocol PixelProtocol MusicRipper MusicRipperState + ABIFuncLabel string } // UndoKind identifies the kind of undo entry. From 077959554fc131842629013d3301debb23522cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Tue, 27 Oct 2026 12:57:08 +0200 Subject: [PATCH 07/10] docs: update ARCHITECTURE.md and README.md for agent CLI and TUI features --- ARCHITECTURE.md | 63 ++++++++++++++++++++++++++++++++----------------- README.md | 13 ++++++---- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b8c974e..e20bf28 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,20 +10,22 @@ │ │ │ ▼ ▼ ▼ ┌───────────────────────────────────────────────────────────────────┐ -│ Unified 3-Stage Analyzer │ +│ Unified 4-Stage Analyzer │ │ Stage 1: Ground-Truth Seeding (HUNK_RELOC32, XDEF, Entry Points, │ │ LAB__ / LAB_ Labels) │ │ Stage 2: Unified Worklist Code Tracer + Jump/Vector/Trampoline │ -│ Tables + Monotonic Sandbox-Validated Gap Sweep │ +│ Tables + Sandbox Gap Sweep + Base-Reg Propagation │ │ Stage 3: Label/Reloc-Bounded Data Classification & │ │ Relocation-Exact Operand Formatting │ +│ Stage 4: Subroutine Boundary, Stack-Frame & No-Return Inference │ └───────────────┬───────────────────────────────┬───────────────────┘ │ │ ▼ ▼ ┌───────────────────────────────┐ ┌─────────────────────────────────┐ -│ CallGraph (`internal/callgraph`) │ │ Emitter (`internal/emitter`) │ -│ Subroutine & d16(a5) vector │ │ Slices unified address space │ -│ inference, worklists, --code │ │ + source-linkable `; XREF:` │ +│ CallGraph & Agent CLI │ │ Emitter (`internal/emitter`) │ +│ `cg` (`--indirect`, `--code`),│ │ Slices unified address space │ +│ `xref`, `data`, `search`, │ │ + `; XREF:`, `ABI:`, & struct │ +│ `abi --verify`, `dump` │ │ field displacement rendering │ └───────────────────────────────┘ └─────────────┬───────────────────┘ │ ┌──────────────┴──────────────┐ @@ -39,15 +41,16 @@ ``` cmd/ └── hunks/ - └── main.go CLI entry point (cobra), command dispatch (`init`, `analyze`, `emit`, `cg`, `tui`, ...) + ├── main.go CLI entry point (cobra), core dispatch (`init`, `analyze`, `emit`, `cg`, `hint`, `rename`, `comment`, `tui`, ...) + └── agent_cmds.go Query, inspection & annotation commands (`xref`, `data --copper/--struct`, `search`, `abi`, `region`, `dump`) internal/ ├── hunk/ │ └── parser.go AmigaOS Hunk executable parser (CODE/DATA/BSS, RELOC32/DREL32, CHIP/FAST) ├── project/ -│ └── project.go Project config: segments, HunkType, MemoryType, spaces, AutoLabel & ResolveTarget +│ └── project.go Project config: segments, spaces, `MemoryRegion`, `Structs`, global/scoped `RegisterAliases`, `FunctionABIs`, `AutoLabel` & `ResolveTarget` ├── db/ │ ├── types.go Domain enums (`CellType`, `EdgeKind`, `XrefKind`, `LabelKind`) -│ └── controller.go Shared SQLite persistence layer (`modernc.org/sqlite`) + jump-vector XREF resolution +│ └── controller.go Shared SQLite persistence layer (`modernc.org/sqlite`) + jump-vector XREF & range queries ├── amiga/ │ └── equates.go Hardware register/vector name tables & `equates.i` generator ├── m68k/ @@ -59,15 +62,15 @@ internal/ │ ├── classify.go RefType classification + M68000 relocation/instruction validator (`internal/m68k`) │ ├── stage2_code.go Unified worklist descent, jump/vector/trampoline tables, sandbox gap sweep │ ├── stage3_data.go Label/relocation-bounded data & BSS classification -│ ├── stage4_funcmeta.go Subroutine boundary & register liveness / metadata inference +│ ├── stage4_funcmeta.go Subroutine boundary, no-return status & stack-frame metadata inference │ ├── format.go Relocation-exact (Hunk mode) & range-validated (Flat mode) operand formatter -│ ├── baseregs.go Base-register (`a4`/`a5`/`a6`) invariant resolution +│ ├── baseregs.go Base-register (`a0`..`a6`) invariant & custom hardware displacement resolution │ ├── trace.go Trace-driven analysis (`analyze --trace`): `m68kemu` sandbox recorder + analysis bus │ └── chipset.go Minimal 6-level Amiga OCS/ECS chipset state machine (Agnus/Denise/Paula/Blitter/Copper/CIA) ├── emitter/ -│ └── emitter.go Single-file & modular multi-hunk assembly generator + source-linkable XREFs +│ └── emitter.go Single-file & modular multi-hunk assembly generator + source-linkable `; XREF:`, `ABI:` headers, and struct field aliases ├── callgraph/ -│ └── callgraph.go Subroutine inference, d16(An) jump-vector resolution, worklists, DOT/Mermaid/JSON/text +│ └── callgraph.go Subroutine inference, d16(An) jump-vector & `--indirect` dispatch resolution, static ABI verification (`VerifyABI`), worklists, `--code` ├── modules/ │ ├── modules.go Pure byte-buffer module recognizer registry (`Recognizer`, `Match`, `Region`, `Scan`) │ ├── sandbox.go Bounded M68000 (`m68kemu`) scratch-RAM probe for self-executing drivers @@ -81,11 +84,11 @@ internal/ │ └── ... 19 additional ExoticRipper format recognizers (ThePlayer, TFMX, OctaMED, Hippel, ...) └── tui/ ├── types.go Enums, structs (`Modal`, `ListingRow`, `SubRowKind`, `UndoEntry`, `TextInput`) - ├── listing.go `BuildListingRows`: Sub-Row Code View builder, 16-byte `dc.b` coalescer, 3-tier EOL comment priority + ├── listing.go `BuildListingRows`: Sub-Row Code View builder, 16-byte `dc.b` coalescer, `ABI:` headers/call-site hints, struct aliases, 3-tier EOL comment priority ├── app.go `App` (`tea.Model`) + segment/status management - ├── actions.go Interactive type marking (`c`/`b`/`w`/`l`/`p`/`s`/`u`), virtual segment carve/uncarve (`K`), bitplane/music ripper actions (`B`/`M`), & transactional undo (`Ctrl-z`) - ├── nav.go Cursor, byte-offset (`←`/`→`), & automatic cross-hunk / jump-vector reference navigation - ├── popups.go XREFs (`x`), Functions (`f`), Search (`/`), Structs (`t`), and Segment/Space popups + ├── actions.go Interactive type marking (`c`/`b`/`w`/`l`/`p`/`s`/`u`), rename (`n`), ABI (`A`), virtual segment carve/uncarve (`K`), bitplane/music ripper (`B`/`M`), & undo (`Ctrl-z`) + ├── nav.go Cursor, byte-offset (`←`/`→`), `cluster:` search presets, & automatic cross-hunk / jump-vector reference navigation + ├── popups.go XREFs (`x`), Functions (`f`), Search (`/`), Structs (`t`), ABI (`A`), and Segment/Space popups ├── keys.go Keyboard/mouse event handling ├── bitplane.go Interactive Amiga Bitplane Image Viewer (`B`), `BPLCON0`/modulo geometry, planar/EHB/HAM6 decoder, `FORM ILBM` encoder, `XTWINOPS` (`\x1b[14t`) pixel sizing, & block comment round-trip ├── pixelgfx.go Terminal pixel graphics encoders (`Kitty`, `iTerm2`, `Sixel`, Unicode `Half-Block`) @@ -103,12 +106,18 @@ internal/ "entry_points": [ { "addr": 0, "name": "LAB_0_00000", "segment": "hunk0" } ], - "memory_regions": [], + "memory_regions": [ + { "base": 4096, "size": 4096, "name": "Globals_LowRAM", "comment": "Zero-page / .w globals" } + ], "structs": { - "ShipObj": { "90": "so_Type" } + "ShipObj": { "_size": "128", "90": "so_Type" } }, "register_aliases": { - "a0": "ShipObj" + "a0": "ShipObj", + "UpdateShip:a1": "ShipObj" + }, + "function_abis": { + "UpdateShip": "(a1=ShipObj* -> d0=status | clobbers: d1/a0)" }, "segments": [ { @@ -130,6 +139,12 @@ internal/ } ``` +**Live project metadata (`memory_regions`, `structs`, `register_aliases`, `function_abis`).** +Non-byte-affecting reverse-engineering metadata lives in `project.json` and is applied dynamically at view/emit time (`cg --code`, `xref`, `data`, `emit`, and `tui`) without re-running `analyze`: +- `memory_regions`: Named address ranges outside (or inside) the loaded binary (e.g. BSS, low-RAM `.w` globals, custom buffers) resolved by `xref` and `data`. +- `structs` & `register_aliases`: Struct definitions (with optional `_size` stride for `data --struct`) and global (`"a0"`) or function-scoped (`"FuncName:a1"`) base-register bindings rendered into symbolic `Struct.field(An)` operands via `ApplyStructAliasesToOpStr`. +- `function_abis`: Human/agent-authored subroutine ABI envelopes (`(inputs -> outputs | clobbers: ...)`) rendered as `; ABI:` header banners and `[ABI: ...]` call-site annotations, and automatically re-keyed across CLI/TUI renames (`ReKeyFunctionABI`). + **Segments, spaces, split & carve.** The project owns a flat pool of all `segments`; a `Space` is a named, ordered selection of them that forms one self-contained address space, and addressing resolves through `active_space` @@ -158,16 +173,17 @@ Every segment-scoped table includes `seg TEXT NOT NULL` as the leading primary-k - **`operand_formats`** — per-instruction numeric base overrides (`HEX`, `DEC`, `BIN`), keyed by `(seg, addr)` - **`base_regs`** — base-register (`a4`/`a5`/`a6`) invariants, keyed by `(scope_kind, scope_key, reg)` -## Analyzer Internals (Unified 3-Stage Pipeline) +## Analyzer Internals (Unified 4-Stage Pipeline) ### Stage 1: Ground-Truth Seeding & Relocation Invariant 1. **Relocation Table Authority:** In multi-hunk executables, every `HUNK_RELOC32` entry is stored in `relocs` (`site_addr -> target_addr`). All relocation targets receive a deterministic `LAB_{tgt_hunk}_{offset:05X}` label, and any cross-segment reference sets `is_exported = 1` (`XDEF`). 2. **M68000 Relocation Invariant:** Because the first 2 bytes of every M68000 instruction is the opcode word (never 32-bit relocated), no instruction can ever start inside `[site_addr .. site_addr + 4)`, and any relocation falling inside an instruction `[addr + 1 .. addr + size)` must align with a valid 32-bit immediate or `.l` extension word of that instruction. -### Stage 2: Unified Worklist Code Tracer + Sandbox-Validated Gap Sweep +### Stage 2: Unified Worklist Code Tracer, Sandbox Gap Sweep & Base-Register Propagation 1. **Worklist Recursive Descent (`ConfDefinite` / `ConfHigh`):** - Pops addresses from a single shared worklist spanning all `Code` segments. - Follows direct branches/calls (`BSR`, `JSR`, `BRA`, `JMP`, `Bcc`, `DBcc`), PC-relative word-offset jump tables (`JMP`/`JSR d8(PC,Xn)`), and contiguous trampoline/vector tables (`JMP target.L`, `BRA.W target`). + - Records `xrefs` across both mapped segments and unmapped/low-RAM regions (including sign-extended `.w` `EAAbsShort` globals, `$DFFxxx`/`$BFxxxx` hardware registers, and 24-bit `movea.l #imm,An` address loads in flat mode), while creating `LAB_` auto-labels only for in-segment non-`.w` references so `.w` instruction sizing is never perturbed. - Pushes code-segment addresses referenced via `HUNK_RELOC32` or `LEA`/`PEA`/`#imm` into `candidateCode`. 2. **Monotonic Sandbox-Validated Gap Sweep (`ConfMedium`):** - Evaluates `candidateCode` and walks unmapped `Code` gaps using per-segment monotonic cursors. @@ -175,6 +191,8 @@ Every segment-scoped table includes `seg TEXT NOT NULL` as the leading primary-k - Brief extension word bits `10..8 == 0` (`(ext_word & 0x0700) == 0`) on indexed memory modes (`d8(An,Xn)` and `d8(PC,Xn)`). - Byte-immediate extension word high-byte zero (`bytes[2] == 0`) on `.b` immediate instructions and static bit ops (`btst`/`bchg`/`bclr`/`bset`). - No interior label or relocation overlap, and clean termination at `RTS`/`RTE`/`RTR`/`BRA`/`JMP` or verified `CODE`. +3. **Base-Register Propagation (`propagateBaseRegs`):** + - Propagates invariant `a0`..`a6` base registers across the CFG to resolve `d16(An)` jump-vector calls, global data XREFs, and custom hardware register displacements (`$DFF000`). ### Stage 3: Label/Relocation-Bounded Data Classification & Operand Formatting 1. **Data Gap Classification:** @@ -185,6 +203,9 @@ Every segment-scoped table includes `seg TEXT NOT NULL` as the leading primary-k - In **Hunk Mode**, formats `.l` immediate/absolute operands as `LAB_{hunk}_{offset:05X}` **if and only if** the operand's extension-word address is in `relocs`. - In **Flat Mode**, in-segment `.l` operands matching valid segment labels are formatted symbolically at analysis time, while out-of-segment `.l` targets stay numeric in `cells.op_str` and are resolved dynamically against the active `Space`'s `all_labels` at view/emit time (`ResolveFlatCrossSegOperands` + external `equ` directives). +### Stage 4: Subroutine Boundary, Stack-Frame & No-Return Inference +- Infers subroutine entry points and contiguous instruction bounds (`functions` table), detects `LINK`/`PEA` stack-frame sizes and non-returning subroutines, and prunes unreachable fallthrough edges past non-returning calls. + ## Trace-Driven Analysis (`analyze --trace`) An opt-in pass for **flat self-booting binaries** (games/demos) that runs the diff --git a/README.md b/README.md index c829574..41899a6 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,19 @@ Amiga M68k Reverse Engineering System — terminal-native, keyboard-first disass ## Features - **Workspace + Module namespacing (`-p ` + `-m `)**: Automatically deduces `` from binary filenames (`work//`, `src//hunk*.asm`, `disasm/.asm`, shared `src/equates.i`) with `--force` overwrite protection so multiple binaries in the same workspace never collide -- **Unified 3-stage M68000 disassembler** for both multi-hunk AmigaOS executables (`HUNK_CODE`, `HUNK_DATA`, `HUNK_BSS`, `CHIP`/`FAST`) and flat ROM/disk binaries +- **Unified 4-stage M68000 disassembler** for both multi-hunk AmigaOS executables (`HUNK_CODE`, `HUNK_DATA`, `HUNK_BSS`, `CHIP`/`FAST`) and flat ROM/disk binaries - **Trace-seeded M68000 + Amiga OCS/ECS chipset analysis (`analyze --trace`)**: Built-in sandboxed M68000 interpreter (`m68kemu`) + deterministic Agnus/Denise/Paula/Blitter/Copper/8520-CIA state machine that resolves indirect dispatch, self-installed interrupt/trap handlers, and base-register hardware accesses by observation - **Shared SQLite database (`work//project.db`)** and unified virtual address space across all segments of a module (powered by pure-Go `modernc.org/sqlite`) - **Relocation-exact symbol resolution** (`HUNK_RELOC32` / `HUNK_DREL32`), automatic cross-hunk `XDEF` exports, and collision-free `LAB__` auto-labels (`LAB_5_24BE8`) - **Source-linkable `; XREF:` comments** (`; XREF: hunk5:LAB_5_24E84 (jsr 498(a5))`), automatic cross-hunk / jump-vector target hints (`; -> hunk3:LAB_3_26432`), and **Amiga custom register bitfield auto-comments** (`DMACON`, `INTENA`, `INTREQ`, `ADKCON`, `BPLCON0`, `BLTCON0`/`BLTCON1`, `BLTSIZE` decoded into persistent auto-comments that survive `emit`) -- **Static Call Graph CLI (`hunks -m cg`)** with automatic `d16(a5)` jump-vector resolution, self & inclusive (`+callees`) MC68000 cycle bounds (`+loop`, `+ind`), bottom-up `--ready` worklists, `--leaves`, `--anchors` (strings & `$DFFxxx` hardware), `--suspect` audits, and `--code` caller/callee inspection +- **Static Call Graph CLI (`hunks -m cg`)** with automatic `d16(a5)` jump-vector resolution, backward dataflow slicing for register-indirect and indexed jump-table dispatches (`--indirect`), self & inclusive (`+callees`) MC68000 cycle bounds (`+loop`, `+ind`), bottom-up `--ready` worklists, `--leaves`, `--anchors` (strings & `$DFFxxx` hardware), `--suspect` audits, and rich `--code` inspection (inline comments, `ABI:` headers & call-site hints, and live struct field aliases) +- **Data-Flow, Memory & Instruction Query CLI (`xref`, `data`, `search`, `abi`, `region`, `dump`)**: + - `hunks -m xref `: bidirectional cross-reference inspector and address-range rollup (`R`/`W`/`C`/`B`/`P` breakdown by caller function), covering in-image symbols, `.w` low-RAM globals, custom hardware registers (`BLTSIZE`, `COP1LCH`), and declared `region` blocks + - `hunks -m data [len]`: typed hex/ASCII/word/long/pointer inspector, **Amiga Copper list disassembler (`--copper`)**, and **struct array table formatter (`--struct --count `)** + - `hunks -m search `: instruction/operand/comment search with mnemonic (`m:`), operand (`o:`), hot-loop (`--loop`), dense math/graphics cluster (`cluster:3dmath|mul|div|trig|blitter|copper|audio|sprite --dense`), and **self-modifying code (`smc`)** detectors + - `hunks -m abi` & `comment +`: persistent subroutine ABI envelopes (`--verify` checks claimed inputs/clobbers against static register dataflow), instruction-boundary-validated function-relative comments, and structured knowledge export (`hunks -m dump --format json|md`) - **Standalone Amiga music module scanner (`hunks rip `, experimental)** with 26 ExoticRipper recognizers (including sandboxed self-executing M68000 driver probes for David Whittaker and Final Music System) -- **Interactive TUI (`hunks -m tui`)** with full-width Sub-Row Code View, inline 16-byte `dc.b` hex/ASCII grid, 3-tier EOL comment priority, exact MC68000 single-instruction, block-selection (`v`), and subroutine (`V`) self + inclusive (`[incl: ... cyc]`) cycle & R/W bus timing, interactive **Amiga Bitplane Image Viewer & Ripper (`B`)** (Kitty/iTerm2/Sixel/Half-Block pixel graphics, `BPLCON0` 1–7 planes, EHB/HAM6, Copper/`$0RGB` palette extraction, PNG & `FORM ILBM` export), experimental **TUI Music Ripper (`M`)** (scan, jump, rip, & carve), virtual segment `incbin` carves (`v` + `K` in editor, `K` in asset viewers with metadata block comments and round-trip on `B`), configurable truecolor themes (`~/.config/hunks/theme.json`), vertical scrollbar, floating modal overlays, byte-exact cursor splitting, cross-hunk `Enter` navigation, `x` XREFs modal, `f` Functions filter, and struct/alias editors +- **Interactive TUI (`hunks -m tui`)** with full-width Sub-Row Code View, inline 16-byte `dc.b` hex/ASCII grid, 3-tier EOL comment priority, exact MC68000 single-instruction, block-selection (`v`), and subroutine (`V`) self + inclusive (`[incl: ... cyc]`) cycle & R/W bus timing, interactive **Amiga Bitplane Image Viewer & Ripper (`B`)** (Kitty/iTerm2/Sixel/Half-Block pixel graphics, `BPLCON0` 1–7 planes, EHB/HAM6, Copper/`$0RGB` palette extraction, PNG & `FORM ILBM` export), experimental **TUI Music Ripper (`M`)** (scan, jump, rip, & carve), virtual segment `incbin` carves (`v` + `K` in editor, `K` in asset viewers with metadata block comments and round-trip on `B`), configurable truecolor themes (`~/.config/hunks/theme.json`), vertical scrollbar, floating modal overlays, byte-exact cursor splitting, cross-hunk `Enter` navigation, `x` XREFs modal, `f` Functions filter, `A` Function ABI editor, and live `t` struct/scoped register alias editors - **100% bit-for-bit reassembly** verified with `vasmm68k_mot` ## Quick Start @@ -61,7 +66,7 @@ hunks -m disk_1 tui ## Documentation -- [MANUAL.md](MANUAL.md) — Complete user manual with CLI/TUI reference and end-to-end walkthroughs +- [MANUAL.md](MANUAL.md) — Complete user manual with CLI/TUI reference, Agent Reverse-Engineering Playbook, and end-to-end walkthroughs - [ARCHITECTURE.md](ARCHITECTURE.md) — System internals: package map, data model (`project.json` + `project.db` schema), analyzer pipeline, emitter, and call graph - [TESTING.md](TESTING.md) — Verification and regression test procedures - [AGENTS.md](AGENTS.md) — Development guidelines, invariants, and coding standards From 2a43ed6ce4ef20374223116f55f45bfa7e2b1cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Tue, 27 Oct 2026 13:58:46 +0200 Subject: [PATCH 08/10] fix(emitter): emit interior label equates for carved incbin cells and external equates in EmitAllAmiga --- internal/emitter/emitter.go | 44 ++++++++++++++++++-- internal/emitter/emitter_test.go | 71 ++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/internal/emitter/emitter.go b/internal/emitter/emitter.go index f6dc716..37428f1 100644 --- a/internal/emitter/emitter.go +++ b/internal/emitter/emitter.go @@ -1053,6 +1053,25 @@ func (e *Emitter) PopulateCellsItems() error { e.addItemSizedAt(addr, label, mnem, opStr, commentStr, cell.Size) } + if cell.Size > 1 && cell.Type != db.CellCode { + idxStart, _ := slices.BinarySearch(allLabelAddrs, addr+1) + idxEnd, _ := slices.BinarySearch(allLabelAddrs, addr+cell.Size) + for _, intAddr := range allLabelAddrs[idxStart:idxEnd] { + intLbl := getLabelStr(intAddr) + if intLbl == "" { + continue + } + if e.Relocatable && isEntry(intAddr) { + e.addItem("", "xdef", intLbl, "") + } + if label != "" { + e.addItemAt(intAddr, intLbl, "equ", fmt.Sprintf("%s+$%X", label, intAddr-addr), "") + } else { + e.addItemAt(intAddr, intLbl, "equ", fmt.Sprintf("*-$%X", (addr+cell.Size)-intAddr), "") + } + } + } + addr += cell.Size } @@ -1379,16 +1398,33 @@ func EmitAllAmiga(proj *project.Project, outDir string, force bool) error { equatesLines = append(equatesLines, fmt.Sprintf("%s\tequ\t%s", seq.name, formatSignedEquHex(seq.offset))) } } - if err := os.WriteFile(filepath.Join(srcDir, "equates.i"), []byte(strings.Join(equatesLines, "\n")+"\n"), 0o644); err != nil { - return err - } - // 2. Emit each modular hunk to src//{seg.Name}.asm proj.SyncDBScope(sharedCtrl) sharedCache, err := buildSpaceCache(sharedCtrl) if err != nil { return err } + var extEquates []string + for _, addr := range sharedCache.sortedLabelAddrs { + if proj.SegmentAtAddr(addr) != nil { + continue + } + if _, isHW := amiga.GetAmigaHWLabel(addr); isHW { + continue + } + if lbl := sharedCache.allLabels[addr]; lbl != "" { + extEquates = append(extEquates, fmt.Sprintf("%s\tequ\t$%X", lbl, addr)) + } + } + if len(extEquates) > 0 { + equatesLines = append(equatesLines, "", "; --- External/BSS Equates ---") + equatesLines = append(equatesLines, extEquates...) + } + if err := os.WriteFile(filepath.Join(srcDir, "equates.i"), []byte(strings.Join(equatesLines, "\n")+"\n"), 0o644); err != nil { + return err + } + + // 2. Emit each modular hunk to src//{seg.Name}.asm hunkFiles := make([]string, 0, len(segments)) for _, seg := range segments { proj.ActiveSegmentName = seg.Name diff --git a/internal/emitter/emitter_test.go b/internal/emitter/emitter_test.go index 3233315..b1b1232 100644 --- a/internal/emitter/emitter_test.go +++ b/internal/emitter/emitter_test.go @@ -602,3 +602,74 @@ func TestStructEquatesAndABIEmission(t *testing.T) { t.Fatalf("expected Model_VX(a0),d0 in emitted asm:\n%s", text) } } + +func TestIncbinInteriorLabelsEmitEquates(t *testing.T) { + tmpDir := t.TempDir() + // 0x10000: lea $1000C.l,a0 41 F9 00 01 00 0C + // 0x10006: rts 4E 75 + // 0x10008..0x10010: 8-byte data blob with interior label at 0x1000C + codeBytes := []byte{ + 0x41, 0xF9, 0x00, 0x01, 0x00, 0x0C, + 0x4E, 0x75, + 0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44, + } + binPath := filepath.Join(tmpDir, "main.bin") + if err := os.WriteFile(binPath, codeBytes, 0o644); err != nil { + t.Fatal(err) + } + dbPath := filepath.Join(tmpDir, "main.db") + ctrl, err := db.New(dbPath) + if err != nil { + t.Fatal(err) + } + defer ctrl.Close() + + proj := project.New(filepath.Join(tmpDir, "project.json")) + proj.WorkspaceRoot = tmpDir + proj.Data.Segments = append(proj.Data.Segments, project.Segment{ + Name: "main", + HunkIndex: 0, + HunkType: hunk.HunkTypeCode, + MemoryType: hunk.MemoryPublic, + AllocSize: uint32(len(codeBytes)), + File: &binPath, + Base: 0x10000, + DB: dbPath, + Relocatable: false, + }) + proj.Data.Spaces = append(proj.Data.Spaces, project.Space{ + Name: "default", + Segments: []string{"main"}, + }) + proj.Data.ActiveSpace = "default" + + an, err := analyzer.New(proj, ctrl) + if err != nil { + t.Fatal(err) + } + an.Analyze([]uint32{0x10000}) + + // Carve 0x10008..0x10010 (spanning over LAB_1000C) as INCBIN. + _ = ctrl.DeleteCellsInRange(0x10008, 0x10010) + _ = ctrl.SetLabel(0x10008, "CarvedBlob", db.LabelLab, true) + fileStr := "assets/CarvedBlob.bin" + _ = ctrl.InsertCell(&db.Cell{ + Addr: 0x10008, + Size: 8, + Type: db.CellIncbin, + OpStr: &fileStr, + RawBytes: codeBytes[8:16], + }) + + em, err := New(proj, ctrl) + if err != nil { + t.Fatal(err) + } + if err := em.GenerateItems(false); err != nil { + t.Fatal(err) + } + text := strings.Join(em.FormatLines(), "\n") + if !strings.Contains(text, "LAB_1000C\tequ\tCarvedBlob+$4") { + t.Fatalf("expected interior label LAB_1000C equ CarvedBlob+$4 after incbin, got:\n%s", text) + } +} From 56da14adbfdec52910c075a92a6b46f892573918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Tue, 27 Oct 2026 15:04:25 +0200 Subject: [PATCH 09/10] fix: address code review issues across callgraph, search, xref, comment, and analyzer --- MANUAL.md | 2 +- cmd/hunks/agent_cmds.go | 256 +++++++++++++++++---------- cmd/hunks/main.go | 37 ++-- internal/analyzer/classify.go | 2 +- internal/analyzer/trace.go | 1 - internal/callgraph/callgraph.go | 219 ++++++++++++++--------- internal/callgraph/callgraph_test.go | 4 + internal/tui/popups.go | 12 +- 8 files changed, 339 insertions(+), 194 deletions(-) diff --git a/MANUAL.md b/MANUAL.md index bcbe3dd..f57d610 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -140,7 +140,7 @@ Every CLI command that takes a `TARGET` (`callgraph`, `hint`, `comment`, `operan hunks -m search m:muls.w # mnemonic prefix match hunks -m search 'o:$1178' # operand substring match hunks -m search cluster:3dmath --dense # dense pockets (>=3 hits in 24B) of muls/mulu/divs/divu -hunks -m search cluster:blitter # blitter, copper, paula, bitplane, sprite, cia, dma +hunks -m search cluster:blitter # presets: 3dmath, mul, div, trig, blitter, copper, paula, bitplane, sprite, cia, dma, irq hunks -m search smc # self-modifying code writes into CODE cells ``` diff --git a/cmd/hunks/agent_cmds.go b/cmd/hunks/agent_cmds.go index ad2581e..76ed212 100644 --- a/cmd/hunks/agent_cmds.go +++ b/cmd/hunks/agent_cmds.go @@ -431,6 +431,16 @@ func matchesClusterInstruction(clusterName string, mnem, opStr, comment string) return true } return false + case "mul": + return baseMnem == "muls" || baseMnem == "mulu" + case "div": + return baseMnem == "divs" || baseMnem == "divu" + case "trig", "sin", "cos": + switch baseMnem { + case "muls", "mulu", "asr", "lsr", "lsl", "asl": + return strings.Contains(opStr, "(a") && strings.Contains(opStr, ",") + } + return false case "blitter": for _, tok := range []string{"BLT", "DMACON"} { if strings.Contains(combined, tok) { @@ -463,8 +473,10 @@ func matchesClusterInstruction(clusterName string, mnem, opStr, comment string) return strings.Contains(combined, "SPR") case "cia", "timer": return strings.Contains(combined, "CIA") - case "dma", "irq", "interrupt": - for _, tok := range []string{"DMACON", "INTENA", "INTREQ"} { + case "dma": + return strings.Contains(combined, "DMACON") + case "irq", "interrupt": + for _, tok := range []string{"INTENA", "INTREQ"} { if strings.Contains(combined, tok) { return true } @@ -493,6 +505,25 @@ func dispatchControllerSearch(proj *project.Project, ctrl *db.Controller, cg *ca add(ctrl.SearchCode("o", needle, nil, maxDBLimit)) // Also match auto/inline comments (e.g. resolved hardware or base-reg targets). add(ctrl.SearchComments(needle, nil, maxDBLimit)) + if len(proj.Data.RegisterAliases) > 0 && len(proj.Data.Structs) > 0 { + needleLower := strings.ToLower(needle) + for addr, cell := range cg.Cells { + if cell.Type != db.CellCode { + continue + } + var fnName, fnRaw string + if fAddr, ok := cg.InsnToFunc[addr]; ok { + if f, ok := cg.Functions[fAddr]; ok { + fnName = f.Name + fnRaw = f.RawName + } + } + aliased := proj.ApplyStructAliasesToOpStr(cell.OpStrVal(), fnName, fnRaw) + if strings.Contains(strings.ToLower(aliased), needleLower) { + out = append(out, addr) + } + } + } case strings.HasPrefix(qLower, "s:"): add(ctrl.SearchStrings(strings.TrimSpace(q[2:]), nil, maxDBLimit)) case strings.HasPrefix(qLower, "c:"): @@ -557,69 +588,74 @@ func runSearchCommand( continue } seenDBs[seg.DB] = true - ctrl, err := db.New(seg.DB) - if err != nil { - return err - } - proj.SyncDBScope(ctrl) + if err := func(dbPath string) error { + ctrl, err := db.New(dbPath) + if err != nil { + return err + } + defer ctrl.Close() + proj.SyncDBScope(ctrl) - switch { - case qLower == "smc": - // Match all CODE cells with [SMC] comment or XrefDataWrite targeting a CODE cell. - for _, addr := range slices.Sorted(maps.Keys(cg.Cells)) { - cell := cg.Cells[addr] - if cell.Type != db.CellCode { - continue - } - if strings.Contains(cg.Comments[addr].EffectiveComment(), "[SMC]") { - matchedAddrs = append(matchedAddrs, addr) + switch { + case qLower == "smc": + // Match all CODE cells with [SMC] comment or XrefDataWrite targeting a CODE cell. + for _, addr := range slices.Sorted(maps.Keys(cg.Cells)) { + cell := cg.Cells[addr] + if cell.Type != db.CellCode { + continue + } + if strings.Contains(cg.Comments[addr].EffectiveComment(), "[SMC]") { + matchedAddrs = append(matchedAddrs, addr) + } } - } - for _, x := range ctrl.AllXrefs() { - if x.Kind == db.XrefDataWrite { - if srcCell, ok := cg.Cells[x.Source]; ok && srcCell.Type == db.CellCode { - if tgtCell, ok := cg.Cells[x.Target]; ok && tgtCell.Type == db.CellCode { - if !slices.Contains(matchedAddrs, x.Source) { - matchedAddrs = append(matchedAddrs, x.Source) + for _, x := range ctrl.AllXrefs() { + if x.Kind == db.XrefDataWrite { + if srcCell, ok := cg.Cells[x.Source]; ok && srcCell.Type == db.CellCode { + if tgtCell, ok := cg.Cells[x.Target]; ok && tgtCell.Type == db.CellCode { + if !slices.Contains(matchedAddrs, x.Source) { + matchedAddrs = append(matchedAddrs, x.Source) + } } } } } - } - case strings.HasPrefix(qLower, "cluster:"): - clusterName := strings.TrimSpace(query[len("cluster:"):]) - var clusterAddrs []uint32 - for _, addr := range slices.Sorted(maps.Keys(cg.Cells)) { - cell := cg.Cells[addr] - if cell.Type != db.CellCode { - continue - } - cmt := cg.Comments[addr].EffectiveComment() - if matchesClusterInstruction(clusterName, cell.MnemonicStr(), cell.OpStrVal(), cmt) { - clusterAddrs = append(clusterAddrs, addr) - } - } - if denseOnly { - for i := range clusterAddrs { - j := i - for j < len(clusterAddrs) && clusterAddrs[j]-clusterAddrs[i] <= 24 { - j++ + case strings.HasPrefix(qLower, "cluster:"): + clusterName := strings.TrimSpace(query[len("cluster:"):]) + var clusterAddrs []uint32 + for _, addr := range slices.Sorted(maps.Keys(cg.Cells)) { + cell := cg.Cells[addr] + if cell.Type != db.CellCode { + continue } - if j-i >= 3 { - for k := i; k < j; k++ { - matchedAddrs = append(matchedAddrs, clusterAddrs[k]) + cmt := cg.Comments[addr].EffectiveComment() + if matchesClusterInstruction(clusterName, cell.MnemonicStr(), cell.OpStrVal(), cmt) { + clusterAddrs = append(clusterAddrs, addr) + } + } + if denseOnly { + for i := range clusterAddrs { + j := i + for j < len(clusterAddrs) && clusterAddrs[j]-clusterAddrs[i] <= 24 { + j++ + } + if j-i >= 3 { + for k := i; k < j; k++ { + matchedAddrs = append(matchedAddrs, clusterAddrs[k]) + } } } + } else { + matchedAddrs = append(matchedAddrs, clusterAddrs...) } - } else { - matchedAddrs = append(matchedAddrs, clusterAddrs...) - } - default: - matchedAddrs = append(matchedAddrs, dispatchControllerSearch(proj, ctrl, cg, query)...) + default: + matchedAddrs = append(matchedAddrs, dispatchControllerSearch(proj, ctrl, cg, query)...) + } + return nil + }(seg.DB); err != nil { + return err } - _ = ctrl.Close() } slices.Sort(matchedAddrs) @@ -639,6 +675,14 @@ func runSearchCommand( if normSeg != "" && !strings.EqualFold(segName, normSeg) { continue } + var fnName, fnRaw, fnOffStr string + if fAddr, ok := cg.InsnToFunc[addr]; ok { + if f, ok := cg.Functions[fAddr]; ok { + fnName = f.Name + fnRaw = f.RawName + fnOffStr = formatFnOffset(addr, f.EntryAddr) + } + } cell, ok := cg.Cells[addr] cellType := "DATA" var insnText string @@ -646,6 +690,9 @@ func runSearchCommand( cellType = cell.Type.AsStr() mnem := cell.MnemonicStr() ops := cell.OpStrVal() + if cell.Type == db.CellCode { + ops = proj.ApplyStructAliasesToOpStr(ops, fnName, fnRaw) + } if mnem != "" { insnText = strings.TrimSpace(fmt.Sprintf("%-8s %s", mnem, ops)) } else if ops != "" { @@ -657,14 +704,6 @@ func runSearchCommand( cellType = "LABEL" insnText = lbl } - - var fnName, fnOffStr string - if fAddr, ok := cg.InsnToFunc[addr]; ok { - if f, ok := cg.Functions[fAddr]; ok { - fnName = f.Name - fnOffStr = formatFnOffset(addr, f.EntryAddr) - } - } cmt := cg.Comments[addr].EffectiveComment() hits = append(hits, searchHitJSON{ Addr: fmt.Sprintf("0x%X", addr), @@ -837,7 +876,7 @@ func renderDataInspection( items = append(items, dataItemJSON{ Offset: fmt.Sprintf("+%05X", off), Addr: fmt.Sprintf("0x%X", cur), - Type: "STRING", + Type: c.Type.AsStr(), Asm: fmt.Sprintf("dc.b %q,0", strPreview), Comment: cg.Comments[cur].EffectiveComment(), }) @@ -849,7 +888,7 @@ func renderDataInspection( items = append(items, dataItemJSON{ Offset: fmt.Sprintf("+%05X", off), Addr: fmt.Sprintf("0x%X", cur), - Type: "DATA_W", + Type: c.Type.AsStr(), Asm: fmt.Sprintf("dc.w $%04X (%d)", val, int16(val)), Comment: cg.Comments[cur].EffectiveComment(), }) @@ -1182,6 +1221,30 @@ type xrefHitJSON struct { TargetAddr string `json:"target_addr"` } +func collectXrefsToRange(proj *project.Project, fallbackCtrl *db.Controller, startAddr, endAddr uint32, externalOnly bool) []db.Xref { + var rawXrefs []db.Xref + seenDBs := make(map[string]bool) + for _, seg := range proj.ActiveSegments() { + if seenDBs[seg.DB] { + continue + } + seenDBs[seg.DB] = true + func(dbPath string) { + ctrl, err := db.New(dbPath) + if err != nil { + return + } + defer ctrl.Close() + proj.SyncDBScope(ctrl) + rawXrefs = append(rawXrefs, ctrl.GetXrefsToRange(startAddr, endAddr, externalOnly)...) + }(seg.DB) + } + if len(seenDBs) == 0 && fallbackCtrl != nil { + rawXrefs = fallbackCtrl.GetXrefsToRange(startAddr, endAddr, externalOnly) + } + return rawXrefs +} + func runXrefCommand( proj *project.Project, ctrl *db.Controller, @@ -1222,7 +1285,7 @@ func runXrefCommand( } } - rawXrefs := ctrl.GetXrefsToRange(targetAddr, targetAddr, false) + rawXrefs := collectXrefsToRange(proj, ctrl, targetAddr, targetAddr, false) hits := formatXrefHits(proj, cg, rawXrefs, 0, 0, false) lbl := cg.Labels[targetAddr] @@ -1270,7 +1333,7 @@ func renderXrefRange( startAddr, endAddr uint32, format string, ) (string, error) { - rawXrefs := ctrl.GetXrefsToRange(startAddr, endAddr, true) + rawXrefs := collectXrefsToRange(proj, ctrl, startAddr, endAddr, true) // Exclude internal references where source is also inside [startAddr, endAddr]. hits := formatXrefHits(proj, cg, rawXrefs, startAddr, endAddr, true) @@ -1824,40 +1887,45 @@ func runDumpCommand(proj *project.Project, section string, format string) (strin continue } seenDBs[seg.DB] = true - ctrl, err := db.New(seg.DB) - if err != nil { - return "", err - } - proj.SyncDBScope(ctrl) - for _, ac := range ctrl.GetUserComments() { - addr := ac.Addr - s := proj.SegmentAtAddr(addr) - segName := "" - var off uint32 - if s != nil { - segName = s.Name - if addr >= s.Base { - off = addr - s.Base - } + if err := func(dbPath string) error { + ctrl, err := db.New(dbPath) + if err != nil { + return err } - var fnName, fnOff string - if fAddr, ok := cg.InsnToFunc[addr]; ok { - if f, ok := cg.Functions[fAddr]; ok { - fnName = f.Name - fnOff = formatFnOffset(addr, f.EntryAddr) + defer ctrl.Close() + proj.SyncDBScope(ctrl) + for _, ac := range ctrl.GetUserComments() { + addr := ac.Addr + s := proj.SegmentAtAddr(addr) + segName := "" + var off uint32 + if s != nil { + segName = s.Name + if addr >= s.Base { + off = addr - s.Base + } + } + var fnName, fnOff string + if fAddr, ok := cg.InsnToFunc[addr]; ok { + if f, ok := cg.Functions[fAddr]; ok { + fnName = f.Name + fnOff = formatFnOffset(addr, f.EntryAddr) + } } + userComments = append(userComments, commentEntry{ + Addr: fmt.Sprintf("0x%X", addr), + Segment: segName, + Offset: fmt.Sprintf("+%05X", off), + Func: fnName, + FnOffset: fnOff, + Comment: ac.Comment, + BlockComment: ac.BlockComment, + }) } - userComments = append(userComments, commentEntry{ - Addr: fmt.Sprintf("0x%X", addr), - Segment: segName, - Offset: fmt.Sprintf("+%05X", off), - Func: fnName, - FnOffset: fnOff, - Comment: ac.Comment, - BlockComment: ac.BlockComment, - }) + return nil + }(seg.DB); err != nil { + return "", err } - _ = ctrl.Close() } for _, addr := range slices.Sorted(maps.Keys(cg.Cells)) { diff --git a/cmd/hunks/main.go b/cmd/hunks/main.go index ec9ccb4..4699780 100644 --- a/cmd/hunks/main.go +++ b/cmd/hunks/main.go @@ -1089,6 +1089,7 @@ func main() { defer ctrl.Close() proj.SyncDBScope(ctrl) return ctrl.WithTransaction(func() error { + renamesMap, _ := ctrl.GetAllRenames() var affectedAddrs []uint32 for _, p := range pairs { cleanOld := p.oldName @@ -1120,12 +1121,17 @@ func main() { effectiveOld = auto } } + prevRenamed := renamesMap[effectiveOld] if err := ctrl.SetRename(effectiveOld, p.newName); err != nil { ctrl.SetActiveSeg(prevSeg) return err } + renamesMap[effectiveOld] = p.newName proj.ReKeyFunctionABI(effectiveOld, p.newName) proj.ReKeyFunctionABI(cleanOld, p.newName) + if prevRenamed != "" { + proj.ReKeyFunctionABI(prevRenamed, p.newName) + } if hasResolved { affectedAddrs = append(affectedAddrs, resolvedAddr) } else if targetAddr, ok := ctrl.LabelAddrByName(effectiveOld, false); ok { @@ -1186,6 +1192,23 @@ func main() { return err } + updateAddrComment := func(addrU32 uint32, val string) error { + targetCtrl := ctrl + if tgtSeg := proj.SegmentAtAddr(addrU32); tgtSeg != nil && tgtSeg.DB != "" && tgtSeg.DB != seg.DB { + c, err := db.New(tgtSeg.DB) + if err != nil { + return err + } + defer c.Close() + proj.SyncDBScope(c) + targetCtrl = c + } + if commentBlock { + return targetCtrl.UpdateBlockComment(addrU32, val) + } + return targetCtrl.UpdateComment(addrU32, val) + } + applyOne := func(addrStr, val string) error { addrU32, note, err := resolveStrictCommentAddr(proj, ctrl, cg, addrStr) if err != nil { @@ -1194,10 +1217,7 @@ func main() { if note != "" { fmt.Fprintln(os.Stderr, note) } - if commentBlock { - return ctrl.UpdateBlockComment(addrU32, val) - } - return ctrl.UpdateComment(addrU32, val) + return updateAddrComment(addrU32, val) } if commentBatchFile != "" { @@ -1258,19 +1278,16 @@ func main() { if note != "" { fmt.Fprintln(os.Stderr, note) } + if err := updateAddrComment(addrU32, val); err != nil { + return err + } if commentBlock { - if err := ctrl.UpdateBlockComment(addrU32, val); err != nil { - return err - } if val == "" { fmt.Printf("Removed block comment at 0x%X\n", addrU32) } else { fmt.Printf("Added block comment at 0x%X\n", addrU32) } } else { - if err := ctrl.UpdateComment(addrU32, val); err != nil { - return err - } if val == "" { fmt.Printf("Removed comment at 0x%X\n", addrU32) } else { diff --git a/internal/analyzer/classify.go b/internal/analyzer/classify.go index f123260..385bc62 100644 --- a/internal/analyzer/classify.go +++ b/internal/analyzer/classify.go @@ -233,7 +233,7 @@ func (a *Analyzer) classifyImmOperand(addr uint32, insn *m68k.Insn, op m68k.Oper if (a.IsInAnyProjectSegment(val) || a.IsInMemoryRegion(val)) && val%2 == 0 { return RefType{Kind: RefDataAddr, Target: val}, true } - if insn.Op == m68k.OpMOVEA && val > 0 && (val&0xFF000000) == 0 { + if insn.Op == m68k.OpMOVEA && val >= 0x100 && val%2 == 0 && (val&0xFF000000) == 0 { return RefType{Kind: RefDataAddr, Target: val}, true } return RefType{}, false diff --git a/internal/analyzer/trace.go b/internal/analyzer/trace.go index 5ff898f..ab6a2ce 100644 --- a/internal/analyzer/trace.go +++ b/internal/analyzer/trace.go @@ -500,7 +500,6 @@ func RunTrace(image []byte, base, entry uint32, opts TraceOptions) TraceReport { } } else { trampOriginPC = pc - trampFirstOutPC = newPC if hasDispatch { hasTrampOrigin = true trampOriginDisp = dispatch diff --git a/internal/callgraph/callgraph.go b/internal/callgraph/callgraph.go index 586f56d..8c32041 100644 --- a/internal/callgraph/callgraph.go +++ b/internal/callgraph/callgraph.go @@ -120,17 +120,48 @@ func VerifyABI(abiStr string, inRegs, modRegs, savedRegs []string) []string { inSection = before } + isValidReg := func(tok string) bool { + if len(tok) != 2 { + return false + } + if tok[0] == 'd' { + return tok[1] >= '0' && tok[1] <= '7' + } + if tok[0] == 'a' { + return tok[1] >= '0' && tok[1] <= '6' + } + return false + } + extractRegs := func(s string) []string { tokens := strings.FieldsFunc(s, func(r rune) bool { - return !unicode.IsLetter(r) && !unicode.IsDigit(r) + return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' }) var regs []string + addReg := func(r string) { + if isValidReg(r) && !slices.Contains(regs, r) { + regs = append(regs, r) + } + } for _, tok := range tokens { - if len(tok) == 2 && (tok[0] == 'd' || tok[0] == 'a') && tok[1] >= '0' && tok[1] <= '6' { - if !slices.Contains(regs, tok) { - regs = append(regs, tok) + if startR, endR, hasDash := strings.Cut(tok, "-"); hasDash { + if isValidReg(startR) { + if len(endR) == 1 && endR[0] >= '0' && endR[0] <= '7' { + endR = string(startR[0]) + endR + } + if isValidReg(endR) && startR[0] == endR[0] && startR[1] <= endR[1] { + for ch := startR[1]; ch <= endR[1]; ch++ { + addReg(string([]byte{startR[0], ch})) + } + continue + } } + for _, sub := range strings.Split(tok, "-") { + addReg(sub) + } + continue } + addReg(tok) } return regs } @@ -158,12 +189,19 @@ type IndirectSiteInfo struct { Segment string `json:"segment"` OffsetInSeg uint32 `json:"offset_in_seg"` FuncName string `json:"func_name"` - FnOffset uint32 `json:"fn_offset"` + FnOffset string `json:"fn_offset"` InsnText string `json:"insn"` Status string `json:"status"` Targets []string `json:"targets"` } +func formatFnOffset(addr, entryAddr uint32) string { + if addr >= entryAddr { + return fmt.Sprintf("+%X", addr-entryAddr) + } + return fmt.Sprintf("-%X", entryAddr-addr) +} + func formatCycleSpan(lo, hi uint32) string { if lo == hi { return strconv.FormatUint(uint64(lo), 10) @@ -240,6 +278,11 @@ type edgeTarget struct { kind db.EdgeKind } +type xrefTarget struct { + addr uint32 + kind db.XrefKind +} + // Build constructs the complete CallGraph for `proj`'s active address space. // // It infers subroutine entry points from explicit project entries, segment bases, direct @@ -260,7 +303,7 @@ func Build(proj *project.Project) (*CallGraph, error) { edgesBySrc := make(map[uint32][]edgeTarget) callTargets := make(map[uint32]bool) branchTargets := make(map[uint32]bool) - xrefsBySrc := make(map[uint32][]AddrStringPair) + xrefsBySrc := make(map[uint32][]xrefTarget) pointerTargets := make(map[uint32]bool) jmpTableBases := make(map[string]uint32) @@ -270,56 +313,60 @@ func Build(proj *project.Project) (*CallGraph, error) { continue } seenDBs[seg.DB] = true - ctrl, err := db.New(seg.DB) - if err != nil { - return nil, err - } - proj.SyncDBScope(ctrl) - - allCells, err := ctrl.GetAllCells() - if err != nil { - _ = ctrl.Close() - return nil, err - } - for _, c := range allCells { - cells[c.Addr] = c - cellsTree.ReplaceOrInsert(c) - } + if err := func(dbPath string) error { + ctrl, err := db.New(dbPath) + if err != nil { + return err + } + defer ctrl.Close() + proj.SyncDBScope(ctrl) - for _, lb := range ctrl.AllLabelsWithExport() { - rawLabels[lb.Addr] = lb.Name - if lb.IsExported { - exportedSet[lb.Addr] = true + allCells, err := ctrl.GetAllCells() + if err != nil { + return err + } + for _, c := range allCells { + cells[c.Addr] = c + cellsTree.ReplaceOrInsert(c) } - } - if resolvedMap, err := ctrl.GetAllLabelsResolved(); err == nil { - maps.Copy(labels, resolvedMap) - } - if cmts, err := ctrl.GetAllTargetComments(); err == nil { - maps.Copy(comments, cmts) - } + for _, lb := range ctrl.AllLabelsWithExport() { + rawLabels[lb.Addr] = lb.Name + if lb.IsExported { + exportedSet[lb.Addr] = true + } + } - for _, e := range ctrl.AllEdgesNoFallthrough() { - if e.Kind == db.EdgeCall { - callTargets[e.Target] = true - } else { - branchTargets[e.Target] = true + if resolvedMap, err := ctrl.GetAllLabelsResolved(); err == nil { + maps.Copy(labels, resolvedMap) + } + if cmts, err := ctrl.GetAllTargetComments(); err == nil { + maps.Copy(comments, cmts) } - edgesBySrc[e.Source] = append(edgesBySrc[e.Source], edgeTarget{target: e.Target, kind: e.Kind}) - } - for _, x := range ctrl.AllXrefs() { - if srcCell, ok := cells[x.Source]; ok && srcCell.Type == db.CellPointer { - pointerTargets[x.Target] = true + for _, e := range ctrl.AllEdgesNoFallthrough() { + if e.Kind == db.EdgeCall { + callTargets[e.Target] = true + } else { + branchTargets[e.Target] = true + } + edgesBySrc[e.Source] = append(edgesBySrc[e.Source], edgeTarget{target: e.Target, kind: e.Kind}) } - xrefsBySrc[x.Source] = append(xrefsBySrc[x.Source], AddrStringPair{ - Addr: x.Target, - Text: x.Kind.AsStr(), - }) + + for _, x := range ctrl.AllXrefs() { + if srcCell, ok := cells[x.Source]; ok && srcCell.Type == db.CellPointer { + pointerTargets[x.Target] = true + } + xrefsBySrc[x.Source] = append(xrefsBySrc[x.Source], xrefTarget{ + addr: x.Target, + kind: x.Kind, + }) + } + maps.Copy(jmpTableBases, ctrl.DetectJumpVectorBases()) + return nil + }(seg.DB); err != nil { + return nil, err } - maps.Copy(jmpTableBases, ctrl.DetectJumpVectorBases()) - _ = ctrl.Close() } activeSegs := proj.ActiveSegments() @@ -393,7 +440,7 @@ func Build(proj *project.Project) (*CallGraph, error) { } } if trampXrefs := xrefsBySrc[trampAddr+2]; len(trampXrefs) > 0 { - return regStr, disp, trampXrefs[0].Addr, true + return regStr, disp, trampXrefs[0].addr, true } if isCodeAt(trampAddr) { return regStr, disp, trampAddr, true @@ -606,30 +653,30 @@ func Build(proj *project.Project) (*CallGraph, error) { for subAddr := ia; subAddr < ia+cell.Size; subAddr++ { for _, xr := range xrefsBySrc[subAddr] { - if _, isHW := amiga.GetAmigaHWLabel(xr.Addr); isHW { + if _, isHW := amiga.GetAmigaHWLabel(xr.addr); isHW { continue } - if xr.Addr >= 0xDFF000 && xr.Addr <= 0xDFF1FF { + if xr.addr >= 0xDFF000 && xr.addr <= 0xDFF1FF { continue } - if tgtCell, ok := cells[xr.Addr]; ok && tgtCell.Type == db.CellStr { - sVal := extractStringPreview(proj, xr.Addr, &tgtCell, segDataCache) + if tgtCell, ok := cells[xr.addr]; ok && tgtCell.Type == db.CellStr { + sVal := extractStringPreview(proj, xr.addr, &tgtCell, segDataCache) if sVal != "" { - stringMap[xr.Addr] = sVal + stringMap[xr.addr] = sVal continue } } - lbl := labels[xr.Addr] - switch xr.Text { - case "DATA_READ": - globalsReadMap[xr.Addr] = lbl - case "DATA_WRITE": - globalsWriteMap[xr.Addr] = lbl - if tgtCell, ok := cells[xr.Addr]; ok && tgtCell.Type == db.CellCode { + lbl := labels[xr.addr] + switch xr.kind { + case db.XrefDataRead: + globalsReadMap[xr.addr] = lbl + case db.XrefDataWrite: + globalsWriteMap[xr.addr] = lbl + if tgtCell, ok := cells[xr.addr]; ok && tgtCell.Type == db.CellCode { hasSMC = true } - case "DATA_ADDR": - dataAddrMap[xr.Addr] = lbl + case db.XrefDataAddr: + dataAddrMap[xr.addr] = lbl } } } @@ -918,26 +965,11 @@ func Build(proj *project.Project) (*CallGraph, error) { offInSeg = ia - seg.Base } } - var fnName string - var fnOff uint32 + var fnName, fnOff string if fAddr, ok := insnToFunc[ia]; ok { if f, ok := functions[fAddr]; ok { - if ia >= f.EntryAddr { - fnName = f.QualifiedName() - fnOff = ia - f.EntryAddr - } else if lbl, hasLbl := labels[ia]; hasLbl && lbl != "" { - fnName = segName + ":" + lbl - fnOff = 0 - } else if len(f.InsnAddrs) > 0 && ia >= f.InsnAddrs[0] { - startLbl := labels[f.InsnAddrs[0]] - if startLbl == "" { - startLbl = proj.AutoLabel(f.InsnAddrs[0]) - } - fnName = segName + ":" + startLbl - fnOff = ia - f.InsnAddrs[0] - } else { - fnName = f.QualifiedName() - } + fnName = f.QualifiedName() + fnOff = formatFnOffset(ia, f.EntryAddr) } } mnem := c.MnemonicStr() @@ -1227,7 +1259,7 @@ func (cg *CallGraph) renderIndirect(opts *Options, normSeg string) (string, erro for _, s := range sites { fnCtx := "" if s.FuncName != "" { - fnCtx = fmt.Sprintf("[%s+%X]", s.FuncName, s.FnOffset) + fnCtx = fmt.Sprintf("[%s%s]", s.FuncName, s.FnOffset) } if s.Status == "resolved" { fmt.Fprintf(&out, " +%05X %-24s %-22s -> %s (resolved)\n", s.OffsetInSeg, fnCtx, s.InsnText, strings.Join(s.Targets, ", ")) @@ -1263,13 +1295,32 @@ func (cg *CallGraph) renderTarget(proj *project.Project, funcAddr uint32, opts * if ia < f.EntryAddr { fnOffStr = fmt.Sprintf("-%03X", f.EntryAddr-ia) } + ops := proj.ApplyStructAliasesToOpStr(c.OpStrVal(), f.Name, f.RawName) + cmt := cg.Comments[ia].EffectiveComment() + for _, site := range f.Callees { + if site.CallInsn == ia { + if tf, ok := cg.Functions[site.TargetFunc]; ok { + if cmt == "" && (tf.Segment != f.Segment || strings.Contains(ops, "(")) { + cmt = fmt.Sprintf("-> %s", tf.QualifiedName()) + } + if tf.ABI != "" && !strings.Contains(cmt, "ABI:") { + if cmt == "" { + cmt = fmt.Sprintf("[ABI: %s]", tf.ABI) + } else { + cmt += fmt.Sprintf(" [ABI: %s]", tf.ABI) + } + } + } + break + } + } codeLines = append(codeLines, codeLine{ Offset: fmt.Sprintf("+%05X", off), FnOffset: fnOffStr, Label: cg.Labels[ia], Mnemonic: c.MnemonicStr(), - Operands: c.OpStrVal(), - Comment: cg.Comments[ia].EffectiveComment(), + Operands: ops, + Comment: cmt, }) } } diff --git a/internal/callgraph/callgraph_test.go b/internal/callgraph/callgraph_test.go index e828af6..dcab831 100644 --- a/internal/callgraph/callgraph_test.go +++ b/internal/callgraph/callgraph_test.go @@ -354,6 +354,10 @@ func TestCallGraphStaticRegsABIAndDualOffsets(t *testing.T) { if len(warnings) != 2 { t.Fatalf("expected 2 ABI verification warnings (a1 never read, d4 never modified), got %v", warnings) } + rangeWarnings := VerifyABI("(a0=obj, d7=count) -> d0 | clobbers: d0-d2", fn.InRegs, fn.ModRegs, fn.SavedRegs) + if len(rangeWarnings) != 3 { + t.Fatalf("expected 3 warnings for d7 input and d1/d2 range clobbers, got %v", rangeWarnings) + } rendered, err := cg.Render(proj, &Options{ Target: "CalcStep", diff --git a/internal/tui/popups.go b/internal/tui/popups.go index 9c78d73..272b52c 100644 --- a/internal/tui/popups.go +++ b/internal/tui/popups.go @@ -417,8 +417,12 @@ func (a *App) searchBytes(content string, targetSeg *project.Segment, rng *db.Ad func (a *App) searchCluster(preset string, rng *db.AddrRange) []SearchHit { var sig clusterSig switch strings.ToLower(strings.TrimSpace(preset)) { - case "3dmath", "math": + case "3dmath", "math", "trig", "sin", "cos": sig = clusterSig{mnems: []string{"mul", "div"}} + case "mul": + sig = clusterSig{mnems: []string{"mul"}} + case "div": + sig = clusterSig{mnems: []string{"div"}} case "blitter": sig = clusterSig{offs: [][2]uint16{{0x040, 0x074}}} case "copper": @@ -429,8 +433,10 @@ func (a *App) searchCluster(preset string, rng *db.AddrRange) []SearchHit { sig = clusterSig{offs: [][2]uint16{{0x0A0, 0x0DF}, {0x09E, 0x09E}}} case "sprite", "sprites": sig = clusterSig{offs: [][2]uint16{{0x120, 0x17E}}} - case "dma", "irq", "interrupt": - sig = clusterSig{offs: [][2]uint16{{0x002, 0x002}, {0x01C, 0x01E}, {0x096, 0x09C}}} + case "dma": + sig = clusterSig{offs: [][2]uint16{{0x002, 0x002}, {0x096, 0x096}}} + case "irq", "interrupt": + sig = clusterSig{offs: [][2]uint16{{0x01C, 0x01E}, {0x09A, 0x09C}}} default: return nil } From 7707becf0f492e44a6f33b6f944c3f01818c8ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikolas=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Tue, 27 Oct 2026 16:56:38 +0200 Subject: [PATCH 10/10] chore(release): bump version to v1.2.0 --- cmd/hunks/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/hunks/main.go b/cmd/hunks/main.go index 4699780..72de072 100644 --- a/cmd/hunks/main.go +++ b/cmd/hunks/main.go @@ -22,7 +22,7 @@ import ( "github.com/spf13/cobra" ) -var version = "v1.1.0" +var version = "v1.2.0" func parseHex(val string) (uint32, error) { return project.ParseUint32(val)