diff --git a/MANUAL.md b/MANUAL.md index c3372ac..7a36c83 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -46,6 +46,23 @@ vasmm68k_mot -m68000 -no-opt -Fbin -o build/disk_1.bin disasm/disk_1.asm cmp -l assets/disk.1 build/disk_1.bin # 0 differences ``` +### C. Live Amiberry GDB Snapshot (`init --gdb`) + +```bash +# 1. Capture live CPU registers, custom chipset ($DFF000-$DFF1FF), and all RAM regions (chip/slow/fast) +# from a running Amiberry instance (gdb_server=true on port 2345) into work/lotus2/ +hunks init --gdb localhost:2345 -m lotus2 + +# Or arm a process load breakpoint first and capture at the entry point: +hunks init --gdb localhost:2345 --process-break lotus2 -m lotus2 + +# 2. Run static + snapshot-seeded + trace-seeded analysis from the captured machine state +hunks -m lotus2 analyze --trace + +# 3. Inspect in TUI (Bitplane Viewer 'B' automatically pre-loads captured BPLxPT, BPLCON0, and COLOR00..31) +hunks -m lotus2 tui +``` + --- ## CLI Commands & Workspace Model @@ -58,6 +75,7 @@ cmp -l assets/disk.1 build/disk_1.bin # 0 differences |---------|-------------| | `init --hunk-file PATH [--force]` | Initialize multi-hunk AmigaOS module in `/work//` (auto-extracts `hunk*.bin`, relocations, and `XDEF`s into `project.db`) | | `init --binary PATH --base HEX [--output FILE] [--force]` | Initialize flat binary module in `/work//` loaded at `base` | +| `init --gdb [HOST:PORT] [--process-break NAME] -m MODULE [--force]` | Connect to a live Amiberry GDB RSP server (default `localhost:2345`), optionally wait for `--process-break`, dump all active RAM regions (`chip.bin`, `slow.bin`, `fast*.bin`) + custom register snapshot (`custom.bin`) + CPU registers into `/work//`, and detach cleanly | | `-m entry [SEG:]ADDR --name NAME` | Add a segment-qualified code entry point (resolved to owning segment in active space, or explicit `SEG:ADDR`) | | `-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. | @@ -643,7 +661,41 @@ guard does not apply and you may carve freely. --- -### Walkthrough 5: Interactive TUI Recipes (`hunks tui`) +### Walkthrough 5: Live Amiberry GDB Snapshot & Trace-Seeded Analysis (`init --gdb`) + +Many trackloaded Amiga games and demos unpack or relocate themselves in RAM at boot, overwrite the AmigaOS executive, and drive execution through custom VBlank/Copper/CIA interrupts and indirect state machines. With Amiberry's built-in GDB Remote Serial Protocol server (`gdb_server=true`, default port `2345`), `hunks init --gdb` captures the complete live machine state in one command. + +#### Step 1: Capture the Live Snapshot +```bash +# Connect to a running Amiberry instance (halts cleanly, dumps state, and detaches): +hunks init --gdb localhost:2345 -m lotus2 + +# Or arm an AmigaDOS process load breakpoint before booting the disk/executable: +hunks init --gdb localhost:2345 --process-break lotus2 -m lotus2 +``` + +During `init --gdb`, `hunks`: +1. Connects to Amiberry's GDB stub, halts the CPU (`?` $\rightarrow$ `S05`), or arms `monitor process-break ` and waits for the target process to stop at its entry point. +2. Queries `monitor memory-map` (with `qXfer:memory-map:read` XML fallback) and dumps **all** active `"type": "ram"` regions (`chip.bin` at `$000000`, `slow.bin` at `$C00000`, `fast*.bin`) in 16 KiB chunks into `work//`. +3. Reads the 512-byte custom chip aperture `$DFF000–$DFF1FF` (`mdff000,200`) into `work//custom.bin`. Because Amiberry routes `$DFF000–$DFF1FF` GDB reads through `save_custom`, this captures the live values of write-only hardware registers (`DMACON`, `INTENA`, `INTREQ`, `ADKCON`, `COP1LC`, `COP2LC`, `BPL1PT..8PT`, `BPLCON0..2`, `BPL1MOD`/`BPL2MOD`, `SPR0PT..7PT`, and `COLOR00..31`). +4. Reads all M68000 registers (`D0..D7`, `A0..A7`, `SR`, `PC`) and optional AmigaDOS hunk segments (`monitor segments`), records them under `"snapshot"` in `work//project.json`, and sends `D` (detach) so Amiberry resumes running cleanly. + +#### Step 2: Run Snapshot-Seeded & Trace-Seeded Analysis +```bash +hunks -m lotus2 analyze --trace +``` +When a project contains a `"snapshot"` block: +- **Static Snapshot Seeding (`SeedFromSnapshot`)** automatically: + - Seeds `Entry_PC` at the captured `PC`. + - Reads the 68000 exception/interrupt vector table in Chip RAM (`$64..$7C` Level 1–7 autovectors and `$80..$BC` `TRAP #0..#15`) and seeds every valid handler (`Vec_Level3_VBlank_Copper_Blit`, `Vec_Level2_CIAA`, etc.). + - Walks the active stack (`A7` up to +256 bytes) for return addresses immediately following a `BSR` or `JSR` instruction, seeding both the caller return site and any direct callee target. + - Detects address registers (`A0..A6`) holding `$00DFF000` and registers `$DFF000` base-register hints. + - Labels active chipset DMA pointers from `custom.bin` (`COP1LC_Main`, `COP2LC_List`, `BPL1_Screen..BPLn_Screen`, `SPR0_Data..SPR7_Data`). +- **Trace-Seeded Resume (`analyze --trace`)** preloads `D0..D7`, `A0..A7`, `PC`, `SR`, `custom.bin` chipset state (`DMACON`, `INTENA`, `COP1LC`, `BPLxPT`, `COLOR00..31`), and multi-segment RAM (`chip.bin` + `$C00000` `slow.bin` Ranger RAM) into the `m68kemu` sandbox and steps forward from the exact halted instruction to observe runtime indirect calls, jump tables, and interrupt service routines. + +--- + +### Walkthrough 6: Interactive TUI Recipes (`hunks tui`) Launch the TUI on a target: ```bash @@ -804,6 +856,7 @@ The viewer manages a single image at a time with up to 7 bitplanes (including AG 1. **`BPU: 1 2 3 4 5 6 7`**: Sequential active bitplane count in `BPLCON0` (bits 14–12). Digits $1..BPU$ are highlighted in bright cyan (`#7AA2F7 bold`); inactive digits in legible slate gray (`#65728A`). 2. **`Synced: 1 2 3 4 5 6 7`**: Independent pointer adjustment group (`BPL1PT`–`BPL7PT`). Armed numbers are rendered in bright warm gold (`#E0AF68 bold`); unarmed numbers in legible slate gray (`#65728A`). 3. **`Pal`**: Active color mode and live truecolor RGB swatches. +- **Automatic GDB Snapshot Preloading (`custom.bin`)**: When a project was initialized via `hunks init --gdb` (or has a captured `work//custom.bin` custom chipset snapshot), pressing `B` automatically pre-populates the active bitplane depth ($BPU$), `HIRES` / `HAM` mode flags, `BPL1MOD` row modulo, `BPL1PT..BPLnPT` starting pointers, and the 32-color `$DFF180..$DFF1BE` hardware palette (`Snapshot@$DFF180`). The viewer queries the terminal text area pixel dimensions at startup via the cross-platform `XTWINOPS` escape sequence (`\x1b[14t` $\rightarrow$ `\x1b[4;;t`) to automatically select the optimal integer scaling factor ($1\times..8\times$) and clip oversized bitmaps to the canvas, ensuring native Amiga bitmaps (320×200, 320×256, 640×512) fit cleanly with crisp, square device pixels on Linux, macOS, and Windows. @@ -881,9 +934,11 @@ 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, function ABIs, memory regions) | +| `work//project.json` | Target metadata (`target_name`, segments, entry points, spaces, structs, register aliases, function ABIs, memory regions, and optional GDB `snapshot` CPU/chipset state) | | `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 `` | +| `work//chip.bin`, `slow.bin`, `fast*.bin` | Dumped RAM region payloads captured by `hunks init --gdb` | +| `work//custom.bin` | 512-byte custom chip register aperture (`$DFF000–$DFF1FF`) captured by `hunks init --gdb` | | `disasm/.asm` | Master assembly file (`opt o-,a-` + `include "src/equates.i"` + `include "src//hunk*.asm"`, or flat binary output) | | `src//hunk*.asm` | Modular per-hunk assembly files (`SECTION S_N,code/data/bss[,chip/fast]` + `XDEF` exports) | | `src/equates.i` | Shared Amiga custom chip (`$DFFxxx`), CIA (`$BFExxx`/`$BFDxxx`), and jump-vector (`_LVO_`) equates | diff --git a/cmd/hunks/main.go b/cmd/hunks/main.go index 12d87ce..833c9bf 100644 --- a/cmd/hunks/main.go +++ b/cmd/hunks/main.go @@ -9,12 +9,14 @@ import ( "path/filepath" "slices" "strings" + "time" "unicode" "github.com/mikolas/hunks/internal/analyzer" "github.com/mikolas/hunks/internal/callgraph" "github.com/mikolas/hunks/internal/db" "github.com/mikolas/hunks/internal/emitter" + "github.com/mikolas/hunks/internal/gdb" "github.com/mikolas/hunks/internal/hunk" "github.com/mikolas/hunks/internal/m68k" "github.com/mikolas/hunks/internal/modules" @@ -254,42 +256,54 @@ func runTraceAnalysis(proj *project.Project, ctrl *db.Controller, budget uint64) if len(segs) == 0 { return fmt.Errorf("no segments to trace") } - base := segs[0].Base - var top uint32 - for _, s := range segs { - if s.Base < base { - base = s.Base - } - sz := s.AllocSize - if sz == 0 { - sz = uint32(len(s.LoadBinaryData())) - } - if s.Base+sz > top { - top = s.Base + sz - } - } - image := make([]byte, top-base) - for _, s := range segs { - data := s.LoadBinaryData() - off := int(s.Base - base) - end := min(off+len(data), len(image)) - if off < end { - copy(image[off:end], data[:end-off]) - } - } - entry := base - if len(proj.Data.EntryPoints) > 0 { - entry = proj.Data.EntryPoints[0].Addr + if proj.Data.Snapshot != nil { + analyzer.SeedFromSnapshot(proj, ctrl) } opts := analyzer.DefaultTraceOptions() if budget > 0 { opts.Budget = budget } + opts.Snapshot = proj.Data.Snapshot + if customSnap, ok := proj.LoadCustomSnapshot(); ok { + opts.CustomSnap = customSnap + } + + var base, top uint32 + var image []byte + if len(segs) == 1 { + base = segs[0].Base + image = segs[0].LoadBinaryData() + top = base + uint32(len(image)) + } else { + base = segs[0].Base + image = segs[0].LoadBinaryData() + top = base + uint32(len(image)) + for _, s := range segs { + data := s.LoadBinaryData() + opts.Segments = append(opts.Segments, analyzer.TraceSegment{ + Base: s.Base, + Data: data, + }) + if s.Base < base { + base = s.Base + } + if s.Base+uint32(len(data)) > top { + top = s.Base + uint32(len(data)) + } + } + } + + entry := base + if proj.Data.Snapshot != nil && proj.Data.Snapshot.PC != 0 { + entry = proj.Data.Snapshot.PC & 0x00FFFFFF + } else if len(proj.Data.EntryPoints) > 0 { + entry = proj.Data.EntryPoints[0].Addr + } fmt.Printf("Trace-driven analysis for module '%s':\n", proj.Data.TargetName) - fmt.Printf(" image %#08X..%#08X (%d bytes), entry %#08X, budget %d\n", - base, top, len(image), entry, opts.Budget) + fmt.Printf(" image %#08X..%#08X (%d segment(s)), entry %#08X, budget %d\n", + base, top, len(segs), entry, opts.Budget) report := analyzer.RunTrace(image, base, entry, opts) fmt.Printf("\n stop reason : %s\n", report.Stop) @@ -353,6 +367,9 @@ func runTraceAnalysis(proj *project.Project, ctrl *db.Controller, budget uint64) } func runProjectAnalysis(proj *project.Project, ctrl *db.Controller, segFilter string) error { + if proj.Data.Snapshot != nil { + analyzer.SeedFromSnapshot(proj, ctrl) + } isReloc := false for i := range proj.Data.Segments { if proj.Data.Segments[i].IsRelocatable() { @@ -360,13 +377,15 @@ func runProjectAnalysis(proj *project.Project, ctrl *db.Controller, segFilter st break } } - if isReloc && segFilter == "" { + useUnified := isReloc || (proj.Data.Snapshot != nil && len(proj.Data.Segments) > 1) + if useUnified && segFilter == "" { fmt.Printf("Running unified multi-stage analysis for module '%s' across %d segment(s)...\n", proj.Data.TargetName, len(proj.Data.Segments)) an, err := analyzer.New(proj, ctrl) if err != nil { return err } an.Analyze(nil) + proj.RefreshCoverageStats(ctrl) if err := proj.Save(); err != nil { return err } @@ -472,12 +491,16 @@ func main() { rootCmd.AddCommand(ripCmd) // --- init --- - var initBinary, initBase, initOutput, initHunkFile string + var initBinary, initBase, initOutput, initHunkFile, initGDB, initProcessBreak string var initForce bool initCmd := &cobra.Command{ Use: "init", - Short: "Initialize a module workspace from an AmigaDOS hunk file or raw binary", - RunE: func(_ *cobra.Command, _ []string) error { + Short: "Initialize a module workspace from an AmigaDOS hunk file, raw binary, or live Amiberry GDB snapshot", + RunE: func(cmd *cobra.Command, args []string) error { + gdbEnabled := cmd.Flags().Changed("gdb") + if gdbEnabled && (initGDB == "" || initGDB == gdb.DefaultAddr) && len(args) == 1 { + initGDB = args[0] + } workspaceRoot := getWorkspaceRoot() var rawTarget string switch { @@ -487,8 +510,12 @@ func main() { rawTarget = project.DeduceTargetName(initHunkFile) case initBinary != "": rawTarget = project.DeduceTargetName(initBinary) + case gdbEnabled && initProcessBreak != "": + rawTarget = project.DeduceTargetName(initProcessBreak) + case gdbEnabled: + rawTarget = "snapshot" default: - return fmt.Errorf("must provide either --hunk-file or both --binary and --base ") + return fmt.Errorf("must provide --hunk-file , --binary --base , or --gdb [host:port]") } targetName := strings.TrimPrefix(rawTarget, "work/") targetName = strings.TrimSuffix(targetName, "/") @@ -813,14 +840,169 @@ func main() { } fmt.Printf("Initialized module '%s' (work/%s/) for %s at base 0x%X\n", targetName, targetName, initBinary, baseVal) return nil + } else if gdbEnabled { + gdbAddr := gdb.NormalizeAddr(initGDB) + fmt.Printf("Connecting to Amiberry GDB server at %s...\n", gdbAddr) + client, err := gdb.Dial(gdbAddr, 10*time.Second) + if err != nil { + return err + } + defer func() { _ = client.Detach() }() + + if initProcessBreak != "" { + fmt.Printf("Waiting for AmigaDOS process %q to start...\n", initProcessBreak) + if err := client.WaitProcessBreak(initProcessBreak, 60*time.Second); err != nil { + return err + } + } + + regs, err := client.ReadRegisters() + if err != nil { + return err + } + regions, err := client.MemoryMap() + if err != nil { + return err + } + customSnap, err := client.ReadCustomSnapshot() + if err != nil { + return err + } + + customFilename := "custom.bin" + if err := os.WriteFile(customFilename, customSnap[:], 0o644); err != nil { + return err + } + + sharedDBName := "project.db" + _ = os.Remove(sharedDBName) + ctrl, err := db.New(sharedDBName) + if err != nil { + return err + } + defer ctrl.Close() + + var chipIdx, slowIdx, fastIdx int + var spaceSegs []string + var segIdx int + for _, r := range regions { + if !strings.EqualFold(r.Type, "ram") || r.Size == 0 { + continue + } + var segName string + switch { + case r.Chip || r.Address == 0: + chipIdx++ + if chipIdx == 1 { + segName = "chip" + } else { + segName = fmt.Sprintf("chip%d", chipIdx) + } + case r.Address >= 0x00C00000 && r.Address < 0x00D80000: + slowIdx++ + if slowIdx == 1 { + segName = "slow" + } else { + segName = fmt.Sprintf("slow%d", slowIdx) + } + default: + fastIdx++ + if fastIdx == 1 { + segName = "fast" + } else { + segName = fmt.Sprintf("fast%d", fastIdx) + } + } + + fmt.Printf(" Dumping %s (%s): 0x%06X..0x%06X (%d KB)...\n", + segName, r.Name, r.Address, r.Address+r.Size, r.Size/1024) + rawRAM, err := client.ReadMemory(r.Address, r.Size) + if err != nil { + return err + } + binFilename := fmt.Sprintf("%s.bin", segName) + if err := os.WriteFile(binFilename, rawRAM, 0o644); err != nil { + return err + } + fileDataLen := r.Size + memType := hunk.MemoryFast + if r.Chip || r.Address == 0 { + memType = hunk.MemoryChip + } + proj.Data.Segments = append(proj.Data.Segments, project.Segment{ + Name: segName, + HunkIndex: segIdx, + HunkType: hunk.HunkTypeCode, + MemoryType: memType, + AllocSize: r.Size, + FileDataLen: &fileDataLen, + File: &binFilename, + Base: r.Address, + DB: sharedDBName, + Relocatable: false, + }) + spaceSegs = append(spaceSegs, segName) + segIdx++ + } + if len(proj.Data.Segments) == 0 { + return fmt.Errorf("no RAM regions returned by Amiberry memory-map") + } + + proj.Data.Spaces = append(proj.Data.Spaces, project.Space{ + Name: "default", + Segments: spaceSegs, + }) + proj.Data.ActiveSpace = "default" + proj.Data.Snapshot = &project.SnapshotState{ + PC: regs.PC & 0x00FFFFFF, + SR: regs.SR, + D: regs.D, + A: regs.A, + CustomFile: customFilename, + CustomInfo: customSnap.SummaryMap(), + } + if pcSeg := proj.SegmentAtAddr(regs.PC & 0x00FFFFFF); pcSeg != nil { + proj.ActiveSegmentName = pcSeg.Name + } + + proj.SyncDBScope(ctrl) + ctrl.Begin() + seedSummary := analyzer.SeedFromSnapshot(proj, ctrl) + ctrl.Commit() + + if err := proj.Save(); err != nil { + return err + } + + fmt.Printf("Initialized GDB snapshot module '%s' (work/%s/):\n", targetName, targetName) + fmt.Printf(" RAM segments : %d (%s)\n", len(spaceSegs), strings.Join(spaceSegs, ", ")) + fmt.Printf(" CPU state : PC=0x%06X SR=0x%04X SP(A7)=0x%06X\n", regs.PC&0x00FFFFFF, regs.SR, regs.A[7]&0x00FFFFFF) + fmt.Printf(" Custom chips : DMACON=0x%04X INTENA=0x%04X COP1LC=0x%06X BPLCON0=0x%04X\n", + customSnap.DMACON(), customSnap.INTENA(), customSnap.COP1LC(), customSnap.BPLCON0()) + if len(seedSummary.VectorSeeds) > 0 { + fmt.Printf(" IRQ/Trap vecs: %s\n", strings.Join(seedSummary.VectorSeeds, ", ")) + } + if len(seedSummary.StackSeeds) > 0 { + fmt.Printf(" Stack seeds : %d return address(es) discovered\n", len(seedSummary.StackSeeds)) + } + if len(seedSummary.BaseRegHints) > 0 { + fmt.Printf(" Base regs : %s\n", strings.Join(seedSummary.BaseRegHints, ", ")) + } + if len(seedSummary.CustomLabels) > 0 { + fmt.Printf(" DMA pointers : %s\n", strings.Join(seedSummary.CustomLabels, ", ")) + } + return nil } - return fmt.Errorf("must provide either --hunk-file or both --binary and --base ") + return fmt.Errorf("must provide --hunk-file , --binary --base , or --gdb [host:port]") }, } initCmd.Flags().StringVar(&initBinary, "binary", "", "Path to flat binary file") initCmd.Flags().StringVar(&initBase, "base", "", "Base address in hex (for --binary)") initCmd.Flags().StringVar(&initOutput, "output", "", "Output assembly file path") initCmd.Flags().StringVar(&initHunkFile, "hunk-file", "", "Path to AmigaOS multi-hunk executable") + initCmd.Flags().StringVar(&initGDB, "gdb", "", "Snapshot a live Amiberry GDB server (default 127.0.0.1:2345)") + initCmd.Flags().Lookup("gdb").NoOptDefVal = gdb.DefaultAddr + initCmd.Flags().StringVar(&initProcessBreak, "process-break", "", "Arm a one-shot AmigaDOS process entry break before snapshotting (--gdb)") initCmd.Flags().BoolVar(&initForce, "force", false, "Overwrite existing target workspace if already initialized") rootCmd.AddCommand(initCmd) diff --git a/internal/amiga/custom_snapshot.go b/internal/amiga/custom_snapshot.go new file mode 100644 index 0000000..a2d49fb --- /dev/null +++ b/internal/amiga/custom_snapshot.go @@ -0,0 +1,170 @@ +package amiga + +import ( + "encoding/binary" + "fmt" +) + +// CustomSnapshotLen is the byte length of the Amiga custom chip register aperture ($DFF000..$DFF1FF). +const CustomSnapshotLen = 512 + +// CustomSnapshot wraps the 512-byte ($DFF000..$DFF1FF) custom chip register block +// returned by Amiberry's GDB stub (`save_custom` in `custom.cpp`). +type CustomSnapshot [CustomSnapshotLen]byte + +// Word reads a big-endian 16-bit word at a register offset relative to $DFF000 ($000..$1FE). +func (s *CustomSnapshot) Word(offset uint16) uint16 { + off := int(offset & 0x1FE) + if off+2 > len(s) { + return 0 + } + return binary.BigEndian.Uint16(s[off : off+2]) +} + +// Long reads a big-endian 32-bit longword at a register offset relative to $DFF000 ($000..$1FC). +func (s *CustomSnapshot) Long(offset uint16) uint32 { + off := int(offset & 0x1FE) + if off+4 > len(s) { + return 0 + } + return binary.BigEndian.Uint32(s[off : off+4]) +} + +// DMACON returns the active DMA control bits (reading $096 DMACON, with fallback to $002 DMACONR). +func (s *CustomSnapshot) DMACON() uint16 { + if v := s.Word(0x096); v != 0 { + return v & 0x7FFF + } + return s.Word(0x002) & 0x7FFF +} + +// INTENA returns the active interrupt enable mask (reading $09A INTENA, with fallback to $01C INTENAR). +func (s *CustomSnapshot) INTENA() uint16 { + if v := s.Word(0x09A); v != 0 { + return v & 0x7FFF + } + return s.Word(0x01C) & 0x7FFF +} + +// INTREQ returns the active interrupt request bits (reading $09C INTREQ, with fallback to $01E INTREQR). +func (s *CustomSnapshot) INTREQ() uint16 { + if v := s.Word(0x09C); v != 0 { + return v & 0x7FFF + } + return s.Word(0x01E) & 0x7FFF +} + +// ADKCON returns the audio/disk/UART control word (reading $09E ADKCON, with fallback to $010 ADKCONR). +func (s *CustomSnapshot) ADKCON() uint16 { + if v := s.Word(0x09E); v != 0 { + return v & 0x7FFF + } + return s.Word(0x010) & 0x7FFF +} + +// COP1LC returns the 24-bit word-aligned Copper list 1 pointer ($080..$083). +func (s *CustomSnapshot) COP1LC() uint32 { + return s.Long(0x080) & 0x00FFFFFE +} + +// COP2LC returns the 24-bit word-aligned Copper list 2 pointer ($084..$087). +func (s *CustomSnapshot) COP2LC() uint32 { + return s.Long(0x084) & 0x00FFFFFE +} + +// DIWSTRT returns the display window start register ($08E). +func (s *CustomSnapshot) DIWSTRT() uint16 { return s.Word(0x08E) } + +// DIWSTOP returns the display window stop register ($090). +func (s *CustomSnapshot) DIWSTOP() uint16 { return s.Word(0x090) } + +// DDFSTRT returns the display data fetch start register ($092). +func (s *CustomSnapshot) DDFSTRT() uint16 { return s.Word(0x092) } + +// DDFSTOP returns the display data fetch stop register ($094). +func (s *CustomSnapshot) DDFSTOP() uint16 { return s.Word(0x094) } + +// BPLPT returns the 8 bitplane pointers ($0E0..$0FE), masked to 24-bit even addresses. +func (s *CustomSnapshot) BPLPT() [8]uint32 { + var pts [8]uint32 + for i := range pts { + pts[i] = s.Long(uint16(0x0E0+i*4)) & 0x00FFFFFE + } + return pts +} + +// BPLCON0 returns the bitplane control register 0 ($100). +func (s *CustomSnapshot) BPLCON0() uint16 { return s.Word(0x100) } + +// BPLCON1 returns the bitplane control register 1 ($102). +func (s *CustomSnapshot) BPLCON1() uint16 { return s.Word(0x102) } + +// BPLCON2 returns the bitplane control register 2 ($104). +func (s *CustomSnapshot) BPLCON2() uint16 { return s.Word(0x104) } + +// BPL1MOD returns the signed odd-bitplane modulo ($108). +func (s *CustomSnapshot) BPL1MOD() int16 { return int16(s.Word(0x108)) } + +// BPL2MOD returns the signed even-bitplane modulo ($10A). +func (s *CustomSnapshot) BPL2MOD() int16 { return int16(s.Word(0x10A)) } + +// ActivePlanes returns the number of enabled bitplanes (0..8) decoded from BPLCON0. +func (s *CustomSnapshot) ActivePlanes() int { + con0 := s.BPLCON0() + bpu := int((con0 >> 12) & 0x7) + if bpu == 0 && (con0&0x0010) != 0 { + return 8 + } + return bpu +} + +// Hires reports whether BPLCON0 bit 15 (HIRES) is set. +func (s *CustomSnapshot) Hires() bool { + return (s.BPLCON0() & 0x8000) != 0 +} + +// HAM reports whether BPLCON0 bit 11 (HOMOD) is set. +func (s *CustomSnapshot) HAM() bool { + return (s.BPLCON0() & 0x0800) != 0 +} + +// SPRPT returns the 8 hardware sprite pointers ($120..$13E), masked to 24-bit even addresses. +func (s *CustomSnapshot) SPRPT() [8]uint32 { + var pts [8]uint32 + for i := range pts { + pts[i] = s.Long(uint16(0x120+i*4)) & 0x00FFFFFE + } + return pts +} + +// Palette12 returns the 32 12-bit ($0RGB) color registers ($180..$1BE). +func (s *CustomSnapshot) Palette12() [32]uint16 { + var pal [32]uint16 + for i := range pal { + pal[i] = s.Word(uint16(0x180+i*2)) & 0x0FFF + } + return pal +} + +// SummaryMap produces a compact key-value map of the key active custom chip registers +// suitable for storing in `project.json` (`snapshot.custom_info`). +func (s *CustomSnapshot) SummaryMap() map[string]string { + m := map[string]string{ + "DMACON": fmt.Sprintf("0x%04X", s.DMACON()), + "INTENA": fmt.Sprintf("0x%04X", s.INTENA()), + "INTREQ": fmt.Sprintf("0x%04X", s.INTREQ()), + "COP1LC": fmt.Sprintf("0x%06X", s.COP1LC()), + "COP2LC": fmt.Sprintf("0x%06X", s.COP2LC()), + "BPLCON0": fmt.Sprintf("0x%04X", s.BPLCON0()), + "BPL1MOD": fmt.Sprintf("%d", s.BPL1MOD()), + "BPL2MOD": fmt.Sprintf("%d", s.BPL2MOD()), + } + planes := s.ActivePlanes() + bpl := s.BPLPT() + for i := 0; i < min(planes, len(bpl)); i++ { + if bpl[i] != 0 { + m[fmt.Sprintf("BPL%dPT", i+1)] = fmt.Sprintf("0x%06X", bpl[i]) + } + } + return m +} diff --git a/internal/analyzer/chipset.go b/internal/analyzer/chipset.go index 5b0c504..9abcc3d 100644 --- a/internal/analyzer/chipset.go +++ b/internal/analyzer/chipset.go @@ -1,6 +1,10 @@ package analyzer -import "encoding/binary" +import ( + "encoding/binary" + + "github.com/mikolas/hunks/internal/amiga" +) // --- Paula INTENA / INTREQ bit definitions ---------------------------------- const ( @@ -700,6 +704,26 @@ func NewChipset() *Chipset { } } +// LoadCustomSnapshot hydrates the chipset state machine from a captured 512-byte +// ($DFF000..$DFF1FF) Amiberry custom register snapshot. +func (cs *Chipset) LoadCustomSnapshot(snap *amiga.CustomSnapshot) { + if cs == nil || snap == nil { + return + } + cs.Agnus.DMACON = snap.DMACON() + cs.Paula.INTENA = snap.INTENA() + cs.Paula.INTREQ = snap.INTREQ() + cs.Paula.ADKCON = snap.ADKCON() + if cop1 := snap.COP1LC(); cop1 != 0 { + cs.Agnus.COP1LC = cop1 + cs.Agnus.COP1LCWritten = true + } + if cop2 := snap.COP2LC(); cop2 != 0 { + cs.Agnus.COP2LC = cop2 + cs.Agnus.COP2LCWritten = true + } +} + // RunCopperList walks a Copper instruction list in Chip RAM starting at `startPC` // (bounded to `copperMaxSteps` instructions to prevent infinite `COPJMP` loops). // WAIT/SKIP instructions (`ir1 & 1 != 0`) are stepped over, `$FFFF,$FFFE` terminates diff --git a/internal/analyzer/snapshot_seed.go b/internal/analyzer/snapshot_seed.go new file mode 100644 index 0000000..d5c55de --- /dev/null +++ b/internal/analyzer/snapshot_seed.go @@ -0,0 +1,226 @@ +package analyzer + +import ( + "encoding/binary" + "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" +) + +// SeedSummary reports the symbols and entry points seeded from a captured GDB snapshot. +type SeedSummary struct { + EntryPC uint32 + VectorSeeds []string + StackSeeds []uint32 + BaseRegHints []string + CustomLabels []string +} + +// SeedFromSnapshot seeds entry points, interrupt/trap handler labels, stack return addresses, +// base-register hints ($DFF000), and custom chip DMA pointer labels (COP1LC, COP2LC, BPLxPT, SPRxPT) +// from `proj.Data.Snapshot` into `proj` and `ctrl`. +func SeedFromSnapshot(proj *project.Project, ctrl *db.Controller) SeedSummary { + var summary SeedSummary + if proj == nil || proj.Data.Snapshot == nil || ctrl == nil { + return summary + } + snap := proj.Data.Snapshot + + segDataMap := make(map[string][]byte, len(proj.Data.Segments)) + for i := range proj.Data.Segments { + seg := &proj.Data.Segments[i] + segDataMap[seg.Name] = seg.LoadBinaryData() + } + + readBytesAt := func(addr uint32, length int) ([]byte, *project.Segment) { + seg := proj.SegmentAtAddr(addr) + if seg == nil { + return nil, nil + } + data := segDataMap[seg.Name] + off := int(addr - seg.Base) + if off < 0 || off+length > len(data) { + return nil, nil + } + return data[off : off+length], seg + } + + hasEntryPoint := func(addr uint32) bool { + for _, ep := range proj.Data.EntryPoints { + if ep.Addr == addr { + return true + } + } + return false + } + + addCodeEntry := func(addr uint32, preferredName string) bool { + addr &= 0x00FFFFFE + raw, seg := readBytesAt(addr, 2) + if seg == nil || len(raw) < 2 { + return false + } + if binary.BigEndian.Uint16(raw) == 0x0000 { + return false + } + name := preferredName + if name == "" { + name = proj.AutoLabel(addr) + } + if !hasEntryPoint(addr) { + proj.Data.EntryPoints = append(proj.Data.EntryPoints, project.EntryPoint{ + Addr: addr, + Name: name, + Segment: seg.Name, + }) + } + ctrl.WithActiveSeg(seg.Name, func() { + if existing, ok, _ := ctrl.GetLabel(addr); !ok || existing == "" || strings.HasPrefix(existing, "LAB_") { + isAuto := preferredName == "" || strings.HasPrefix(preferredName, "LAB_") + _ = ctrl.SetLabel(addr, name, db.LabelLab, isAuto) + } + }) + return true + } + + setDataLabelIfUnset := func(addr uint32, name string) bool { + addr &= 0x00FFFFFE + if addr == 0 { + return false + } + _, seg := readBytesAt(addr, 2) + if seg == nil { + return false + } + added := false + ctrl.WithActiveSeg(seg.Name, func() { + if existing, ok, _ := ctrl.GetLabel(addr); !ok || existing == "" { + _ = ctrl.SetLabel(addr, name, db.LabelDat, false) + added = true + } + }) + return added + } + + // 1. Live PC Entry Point + pc := snap.PC & 0x00FFFFFF + if pc%2 == 0 && addCodeEntry(pc, "Entry_PC") { + summary.EntryPC = pc + } + + // 2. Active M68k Autovectors ($64..$7C) and Trap Vectors ($80..$BC) + for vecAddr := uint32(0x64); vecAddr < 0xC0; vecAddr += 4 { + vecName, ok := amiga.GetM68kVectorLabel(vecAddr) + if !ok { + continue + } + raw, _ := readBytesAt(vecAddr, 4) + if len(raw) < 4 { + continue + } + handler := binary.BigEndian.Uint32(raw) & 0x00FFFFFF + if handler == 0 || handler%2 != 0 { + continue + } + handlerName := vecName + "_Handler" + if addCodeEntry(handler, handlerName) { + summary.VectorSeeds = append(summary.VectorSeeds, fmt.Sprintf("$%02X->%s(0x%X)", vecAddr, handlerName, handler)) + } + } + + // 3. Active Stack Return-Address Discovery (A7) + sp := snap.A[7] & 0x00FFFFFE + if sp != 0 { + for off := uint32(0); off < 128; off += 2 { + raw, _ := readBytesAt(sp+off, 4) + if len(raw) < 4 { + break + } + retAddr := binary.BigEndian.Uint32(raw) & 0x00FFFFFF + if retAddr < 6 || retAddr%2 != 0 { + continue + } + // Check if 2, 4, or 6 bytes before retAddr is a valid BSR or JSR instruction. + var matchedCall bool + var callTarget uint32 + for _, insnLen := range []uint32{2, 4, 6} { + callSite := retAddr - insnLen + codeBytes, seg := readBytesAt(callSite, int(insnLen)) + if seg == nil || len(codeBytes) < int(insnLen) { + continue + } + insn, ok := m68k.Decode(codeBytes, callSite) + if !ok || uint32(insn.Size) != insnLen { + continue + } + if insn.Op == m68k.OpBSR || insn.Op == m68k.OpJSR { + matchedCall = true + if insn.NumOps > 0 { + switch insn.Ops[0].Mode { + case m68k.EABranch, m68k.EAPCDisp16, m68k.EAAbsLong, m68k.EAAbsShort: + callTarget = insn.Ops[0].UVal & 0x00FFFFFF + } + } + break + } + } + if matchedCall { + if addCodeEntry(retAddr, "") { + summary.StackSeeds = append(summary.StackSeeds, retAddr) + } + if callTarget != 0 && callTarget%2 == 0 { + addCodeEntry(callTarget, "") + } + } + } + } + + // 4. Live Base Registers (A0..A6) + for r := 0; r <= 6; r++ { + val := snap.A[r] & 0x00FFFFFF + if val == amiga.CustomBase { + regName := fmt.Sprintf("a%d", r) + _ = ctrl.SetBaseReg("global", "", regName, amiga.CustomBase, "hint") + summary.BaseRegHints = append(summary.BaseRegHints, fmt.Sprintf("%s=0x%06X", regName, amiga.CustomBase)) + } + } + + // 5. Custom Chip DMA Pointers ($DFF000..$DFF1FF) + if custom, ok := proj.LoadCustomSnapshot(); ok { + cop1 := custom.COP1LC() + if setDataLabelIfUnset(cop1, "CopperList_1") { + summary.CustomLabels = append(summary.CustomLabels, fmt.Sprintf("CopperList_1@0x%X", cop1)) + } + cop2 := custom.COP2LC() + if cop2 != cop1 && setDataLabelIfUnset(cop2, "CopperList_2") { + summary.CustomLabels = append(summary.CustomLabels, fmt.Sprintf("CopperList_2@0x%X", cop2)) + } + planes := custom.ActivePlanes() + bpl := custom.BPLPT() + for i := 0; i < min(planes, len(bpl)); i++ { + lbl := fmt.Sprintf("Bitplane_%d", i+1) + if setDataLabelIfUnset(bpl[i], lbl) { + summary.CustomLabels = append(summary.CustomLabels, fmt.Sprintf("%s@0x%X", lbl, bpl[i])) + } + } + spr := custom.SPRPT() + for i := range spr { + if spr[i] == 0 { + continue + } + // Only label sprites whose 4-byte POS/CTL header is non-zero. + if hdr, _ := readBytesAt(spr[i], 4); len(hdr) == 4 && binary.BigEndian.Uint32(hdr) != 0 { + lbl := fmt.Sprintf("Sprite_%d", i) + if setDataLabelIfUnset(spr[i], lbl) { + summary.CustomLabels = append(summary.CustomLabels, fmt.Sprintf("%s@0x%X", lbl, spr[i])) + } + } + } + } + + return summary +} diff --git a/internal/analyzer/snapshot_seed_test.go b/internal/analyzer/snapshot_seed_test.go new file mode 100644 index 0000000..003b7d9 --- /dev/null +++ b/internal/analyzer/snapshot_seed_test.go @@ -0,0 +1,138 @@ +package analyzer + +import ( + "encoding/binary" + "os" + "path/filepath" + "testing" + + "github.com/mikolas/hunks/internal/amiga" + "github.com/mikolas/hunks/internal/db" + "github.com/mikolas/hunks/internal/hunk" + "github.com/mikolas/hunks/internal/project" +) + +func TestSeedFromSnapshotAndTraceResume(t *testing.T) { + tmpDir := t.TempDir() + chipBytes := make([]byte, 0x4000) + slowBytes := make([]byte, 0x2000) + + // Zero-page Level 3 Interrupt vector ($6C) -> 0x00000400 + binary.BigEndian.PutUint32(chipBytes[0x6C:], 0x00000400) + // Level 3 handler at 0x0400: move.w #$0020,$DFF09C (INTREQ); rte + copy(chipBytes[0x0400:], []byte{ + 0x33, 0xFC, 0x00, 0x20, 0x00, 0xDF, 0xF0, 0x9C, + 0x4E, 0x73, + }) + + // Caller in Chip RAM at 0x0200: bsr.w 0x0300 (6100 00FE); rts (4E75) at 0x0204 + copy(chipBytes[0x0200:], []byte{0x61, 0x00, 0x00, 0xFE, 0x4E, 0x75}) + + // Paused PC at 0x0300: jsr (a2) (where a2 = 0xC00100 in Slow RAM!); rts + copy(chipBytes[0x0300:], []byte{0x4E, 0x92, 0x4E, 0x75}) + + // Target in Slow RAM ($C00100): move.w #$0020,$9C(a5) (where a5 = $DFF000); rts + copy(slowBytes[0x0100:], []byte{0x3B, 0x7C, 0x00, 0x20, 0x00, 0x9C, 0x4E, 0x75}) + + // Stack at A7 = 0x3F00 holds return address 0x00000204 (right after bsr.w at 0x0200) + binary.BigEndian.PutUint32(chipBytes[0x3F00:], 0x00000204) + + var customSnap amiga.CustomSnapshot + binary.BigEndian.PutUint16(customSnap[0x096:], 0x03E0) // DMACON + binary.BigEndian.PutUint16(customSnap[0x09A:], 0x4020) // INTENA: MASTER | VERTB + binary.BigEndian.PutUint32(customSnap[0x080:], 0x00001000) + binary.BigEndian.PutUint16(customSnap[0x100:], 0x1200) // 1 plane + binary.BigEndian.PutUint32(customSnap[0x0E0:], 0x00002000) + + chipFile := filepath.Join(tmpDir, "chip.bin") + slowFile := filepath.Join(tmpDir, "slow.bin") + customFile := filepath.Join(tmpDir, "custom.bin") + if err := os.WriteFile(chipFile, chipBytes, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(slowFile, slowBytes, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(customFile, customSnap[:], 0o644); err != nil { + t.Fatal(err) + } + + dbPath := filepath.Join(tmpDir, "project.db") + ctrl, err := db.New(dbPath) + if err != nil { + t.Fatal(err) + } + defer ctrl.Close() + + proj := project.New(filepath.Join(tmpDir, "project.json")) + chipLen := uint32(len(chipBytes)) + slowLen := uint32(len(slowBytes)) + proj.Data.Segments = []project.Segment{ + { + Name: "chip", + HunkIndex: 0, + HunkType: hunk.HunkTypeCode, + MemoryType: hunk.MemoryChip, + AllocSize: chipLen, + FileDataLen: &chipLen, + File: &chipFile, + Base: 0x000000, + DB: dbPath, + Relocatable: false, + }, + { + Name: "slow", + HunkIndex: 1, + HunkType: hunk.HunkTypeCode, + MemoryType: hunk.MemoryFast, + AllocSize: slowLen, + FileDataLen: &slowLen, + File: &slowFile, + Base: 0xC00000, + DB: dbPath, + Relocatable: false, + }, + } + proj.Data.Spaces = []project.Space{{Name: "default", Segments: []string{"chip", "slow"}}} + proj.Data.ActiveSpace = "default" + proj.Data.Snapshot = &project.SnapshotState{ + PC: 0x00000300, + SR: 0x2000, + A: [8]uint32{0, 0, 0x00C00100, 0, 0, 0x00DFF000, 0, 0x00003F00}, + CustomFile: "custom.bin", + } + proj.SyncDBScope(ctrl) + + summary := SeedFromSnapshot(proj, ctrl) + if summary.EntryPC != 0x00000300 { + t.Fatalf("expected EntryPC 0x300, got 0x%X", summary.EntryPC) + } + if len(summary.VectorSeeds) != 1 { + t.Fatalf("expected 1 vector seed, got %v", summary.VectorSeeds) + } + if len(summary.StackSeeds) != 1 || summary.StackSeeds[0] != 0x00000204 { + t.Fatalf("expected stack seed 0x204, got %v", summary.StackSeeds) + } + if len(summary.BaseRegHints) != 1 || summary.BaseRegHints[0] != "a5=0xDFF000" { + t.Fatalf("expected a5=0xDFF000 base reg hint, got %v", summary.BaseRegHints) + } + if len(summary.CustomLabels) != 2 { + t.Fatalf("expected CopperList_1 and Bitplane_1 labels, got %v", summary.CustomLabels) + } + + opts := DefaultTraceOptions() + opts.Budget = 500 + opts.Snapshot = proj.Data.Snapshot + opts.CustomSnap = &customSnap + opts.Segments = []TraceSegment{ + {Base: 0x000000, Data: chipBytes}, + {Base: 0xC00000, Data: slowBytes}, + } + report := RunTrace(chipBytes, 0, 0x0300, opts) + if len(report.Sites) == 0 { + t.Fatalf("expected indirect jsr (a2) at 0x300 to be recorded in trace sites") + } + if report.Sites[0].Targets[0x00C00100] == 0 { + t.Fatalf("expected jsr (a2) to resolve to 0x00C00100 in Slow RAM, got %v", report.Sites[0].Targets) + } +} diff --git a/internal/analyzer/trace.go b/internal/analyzer/trace.go index ab6a2ce..2073016 100644 --- a/internal/analyzer/trace.go +++ b/internal/analyzer/trace.go @@ -10,9 +10,14 @@ import ( "github.com/mikolas/hunks/internal/amiga" "github.com/mikolas/hunks/internal/db" "github.com/mikolas/hunks/internal/m68k" + "github.com/mikolas/hunks/internal/project" ) -const traceRAMLen uint32 = 0x00800000 +const ( + traceRAMLen uint32 = 0x00800000 + traceRangerBase uint32 = 0x00C00000 + traceRangerLen uint32 = 0x00180000 +) // traceDevice implements m68kemu.Device over an 8 MiB flat RAM window plus a minimal // Amiga 24-bit MMIO bus (Custom chips at $DFF000, CIAs at $BF0000, Ranger RAM at @@ -21,21 +26,38 @@ const traceRAMLen uint32 = 0x00800000 // address in `fault` so the trace loop can stop cleanly with `StopFault`. type traceDevice struct { mem []byte + ranger []byte fault *uint32 chipset *Chipset } func newTraceDevice(image []byte, base uint32) *traceDevice { - mem := make([]byte, traceRAMLen) - b := int(base) - if b < len(mem) { - end := min(b+len(image), len(mem)) - copy(mem[b:end], image[:end-b]) - } - return &traceDevice{ - mem: mem, + d := &traceDevice{ + mem: make([]byte, traceRAMLen), chipset: NewChipset(), } + d.loadSegment(base, image) + return d +} + +func (d *traceDevice) loadSegment(base uint32, data []byte) { + if len(data) == 0 { + return + } + b := base & 0x00FFFFFF + if b >= traceRangerBase && b < traceRangerBase+traceRangerLen { + if d.ranger == nil { + d.ranger = make([]byte, traceRangerLen) + } + off := int(b - traceRangerBase) + end := min(off+len(data), len(d.ranger)) + copy(d.ranger[off:end], data[:end-off]) + return + } + if int(b) < len(d.mem) { + end := min(int(b)+len(data), len(d.mem)) + copy(d.mem[b:end], data[:end-int(b)]) + } } func (d *traceDevice) AddressRange() (uint32, uint32) { @@ -70,6 +92,9 @@ func (d *traceDevice) readByte(a uint32) uint8 { if a >= 0x00BF0000 && a <= 0x00BFFFFF { return d.chipset.ReadCIAByte(a) } + if d.ranger != nil && a >= traceRangerBase && a < traceRangerBase+traceRangerLen { + return d.ranger[a-traceRangerBase] + } if isTraceMMIO(a) { return 0 } @@ -87,6 +112,10 @@ func (d *traceDevice) readWord(a uint32) uint16 { if a >= 0x00BF0000 && a <= 0x00BFFFFF { return (uint16(d.readByte(a)) << 8) | uint16(d.readByte(a+1)) } + if d.ranger != nil && a >= traceRangerBase && a+2 <= traceRangerBase+traceRangerLen { + off := a - traceRangerBase + return binary.BigEndian.Uint16(d.ranger[off : off+2]) + } if isTraceMMIO(a) { return 0 } @@ -106,6 +135,10 @@ func (d *traceDevice) writeByte(a uint32, v uint8) { d.chipset.WriteCIAByte(a, v) return } + if d.ranger != nil && a >= traceRangerBase && a < traceRangerBase+traceRangerLen { + d.ranger[a-traceRangerBase] = v + return + } if isTraceMMIO(a) { return } @@ -265,10 +298,19 @@ type TraceReport struct { Vectors []VectorEntry } +// TraceSegment represents an additional RAM segment loaded into the trace bus. +type TraceSegment struct { + Base uint32 + Data []byte +} + // TraceOptions configures a trace run. type TraceOptions struct { - Budget uint64 - StuckN uint64 + Budget uint64 + StuckN uint64 + Snapshot *project.SnapshotState + CustomSnap *amiga.CustomSnapshot + Segments []TraceSegment } // DefaultTraceOptions returns the default trace options. @@ -337,21 +379,47 @@ func RunTrace(image []byte, base, entry uint32, opts TraceOptions) TraceReport { } dev := newTraceDevice(image, base) + for _, seg := range opts.Segments { + dev.loadSegment(seg.Base, seg.Data) + } + if opts.CustomSnap != nil { + dev.chipset.LoadCustomSnapshot(opts.CustomSnap) + } + stackTop := uint32(0x00700000) if base >= 0x2000 { stackTop = (base - 0x1000) &^ 1 } - dev.writeLong(0, stackTop) - dev.writeLong(4, entry) bus := m68kemu.NewBus(dev) cpu, _ := m68kemu.NewCPU(bus) initRegs := cpu.Registers() - initRegs.A[7] = stackTop - initRegs.SSP = stackTop - initRegs.USP = stackTop - initRegs.PC = entry - initRegs.SR = 0x2000 + if opts.Snapshot != nil { + initRegs.D = opts.Snapshot.D + initRegs.A = opts.Snapshot.A + if initRegs.A[7] == 0 { + initRegs.A[7] = stackTop + } + initRegs.SSP = initRegs.A[7] + initRegs.USP = initRegs.A[7] + if opts.Snapshot.PC != 0 { + entry = opts.Snapshot.PC & 0x00FFFFFF + } + initRegs.PC = entry + if opts.Snapshot.SR != 0 { + initRegs.SR = opts.Snapshot.SR + } else { + initRegs.SR = 0x2000 + } + } else { + dev.writeLong(0, stackTop) + dev.writeLong(4, entry) + initRegs.A[7] = stackTop + initRegs.SSP = stackTop + initRegs.USP = stackTop + initRegs.PC = entry + initRegs.SR = 0x2000 + } cpu.SetRegisters(initRegs) cpu.SetPC(entry) @@ -364,6 +432,17 @@ func RunTrace(image []byte, base, entry uint32, opts TraceOptions) TraceReport { }) imgEnd := uint64(base) + uint64(len(image)) + isAddrInLoadedSegments := func(addr uint32) bool { + if uint64(addr) >= uint64(base) && uint64(addr) < imgEnd { + return true + } + for _, seg := range opts.Segments { + if uint64(addr) >= uint64(seg.Base) && uint64(addr) < uint64(seg.Base)+uint64(len(seg.Data)) { + return true + } + } + return false + } var steps uint64 var servicedInts [6]uint64 var servicedExcs uint64 @@ -395,13 +474,14 @@ func RunTrace(image []byte, base, entry uint32, opts TraceOptions) TraceReport { } curRegs := cpu.Registers() pc := curRegs.PC & 0x00FFFFFF - inImage := uint64(pc) >= uint64(base) && uint64(pc) < imgEnd + inImage := isAddrInLoadedSegments(pc) if !inImage { if outsideSteps == 0 { trampFirstOutPC = pc } outsideSteps++ - if outsideSteps > 8 || pc%2 != 0 || uint64(pc) >= uint64(traceRAMLen) || dev.readWord(pc) == 0 { + validRAM := uint64(pc) < uint64(traceRAMLen) || (dev.ranger != nil && pc >= traceRangerBase && pc < traceRangerBase+traceRangerLen) + if outsideSteps > 8 || pc%2 != 0 || !validRAM || dev.readWord(pc) == 0 { stop = StopReason{Kind: StopLeftImage, To: trampFirstOutPC, AtPC: trampFirstOutPC, Step: steps} break } @@ -486,7 +566,7 @@ func RunTrace(image []byte, base, entry uint32, opts TraceOptions) TraceReport { } } - newInImage := uint64(newPC) >= uint64(base) && uint64(newPC) < imgEnd + newInImage := isAddrInLoadedSegments(newPC) if inImage { if newInImage { hasTrampOrigin = false diff --git a/internal/gdb/client.go b/internal/gdb/client.go new file mode 100644 index 0000000..4f60736 --- /dev/null +++ b/internal/gdb/client.go @@ -0,0 +1,390 @@ +package gdb + +import ( + "bufio" + "encoding/binary" + "encoding/hex" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net" + "strings" + "time" + + "github.com/mikolas/hunks/internal/amiga" +) + +const ( + // DefaultAddr is Amiberry's default loopback GDB server address. + DefaultAddr = "127.0.0.1:2345" + + // maxReadChunk is the maximum byte count requested per `m` packet. + // Amiberry's Session::packet_size is 65536 (max 32768 raw bytes); 16 KiB stays well below it. + maxReadChunk uint32 = 0x4000 +) + +// Registers holds the 18 Motorola 68000 registers returned by Amiberry's `g` packet. +type Registers struct { + D [8]uint32 `json:"d"` + A [8]uint32 `json:"a"` + SR uint16 `json:"sr"` + PC uint32 `json:"pc"` +} + +// MemoryRegion describes a physical memory region reported by Amiberry's `monitor memory-map`. +type MemoryRegion struct { + Address uint32 `json:"address"` + Size uint32 `json:"size"` + Type string `json:"type"` // "ram", "rom", "io" + Chip bool `json:"chip"` + Name string `json:"name"` +} + +// AmigaSegment describes a loaded AmigaDOS hunk reported by `monitor segments`. +type AmigaSegment struct { + Index int `json:"index"` + Address uint32 `json:"address"` + Size uint32 `json:"size"` +} + +// ProcessSegments describes the active AmigaDOS process and its loaded hunks from `monitor segments`. +type ProcessSegments struct { + Process uint32 `json:"process"` + Name string `json:"name"` + Command string `json:"command"` + Segments []AmigaSegment `json:"segments"` +} + +// Client is a pure-Go GDB Remote Serial Protocol client tailored to Amiberry's GDB stub. +type Client struct { + conn net.Conn + reader *bufio.Reader + timeout time.Duration + noAck bool +} + +// NormalizeAddr normalizes a user-supplied GDB address string ("", "default", ":2345", "2345", or "host:port"). +func NormalizeAddr(addr string) string { + clean := strings.TrimSpace(addr) + if clean == "" || strings.EqualFold(clean, "default") || clean == "true" { + return DefaultAddr + } + if strings.HasPrefix(clean, ":") { + return "127.0.0.1" + clean + } + if !strings.Contains(clean, ":") { + return "127.0.0.1:" + clean + } + return clean +} + +// Dial connects to Amiberry's GDB stub, negotiates QStartNoAckMode, and waits for the CPU to halt (`?` -> `S05`). +func Dial(addr string, timeout time.Duration) (*Client, error) { + if timeout <= 0 { + timeout = 10 * time.Second + } + norm := NormalizeAddr(addr) + conn, err := net.DialTimeout("tcp", norm, timeout) + if err != nil { + return nil, fmt.Errorf("connect to Amiberry GDB server at %s: %w", norm, err) + } + c := &Client{ + conn: conn, + reader: bufio.NewReaderSize(conn, 128*1024), + timeout: timeout, + } + + if reply, err := c.sendPacket("QStartNoAckMode"); err == nil && reply == "OK" { + c.noAck = true + } + + // Amiberry halts the CPU asynchronously at the next debugger boundary on connect. + // Sending `?` sets `await_stop = true` in Amiberry and blocks until `S05` is returned. + stopReply, err := c.sendPacket("?") + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("failed waiting for Amiberry CPU halt (is Amiberry paused in UI?): %w", err) + } + if strings.HasPrefix(stopReply, "E") { + _ = conn.Close() + return nil, fmt.Errorf("unexpected error halting Amiberry CPU: %s", stopReply) + } + return c, nil +} + +// Detach sends `D` so Amiberry cleans up temporary breakpoints/watches and resumes execution, then closes the socket. +func (c *Client) Detach() error { + if c == nil || c.conn == nil { + return nil + } + _, _ = c.sendPacket("D") + err := c.conn.Close() + c.conn = nil + return err +} + +// Close closes the underlying socket connection without sending `D` if already detached. +func (c *Client) Close() error { + if c == nil || c.conn == nil { + return nil + } + err := c.conn.Close() + c.conn = nil + return err +} + +func packetChecksum(payload string) byte { + var sum byte + for i := 0; i < len(payload); i++ { + sum += payload[i] + } + return sum +} + +func (c *Client) sendPacket(payload string) (string, error) { + return c.sendPacketWithTimeout(payload, c.timeout) +} + +func (c *Client) sendPacketWithTimeout(payload string, timeout time.Duration) (string, error) { + if c.conn == nil { + return "", fmt.Errorf("GDB connection is closed") + } + _ = c.conn.SetDeadline(time.Now().Add(timeout)) + sum := packetChecksum(payload) + wire := fmt.Sprintf("$%s#%02x", payload, sum) + if _, err := io.WriteString(c.conn, wire); err != nil { + return "", err + } + + if !c.noAck { + for { + b, err := c.reader.ReadByte() + if err != nil { + return "", err + } + if b == '+' { + break + } + if b == '-' { + if _, err := io.WriteString(c.conn, wire); err != nil { + return "", err + } + } else if b == '$' { + _ = c.reader.UnreadByte() + break + } + } + } + + return c.readReplyFrame() +} + +func (c *Client) readReplyFrame() (string, error) { + for { + b, err := c.reader.ReadByte() + if err != nil { + return "", err + } + if b == '$' { + break + } + } + body, err := c.reader.ReadString('#') + if err != nil { + return "", err + } + payload := body[:len(body)-1] + var chk [2]byte + if _, err := io.ReadFull(c.reader, chk[:]); err != nil { + return "", err + } + wantBytes, err := hex.DecodeString(string(chk[:])) + if err != nil || len(wantBytes) != 1 || wantBytes[0] != packetChecksum(payload) { + if !c.noAck { + _, _ = io.WriteString(c.conn, "-") + } + return "", fmt.Errorf("GDB packet checksum mismatch on reply %q", payload) + } + if !c.noAck { + _, _ = io.WriteString(c.conn, "+") + } + return payload, nil +} + +// ReadRegisters fetches the 18 big-endian 32-bit registers (D0-D7, A0-A7, SR, PC) via `g`. +func (c *Client) ReadRegisters() (Registers, error) { + reply, err := c.sendPacket("g") + if err != nil { + return Registers{}, err + } + if strings.HasPrefix(reply, "E") { + return Registers{}, fmt.Errorf("GDB read registers failed: %s", reply) + } + if len(reply) < 18*8 { + return Registers{}, fmt.Errorf("short GDB register reply (%d hex chars, want >= 144)", len(reply)) + } + raw, err := hex.DecodeString(reply[:18*8]) + if err != nil { + return Registers{}, fmt.Errorf("invalid hex in GDB register reply: %w", err) + } + var regs Registers + for i := 0; i < 8; i++ { + regs.D[i] = binary.BigEndian.Uint32(raw[i*4 : (i+1)*4]) + } + for i := 0; i < 8; i++ { + regs.A[i] = binary.BigEndian.Uint32(raw[(8+i)*4 : (9+i)*4]) + } + regs.SR = uint16(binary.BigEndian.Uint32(raw[16*4 : 17*4])) + regs.PC = binary.BigEndian.Uint32(raw[17*4 : 18*4]) + return regs, nil +} + +// ReadMemory reads `length` bytes starting at physical `addr` in 16 KiB `m` chunks. +func (c *Client) ReadMemory(addr, length uint32) ([]byte, error) { + out := make([]byte, 0, length) + cur := addr + rem := length + for rem > 0 { + chunk := min(rem, maxReadChunk) + cmd := fmt.Sprintf("m%x,%x", cur, chunk) + reply, err := c.sendPacket(cmd) + if err != nil { + return nil, fmt.Errorf("read memory at 0x%X (len 0x%X): %w", cur, chunk, err) + } + if strings.HasPrefix(reply, "E") { + return nil, fmt.Errorf("Amiberry rejected memory read at 0x%X (len 0x%X): %s", cur, chunk, reply) + } + decoded, err := hex.DecodeString(reply) + if err != nil { + return nil, fmt.Errorf("decode memory reply at 0x%X: %w", cur, err) + } + if uint32(len(decoded)) != chunk { + return nil, fmt.Errorf("short memory read at 0x%X: got %d bytes, want %d", cur, len(decoded), chunk) + } + out = append(out, decoded...) + cur += chunk + rem -= chunk + } + return out, nil +} + +// ReadCustomSnapshot reads Amiberry's 512-byte saved custom chip register snapshot ($DFF000..$DFF1FF). +func (c *Client) ReadCustomSnapshot() (amiga.CustomSnapshot, error) { + var snap amiga.CustomSnapshot + raw, err := c.ReadMemory(amiga.CustomBase, amiga.CustomSnapshotLen) + if err != nil { + return snap, err + } + copy(snap[:], raw) + return snap, nil +} + +// Monitor sends an Amiberry `qRcmd` monitor command and returns the decoded text/JSON response. +func (c *Client) Monitor(command string) (string, error) { + hexCmd := hex.EncodeToString([]byte(command)) + reply, err := c.sendPacket("qRcmd," + hexCmd) + if err != nil { + return "", err + } + if reply == "" { + return "", fmt.Errorf("monitor command %q unsupported by target", command) + } + if strings.HasPrefix(reply, "E") { + return "", fmt.Errorf("monitor command %q failed: %s", command, reply) + } + if reply == "OK" { + return "", nil + } + decoded, err := hex.DecodeString(reply) + if err != nil { + return "", fmt.Errorf("invalid hex in monitor reply for %q: %w", command, err) + } + return string(decoded), nil +} + +// MemoryMap queries `monitor memory-map` (with `qXfer:memory-map:read` XML fallback) +// and returns all physical memory regions reported by Amiberry. +func (c *Client) MemoryMap() ([]MemoryRegion, error) { + if jsonText, err := c.Monitor("memory-map"); err == nil && jsonText != "" { + var wrapper struct { + Addressing string `json:"addressing"` + Truncated bool `json:"truncated"` + Regions []MemoryRegion `json:"regions"` + } + if err := json.Unmarshal([]byte(jsonText), &wrapper); err == nil && len(wrapper.Regions) > 0 { + return wrapper.Regions, nil + } + } + + // Fallback to standard GDB qXfer:memory-map:read::0,fff XML + reply, err := c.sendPacket("qXfer:memory-map:read::0,7fff") + if err != nil || len(reply) < 2 || strings.HasPrefix(reply, "E") { + return nil, fmt.Errorf("failed to query Amiberry memory map") + } + xmlPayload := reply[1:] + var xmlMap struct { + Memories []struct { + Type string `xml:"type,attr"` + Start uint32 `xml:"start,attr"` + Length uint32 `xml:"length,attr"` + } `xml:"memory"` + } + if err := xml.Unmarshal([]byte(xmlPayload), &xmlMap); err != nil { + return nil, fmt.Errorf("failed to parse memory-map XML: %w", err) + } + regions := make([]MemoryRegion, 0, len(xmlMap.Memories)) + for _, m := range xmlMap.Memories { + isChip := m.Type == "ram" && m.Start == 0 + name := m.Type + if isChip { + name = "Chip memory" + } + regions = append(regions, MemoryRegion{ + Address: m.Start, + Size: m.Length, + Type: m.Type, + Chip: isChip, + Name: name, + }) + } + return regions, nil +} + +// Segments queries `monitor segments` to inspect the active AmigaDOS process's loaded hunks. +// Returns (nil, nil) if no AmigaDOS process is active (e.g. non-DOS trackloader game). +func (c *Client) Segments() (*ProcessSegments, error) { + jsonText, err := c.Monitor("segments") + if err != nil || jsonText == "" { + return nil, nil + } + var proc ProcessSegments + if err := json.Unmarshal([]byte(jsonText), &proc); err != nil { + return nil, err + } + return &proc, nil +} + +// WaitProcessBreak arms a one-shot stop at the entry of AmigaDOS process `procName`, +// resumes emulation (`c`), and blocks until Amiberry stops at the process entry. +func (c *Client) WaitProcessBreak(procName string, timeout time.Duration) error { + if timeout <= 0 { + timeout = 60 * time.Second + } + if _, err := c.Monitor("process-break name " + procName); err != nil { + return fmt.Errorf("arm process-break for %q: %w", procName, err) + } + stopReply, err := c.sendPacketWithTimeout("c", timeout) + if err != nil { + // Attempt to re-halt the target and clear the armed process-break before returning. + _ = c.conn.SetDeadline(time.Now().Add(3 * time.Second)) + _, _ = c.conn.Write([]byte{0x03}) + _, _ = c.readReplyFrame() + _, _ = c.Monitor("process-break clear") + return fmt.Errorf("waiting for process %q to start: %w", procName, err) + } + if !strings.HasPrefix(stopReply, "S") && !strings.HasPrefix(stopReply, "T") { + return fmt.Errorf("unexpected stop reply waiting for process %q: %s", procName, stopReply) + } + return nil +} diff --git a/internal/gdb/client_test.go b/internal/gdb/client_test.go new file mode 100644 index 0000000..fc835f3 --- /dev/null +++ b/internal/gdb/client_test.go @@ -0,0 +1,223 @@ +package gdb + +import ( + "bufio" + "bytes" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "net" + "strings" + "testing" + "time" +) + +type mockAmiberryServer struct { + ln net.Listener + regs Registers + chipRAM []byte + slowRAM []byte + custom [512]byte + detached bool +} + +func startMockAmiberry(t *testing.T) *mockAmiberryServer { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + srv := &mockAmiberryServer{ + ln: ln, + chipRAM: make([]byte, 0x8000), // 32 KiB (2 chunks of 16 KiB) + slowRAM: make([]byte, 0x4000), // 16 KiB at $C00000 + } + for i := range srv.chipRAM { + srv.chipRAM[i] = byte((i * 7) & 0xFF) + } + for i := range srv.slowRAM { + srv.slowRAM[i] = byte((i * 3) & 0xFF) + } + // Populate custom snapshot: DMACON=$03E0, INTENA=$4020, COP1LC=$00002400, BPLCON0=$4200 (4 planes), BPL1PT=$00003000 + binary.BigEndian.PutUint16(srv.custom[0x096:], 0x03E0) + binary.BigEndian.PutUint16(srv.custom[0x09A:], 0x4020) + binary.BigEndian.PutUint32(srv.custom[0x080:], 0x00002400) + binary.BigEndian.PutUint16(srv.custom[0x100:], 0x4200) + binary.BigEndian.PutUint32(srv.custom[0x0E0:], 0x00003000) + binary.BigEndian.PutUint16(srv.custom[0x180:], 0x0123) + binary.BigEndian.PutUint16(srv.custom[0x182:], 0x0FFF) + + srv.regs = Registers{ + D: [8]uint32{1, 2, 3, 4, 5, 6, 7, 8}, + A: [8]uint32{0x1000, 0x2000, 0x3000, 0x4000, 0x5000, 0x00DFF000, 0x6000, 0x7F00}, + SR: 0x2700, + PC: 0x00001234, + } + + go srv.serve() + t.Cleanup(func() { _ = ln.Close() }) + return srv +} + +func writeGDBFrame(w io.Writer, noAck bool, payload string) { + if !noAck { + _, _ = io.WriteString(w, "+") + } + sum := packetChecksum(payload) + _, _ = fmt.Fprintf(w, "$%s#%02x", payload, sum) +} + +func (s *mockAmiberryServer) serve() { + conn, err := s.ln.Accept() + if err != nil { + return + } + defer conn.Close() + r := bufio.NewReader(conn) + noAck := false + for { + b, err := r.ReadByte() + if err != nil { + return + } + if b != '$' { + continue + } + body, err := r.ReadString('#') + if err != nil { + return + } + var chk [2]byte + if _, err := io.ReadFull(r, chk[:]); err != nil { + return + } + req := body[:len(body)-1] + switch { + case req == "QStartNoAckMode": + writeGDBFrame(conn, noAck, "OK") + noAck = true + case req == "?": + writeGDBFrame(conn, noAck, "S05") + case req == "D": + s.detached = true + writeGDBFrame(conn, noAck, "OK") + return + case req == "c": + writeGDBFrame(conn, noAck, "S05") + case req == "g": + var buf [18 * 4]byte + for i := 0; i < 8; i++ { + binary.BigEndian.PutUint32(buf[i*4:], s.regs.D[i]) + binary.BigEndian.PutUint32(buf[(8+i)*4:], s.regs.A[i]) + } + binary.BigEndian.PutUint32(buf[16*4:], uint32(s.regs.SR)) + binary.BigEndian.PutUint32(buf[17*4:], s.regs.PC) + writeGDBFrame(conn, noAck, hex.EncodeToString(buf[:])) + case strings.HasPrefix(req, "m"): + parts := strings.Split(req[1:], ",") + var addr, length uint32 + _, _ = fmt.Sscanf(parts[0], "%x", &addr) + _, _ = fmt.Sscanf(parts[1], "%x", &length) + switch { + case addr == 0xDFF000 && length == 0x200: + writeGDBFrame(conn, noAck, hex.EncodeToString(s.custom[:])) + case addr+length <= uint32(len(s.chipRAM)): + writeGDBFrame(conn, noAck, hex.EncodeToString(s.chipRAM[addr:addr+length])) + case addr >= 0xC00000 && (addr-0xC00000)+length <= uint32(len(s.slowRAM)): + off := addr - 0xC00000 + writeGDBFrame(conn, noAck, hex.EncodeToString(s.slowRAM[off:off+length])) + default: + writeGDBFrame(conn, noAck, "E01") + } + case strings.HasPrefix(req, "qRcmd,"): + rawCmd, _ := hex.DecodeString(req[6:]) + cmd := string(rawCmd) + switch { + case cmd == "memory-map": + jsonReply := fmt.Sprintf( + `{"addressing":"physical","truncated":false,"regions":[{"address":0,"size":%d,"type":"ram","chip":true,"name":"Chip memory"},{"address":12582912,"size":%d,"type":"ram","chip":false,"name":"Slow memory"},{"address":16252928,"size":524288,"type":"rom","chip":false,"name":"Kickstart ROM"}]}`, + len(s.chipRAM), len(s.slowRAM), + ) + writeGDBFrame(conn, noAck, hex.EncodeToString([]byte(jsonReply))) + case strings.HasPrefix(cmd, "process-break name "): + writeGDBFrame(conn, noAck, "OK") + default: + writeGDBFrame(conn, noAck, "E01") + } + default: + writeGDBFrame(conn, noAck, "") + } + } +} + +func TestClientSnapshotWorkflow(t *testing.T) { + srv := startMockAmiberry(t) + c, err := Dial(srv.ln.Addr().String(), 5*time.Second) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer c.Close() + + if err := c.WaitProcessBreak("MyGame", 2*time.Second); err != nil { + t.Fatalf("WaitProcessBreak: %v", err) + } + + regs, err := c.ReadRegisters() + if err != nil { + t.Fatalf("ReadRegisters: %v", err) + } + if regs.PC != 0x00001234 || regs.SR != 0x2700 || regs.A[5] != 0x00DFF000 { + t.Fatalf("unexpected registers: %+v", regs) + } + + regions, err := c.MemoryMap() + if err != nil { + t.Fatalf("MemoryMap: %v", err) + } + if len(regions) != 3 { + t.Fatalf("expected 3 regions, got %d", len(regions)) + } + if !regions[0].Chip || regions[0].Type != "ram" || regions[0].Size != uint32(len(srv.chipRAM)) { + t.Fatalf("unexpected region[0]: %+v", regions[0]) + } + + chipDump, err := c.ReadMemory(0, uint32(len(srv.chipRAM))) + if err != nil { + t.Fatalf("ReadMemory chip: %v", err) + } + if !bytes.Equal(chipDump, srv.chipRAM) { + t.Fatalf("chipRAM mismatch across multi-chunk read") + } + + slowDump, err := c.ReadMemory(0xC00000, uint32(len(srv.slowRAM))) + if err != nil { + t.Fatalf("ReadMemory slow: %v", err) + } + if !bytes.Equal(slowDump, srv.slowRAM) { + t.Fatalf("slowRAM mismatch") + } + + custom, err := c.ReadCustomSnapshot() + if err != nil { + t.Fatalf("ReadCustomSnapshot: %v", err) + } + if custom.DMACON() != 0x03E0 || custom.INTENA() != 0x4020 || custom.COP1LC() != 0x00002400 { + t.Fatalf("unexpected custom snapshot values: DMACON=%#x INTENA=%#x COP1LC=%#x", + custom.DMACON(), custom.INTENA(), custom.COP1LC()) + } + if custom.ActivePlanes() != 4 || custom.BPLPT()[0] != 0x00003000 { + t.Fatalf("unexpected BPL state: planes=%d BPL1PT=%#x", custom.ActivePlanes(), custom.BPLPT()[0]) + } + pal := custom.Palette12() + if pal[0] != 0x0123 || pal[1] != 0x0FFF { + t.Fatalf("unexpected palette: %#x, %#x", pal[0], pal[1]) + } + + if err := c.Detach(); err != nil { + t.Fatalf("Detach: %v", err) + } + if !srv.detached { + t.Fatalf("expected server to receive D (detach)") + } +} diff --git a/internal/project/project.go b/internal/project/project.go index 582bbae..876cd54 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -314,6 +314,15 @@ type AnalysisData struct { IndirectAddressing []string `json:"indirect_addressing"` } +type SnapshotState struct { + PC uint32 `json:"pc"` + SR uint16 `json:"sr"` + D [8]uint32 `json:"d"` + A [8]uint32 `json:"a"` + CustomFile string `json:"custom_file,omitempty"` + CustomInfo map[string]string `json:"custom_info,omitempty"` +} + type ProjectData struct { TargetName string `json:"target_name"` Segments []Segment `json:"segments"` @@ -325,6 +334,7 @@ type ProjectData struct { Structs map[string]map[string]string `json:"structs"` RegisterAliases map[string]string `json:"register_aliases"` FunctionABIs map[string]string `json:"function_abis,omitempty"` + Snapshot *SnapshotState `json:"snapshot,omitempty"` Analysis AnalysisData `json:"_analysis"` } @@ -859,3 +869,22 @@ func (p *Project) ResolveTarget(ctrl *db.Controller, input string) (uint32, bool } return 0, false } + +// LoadCustomSnapshot loads the 512-byte ($DFF000..$DFF1FF) custom chip snapshot +// from `Snapshot.CustomFile` if present. +func (p *Project) LoadCustomSnapshot() (*amiga.CustomSnapshot, bool) { + if p == nil || p.Data.Snapshot == nil || p.Data.Snapshot.CustomFile == "" { + return nil, false + } + cf := p.Data.Snapshot.CustomFile + if !filepath.IsAbs(cf) && p.Filepath != "" { + cf = filepath.Join(filepath.Dir(p.Filepath), cf) + } + raw, err := os.ReadFile(cf) + if err != nil || len(raw) < amiga.CustomSnapshotLen { + return nil, false + } + var snap amiga.CustomSnapshot + copy(snap[:], raw[:amiga.CustomSnapshotLen]) + return &snap, true +} diff --git a/internal/tui/actions.go b/internal/tui/actions.go index a1aa24d..3acfeb3 100644 --- a/internal/tui/actions.go +++ b/internal/tui/actions.go @@ -564,6 +564,11 @@ func (a *App) OpenBitplaneViewer() { } if st == nil { st = NewBitplaneState(baseOff) + if a.Proj != nil { + if snap, ok := a.Proj.LoadCustomSnapshot(); ok { + st.ApplyCustomSnapshot(snap, a.BaseAddr, baseOff) + } + } } proto := DetectProtocol() diff --git a/internal/tui/bitplane.go b/internal/tui/bitplane.go index 2649a62..48f2dbf 100644 --- a/internal/tui/bitplane.go +++ b/internal/tui/bitplane.go @@ -12,6 +12,7 @@ import ( "time" "github.com/charmbracelet/x/term" + "github.com/mikolas/hunks/internal/amiga" "github.com/mikolas/hunks/internal/project" "github.com/muesli/cancelreader" ) @@ -70,6 +71,59 @@ func NewBitplaneState(base uint32) *BitplaneState { return s } +// ApplyCustomSnapshot populates the bitplane state's palette, depth, hires/HAM flags, +// and bitplane pointers from a captured 512-byte ($DFF000..$DFF1FF) custom chip snapshot. +func (s *BitplaneState) ApplyCustomSnapshot(snap *amiga.CustomSnapshot, baseAddr, cursorOff uint32) { + if s == nil || snap == nil { + return + } + pal12 := snap.Palette12() + hasNonZeroColor := false + for i, c := range pal12 { + if c != 0 { + hasNonZeroColor = true + } + s.CustomPalette[i] = Convert0RGBToRGBA(c) + } + if hasNonZeroColor { + s.CustomPalName = "Snapshot@$DFF180" + s.PaletteMode = PaletteCustom + } + + bpu := snap.ActivePlanes() + if bpu >= 1 && bpu <= 7 { + s.SetBPU(bpu) + s.Hires = snap.Hires() + s.HAM = snap.HAM() + if s.Hires && s.RowBytes == 40 { + s.RowBytes = 80 + } + if mod := snap.BPL1MOD(); mod != 0 { + s.Modulo = int(mod) + } + mask := uint8((1 << bpu) - 1) + s.ControlMask = mask + s.VisibleMask = mask + bpl := snap.BPLPT() + // Use the captured BPL1PT..BPLnPT pointers when opening at the start of the segment + // or directly on one of the captured bitplane addresses. + matchesBPL := cursorOff == 0 + for i := 0; i < bpu; i++ { + if bpl[i] >= baseAddr && bpl[i]-baseAddr == cursorOff { + matchesBPL = true + break + } + } + if matchesBPL { + for i := 0; i < bpu; i++ { + if bpl[i] >= baseAddr { + s.BPL[i] = bpl[i] - baseAddr + } + } + } + } +} + // DetectOptimalScale calculates the optimal integer scaling factor (1x..8x) // to display a native Amiga bitmap scaled against the terminal text area resolution. func DetectOptimalScale(srcW, srcH int) int { diff --git a/internal/tui/bitplane_test.go b/internal/tui/bitplane_test.go index 1ca8fcb..b381eb0 100644 --- a/internal/tui/bitplane_test.go +++ b/internal/tui/bitplane_test.go @@ -1,8 +1,11 @@ package tui import ( + "encoding/binary" "image/color" "testing" + + "github.com/mikolas/hunks/internal/amiga" ) func TestBitplaneStateGeometry(t *testing.T) { @@ -503,3 +506,36 @@ func TestAddPlanesConsecutiveAfterSteppingPlane1(t *testing.T) { 0x0C00+planeSize, 0x0C00+2*planeSize, st.BPL[1], st.BPL[2]) } } + +func TestApplyCustomSnapshot(t *testing.T) { + var snap amiga.CustomSnapshot + binary.BigEndian.PutUint16(snap[0x08E:0x090], 0x2C81) // DIWSTRT (vstart=44) + binary.BigEndian.PutUint16(snap[0x090:0x092], 0x2CC1) // DIWSTOP (vstop=300 -> height 256) + binary.BigEndian.PutUint16(snap[0x092:0x094], 0x0038) // DDFSTRT + binary.BigEndian.PutUint16(snap[0x094:0x096], 0x00D0) // DDFSTOP (20 words -> 40 bytes/row) + binary.BigEndian.PutUint32(snap[0x0E0:0x0E4], 0x00028000) + binary.BigEndian.PutUint32(snap[0x0E4:0x0E8], 0x0002A800) + binary.BigEndian.PutUint32(snap[0x0E8:0x0EC], 0x0002D000) + binary.BigEndian.PutUint32(snap[0x0EC:0x0F0], 0x0002F800) + binary.BigEndian.PutUint16(snap[0x100:0x102], 0x4200) // BPLCON0: 4 planes, COLOR + binary.BigEndian.PutUint16(snap[0x108:0x10A], 0x0004) // BPL1MOD: 4 + binary.BigEndian.PutUint16(snap[0x10A:0x10C], 0x0004) // BPL2MOD: 4 + binary.BigEndian.PutUint16(snap[0x180:0x182], 0x0123) // COLOR00 + binary.BigEndian.PutUint16(snap[0x182:0x184], 0x0F80) // COLOR01 + + st := NewBitplaneState(0) + st.ApplyCustomSnapshot(&snap, 0, 0) + + if st.BPU() != 4 { + t.Fatalf("expected BPU 4, got %d", st.BPU()) + } + if st.BPL[0] != 0x28000 || st.BPL[3] != 0x2F800 { + t.Fatalf("unexpected BPL pointers: %#x .. %#x", st.BPL[0], st.BPL[3]) + } + if st.Modulo != 4 { + t.Fatalf("expected Modulo=4, got %d", st.Modulo) + } + if st.PaletteMode != PaletteCustom || st.CustomPalette[1] != Convert0RGBToRGBA(0x0F80) { + t.Fatalf("expected custom palette with COLOR01=$0F80, got mode=%d col1=%v", st.PaletteMode, st.CustomPalette[1]) + } +}