diff --git a/MANUAL.md b/MANUAL.md index d4ca518..7b0a992 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -470,7 +470,8 @@ Instructions that jump/call across hunks or through a register jump-vector table | Key | Action | |-----|--------| -| `v` | Toggle range selection (`Esc` cancels active selection) | +| `v` | Toggle range selection (`Esc` cancels active selection); when code instructions are selected, the status bar displays aggregate byte size, instruction count, and MC68000 execution cycles + `(R/W)` bus words | +| `V` | Select (or cancel selection of) the current subroutine and display its aggregate byte size, instruction count, and MC68000 execution cycles + `(R/W)` bus words in the status bar | | `c` | Mark cursor (or selection) as `CODE` (`confidence = 100`) and run incremental analysis | | `b` | Mark as `DATA_B` (1 byte) | | `w` | Mark as `DATA_W` (2 bytes) | diff --git a/README.md b/README.md index b28ed01..3ccb481 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Amiga M68k Reverse Engineering System — terminal-native, keyboard-first disass - **Source-linkable `; XREF:` comments** (`; XREF: hunk5:LAB_5_24E84 (jsr 498(a5))`) and automatic cross-hunk / jump-vector target hints (`; -> hunk3:LAB_3_26432`) - **Static Call Graph CLI (`hunks -m cg`)** with automatic `d16(a5)` jump-vector resolution, bottom-up `--ready` worklists, `--leaves`, `--anchors` (strings & `$DFFxxx` hardware), `--suspect` audits, and `--code` caller/callee inspection - **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 instruction 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`) 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 - **100% bit-for-bit reassembly** verified with `vasmm68k_mot` ## Quick Start diff --git a/internal/db/controller.go b/internal/db/controller.go index a0f5ca3..356f125 100644 --- a/internal/db/controller.go +++ b/internal/db/controller.go @@ -1135,24 +1135,25 @@ func (c *Controller) FunctionLabelAddrs() []uint32 { return out } sql1 := fmt.Sprintf( - `SELECT DISTINCT l.addr FROM labels l JOIN cells c ON c.seg = l.seg AND c.addr = l.addr - WHERE c.seg IN (%s) AND c.type = 'CODE' AND ( - l.is_exported = 1 - OR EXISTS (SELECT 1 FROM edges e WHERE e.seg IN (%s) AND e.target_addr = l.addr AND e.type = 'CALL') - OR EXISTS (SELECT 1 FROM xrefs x WHERE x.seg IN (%s) AND x.target_addr = l.addr) - ) ORDER BY l.addr ASC`, - inClause, inClause, inEmpty, + `SELECT DISTINCT addr FROM ( + SELECT l.addr AS addr FROM labels l JOIN cells c ON c.seg = l.seg AND c.addr = l.addr + WHERE c.seg IN (%s) AND c.type = 'CODE' AND ( + l.kind = 'SUB' + OR l.is_exported = 1 + OR EXISTS (SELECT 1 FROM edges e WHERE e.seg IN (%s) AND e.target_addr = l.addr AND e.type = 'CALL') + OR ( + EXISTS (SELECT 1 FROM xrefs x WHERE x.seg IN (%s) AND x.target_addr = l.addr AND x.type != 'CODE_BRANCH') + AND NOT EXISTS (SELECT 1 FROM xrefs xb WHERE xb.seg IN (%s) AND xb.target_addr = l.addr AND xb.type = 'CODE_BRANCH') + ) + ) + UNION + SELECT f.entry_addr AS addr FROM functions f JOIN cells c ON c.seg = f.seg AND c.addr = f.entry_addr + WHERE c.seg IN (%s) AND c.type = 'CODE' + AND NOT EXISTS (SELECT 1 FROM xrefs xb WHERE xb.seg IN (%s) AND xb.target_addr = f.entry_addr AND xb.type = 'CODE_BRANCH') + ) ORDER BY addr ASC`, + inClause, inClause, inEmpty, inEmpty, inClause, inEmpty, ) - out := queryInto(sql1) - if len(out) == 0 { - sql2 := fmt.Sprintf( - `SELECT DISTINCT l.addr FROM labels l JOIN cells c ON c.seg = l.seg AND c.addr = l.addr - WHERE c.seg IN (%s) AND c.type = 'CODE' ORDER BY l.addr ASC`, - inClause, - ) - out = queryInto(sql2) - } - return out + return queryInto(sql1) } func (c *Controller) searchBoundsAndClause(rng *AddrRange, col string, includeEmpty bool) (uint32, uint32, string) { diff --git a/internal/tui/app.go b/internal/tui/app.go index 493caf8..de56d5a 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -2,7 +2,11 @@ package tui import ( "fmt" + "maps" "os" + "slices" + "strconv" + "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/term" @@ -247,6 +251,197 @@ func (a *App) ToggleSelection() { } } +// SelectSubroutine selects (or cancels selection of) the subroutine enclosing the cursor. +func (a *App) SelectSubroutine() { + funcStart, lastInsnAddr, ok := a.subroutineBoundsAtCursor() + if !ok { + a.StatusMsg = "No subroutine at cursor" + return + } + if selStart, selEnd, hasSel := a.GetSelectionRange(); hasSel && selStart == funcStart && selEnd == lastInsnAddr { + a.SelectionStart = nil + a.StatusMsg = "Selection cancelled" + return + } + a.SelectionStart = &funcStart + a.GotoAddress(lastInsnAddr) +} + +func isSubroutineTerminator(mnem string) bool { + op, _ := m68k.ParseMnemonic(mnem) + switch op { + case m68k.OpRTS, m68k.OpRTE, m68k.OpRTR, m68k.OpJMP, m68k.OpBRA, m68k.OpILLEGAL: + return true + } + return false +} + +func branchTargetAddr(row *ListingRow, curAddr uint32) (uint32, bool) { + if len(row.RawBytes) >= 2 { + if insn, ok := m68k.Decode(row.RawBytes, curAddr); ok { + if insn.IsBranch() && !insn.IsCall() { + for _, op := range insn.Operands() { + if op.Mode == m68k.EABranch || op.Mode == m68k.EAPCDisp16 { + return op.UVal, true + } + } + if row.RefTarget != nil { + return *row.RefTarget, true + } + } + return 0, false + } + } + op, _ := m68k.ParseMnemonic(row.Mnem) + if (op == m68k.OpBcc || op == m68k.OpDBcc || op == m68k.OpBRA) && row.RefTarget != nil { + return *row.RefTarget, true + } + return 0, false +} + +func (a *App) subroutineBoundsAtCursor() (uint32, uint32, bool) { + cur, ok := a.CursorAddr() + if !ok { + return 0, 0, false + } + + var codeIdxs []int + curCodePos := -1 + for i := range a.DisasmRows { + r := &a.DisasmRows[i] + if r.Kind == SubRowCode && r.Addr != nil { + if *r.Addr == cur && curCodePos < 0 { + curCodePos = len(codeIdxs) + } + codeIdxs = append(codeIdxs, i) + } + } + if len(codeIdxs) == 0 || curCodePos < 0 { + return 0, 0, false + } + + funcSet := make(map[uint32]struct{}) + if a.DB != nil { + for _, fa := range a.DB.FunctionLabelAddrs() { + funcSet[fa] = struct{}{} + } + } + if a.Proj != nil { + for _, ep := range a.Proj.Data.EntryPoints { + if ep.Segment == "" || strings.EqualFold(ep.Segment, a.Proj.ActiveSegmentName) { + funcSet[ep.Addr] = struct{}{} + } + } + } + funcAddrs := slices.Sorted(maps.Keys(funcSet)) + + subStartPos := 0 + var maxBranchTarget uint32 + for pos := 0; pos < len(codeIdxs); pos++ { + curRow := &a.DisasmRows[codeIdxs[pos]] + curAddr := *curRow.Addr + curSize := max(curRow.Size, 2) + nextAddr := curAddr + curSize + + if brTgt, isBr := branchTargetAddr(curRow, curAddr); isBr && brTgt > curAddr { + idx, _ := slices.BinarySearch(funcAddrs, curAddr+1) + crossesFunc := idx < len(funcAddrs) && funcAddrs[idx] <= brTgt + if !crossesFunc && brTgt > maxBranchTarget { + maxBranchTarget = brTgt + } + } + + isEnd := false + if pos+1 >= len(codeIdxs) { + isEnd = true + } else { + nextRow := &a.DisasmRows[codeIdxs[pos+1]] + if *nextRow.Addr != nextAddr { + isEnd = true + } else if _, isFunc := funcSet[*nextRow.Addr]; isFunc { + isEnd = true + } else if isSubroutineTerminator(curRow.Mnem) && maxBranchTarget < nextAddr { + isEnd = true + } + } + + if isEnd { + if pos >= curCodePos { + startAddr := *a.DisasmRows[codeIdxs[subStartPos]].Addr + endAddr := *a.DisasmRows[codeIdxs[pos]].Addr + return startAddr, endAddr, true + } + subStartPos = pos + 1 + maxBranchTarget = 0 + } + } + + return 0, 0, false +} + +func formatRangeUint32(lo, hi uint32) string { + if lo == hi { + return strconv.FormatUint(uint64(lo), 10) + } + return strconv.FormatUint(uint64(lo), 10) + "-" + strconv.FormatUint(uint64(hi), 10) +} + +func formatBlockTiming(minCyc, maxCyc, minR, maxR, minW, maxW uint32) string { + if maxCyc == 0 { + return "-- cyc" + } + return fmt.Sprintf("%s (%s/%s) cyc", + formatRangeUint32(minCyc, maxCyc), + formatRangeUint32(minR, maxR), + formatRangeUint32(minW, maxW), + ) +} + +func (a *App) selectionBlockStats(start, end uint32) (size uint32, insns int, timingStr string) { + maxEndByte := end + var minCyc, maxCyc uint32 + var minR, maxR uint32 + var minW, maxW uint32 + + for i := range a.DisasmRows { + row := &a.DisasmRows[i] + if row.Kind != SubRowCode || row.Addr == nil { + continue + } + rStart := *row.Addr + rEnd := rStart + max(row.Size, 1) - 1 + if rStart > end || rEnd < start { + continue + } + if rEnd > maxEndByte { + maxEndByte = rEnd + } + insns++ + t := row.Timing + switch t.Kind { + case m68k.TimingBranch, m68k.TimingRange: + minCyc += uint32(min(t.Primary.Cycles, t.Secondary.Cycles)) + maxCyc += uint32(max(t.Primary.Cycles, t.Secondary.Cycles)) + minR += uint32(min(t.Primary.Reads, t.Secondary.Reads)) + maxR += uint32(max(t.Primary.Reads, t.Secondary.Reads)) + minW += uint32(min(t.Primary.Writes, t.Secondary.Writes)) + maxW += uint32(max(t.Primary.Writes, t.Secondary.Writes)) + default: + minCyc += uint32(t.Primary.Cycles) + maxCyc += uint32(t.Primary.Cycles) + minR += uint32(t.Primary.Reads) + maxR += uint32(t.Primary.Reads) + minW += uint32(t.Primary.Writes) + maxW += uint32(t.Primary.Writes) + } + } + size = maxEndByte - start + 1 + if insns > 0 { + timingStr = formatBlockTiming(minCyc, maxCyc, minR, maxR, minW, maxW) + } + return size, insns, timingStr +} + // GetSelectionRange returns the ordered inclusive (start, end) selection range if active. func (a *App) GetSelectionRange() (uint32, uint32, bool) { if a.SelectionStart == nil { @@ -297,7 +492,11 @@ func (a *App) GetStatusLine() string { return " No selection" } if start, end, selOk := a.GetSelectionRange(); selOk { - size := end - start + 1 + size, insns, timingStr := a.selectionBlockStats(start, end) + if insns > 0 { + return fmt.Sprintf(" SELECT: %s–%s (%dB, %d insns) │ %s │ c/b/w/l/s/p/K to apply, Esc cancel", + a.FormatAddr(start), a.FormatAddr(end), size, insns, timingStr) + } return fmt.Sprintf(" SELECT: %s–%s (%dB) │ c/b/w/l/s/p/K to apply, Esc cancel", a.FormatAddr(start), a.FormatAddr(end), size) } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index f360e7e..5e9e997 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -229,6 +229,8 @@ func (a *App) HandleKey(key string) bool { } case "v": a.ToggleSelection() + case "V": + a.SelectSubroutine() case "esc": if a.SelectionStart != nil { a.SelectionStart = nil diff --git a/internal/tui/render.go b/internal/tui/render.go index 49b68f5..c2e2e3f 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -992,7 +992,7 @@ func renderModal(buf *screenBuffer, a *App, screen rect) { " # Cycle format (HEX/DEC/BIN)", "", "Type Marking:", - " c Code b Byte w Word l Long v Select", + " c Code b Byte w Word l Long v/V Select/Sub", " s String p Pointer P Ptr table u Undefine", " K Carve selection->incbin (on carve: uncarve)", "", diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index ec6bf32..bbc44ab 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1250,3 +1250,181 @@ func TestMusicRipperModalAndCarve(t *testing.T) { t.Fatalf("expected cursor at 0x%X, got 0x%X (ok=%v)", 0x10000+modOffset, curAddr, ok) } } + +func TestBlockAndSubroutineCycles(t *testing.T) { + // Subroutine 1 at 0x10000: + // 0x10000: moveq #0,d0 (0x7000) -> 4 (1/0) cyc + // 0x10002: addq.w #1,d0 (0x5240) -> 4 (1/0) cyc + // 0x10004: rts (0x4E75) -> 16 (4/0) cyc + // Total Subroutine 1 = 6B, 3 insns, 24 (6/0) cyc + // + // Subroutine 2 at 0x10006 (contains variable-timing muls.w): + // 0x10006: muls.w d1,d0 (0xC1C1) -> 38-70 (1/0) cyc + // 0x10008: rts (0x4E75) -> 16 (4/0) cyc + // Total Subroutine 2 = 4B, 2 insns, 54-86 (5/0) cyc + // + // Subroutine 3 at 0x1000A (has internal branch label LAB_10014 and a jmp skipped by beq.s): + // 0x1000A: tst.w d0 (0x4A40) + // 0x1000C: beq.s $10014 (0x6706) -> branches past jmp to 0x10014 + // 0x1000E: jmp $10000.l (0x4EF9 0x00010000) -> skipped by beq.s, must NOT end Sub3! + // 0x10014: addq.w #1,d0 (0x5240) [LAB_10014 with XrefCodeBranch] + // 0x10016: rts (0x4E75) -> actual end of Sub3 + // + // Subroutine 4 at 0x10018 (ends via unskipped tail-call jmp): + // 0x10018: moveq #1,d0 (0x7001) + // 0x1001A: jmp $10000.l (0x4EF9 0x00010000) -> unskipped tail-call jmp ends Sub4! + // + // Trailing data at 0x10020..0x10023 (4 bytes) + raw := []byte{ + 0x70, 0x00, + 0x52, 0x40, + 0x4E, 0x75, + 0xC1, 0xC1, + 0x4E, 0x75, + 0x4A, 0x40, + 0x67, 0x06, + 0x4E, 0xF9, 0x00, 0x01, 0x00, 0x00, + 0x52, 0x40, + 0x4E, 0x75, + 0x70, 0x01, + 0x4E, 0xF9, 0x00, 0x01, 0x00, 0x00, + 0xAA, 0xBB, 0xCC, 0xDD, + } + + tmpDir := t.TempDir() + proj := project.New(filepath.Join(tmpDir, "project.json")) + proj.WorkspaceRoot = tmpDir + proj.Data.Segments = []project.Segment{{ + Name: "hunk0", + HunkIndex: 0, + HunkType: hunk.HunkTypeCode, + Base: 0x10000, + AllocSize: uint32(len(raw)), + }} + proj.Data.Spaces = []project.Space{{Name: "default", Segments: []string{"hunk0"}}} + proj.Data.ActiveSpace = "default" + proj.ActiveSegmentName = "hunk0" + + ctrl, err := db.New(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ctrl.Close() }) + proj.SyncDBScope(ctrl) + + mustInsertCode := func(addr uint32, rawBytes []byte, mnem, op string) { + m := mnem + o := op + if err := ctrl.InsertCell(&db.Cell{ + Seg: "hunk0", + Addr: addr, + Size: uint32(len(rawBytes)), + Type: db.CellCode, + Mnemonic: &m, + OpStr: &o, + RawBytes: rawBytes, + }); err != nil { + t.Fatal(err) + } + } + mustInsertCode(0x10000, raw[0:2], "moveq", "#0,d0") + mustInsertCode(0x10002, raw[2:4], "addq.w", "#1,d0") + mustInsertCode(0x10004, raw[4:6], "rts", "") + mustInsertCode(0x10006, raw[6:8], "muls.w", "d1,d0") + mustInsertCode(0x10008, raw[8:10], "rts", "") + mustInsertCode(0x1000A, raw[10:12], "tst.w", "d0") + mustInsertCode(0x1000C, raw[12:14], "beq.s", "LAB_10014") + mustInsertCode(0x1000E, raw[14:20], "jmp", "$10000.l") + mustInsertCode(0x10014, raw[20:22], "addq.w", "#1,d0") + mustInsertCode(0x10016, raw[22:24], "rts", "") + mustInsertCode(0x10018, raw[24:26], "moveq", "#1,d0") + mustInsertCode(0x1001A, raw[26:32], "jmp", "$10000.l") + + _ = ctrl.SetLabel(0x10014, "LAB_10014", db.LabelLab, true) + _ = ctrl.InsertXref(0x1000C, 0x10014, db.XrefCodeBranch) + + _ = ctrl.InsertFunction(0x10000, "Sub1", 255) + _ = ctrl.UpdateFunctionMetadataBatch([]db.FunctionMeta{{ + Entry: 0x10000, + EndAddr: 0x10006, + HasEnd: true, + }}) + + app, err := NewApp(proj, ctrl) + if err != nil { + t.Fatal(err) + } + app.BinaryData = raw + if err := app.LoadData(); err != nil { + t.Fatal(err) + } + + // 1. Manual range selection ('v' + move down 1 row) across first 2 instructions of Sub1 + app.GotoAddress(0x10000) + app.HandleKey("v") + app.HandleKey("j") + status := app.GetStatusLine() + if !strings.Contains(status, "(4B, 2 insns) │ 8 (2/0) cyc") { + t.Fatalf("expected 4B, 2 insns, 8 (2/0) cyc in status line, got %q", status) + } + app.HandleKey("esc") + if app.SelectionStart != nil { + t.Fatalf("expected selection cleared on esc") + } + + // 2. Press 'V' inside Sub1 (at 0x10002) -> selects [0x10000..0x10004] + app.GotoAddress(0x10002) + app.HandleKey("V") + selStart, selEnd, ok := app.GetSelectionRange() + if !ok || selStart != 0x10000 || selEnd != 0x10004 { + t.Fatalf("expected Sub1 selection [0x10000..0x10004], got [0x%X..0x%X] ok=%v", selStart, selEnd, ok) + } + status = app.GetStatusLine() + if !strings.Contains(status, "(6B, 3 insns) │ 24 (6/0) cyc") { + t.Fatalf("expected Sub1 status '(6B, 3 insns) │ 24 (6/0) cyc', got %q", status) + } + + // Pressing 'V' again toggles/cancels the subroutine selection + app.HandleKey("V") + if app.SelectionStart != nil { + t.Fatalf("expected second 'V' to cancel selection") + } + + // 3. Press 'V' inside Sub2 (at 0x10008) -> selects [0x10006..0x10008] + app.GotoAddress(0x10008) + app.HandleKey("V") + selStart, selEnd, ok = app.GetSelectionRange() + if !ok || selStart != 0x10006 || selEnd != 0x10008 { + t.Fatalf("expected Sub2 selection [0x10006..0x10008], got [0x%X..0x%X] ok=%v", selStart, selEnd, ok) + } + status = app.GetStatusLine() + if !strings.Contains(status, "(4B, 2 insns) │ 54-86 (5/0) cyc") { + t.Fatalf("expected Sub2 variable timing '(4B, 2 insns) │ 54-86 (5/0) cyc', got %q", status) + } + app.HandleKey("esc") + + // 4. Press 'V' inside Sub3 (at 0x1000A) -> walks across LAB_10014 and skipped jmp to rts at 0x10016 + app.GotoAddress(0x1000A) + app.HandleKey("V") + selStart, selEnd, ok = app.GetSelectionRange() + if !ok || selStart != 0x1000A || selEnd != 0x10016 { + t.Fatalf("expected Sub3 selection [0x1000A..0x10016] across internal label and skipped jmp, got [0x%X..0x%X] ok=%v", selStart, selEnd, ok) + } + app.HandleKey("esc") + + // 5. Press 'V' inside Sub4 (at 0x10018) -> stops at unskipped tail-call jmp at 0x1001A + app.GotoAddress(0x10018) + app.HandleKey("V") + selStart, selEnd, ok = app.GetSelectionRange() + if !ok || selStart != 0x10018 || selEnd != 0x1001A { + t.Fatalf("expected Sub4 selection [0x10018..0x1001A] ending at tail-call jmp, got [0x%X..0x%X] ok=%v", selStart, selEnd, ok) + } + app.HandleKey("esc") + + // 6. Press 'V' on data-only row -> clears/refuses with "No subroutine at cursor" + app.GotoAddress(0x10020) + app.HandleKey("V") + if app.SelectionStart != nil || app.StatusMsg != "No subroutine at cursor" { + t.Fatalf("expected 'No subroutine at cursor' on data row, got StatusMsg=%q", app.StatusMsg) + } +}