diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9f833ee..b8c974e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,14 +83,14 @@ internal/ ├── 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 ├── app.go `App` (`tea.Model`) + segment/status management - ├── actions.go Interactive type marking (`c`/`b`/`w`/`l`/`p`/`s`/`u`), carve/uncarve & undo + ├── 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 ├── 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 + ├── 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`) ├── theme.go Configurable truecolor `Theme`, `LoadTheme`, and per-token operand/comment styling - └── render.go Fixed-grid 2D `screenBuffer` compositor, scrollbars, floating modals + └── render.go Fixed-grid 2D `screenBuffer` compositor, scrollbars, floating modals (`ModalBitplane`, `ModalMusicRipper`, etc.) ``` ## Data Model @@ -217,9 +217,9 @@ preserved. register-context-resolved `d16(An)` hardware accesses are named (`INTREQR+1` etc.). -## Music Module Recognizer Engine (`internal/modules/`) +## Music Module Recognizer Engine (`internal/modules/`, Experimental) -A self-contained recognizer subsystem (reverse-engineered from ExoticRipper +A self-contained experimental recognizer subsystem (reverse-engineered from ExoticRipper V2.31) that operates directly on `[]byte` buffers without SQLite, `Project`, or I/O dependencies: diff --git a/MANUAL.md b/MANUAL.md index cb76d53..d4ca518 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -83,7 +83,7 @@ cmp -l assets/disk.1 build/disk_1.bin # 0 differences | `-m map load FILE` | Bulk-load ` [type]` label mappings from a text file | | `-m status` | Show module byte coverage statistics | | `-m tui` | Launch the interactive terminal UI | -| `rip FILE` | Scan a raw binary/memory dump for embedded Amiga music/tracker modules (26 ExoticRipper recognizers) and report detected formats, offsets, and region bounds | +| `rip FILE` | *(Experimental)* Scan a raw binary/memory dump for embedded Amiga music/tracker modules (26 ExoticRipper recognizers) and report detected formats, offsets, and region bounds | --- @@ -478,7 +478,7 @@ Instructions that jump/call across hunks or through a register jump-vector table | `p` | Mark as `POINTER` (4-byte address, resolves target label) | | `P` | Mark pointer table (prompts for entry count) | | `s` | Mark as null-terminated ASCII `STRING` | -| `K` | Carve v-selection into an `incbin` block (prompts for a name); with no selection, uncarve the carved range at the cursor. Refuses interior relocations in relocatable segments | +| `K` | Carve v-selection into an `incbin` block (prompts for a label name and jumps cursor to the carve start); with no selection, uncarve the carved range at the cursor (undoable via `Ctrl-z`). Refuses interior relocations in relocatable segments | | `u` | Undefine cell(s) (revert to raw unmapped bytes) | ### Annotation Keys @@ -502,8 +502,9 @@ Instructions that jump/call across hunks or through a register jump-vector table | `m` | Memory map region editor (`d` to delete region) | | `t` | Struct editor (`n` = new struct, `Enter` = edit struct fields, `a` = add field, `d` = delete, `Esc` = back) | | `a` | Register alias editor (maps a base register to a struct for automatic `d16(An)` field substitution) | -| `Ctrl-z` | Undo last type-marking action | +| `Ctrl-z` | Undo last edit (type-marking, carve/uncarve, rename, inline/block comment) | | `B` | Open interactive Bitplane Image Viewer at cursor address | +| `M` | *(Experimental)* Open interactive Music Module Ripper (scan, browse, jump, rip, carve) | | `?` | Help popup | | `q` | Quit | @@ -550,9 +551,26 @@ The viewer queries the terminal text area pixel dimensions at startup via the cr | `v` | Cycle terminal graphics protocol (`Kitty` $\rightarrow$ `iTerm2` $\rightarrow$ `Sixel` $\rightarrow$ `Half-Block`) | | `w` | Export unscaled bitmap as standard PNG to `assets/__.png` | | `W` | Export bitmap as standard Commodore Amiga `FORM ILBM` file to `assets/__.ilbm` | -| `K` | Carve bitplane byte range in database as Data (`DAT`) with label `gfx_xx` (undoable via `Ctrl-z`) | +| `K` | Carve bitplane byte range as `incbin` Virtual Segment (prompts for `gfx_xx[i][h][ham]` label, writes `; Bitmap:` / `; Span:` / `; Regs:` / `; Planes:` / `; Pal:` block comment above the label; round-trips geometry, modulo, `VIS`/`SYNC` masks, plane pointers, and palette/invert/bias on `B`; flat binaries only; undoable via `Ctrl-z`) | | `Esc` / `q` | Close viewer and return immediately to disassembly listing | +### Interactive Music Module Ripper (`M`) — Experimental + +> **Note:** The Music Module Ripper (`M` in TUI and `hunks rip`) is currently **experimental**. + +Pressing `M` in the Disassembly view scans the active segment using the zero-dependency recognizer engine (`modules.Scan`) for embedded tracker and sound driver modules across 26 ExoticRipper-derived formats (including ProTracker, SoundTracker 2.6, Old SoundTracker, Future Composer, David Whittaker, Delta Music 1.0, Final Music System, ThePlayer, TFMX, OctaMED, and Jochen Hippel). + +If modules are detected, `ModalMusicRipper` displays all found songs and companion regions (e.g. StarTrekker sample banks) with format, variant, offset, and size: + +| Key | Action | +|-----|--------| +| `j` / `k` or `↓` / `↑` | Select detected module region | +| `PgUp` / `PgDn` / `Home` / `End` | Page / jump through detected module regions | +| `Enter` | Jump disassembly cursor directly to the module's start address | +| `w` | Rip / export the selected module directly to `assets/_.mod` (or `.bin`, keyed on format ID) | +| `K` | Carve module byte range as `incbin` Virtual Segment (prompts for `mod__` label, writes `; Music:` / `; Span:` block comment above the label; flat binaries only; undoable via `Ctrl-z`) | +| `Esc` / `q` | Close ripper and return to disassembly listing | + ### Theme Customization (`~/.config/hunks/theme.json`) The TUI uses a built-in dark truecolor palette out of the box. You can override any subset of token styles in `~/.config/hunks/theme.json` (or via `$HUNKS_THEME`) using `" [bg:] [bold] [italic] [reverse]"` specifications: diff --git a/README.md b/README.md index 897c260..b28ed01 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ Amiga M68k Reverse Engineering System — terminal-native, keyboard-first disass - **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))`) 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 `)** 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), 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 +- **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 - **100% bit-for-bit reassembly** verified with `vasmm68k_mot` ## Quick Start diff --git a/TESTING.md b/TESTING.md index f7bc2a2..ca9feba 100644 --- a/TESTING.md +++ b/TESTING.md @@ -124,6 +124,7 @@ Verify: - `/` search; `f` Functions; `x` XREFs (`Enter` jumps to caller). - `Enter` follows branches, cross-hunk refs (`⇢`), and jump-vector hints; `-`/`Bksp` returns. - `←`/`→` moves the byte cursor on `DATA` rows or cycles caller badges on `; XREF` rows; `s`/`b`/`w`/`l`/`p`/`c`/`u` mark cells; `K` carve/uncarve; `Ctrl-z` undo. +- `B` opens the Bitplane Image Viewer (`K` carves as `incbin` with metadata block comment and round-trips on `B`); `M` opens the experimental Music Module Ripper (`Enter` jumps, `w` rips to `assets/`, `K` carves as `incbin`). - `?` Help; `q` quit. --- @@ -158,7 +159,7 @@ Expected: - (b) carve over an interior reloc in a relocatable hunk **exits non-zero** with a clean refusal (no panic). - (c) carve on the flat part succeeds (guard does not apply to non-relocatable parts). -- (d) `uncarve` empties `project.json` `carves`. +- (d) `uncarve` removes the `INCBIN` cell, label, and block comment from `project.db`. ### Carve byte-neutrality on relocatable hunks Carve a **reloc-free DATA range**, emit, re-assemble, confirm load-image parity: diff --git a/cmd/hunks/main.go b/cmd/hunks/main.go index fdce0bc..7fe790a 100644 --- a/cmd/hunks/main.go +++ b/cmd/hunks/main.go @@ -22,7 +22,7 @@ import ( "github.com/spf13/cobra" ) -var version = "dev" +var version = "v1.1.0" func parseHex(val string) (uint32, error) { return project.ParseUint32(val) @@ -1788,17 +1788,21 @@ func main() { segBytes := seg.LoadBinaryData() off := int(startVal - seg.Base) endOff := int(endVal - seg.Base) - if endOff > len(segBytes) { + if off < 0 || endOff > len(segBytes) { return fmt.Errorf("carve range exceeds segment payload") } - raw := slices.Clone(segBytes[off:endOff]) label := carveName + isAuto := false if label == "" { label = proj.AutoLabel(startVal) + isAuto = true } sidecar := carveFile - if sidecar == "" { + var opStr *string + if sidecar != "" { + opStr = &sidecar + } else { sidecar = fmt.Sprintf("assets/%s.bin", label) } @@ -1815,27 +1819,15 @@ func main() { Size: endVal - startVal, Type: db.CellIncbin, Confidence: 255, - RawBytes: raw, + OpStr: opStr, }); err != nil { return err } - return ctrl.SetLabel(startVal, label, db.LabelDat, false) + return ctrl.SetLabel(startVal, label, db.LabelDat, isAuto) }); err != nil { return err } - proj.Data.Carves = slices.DeleteFunc(proj.Data.Carves, func(c project.Carve) bool { - return c.Start == startVal - }) - proj.Data.Carves = append(proj.Data.Carves, project.Carve{ - Start: startVal, - End: endVal, - File: sidecar, - Name: label, - }) - if err := proj.Save(); err != nil { - return err - } fmt.Printf("Carved [%#X..%#X] (%d bytes) in '%s' -> %s (label %s)\n", startVal, endVal, endVal-startVal, seg.Name, sidecar, label) return nil @@ -1862,17 +1854,6 @@ func main() { if err != nil { return err } - var foundCarve *project.Carve - for i := range proj.Data.Carves { - if proj.Data.Carves[i].Start == startVal { - foundCarve = &proj.Data.Carves[i] - break - } - } - if foundCarve == nil { - return fmt.Errorf("no carve starts at $%X", startVal) - } - carve := *foundCarve seg := proj.SegmentAtAddr(startVal) if seg == nil { return fmt.Errorf("no segment owns address $%X", startVal) @@ -1884,17 +1865,24 @@ func main() { defer ctrl.Close() proj.ActiveSegmentName = seg.Name proj.SyncDBScope(ctrl) - if err := ctrl.DeleteCellsInRange(carve.Start, carve.End); err != nil { - return err + cell, err := ctrl.GetCell(startVal) + if err != nil || cell == nil || cell.Type != db.CellIncbin || cell.Size == 0 { + return fmt.Errorf("no carve starts at $%X", startVal) } - proj.Data.Carves = slices.DeleteFunc(proj.Data.Carves, func(c project.Carve) bool { - return c.Start == startVal - }) - if err := proj.Save(); err != nil { + carveEnd := cell.Addr + cell.Size + if err := ctrl.WithTransaction(func() error { + if err := ctrl.DeleteCellsInRange(cell.Addr, carveEnd); err != nil { + return err + } + if err := ctrl.DeleteLabelAt(cell.Addr); err != nil { + return err + } + return ctrl.UpdateBlockComment(cell.Addr, "") + }); err != nil { return err } fmt.Printf("Uncarved [%#X..%#X] in '%s'. Run `hunks analyze` to reclassify the range.\n", - carve.Start, carve.End, seg.Name) + cell.Addr, carveEnd, seg.Name) return nil }, } diff --git a/internal/db/controller.go b/internal/db/controller.go index 0b969a0..a0f5ca3 100644 --- a/internal/db/controller.go +++ b/internal/db/controller.go @@ -1861,14 +1861,15 @@ func (c *Controller) ClearOverlaps(addr, size uint32) error { end := addr + size type partialCell struct { - addr uint32 - size uint32 - raw []byte - conf uint8 + addr uint32 + size uint32 + typeStr string + raw []byte + conf uint8 } var partials []partialCell rows, err := c.conn.Query( - "SELECT addr, size, raw_bytes, confidence FROM cells WHERE seg = ?1 AND addr < ?3 AND addr + size > ?2", + "SELECT addr, size, type, raw_bytes, confidence FROM cells WHERE seg = ?1 AND addr < ?3 AND addr + size > ?2", seg, addr, end, ) if err != nil { @@ -1876,7 +1877,7 @@ func (c *Controller) ClearOverlaps(addr, size uint32) error { } for rows.Next() { var p partialCell - if err := rows.Scan(&p.addr, &p.size, &p.raw, &p.conf); err != nil { + if err := rows.Scan(&p.addr, &p.size, &p.typeStr, &p.raw, &p.conf); err != nil { _ = rows.Close() return err } @@ -1901,11 +1902,14 @@ func (c *Controller) ClearOverlaps(addr, size uint32) error { return err } - // Remnant head/tail bytes are reinserted as individual DATA_B cells (one per byte) to - // preserve exact emission/label boundaries. ClearOverlaps is called both standalone - // (TUI) and inside an outer transaction (stage2, CLI pointer hints), so it must not open - // its own transaction here — doing so would nest BEGIN on the single SQLite connection. + // For small cells (<= 16 bytes, non-INCBIN), remnant head/tail bytes are reinserted as + // individual DATA_B cells to preserve exact emission/label boundaries. Large cells + // (INCBIN, multi-KB DATA_ZERO, long STRING) are never shattered into thousands of 1-byte + // rows; their unmapped remnants fall through naturally to BinaryData. for _, p := range partials { + if p.typeStr == CellIncbin.AsStr() || p.size > 16 { + continue + } cEnd := p.addr + p.size if p.addr < addr { for bAddr := p.addr; bAddr < addr; bAddr++ { diff --git a/internal/emitter/emitter.go b/internal/emitter/emitter.go index 6aedb8b..c4c654c 100644 --- a/internal/emitter/emitter.go +++ b/internal/emitter/emitter.go @@ -751,11 +751,12 @@ func (e *Emitter) PopulateCellsItems() error { switch cell.Type { case db.CellIncbin: - file := fmt.Sprintf("assets/incbin_%08X.bin", addr) - for _, c := range e.Proj.Data.Carves { - if c.Start == addr { - file = c.File - break + file := opStr + if file == "" { + if lbl := getLabelStr(addr); lbl != "" { + file = fmt.Sprintf("assets/%s.bin", lbl) + } else { + file = fmt.Sprintf("assets/incbin_%08X.bin", addr) } } outPath := file diff --git a/internal/modules/modules.go b/internal/modules/modules.go index af10040..85721bf 100644 --- a/internal/modules/modules.go +++ b/internal/modules/modules.go @@ -64,6 +64,19 @@ func (m *Match) TotalLen() int { return total } +// FileExtForFormatID returns the canonical file extension ("mod" or "bin") for a FormatID and RegionRole. +func FileExtForFormatID(formatID uint32, role RegionRole) string { + if role != RegionPrimary { + return "bin" + } + switch formatID { + case 22, 23, 42: // SoundTracker 2.6, ProTracker, Old SoundTracker + return "mod" + default: + return "bin" + } +} + // Recognizer is a single-format detector. Pure: inspects data at/around offset and returns // a Match iff a valid module of this format is present. type Recognizer interface { diff --git a/internal/project/project.go b/internal/project/project.go index 95da177..8f96095 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -140,53 +140,6 @@ func (m *MemoryRegion) UnmarshalJSON(data []byte) error { return nil } -type Carve struct { - Start uint32 `json:"start"` - End uint32 `json:"end"` - File string `json:"file"` - Name string `json:"name"` -} - -func (c Carve) MarshalJSON() ([]byte, error) { - type wire struct { - Start string `json:"start"` - End string `json:"end"` - File string `json:"file"` - Name string `json:"name"` - } - return json.Marshal(wire{ - Start: formatHex(c.Start), - End: formatHex(c.End), - File: c.File, - Name: c.Name, - }) -} - -func (c *Carve) UnmarshalJSON(data []byte) error { - var wire struct { - Start json.RawMessage `json:"start"` - End json.RawMessage `json:"end"` - File string `json:"file"` - Name string `json:"name"` - } - if err := json.Unmarshal(data, &wire); err != nil { - return err - } - start, err := parseHexJSON(wire.Start) - if err != nil { - return err - } - end, err := parseHexJSON(wire.End) - if err != nil { - return err - } - c.Start = start - c.End = end - c.File = wire.File - c.Name = wire.Name - return nil -} - type Segment struct { Name string `json:"name"` HunkIndex int `json:"hunk_index"` @@ -354,7 +307,6 @@ type ProjectData struct { MemoryRegions []MemoryRegion `json:"memory_regions"` Structs map[string]map[string]string `json:"structs"` RegisterAliases map[string]string `json:"register_aliases"` - Carves []Carve `json:"carves"` Analysis AnalysisData `json:"_analysis"` } @@ -376,7 +328,6 @@ func New(filepath string) *Project { MemoryRegions: []MemoryRegion{}, Structs: make(map[string]map[string]string), RegisterAliases: make(map[string]string), - Carves: []Carve{}, Analysis: AnalysisData{ IndirectAddressing: []string{}, }, @@ -445,9 +396,6 @@ func (p *Project) Load() error { if p.Data.MemoryRegions == nil { p.Data.MemoryRegions = []MemoryRegion{} } - if p.Data.Carves == nil { - p.Data.Carves = []Carve{} - } if p.Data.Analysis.IndirectAddressing == nil { p.Data.Analysis.IndirectAddressing = []string{} } diff --git a/internal/tui/actions.go b/internal/tui/actions.go index abf53d9..e2191ae 100644 --- a/internal/tui/actions.go +++ b/internal/tui/actions.go @@ -5,12 +5,11 @@ import ( "image/png" "os" "path/filepath" - "slices" "strings" "github.com/mikolas/hunks/internal/analyzer" "github.com/mikolas/hunks/internal/db" - "github.com/mikolas/hunks/internal/project" + "github.com/mikolas/hunks/internal/modules" ) func (a *App) pushUndo(entry UndoEntry) { @@ -23,15 +22,61 @@ func (a *App) pushUndo(entry UndoEntry) { func (a *App) pushUndoForRegion(addr, size uint32) { cells, _ := a.DB.CellsOverlapping(addr, size) comments, _ := a.DB.GetCommentsInRange(addr, addr+size) + var oldLabels []db.Label + if lbl, _ := a.DB.GetLabelFull(addr); lbl != nil { + oldLabels = append(oldLabels, *lbl) + } a.pushUndo(UndoEntry{ Kind: UndoCellDelete, ClearedStart: addr, ClearedLen: size, Cells: cells, Comments: comments, + OldLabels: oldLabels, }) } +// incbinCellAtCursor returns the INCBIN cell if and only if the cursor is currently +// positioned on an INCBIN row in DisasmRows (and backed by a CellIncbin in the DB). +// It never matches dc.x (SubRowData), CODE, or other non-incbin rows. +func (a *App) incbinCellAtCursor() *db.Cell { + if len(a.DisasmRows) > 0 { + idx := min(max(a.SelectedIdx, 0), len(a.DisasmRows)-1) + row := a.DisasmRows[idx] + if row.Kind == SubRowData || row.CellType != "INCBIN" { + return nil + } + if row.Addr != nil { + if cell, err := a.DB.GetCell(*row.Addr); err == nil && cell != nil && cell.Type == db.CellIncbin && cell.Size > 0 { + return cell + } + } + return nil + } + addr, ok := a.CursorAddr() + if !ok { + return nil + } + if cell, err := a.DB.GetCell(addr); err == nil && cell != nil && cell.Type == db.CellIncbin && cell.Size > 0 { + return cell + } + return nil +} + +// rangeOverlapsIncbin returns true if [start, start+size) overlaps any CellIncbin in the DB. +func (a *App) rangeOverlapsIncbin(start, size uint32) bool { + cells, err := a.DB.CellsOverlapping(start, size) + if err != nil { + return false + } + for i := range cells { + if cells[i].Type == db.CellIncbin { + return true + } + } + return false +} + // Undo reverts the most recent user edit from UndoStack. func (a *App) Undo() { if len(a.UndoStack) == 0 { @@ -42,12 +87,67 @@ func (a *App) Undo() { a.UndoStack = a.UndoStack[:len(a.UndoStack)-1] switch entry.Kind { case UndoCellDelete: - _ = a.DB.ClearOverlaps(entry.ClearedStart, entry.ClearedLen) - for i := range entry.Cells { - _ = a.DB.InsertCell(&entry.Cells[i]) - } - for cAddr, ac := range entry.Comments { - _ = a.DB.SetAutoComment(cAddr, ac.AutoComment) + if err := a.DB.WithTransaction(func() error { + if err := a.DB.ClearOverlaps(entry.ClearedStart, entry.ClearedLen); err != nil { + return err + } + clearMin := entry.ClearedStart + clearMax := entry.ClearedStart + entry.ClearedLen + for i := range entry.Cells { + if entry.Cells[i].Addr < clearMin { + clearMin = entry.Cells[i].Addr + } + if end := entry.Cells[i].Addr + entry.Cells[i].Size; end > clearMax { + clearMax = end + } + } + if err := a.DB.DeleteCellsInRange(clearMin, clearMax); err != nil { + return err + } + for i := range entry.Cells { + if err := a.DB.InsertCell(&entry.Cells[i]); err != nil { + return err + } + } + currentComments, _ := a.DB.GetCommentsInRange(entry.ClearedStart, entry.ClearedStart+entry.ClearedLen) + for cAddr := range currentComments { + if _, existed := entry.Comments[cAddr]; !existed { + if err := a.DB.UpdateComment(cAddr, ""); err != nil { + return err + } + if err := a.DB.UpdateBlockComment(cAddr, ""); err != nil { + return err + } + if err := a.DB.SetAutoComment(cAddr, ""); err != nil { + return err + } + } + } + for cAddr, ac := range entry.Comments { + if err := a.DB.UpdateComment(cAddr, ac.Comment); err != nil { + return err + } + if err := a.DB.UpdateBlockComment(cAddr, ac.BlockComment); err != nil { + return err + } + if err := a.DB.SetAutoComment(cAddr, ac.AutoComment); err != nil { + return err + } + } + if entry.DeletedLabel { + if err := a.DB.DeleteLabelAt(entry.ClearedStart); err != nil { + return err + } + } + for _, lbl := range entry.OldLabels { + if err := a.DB.SetLabel(lbl.Addr, lbl.Name, lbl.Type, lbl.IsAuto); err != nil { + return err + } + } + return nil + }); err != nil { + a.StatusMsg = fmt.Sprintf("Undo failed: %v", err) + return } case UndoLabelChange: if entry.OldStr != nil { @@ -80,6 +180,10 @@ func (a *App) Undo() { // MarkString marks a NUL-terminated or printable ASCII string starting at the cursor. func (a *App) MarkString() { + if a.incbinCellAtCursor() != nil { + a.StatusMsg = "Folded incbin — press 'b' or 'K' to unfold first" + return + } addr, ok := a.CursorAddr() if !ok { return @@ -110,31 +214,59 @@ func (a *App) MarkString() { if size == 0 { return } + if a.rangeOverlapsIncbin(addr, size) { + a.StatusMsg = "Folded incbin — press 'b' or 'K' to unfold first" + return + } a.pushUndoForRegion(addr, size) - _ = a.DB.MarkData(addr, size, db.CellStr) + _ = a.DB.WithTransaction(func() error { + return a.DB.MarkData(addr, size, db.CellStr) + }) a.ReloadAtCursor() a.StatusMsg = fmt.Sprintf("Marked %d byte string at %s", size, a.FormatAddr(addr)) } -// MarkType marks size bytes at the cursor as cellType. +// MarkType marks size bytes at the cursor as cellType (or unfolds an incbin when pressing 'b'). func (a *App) MarkType(cellType db.CellType, size uint32) { + if a.incbinCellAtCursor() != nil { + if cellType == db.CellDataB { + a.UncarveAtCursor() + } else { + a.StatusMsg = "Folded incbin — press 'b' or 'K' to unfold first" + } + return + } addr, ok := a.CursorAddr() if !ok { return } + if a.rangeOverlapsIncbin(addr, size) { + a.StatusMsg = "Folded incbin — press 'b' or 'K' to unfold first" + return + } a.pushUndoForRegion(addr, size) - _ = a.DB.MarkData(addr, size, cellType) + _ = a.DB.WithTransaction(func() error { + return a.DB.MarkData(addr, size, cellType) + }) a.ReloadAtCursor() a.StatusMsg = fmt.Sprintf("Marked %s at %s", cellType.AsStr(), a.FormatAddr(addr)) } // MarkCode marks the word-aligned cursor address as a code entry point and runs incremental analysis. func (a *App) MarkCode() { + if a.incbinCellAtCursor() != nil { + a.StatusMsg = "Folded incbin — press 'b' or 'K' to unfold first" + return + } rawAddr, ok := a.CursorAddr() if !ok { return } addr := rawAddr &^ 1 + if a.rangeOverlapsIncbin(addr, 2) { + a.StatusMsg = "Folded incbin — press 'b' or 'K' to unfold first" + return + } a.pushUndoForRegion(addr, 2) if _, err := analyzer.ApplyCodeHint(a.Proj, a.DB, addr); err != nil { a.StatusMsg = fmt.Sprintf("Analyze failed: %v", err) @@ -145,7 +277,11 @@ func (a *App) MarkCode() { } // CarveRange carves [start, endIncl] in the current segment into an INCBIN cell. -func (a *App) CarveRange(start, endIncl uint32, name string) { +func (a *App) CarveRange(start, endIncl uint32, name, blockComment string) { + if endIncl < start || endIncl == 0xFFFFFFFF || start < a.BaseAddr { + a.StatusMsg = "Invalid carve range" + return + } end := endIncl + 1 seg := a.Proj.SegmentAtAddr(start) if seg == nil { @@ -175,12 +311,30 @@ func (a *App) CarveRange(start, endIncl uint32, name string) { a.StatusMsg = "Selection exceeds segment payload" return } - raw := make([]byte, endOff-off) - copy(raw, a.BinaryData[off:endOff]) + + cells, _ := a.DB.CellsOverlapping(start, end-start) + comments, _ := a.DB.GetCommentsInRange(start, end) + var oldLabels []db.Label + hadStartLabel := false + if lbl, _ := a.DB.GetLabelFull(start); lbl != nil { + oldLabels = append(oldLabels, *lbl) + hadStartLabel = true + } + var interiorOverlappedStarts []uint32 + for _, c := range cells { + if c.Type == db.CellIncbin && c.Addr > start && c.Addr < end { + interiorOverlappedStarts = append(interiorOverlappedStarts, c.Addr) + if lbl, _ := a.DB.GetLabelFull(c.Addr); lbl != nil { + oldLabels = append(oldLabels, *lbl) + } + } + } label := strings.TrimSpace(name) + isAuto := false if label == "" { label = a.Proj.AutoLabel(start) + isAuto = true } sidecar := fmt.Sprintf("assets/%s.bin", label) @@ -191,71 +345,113 @@ func (a *App) CarveRange(start, endIncl uint32, name string) { if err := a.DB.DeleteCellsInRange(start, end); err != nil { return err } + for _, cStart := range interiorOverlappedStarts { + if err := a.DB.DeleteLabelAt(cStart); err != nil { + return err + } + if err := a.DB.UpdateBlockComment(cStart, ""); err != nil { + return err + } + } if err := a.DB.InsertCell(&db.Cell{ Seg: a.DB.SegOf(start), Addr: start, Size: end - start, Type: db.CellIncbin, Confidence: 255, - RawBytes: raw, }); err != nil { return err } - return a.DB.SetLabel(start, label, db.LabelDat, false) + if err := a.DB.SetLabel(start, label, db.LabelDat, isAuto); err != nil { + return err + } + if blockComment != "" { + if err := a.DB.UpdateBlockComment(start, blockComment); err != nil { + return err + } + } + return nil }); err != nil { a.StatusMsg = fmt.Sprintf("Carve failed: %v", err) return } - a.Proj.Data.Carves = slices.DeleteFunc(a.Proj.Data.Carves, func(c project.Carve) bool { - return c.Start == start - }) - a.Proj.Data.Carves = append(a.Proj.Data.Carves, project.Carve{ - Start: start, - End: end, - File: sidecar, - Name: label, + a.pushUndo(UndoEntry{ + Kind: UndoCellDelete, + ClearedStart: start, + ClearedLen: end - start, + Cells: cells, + Comments: comments, + OldLabels: oldLabels, + DeletedLabel: !hadStartLabel, }) - _ = a.Proj.Save() + a.SelectionStart = nil a.ReloadAtCursor() + // Move the disassembly cursor to the carved incbin's start address (intentional TUI UX + // when carving from ModalBitplane, ModalMusicRipper, or a downward/upward range selection). + a.GotoAddress(start) a.StatusMsg = fmt.Sprintf("Carved %d bytes at %s -> %s", end-start, a.FormatAddr(start), sidecar) } -// UncarveAtCursor removes the carve whose range contains the cursor address. +// UncarveAtCursor unfolds the INCBIN cell at the cursor row back into bytes. +// It refuses to act if the cursor is on a dc.x data row or any non-INCBIN row. func (a *App) UncarveAtCursor() { - addr, ok := a.CursorAddr() - if !ok { + cell := a.incbinCellAtCursor() + if cell == nil { + a.StatusMsg = "No carve at cursor" return } - idx := -1 - var carve project.Carve - for i, c := range a.Proj.Data.Carves { - if addr >= c.Start && addr < c.End { - idx = i - carve = c - break - } + carveStart := cell.Addr + carveEnd := cell.Addr + cell.Size + cells, _ := a.DB.CellsOverlapping(carveStart, cell.Size) + comments, _ := a.DB.GetCommentsInRange(carveStart, carveEnd) + var oldLabels []db.Label + if lbl, _ := a.DB.GetLabelFull(carveStart); lbl != nil { + oldLabels = append(oldLabels, *lbl) } - if idx < 0 { - a.StatusMsg = "No carve at cursor" + if err := a.DB.WithTransaction(func() error { + if err := a.DB.DeleteCellsInRange(carveStart, carveEnd); err != nil { + return err + } + if err := a.DB.DeleteLabelAt(carveStart); err != nil { + return err + } + if err := a.DB.UpdateBlockComment(carveStart, ""); err != nil { + return err + } + return nil + }); err != nil { + a.StatusMsg = fmt.Sprintf("Uncarve failed: %v", err) return } - _ = a.DB.DeleteCellsInRange(carve.Start, carve.End) - a.Proj.Data.Carves = append(a.Proj.Data.Carves[:idx], a.Proj.Data.Carves[idx+1:]...) - _ = a.Proj.Save() + a.pushUndo(UndoEntry{ + Kind: UndoCellDelete, + ClearedStart: carveStart, + ClearedLen: cell.Size, + Cells: cells, + Comments: comments, + OldLabels: oldLabels, + }) a.ReloadAtCursor() - a.StatusMsg = fmt.Sprintf("Uncarved %s — press 'r' to reclassify", a.FormatAddr(carve.Start)) + a.StatusMsg = fmt.Sprintf("Uncarved %s — press 'r' to reclassify", a.FormatAddr(carveStart)) } -// Undefine deletes the cell and control edges at the cursor address. +// Undefine deletes the cell and control edges at the cursor address (or unfolds an INCBIN). func (a *App) Undefine() { + if a.incbinCellAtCursor() != nil { + a.UncarveAtCursor() + return + } addr, ok := a.CursorAddr() if !ok { return } targetAddr := addr if cell, err := a.DB.GetCellContaining(addr); err == nil && cell != nil { + if cell.Type == db.CellIncbin { + return + } targetAddr = cell.Addr a.pushUndoForRegion(targetAddr, max(cell.Size, 1)) } @@ -349,7 +545,18 @@ func (a *App) OpenBitplaneViewer() { if addr >= a.BaseAddr { baseOff = addr - a.BaseAddr } - st := NewBitplaneState(baseOff) + + var st *BitplaneState + if a.DB != nil { + ac := a.DB.GetAddressComment(addr) + lbl, _, _ := a.DB.GetLabel(addr) + if restored, ok := ParseBitplaneBlockComment(ac.BlockComment, lbl, a.BaseAddr, baseOff, a.BinaryData); ok { + st = restored + } + } + if st == nil { + st = NewBitplaneState(baseOff) + } proto := DetectProtocol() // Scale to fit the terminal window canvas using exact pixel measurements @@ -382,6 +589,13 @@ func (a *App) refreshBitplaneCache() { st.UpdateCacheClipped(a.BinaryData, 2, 6, a.Modal.PixelProtocol, maxOutPxW, maxOutPxH) } +func (a *App) assetsDir() string { + if a.Proj != nil && a.Proj.WorkspaceRoot != "" { + return filepath.Join(a.Proj.WorkspaceRoot, "assets") + } + return "assets" +} + // SaveBitplanePNG saves the full bitmap to assets/__xx.png. func (a *App) SaveBitplanePNG() { st := a.Modal.Bitplane @@ -393,7 +607,7 @@ func (a *App) SaveBitplanePNG() { img := st.DecodeImage(a.BinaryData, 0, 0) st.Scale = oldScale - dir := "assets" + dir := a.assetsDir() _ = os.MkdirAll(dir, 0755) segName := "seg" @@ -429,7 +643,7 @@ func (a *App) SaveBitplaneIFF() { return } - dir := "assets" + dir := a.assetsDir() _ = os.MkdirAll(dir, 0755) segName := "seg" @@ -446,7 +660,7 @@ func (a *App) SaveBitplaneIFF() { a.StatusMsg = fmt.Sprintf("Saved IFF ILBM: %s", outPath) } -// CarveBitplaneRange carves the memory region spanned by the current bitplanes. +// CarveBitplaneRange initiates carving of the memory region spanned by the current bitplanes. func (a *App) CarveBitplaneRange() { st := a.Modal.Bitplane if st == nil || a.DB == nil { @@ -460,22 +674,167 @@ func (a *App) CarveBitplaneRange() { return } startAddr := a.BaseAddr + startOff - length := endOff - startOff + endAddr := a.BaseAddr + endOff - label := fmt.Sprintf("gfx_%dx%dx%d", st.VisibleWidth(), st.Height, st.BPU()) - a.pushUndoForRegion(startAddr, length) - if err := a.DB.WithTransaction(func() error { - if err := a.DB.ClearOverlaps(startAddr, length); err != nil { - return err + seg := a.Proj.SegmentAtAddr(startAddr) + if seg == nil || seg.IsRelocatable() { + a.StatusMsg = "Refused: bitplane carving only supported in flat binaries" + return + } + + iSuf := "" + if st.Interleaved { + iSuf = "i" + } + hSuf := "" + if st.Hires || st.RowBytes >= 80 { + hSuf = "h" + } + hamSuf := "" + if st.HAM && st.BPU() == 6 { + hamSuf = "ham" + } + defaultLabel := fmt.Sprintf("gfx_%dx%dx%d%s%s%s", st.WidthPx(), st.Height, st.BPU(), iSuf, hSuf, hamSuf) + blockComment := st.FormatBlockComment(a.BaseAddr) + + if a.Modal.PixelProtocol == ProtocolKitty { + a.PendingEscapeSeq = ClearKittyPlacements() + } + + a.Modal = Modal{ + Kind: ModalCarveName, + Input: NewTextInput(defaultLabel), + CarveStart: startAddr, + CarveEnd: endAddr - 1, + CarveBlockComment: blockComment, + Bitplane: st, + PixelProtocol: a.Modal.PixelProtocol, + } +} + +// OpenMusicRipper scans the active segment for embedded music modules and opens the ripper modal. +func (a *App) OpenMusicRipper() { + if len(a.BinaryData) == 0 { + a.StatusMsg = "No binary data in active segment" + return + } + matches := modules.Scan(a.BinaryData) + if len(matches) == 0 { + a.StatusMsg = "No music modules found in segment" + return + } + var items []MusicRipperItem + for _, m := range matches { + for _, r := range m.Regions { + items = append(items, MusicRipperItem{ + FormatID: m.FormatID, + Name: m.Name, + Variant: m.Variant, + Role: r.Role, + Start: r.Start, + Len: r.Len, + }) } - if err := a.DB.MarkData(startAddr, length, db.CellDataB); err != nil { - return err + } + cursorOff := 0 + if cur, ok := a.CursorAddr(); ok && cur >= a.BaseAddr { + cursorOff = int(cur - a.BaseAddr) + } + bestIdx := 0 + for i, it := range items { + if cursorOff >= it.Start && cursorOff < it.Start+it.Len { + bestIdx = i + break } - return a.DB.SetLabel(startAddr, label, db.LabelDat, true) - }); err != nil { - a.StatusMsg = fmt.Sprintf("Carve failed: %v", err) + } + a.Modal = Modal{ + Kind: ModalMusicRipper, + MusicRipper: MusicRipperState{ + Items: items, + Selected: bestIdx, + }, + } + a.StatusMsg = "" +} + +func musicFormatSlug(name string) string { + var sb strings.Builder + prevUnderscore := false + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + sb.WriteRune(r) + prevUnderscore = false + } else if !prevUnderscore && sb.Len() > 0 { + sb.WriteByte('_') + prevUnderscore = true + } + } + res := strings.TrimSuffix(sb.String(), "_") + if res == "" { + return "mod" + } + return res +} + +// SaveMusicModule writes the selected module region to assets/_.bin (or .mod). +func (a *App) SaveMusicModule() { + st := a.Modal.MusicRipper + if len(st.Items) == 0 || st.Selected < 0 || st.Selected >= len(st.Items) { return } - a.ReloadAtCursor() - a.StatusMsg = fmt.Sprintf("Carved bitplane range +$%05X..+$%05X (%s)", startOff, endOff, label) + it := st.Items[st.Selected] + endOff := it.Start + it.Len + if it.Start < 0 || endOff > len(a.BinaryData) { + a.StatusMsg = "Selected module exceeds binary payload" + return + } + raw := a.BinaryData[it.Start:endOff] + + dir := a.assetsDir() + _ = os.MkdirAll(dir, 0755) + + slug := musicFormatSlug(it.Name) + ext := modules.FileExtForFormatID(it.FormatID, it.Role) + filename := fmt.Sprintf("%s_%05X.%s", slug, it.Start, ext) + outPath := filepath.Join(dir, filename) + + if err := os.WriteFile(outPath, raw, 0644); err != nil { + a.StatusMsg = fmt.Sprintf("Module write error: %v", err) + return + } + a.StatusMsg = fmt.Sprintf("Saved music module: %s (%d bytes)", outPath, len(raw)) +} + +// CarveMusicModule initiates carving of the selected music module region into an INCBIN cell. +func (a *App) CarveMusicModule() { + st := a.Modal.MusicRipper + if len(st.Items) == 0 || st.Selected < 0 || st.Selected >= len(st.Items) { + return + } + it := st.Items[st.Selected] + startAddr := a.BaseAddr + uint32(it.Start) + endAddr := a.BaseAddr + uint32(it.Start+it.Len) + + seg := a.Proj.SegmentAtAddr(startAddr) + if seg == nil || seg.IsRelocatable() { + a.StatusMsg = "Refused: asset carving only supported in flat binaries" + return + } + + slug := musicFormatSlug(it.Name) + defaultLabel := fmt.Sprintf("mod_%s_%05X", slug, it.Start) + + var sb strings.Builder + fmt.Fprintf(&sb, "Music: %s (%s) — %s\n", it.Name, it.Variant, it.Role) + fmt.Fprintf(&sb, "Span: $%06X..$%06X (%d bytes)", startAddr, endAddr, it.Len) + blockComment := sb.String() + + a.Modal = Modal{ + Kind: ModalCarveName, + Input: NewTextInput(defaultLabel), + CarveStart: startAddr, + CarveEnd: endAddr - 1, + CarveBlockComment: blockComment, + MusicRipper: st, + } } diff --git a/internal/tui/app.go b/internal/tui/app.go index 036f0d1..493caf8 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -269,14 +269,22 @@ func (a *App) MarkSelection(cellType db.CellType, unitSize uint32) { } count := (end-start)/unitSize + 1 size := count * unitSize + if a.rangeOverlapsIncbin(start, size) { + a.StatusMsg = "Folded incbin — press 'b' or 'K' to unfold first" + return + } a.pushUndoForRegion(start, size) - if cellType == db.CellStr { - _ = a.DB.MarkData(start, size, db.CellStr) - } else { + _ = a.DB.WithTransaction(func() error { + if cellType == db.CellStr { + return a.DB.MarkData(start, size, db.CellStr) + } for i := range count { - _ = a.DB.MarkData(start+i*unitSize, unitSize, cellType) + if err := a.DB.MarkData(start+i*unitSize, unitSize, cellType); err != nil { + return err + } } - } + return nil + }) a.SelectionStart = nil a.ReloadAtCursor() a.StatusMsg = fmt.Sprintf("Marked %d bytes as %s at %s", size, cellType.AsStr(), a.FormatAddr(start)) diff --git a/internal/tui/bitplane.go b/internal/tui/bitplane.go index 8e52d50..2649a62 100644 --- a/internal/tui/bitplane.go +++ b/internal/tui/bitplane.go @@ -3,6 +3,7 @@ package tui import ( "bytes" "encoding/binary" + "fmt" "image" "image/color" "os" @@ -11,6 +12,7 @@ import ( "time" "github.com/charmbracelet/x/term" + "github.com/mikolas/hunks/internal/project" "github.com/muesli/cancelreader" ) @@ -283,6 +285,258 @@ func (s *BitplaneState) BPLCON0Hex() uint16 { return val } +// FormatBlockComment generates a multi-line metadata comment for the bitmap. +func (s *BitplaneState) FormatBlockComment(baseAddr uint32) string { + bpu := s.BPU() + w := s.WidthPx() + h := s.Height + layout := "Planar" + if s.Interleaved { + layout = "Interleaved" + } + res := "Lowres" + if s.Hires || s.RowBytes >= 80 { + res = "Hires" + } + colorsDesc := fmt.Sprintf("%d colors", 1< 0 { + geomStr = rest[:idx] + sub := rest[idx:] + if strings.Contains(sub, "Interleaved") { + interleaved = true + } + if strings.Contains(sub, "Hires") { + hires = true + } + if strings.Contains(sub, "HAM") { + ham = true + } + } else { + geomStr = rest + } + if n, _ := fmt.Sscanf(geomStr, "%dx%dx%d", &w, &h, &bpu); n == 3 { + hasGeom = true + } + } + } + } + + if !hasGeom && label != "" { + clean := strings.TrimPrefix(label, "gfx_") + clean = strings.TrimPrefix(clean, "GFX_") + var suf string + if n, _ := fmt.Sscanf(clean, "%dx%dx%d%s", &w, &h, &bpu, &suf); n >= 3 { + hasGeom = true + if strings.Contains(suf, "i") { + interleaved = true + } + if strings.Contains(suf, "h") { + hires = true + } + if strings.Contains(suf, "ham") { + ham = true + } + } + } + + if !hasGeom || w <= 0 || h <= 0 || bpu <= 0 || bpu > 8 { + return nil, false + } + + st := NewBitplaneState(baseOff) + st.RowBytes = max(w/8, 1) + st.Height = h + st.SetBPU(bpu) + st.Interleaved = interleaved + st.Hires = hires + st.HAM = ham + st.ControlMask = uint8((1 << bpu) - 1) + st.VisibleMask = uint8((1 << bpu) - 1) + + var parsedPlanes bool + if blockComment != "" { + for _, line := range strings.Split(blockComment, "\n") { + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, ";") + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Regs:") { + fields := strings.Fields(strings.TrimPrefix(line, "Regs:")) + var hasBpl1Mod bool + var bpl1mod int + for _, f := range fields { + if strings.HasPrefix(f, "BPL1MOD=$") { + if v, err := project.ParseHex(strings.TrimPrefix(f, "BPL1MOD=$")); err == nil { + bpl1mod = int(int16(uint16(v))) + hasBpl1Mod = true + } + } else if strings.HasPrefix(f, "DDF=") { + var ddfW, ddfB int + if n, _ := fmt.Sscanf(f, "DDF=%dpx(%dB)", &ddfW, &ddfB); n == 2 && ddfB > 0 { + st.RowBytes = ddfB + } + } else if strings.HasPrefix(f, "VIS=$") { + if v, err := project.ParseHex(strings.TrimPrefix(f, "VIS=$")); err == nil { + st.VisibleMask = uint8(v) + } + } else if strings.HasPrefix(f, "SYNC=$") { + if v, err := project.ParseHex(strings.TrimPrefix(f, "SYNC=$")); err == nil { + st.ControlMask = uint8(v) + } + } + } + if hasBpl1Mod { + rowBytes := max(st.RowBytes, 1) + cutBytes := max(rowBytes-st.VisibleRowBytes(), 0) + if st.Interleaved { + st.Modulo = bpl1mod - (max(st.BPU(), 1)-1)*rowBytes - cutBytes + } else { + st.Modulo = bpl1mod - cutBytes + } + } + } else if strings.HasPrefix(line, "Planes:") { + parts := strings.Fields(strings.TrimPrefix(line, "Planes:")) + for _, part := range parts { + idxStr, addrStr, ok := strings.Cut(part, ":$") + if !ok { + continue + } + p, err := strconv.Atoi(idxStr) + if err != nil || p < 1 || p > 8 { + continue + } + hexLen := 0 + for hexLen < len(addrStr) { + c := addrStr[hexLen] + if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') { + hexLen++ + } else { + break + } + } + if hexLen > 0 { + if addrVal, err := project.ParseHex(addrStr[:hexLen]); err == nil && addrVal >= baseAddr { + st.BPL[p-1] = addrVal - baseAddr + parsedPlanes = true + } + } + } + } else if strings.HasPrefix(line, "Pal:") { + fields := strings.Fields(strings.TrimPrefix(line, "Pal:")) + if len(fields) > 0 { + palToken := fields[0] + switch { + case palToken == "Grayscale": + st.PaletteMode = PaletteGrayscale + case palToken == "High-Contrast": + st.PaletteMode = PaletteHighContrast + case strings.Contains(palToken, "@+$"): + idx := strings.Index(palToken, "@+$") + offStr := palToken[idx+len("@+$"):] + if offVal, err := project.ParseHex(offStr); err == nil && len(binData) > 0 { + if pal, name, ok := ParseAmigaPalette(binData, offVal); ok { + st.CustomPalette = pal + st.CustomPalName = fmt.Sprintf("%s@+$%05X", name, offVal) + st.PaletteMode = PaletteCustom + } + } + case strings.Contains(palToken, "@$"): + idx := strings.Index(palToken, "@$") + addrStr := palToken[idx+len("@$"):] + if addrVal, err := project.ParseHex(addrStr); err == nil && len(binData) > 0 { + offVal := addrVal + if addrVal >= baseAddr { + offVal = addrVal - baseAddr + } + if pal, name, ok := ParseAmigaPalette(binData, offVal); ok { + st.CustomPalette = pal + st.CustomPalName = fmt.Sprintf("%s@+$%05X", name, offVal) + st.PaletteMode = PaletteCustom + } + } + } + for _, f := range fields[1:] { + if f == "inv" { + st.Invert = true + } else if strings.HasPrefix(f, "bias=") { + if b, err := strconv.Atoi(strings.TrimPrefix(f, "bias=")); err == nil { + st.Brightness = b + } + } + } + } + } + } + } + + if !parsedPlanes { + st.AutoSnapPlanes() + } + + return st, true +} + // RowStrideBytes returns the byte stride between successive scanlines of a plane. func (s *BitplaneState) RowStrideBytes() int { rowBytes := max(s.RowBytes, 1) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index f3dca7e..f360e7e 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -260,6 +260,8 @@ func (a *App) HandleKey(key string) bool { a.Modal = Modal{Kind: ModalHelp} case "B": a.OpenBitplaneViewer() + case "M": + a.OpenMusicRipper() } return true } @@ -884,7 +886,19 @@ func (a *App) handleModalKey(modal Modal, key string) bool { status, val := modal.Input.HandleKey(key) switch status { case ComponentDone: - a.CarveRange(modal.CarveStart, modal.CarveEnd, val) + a.CarveRange(modal.CarveStart, modal.CarveEnd, val, modal.CarveBlockComment) + a.Modal = Modal{Kind: ModalNone} + case ComponentCancel: + if modal.Bitplane != nil { + a.Modal = modal + a.Modal.Kind = ModalBitplane + a.refreshBitplaneCache() + } else if len(modal.MusicRipper.Items) > 0 { + a.Modal = modal + a.Modal.Kind = ModalMusicRipper + } else { + a.Modal = Modal{Kind: ModalNone} + } case ComponentContinue: a.Modal = modal } @@ -958,6 +972,56 @@ func (a *App) handleModalKey(modal Modal, key string) bool { a.Modal = modal } return true + case ModalMusicRipper: + a.Modal = modal + st := modal.MusicRipper + switch key { + case "esc", "q": + a.Modal = Modal{Kind: ModalNone} + case "down", "j": + if st.Selected+1 < len(st.Items) { + st.Selected++ + } + modal.MusicRipper = st + a.Modal = modal + case "up", "k": + if st.Selected > 0 { + st.Selected-- + } + modal.MusicRipper = st + a.Modal = modal + case "pgdown": + if len(st.Items) > 0 { + st.Selected = min(st.Selected+10, len(st.Items)-1) + } + modal.MusicRipper = st + a.Modal = modal + case "pgup": + st.Selected = max(st.Selected-10, 0) + modal.MusicRipper = st + a.Modal = modal + case "home": + st.Selected = 0 + modal.MusicRipper = st + a.Modal = modal + case "end": + if len(st.Items) > 0 { + st.Selected = len(st.Items) - 1 + } + modal.MusicRipper = st + a.Modal = modal + case "enter": + if st.Selected >= 0 && st.Selected < len(st.Items) { + it := st.Items[st.Selected] + a.Modal = Modal{Kind: ModalNone} + a.GotoAddress(a.BaseAddr + uint32(it.Start)) + } + case "w": + a.SaveMusicModule() + case "K": + a.CarveMusicModule() + } + return true } return true } diff --git a/internal/tui/listing.go b/internal/tui/listing.go index 32f368b..330a1cb 100644 --- a/internal/tui/listing.go +++ b/internal/tui/listing.go @@ -22,6 +22,8 @@ func isExplicitCell(c *db.Cell) bool { switch c.Type { case db.CellCode, db.CellStr, db.CellPointer, db.CellIncbin: return true + case db.CellDataB, db.CellDataZero: + return false default: return c.Confidence >= 100 } @@ -1058,11 +1060,12 @@ func buildListingRowsWithData( } case db.CellIncbin: - file := fmt.Sprintf("assets/incbin_%08X.bin", addr) - for _, c := range proj.Data.Carves { - if c.Start == addr { - file = c.File - break + file := cell.OpStrVal() + if file == "" { + if lbl := getLabelStr(addr); lbl != "" { + file = fmt.Sprintf("assets/%s.bin", lbl) + } else { + file = fmt.Sprintf("assets/incbin_%08X.bin", addr) } } ac := commentsMap[addr] diff --git a/internal/tui/popups.go b/internal/tui/popups.go index 709c9eb..6502d1f 100644 --- a/internal/tui/popups.go +++ b/internal/tui/popups.go @@ -590,14 +590,27 @@ func (a *App) OpenAliasEditor() { // MarkPointerTable marks count consecutive 32-bit pointers starting at the cursor. func (a *App) MarkPointerTable(count uint32) { + if a.incbinCellAtCursor() != nil { + a.StatusMsg = "Folded incbin — press 'b' or 'K' to unfold first" + return + } addr, ok := a.CursorAddr() if !ok { return } - a.pushUndoForRegion(addr, count*4) - for i := range count { - _ = a.DB.MarkData(addr+i*4, 4, db.CellPointer) + if a.rangeOverlapsIncbin(addr, count*4) { + a.StatusMsg = "Folded incbin — press 'b' or 'K' to unfold first" + return } + a.pushUndoForRegion(addr, count*4) + _ = a.DB.WithTransaction(func() error { + for i := range count { + if err := a.DB.MarkData(addr+i*4, 4, db.CellPointer); err != nil { + return err + } + } + return nil + }) a.ReloadAtCursor() a.StatusMsg = fmt.Sprintf("Marked %d pointers at %s", count, a.FormatAddr(addr)) } diff --git a/internal/tui/render.go b/internal/tui/render.go index 0ceb685..49b68f5 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -7,6 +7,7 @@ import ( "github.com/mattn/go-runewidth" "github.com/mikolas/hunks/internal/hunk" + "github.com/mikolas/hunks/internal/modules" "github.com/mikolas/hunks/internal/project" ) @@ -976,7 +977,7 @@ func renderModal(buf *screenBuffer, a *App, screen rect) { } renderListModal(buf, area, " Memory Regions (d=delete, Esc=close) ", fgMagenta, rows, m.ListSelect) case ModalHelp: - area := centeredRect(58, 22, screen) + area := centeredRect(64, 22, screen) inner := buf.drawBox(area, " Help (? to close) ", cellStyle{fg: fgWhite}, cellStyle{}) helpLines := []string{ "Navigation:", @@ -997,7 +998,7 @@ func renderModal(buf *screenBuffer, a *App, screen rect) { "", "Other:", " e Export r Re-analyze m Memory map Ctrl-z Undo", - " B Bitplanes t Structs a Aliases ? Help q Quit", + " B Bitplanes M Music t Structs a Aliases ? Help q Quit", "", "Mouse: Scroll=navigate, Right-click=context menu", } @@ -1157,6 +1158,31 @@ func renderModal(buf *screenBuffer, a *App, screen rect) { renderInputModal(buf, screen, " Bitmap Geometry (WxH or WxHxD) ", &m.Input, fgCyan) case ModalBitplanePalette: renderInputModal(buf, screen, " Set Palette Address / Offset ", &m.Input, fgCyan) + case ModalMusicRipper: + st := &m.MusicRipper + h := min(len(st.Items)+4, 18) + area := centeredRect(70, max(h, 6), screen) + rows := make([][]styledSpan, len(st.Items)) + for i, it := range st.Items { + marker := " " + if i == st.Selected { + marker = "▸ " + } + addr := a.BaseAddr + uint32(it.Start) + roleTag := "" + if it.Role != modules.RegionPrimary { + roleTag = fmt.Sprintf(" [%s]", it.Role) + } + rows[i] = []styledSpan{ + {text: marker, style: cellStyle{fg: fgCyan, bold: true}}, + {text: fmt.Sprintf("%-18s", it.Name), style: cellStyle{fg: fgWhite, bold: true}}, + {text: fmt.Sprintf(" (%-8s)", it.Variant), style: cellStyle{fg: fgDarkGray}}, + {text: fmt.Sprintf(" +$%05X ($%05X)", it.Start, addr), style: cellStyle{fg: fgCyan}}, + {text: fmt.Sprintf(" %6d B%s", it.Len, roleTag), style: cellStyle{fg: fgYellow}}, + } + } + title := " Music Ripper (Enter=Jump, w=Rip, K=Carve, Esc=Close) " + renderListModal(buf, area, title, fgCyan, rows, st.Selected) } } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 7393acc..ec6bf32 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1,7 +1,6 @@ package tui import ( - "bytes" "os" "path/filepath" "strings" @@ -234,7 +233,8 @@ func TestTUICarveGuardAndRoundtrip(t *testing.T) { } t.Cleanup(func() { _ = ctrl.Close() }) proj.SyncDBScope(ctrl) - _ = ctrl.MarkData(0x2000, 8, db.CellDataB) + _ = ctrl.MarkData(0x2000, 4, db.CellDataL) + _ = ctrl.MarkData(0x2004, 4, db.CellDataB) app, err := NewApp(proj, ctrl) if err != nil { @@ -243,13 +243,42 @@ func TestTUICarveGuardAndRoundtrip(t *testing.T) { app.BinaryData = []byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88} _ = app.LoadData() - app.CarveRange(0x2002, 0x2005, "FlatBlob") + app.CarveRange(0x2002, 0x2005, "FlatBlob", "Header description\nLine 2") cell, err := app.DB.GetCell(0x2002) - if err != nil || cell == nil || cell.Type != db.CellIncbin || cell.Size != 4 || !bytes.Equal(cell.RawBytes, []byte{0x33, 0x44, 0x55, 0x66}) { + if err != nil || cell == nil || cell.Type != db.CellIncbin || cell.Size != 4 { t.Fatalf("unexpected carved cell: %+v", cell) } - if len(app.Proj.Data.Carves) != 1 || app.Proj.Data.Carves[0].File != "assets/FlatBlob.bin" { - t.Fatalf("unexpected project carves: %+v", app.Proj.Data.Carves) + if lbl, ok, _ := app.DB.GetLabel(0x2002); !ok || lbl != "FlatBlob" { + t.Fatalf("expected label 'FlatBlob', got %q", lbl) + } + if bc := app.DB.GetAddressComment(0x2002).BlockComment; bc != "Header description\nLine 2" { + t.Fatalf("expected block comment, got %q", bc) + } + + // Pressing 'w' or 'l' on a folded incbin must be refused without destroying the incbin. + app.GotoAddress(0x2002) + app.HandleKey("w") + if !strings.Contains(app.StatusMsg, "Folded incbin") { + t.Fatalf("expected Folded incbin refusal on 'w', got %q", app.StatusMsg) + } + if cCheck, _ := app.DB.GetCell(0x2002); cCheck == nil || cCheck.Type != db.CellIncbin { + t.Fatalf("expected CellIncbin intact after refused 'w', got %+v", cCheck) + } + + // Pressing 'b' on a folded incbin unfolds it back into bytes (deletes the CellIncbin), and Ctrl+Z folds it back. + app.HandleKey("b") + if cAfterB, _ := app.DB.GetCell(0x2002); cAfterB != nil && cAfterB.Type == db.CellIncbin { + t.Fatalf("expected CellIncbin unfolded after 'b', got %+v", cAfterB) + } + // Pressing 'K' on the now-unfolded dc.b data row must refuse ("No carve at cursor") and not crash. + app.HandleKey("K") + if app.StatusMsg != "No carve at cursor" { + t.Fatalf("expected 'No carve at cursor' when pressing K on dc.b row, got %q", app.StatusMsg) + } + // Undo the 'b' unfold -> restores CellIncbin + app.Undo() + if cRestoredB, _ := app.DB.GetCell(0x2002); cRestoredB == nil || cRestoredB.Type != db.CellIncbin { + t.Fatalf("expected CellIncbin restored after undoing 'b' unfold, got %+v", cRestoredB) } // TUI reload must NOT write the incbin sidecar to disk. @@ -260,14 +289,66 @@ func TestTUICarveGuardAndRoundtrip(t *testing.T) { t.Fatalf("TUI reload must not write incbin sidecar to disk") } + // Undo carve with Ctrl-z (must also restore the straddling 4-byte CellDataL at 0x2000 with no 1-byte remnant at 0x2001) + app.Undo() + cellUndone, _ := app.DB.GetCell(0x2002) + if cellUndone != nil && cellUndone.Type == db.CellIncbin { + t.Fatalf("expected CellIncbin gone after undoing carve") + } + straddlingRestored, _ := app.DB.GetCell(0x2000) + if straddlingRestored == nil || straddlingRestored.Type != db.CellDataL || straddlingRestored.Size != 4 { + t.Fatalf("expected straddling 4-byte CellDataL at 0x2000 restored after undo, got %+v", straddlingRestored) + } + if remnant, _ := app.DB.GetCell(0x2001); remnant != nil { + t.Fatalf("expected no remnant cell at 0x2001 after undo, got %+v", remnant) + } + if lbl, ok, _ := app.DB.GetLabel(0x2002); ok && lbl != "" { + t.Fatalf("expected label gone after undoing carve, got %q", lbl) + } + if bc := app.DB.GetAddressComment(0x2002).BlockComment; bc != "" { + t.Fatalf("expected block comment gone after undoing carve, got %q", bc) + } + + // Re-carve to test overlapping re-carve (0x2000..0x2005 replacing 0x2002..0x2005) and its undo + app.CarveRange(0x2002, 0x2005, "FlatBlob", "Header description\nLine 2") + app.CarveRange(0x2000, 0x2005, "WiderBlob", "Wider header") + if wider, _ := app.DB.GetCell(0x2000); wider == nil || wider.Type != db.CellIncbin || wider.Size != 6 { + t.Fatalf("expected single replaced CellIncbin at 0x2000, got %+v", wider) + } + if lbl, ok, _ := app.DB.GetLabel(0x2002); ok && lbl != "" { + t.Fatalf("expected interior overlapped label at 0x2002 cleaned up, got %q", lbl) + } + app.Undo() + if prev, _ := app.DB.GetCell(0x2002); prev == nil || prev.Type != db.CellIncbin || prev.Size != 4 { + t.Fatalf("expected previous CellIncbin at 0x2002 restored after undo, got %+v", prev) + } + if lbl, ok, _ := app.DB.GetLabel(0x2002); !ok || lbl != "FlatBlob" { + t.Fatalf("expected label 'FlatBlob' at 0x2002 restored after undo, got %q", lbl) + } app.GotoAddress(0x2002) app.UncarveAtCursor() after, _ := app.DB.GetCell(0x2002) if after != nil && after.Type == db.CellIncbin { t.Fatalf("INCBIN cell must be gone after uncarve") } - if len(app.Proj.Data.Carves) != 0 { - t.Fatalf("expected carves empty after uncarve") + if lbl, ok, _ := app.DB.GetLabel(0x2002); ok && lbl != "" { + t.Fatalf("expected label gone after uncarve, got %q", lbl) + } + if bc := app.DB.GetAddressComment(0x2002).BlockComment; bc != "" { + t.Fatalf("expected block comment gone after uncarve, got %q", bc) + } + + // Undo uncarve with Ctrl-z + app.Undo() + cellReRestored, _ := app.DB.GetCell(0x2002) + if cellReRestored == nil || cellReRestored.Type != db.CellIncbin { + t.Fatalf("expected CellIncbin restored after undoing uncarve") + } + if lbl, ok, _ := app.DB.GetLabel(0x2002); !ok || lbl != "FlatBlob" { + t.Fatalf("expected label 'FlatBlob' restored after undoing uncarve, got %q", lbl) + } + if bc := app.DB.GetAddressComment(0x2002).BlockComment; bc != "Header description\nLine 2" { + t.Fatalf("expected block comment restored after undoing uncarve, got %q", bc) } // Relocatable segment: carve across an interior reloc is refused. @@ -299,8 +380,8 @@ func TestTUICarveGuardAndRoundtrip(t *testing.T) { } app2.BinaryData = make([]byte, 8) _ = app2.LoadData() - app2.CarveRange(0x3000, 0x3005, "ShouldRefuse") - if len(app2.Proj.Data.Carves) != 0 { + app2.CarveRange(0x3000, 0x3005, "ShouldRefuse", "") + if refused, _ := app2.DB.GetCell(0x3000); refused != nil && refused.Type == db.CellIncbin { t.Fatalf("carve over interior reloc must be refused") } } @@ -990,23 +1071,182 @@ func TestBitplaneViewerInteractive(t *testing.T) { t.Fatalf("expected Saved IFF status msg, got %q", app.StatusMsg) } - // Carve with K and verify label + Undo support + // Carve with K -> ModalCarveName -> esc returns to ModalBitplane; K -> enter carves, and verify Modulo/Masks/Palette round-trip on B st.RowBytes = 2 st.Height = 2 - st.SetBPU(1) + st.Modulo = 6 + st.SetBPU(2) + st.VisibleMask = 0x01 + st.ControlMask = 0x02 + st.PaletteMode = PaletteHighContrast + st.Invert = true + st.Brightness = 2 + st.AutoSnapPlanes() app.HandleKey("K") - if !strings.Contains(app.StatusMsg, "Carved bitplane range") { - t.Fatalf("expected Carved bitplane range status msg, got %q", app.StatusMsg) + if app.Modal.Kind != ModalCarveName { + t.Fatalf("expected ModalCarveName after K in bitplane viewer, got %v", app.Modal.Kind) } - - // 8. Exit bitplane viewer with esc app.HandleKey("esc") + if app.Modal.Kind != ModalBitplane { + t.Fatalf("expected return to ModalBitplane on esc from ModalCarveName, got %v", app.Modal.Kind) + } + app.HandleKey("K") + if !strings.HasPrefix(app.Modal.Input.Value, "gfx_") { + t.Fatalf("expected default label starting with gfx_, got %q", app.Modal.Input.Value) + } + if !strings.Contains(app.Modal.CarveBlockComment, "Bitmap:") { + t.Fatalf("expected CarveBlockComment to contain Bitmap:, got %q", app.Modal.CarveBlockComment) + } + app.HandleKey("enter") + if !strings.Contains(app.StatusMsg, "Carved") { + t.Fatalf("expected Carved status msg, got %q", app.StatusMsg) + } if app.Modal.Kind != ModalNone { - t.Fatalf("expected ModalNone after esc, got %v", app.Modal.Kind) + t.Fatalf("expected ModalNone after carving, got %v", app.Modal.Kind) + } + + // Verify round-trip on 'B': reopen bitplane viewer on the carved row + app.HandleKey("B") + if app.Modal.Kind != ModalBitplane { + t.Fatalf("expected ModalBitplane after pressing B, got %v", app.Modal.Kind) } + rb := app.Modal.Bitplane + if rb.RowBytes != 2 || rb.Height != 2 || rb.BPU() != 2 || rb.Modulo != 6 { + t.Fatalf("expected restored geometry 2x2x2 modulo 6, got %dx%dx%d modulo %d", + rb.RowBytes, rb.Height, rb.BPU(), rb.Modulo) + } + if rb.VisibleMask != 0x01 || rb.ControlMask != 0x02 { + t.Fatalf("expected restored masks VIS=$01 SYNC=$02, got VIS=$%02X SYNC=$%02X", + rb.VisibleMask, rb.ControlMask) + } + if rb.PaletteMode != PaletteHighContrast || !rb.Invert || rb.Brightness != 2 { + t.Fatalf("expected restored High-Contrast inv bias=+2, got mode=%v inv=%v bias=%d", + rb.PaletteMode, rb.Invert, rb.Brightness) + } + app.HandleKey("esc") + // Undo the carve with 'ctrl+z' app.HandleKey("ctrl+z") if app.StatusMsg != "Undone" { t.Fatalf("expected Undone after 'ctrl+z', got %q", app.StatusMsg) } } + +func TestMusicRipperModalAndCarve(t *testing.T) { + tmp := t.TempDir() + proj := project.New(filepath.Join(tmp, "project.json")) + proj.WorkspaceRoot = tmp + proj.Data.Segments = append(proj.Data.Segments, project.Segment{ + Name: "main", + HunkIndex: 0, + HunkType: hunk.HunkTypeCode, + MemoryType: hunk.MemoryPublic, + AllocSize: 4096, + Base: 0x10000, + DB: ":memory:", + Relocatable: false, + }) + proj.Data.Spaces = append(proj.Data.Spaces, project.Space{Name: "default", Segments: []string{"main"}}) + proj.Data.ActiveSpace = "default" + proj.ActiveSegmentName = "main" + + ctrl, err := db.New(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ctrl.Close() }) + proj.SyncDBScope(ctrl) + + app, err := NewApp(proj, ctrl) + if err != nil { + t.Fatal(err) + } + + // Create a synthetic ProTracker module at offset 256 + mod := make([]byte, 1084+1024+100) + copy(mod[0:min(len("TestTune"), 20)], "TestTune") + mod[952] = 0 // max pattern index + mod[42] = 0 + mod[43] = 50 // 50 words = 100 bytes sample + copy(mod[1080:1084], "M.K.") + + modOffset := 256 + app.BinaryData = make([]byte, 4096) + copy(app.BinaryData[modOffset:], mod) + _ = app.LoadData() + + // 1. Press 'M' to open Music Ripper + app.HandleKey("M") + if app.Modal.Kind != ModalMusicRipper { + t.Fatalf("expected ModalMusicRipper, got %v", app.Modal.Kind) + } + st := &app.Modal.MusicRipper + if len(st.Items) != 1 { + t.Fatalf("expected 1 detected module item, got %d", len(st.Items)) + } + it := st.Items[0] + if it.Name != "ProTracker" || it.Variant != "ProTracker" || it.Start != modOffset { + t.Fatalf("unexpected module item: %+v", it) + } + + // 2. Press 'w' to save / rip module to /assets/ + app.HandleKey("w") + if !strings.Contains(app.StatusMsg, "Saved music module") { + t.Fatalf("expected Saved music module status, got %q", app.StatusMsg) + } + + // 3. Press 'K' to carve as incbin, test 'esc' returns to ModalMusicRipper, then 'K' again + app.HandleKey("K") + if app.Modal.Kind != ModalCarveName { + t.Fatalf("expected ModalCarveName after K, got %v", app.Modal.Kind) + } + app.HandleKey("esc") + if app.Modal.Kind != ModalMusicRipper { + t.Fatalf("expected return to ModalMusicRipper on esc from ModalCarveName, got %v", app.Modal.Kind) + } + app.HandleKey("K") + if !strings.HasPrefix(app.Modal.Input.Value, "mod_protracker_") { + t.Fatalf("expected label starting with mod_protracker_, got %q", app.Modal.Input.Value) + } + if !strings.Contains(app.Modal.CarveBlockComment, "Music: ProTracker") { + t.Fatalf("expected Music block comment, got %q", app.Modal.CarveBlockComment) + } + + // 4. Press 'enter' to confirm carve and verify cursor jumped to carved start + app.HandleKey("enter") + if app.Modal.Kind != ModalNone { + t.Fatalf("expected ModalNone after carve, got %v", app.Modal.Kind) + } + if cur, ok := app.CursorAddr(); !ok || cur != uint32(0x10000+modOffset) { + t.Fatalf("expected cursor to jump to carved start 0x%X, got 0x%X (ok=%v)", 0x10000+modOffset, cur, ok) + } + cell, err := app.DB.GetCell(uint32(0x10000 + modOffset)) + if err != nil || cell == nil || cell.Type != db.CellIncbin { + t.Fatalf("expected CellIncbin at carve start, got %+v", cell) + } + bc := app.DB.GetAddressComment(uint32(0x10000 + modOffset)).BlockComment + if !strings.Contains(bc, "Music: ProTracker") { + t.Fatalf("expected BlockComment on carved cell, got %q", bc) + } + + // 5. Undo carve with 'ctrl+z' + app.HandleKey("ctrl+z") + cellAfter, _ := app.DB.GetCell(uint32(0x10000 + modOffset)) + if cellAfter != nil && cellAfter.Type == db.CellIncbin { + t.Fatalf("expected CellIncbin gone after undo") + } + + // 6. Test Enter navigation: reopen 'M', press 'enter', verify cursor jump + app.HandleKey("M") + if app.Modal.Kind != ModalMusicRipper { + t.Fatalf("expected ModalMusicRipper, got %v", app.Modal.Kind) + } + app.HandleKey("enter") + if app.Modal.Kind != ModalNone { + t.Fatalf("expected ModalNone after enter, got %v", app.Modal.Kind) + } + curAddr, ok := app.CursorAddr() + if !ok || curAddr != uint32(0x10000+modOffset) { + t.Fatalf("expected cursor at 0x%X, got 0x%X (ok=%v)", 0x10000+modOffset, curAddr, ok) + } +} diff --git a/internal/tui/types.go b/internal/tui/types.go index 2798c61..73297ba 100644 --- a/internal/tui/types.go +++ b/internal/tui/types.go @@ -6,6 +6,7 @@ import ( "github.com/mikolas/hunks/internal/db" "github.com/mikolas/hunks/internal/m68k" + "github.com/mikolas/hunks/internal/modules" ) // SubRowKind identifies the visual sub-row category in the TUI listing engine. @@ -167,6 +168,7 @@ const ( ModalBitplane ModalBitplaneGeometry ModalBitplanePalette + ModalMusicRipper ) // SearchHit represents a single search result row. @@ -257,22 +259,40 @@ type StructEditorState struct { Input string } +// MusicRipperItem represents one region of a detected music module. +type MusicRipperItem struct { + FormatID uint32 + Name string + Variant string + Role modules.RegionRole + Start int + Len int +} + +// MusicRipperState holds state for the music ripper modal. +type MusicRipperState struct { + Items []MusicRipperItem + Selected int +} + // Modal holds the state of the currently active modal dialog. type Modal struct { - Kind ModalKind - Input TextInput - SearchHits []SearchHit - Xrefs []XrefItem - Filter string - AllFuncs []FuncItem - ListSelect int - StructState StructEditorState - CarveStart uint32 - CarveEnd uint32 - FileBrowser FileBrowserState - SegmentForm SegmentFormState - Bitplane *BitplaneState - PixelProtocol PixelProtocol + Kind ModalKind + Input TextInput + SearchHits []SearchHit + Xrefs []XrefItem + Filter string + AllFuncs []FuncItem + ListSelect int + StructState StructEditorState + CarveStart uint32 + CarveEnd uint32 + CarveBlockComment string + FileBrowser FileBrowserState + SegmentForm SegmentFormState + Bitplane *BitplaneState + PixelProtocol PixelProtocol + MusicRipper MusicRipperState } // UndoKind identifies the kind of undo entry. @@ -296,4 +316,6 @@ type UndoEntry struct { Addr uint32 OldStr *string Key string + OldLabels []db.Label + DeletedLabel bool }