diff --git a/README.md b/README.md index ed24212..1c9f7ca 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,115 @@ c65gm automatically: **Error Handling:** If ACME is not found, c65gm shows installation instructions and suggests using `compile` mode instead. +## Peephole Optimizer (Experimental) + +> **⚠️ Experimental.** This feature is new and actively being developed. All optimizations are **disabled by default** and must be explicitly enabled. While thoroughly tested on real C64 projects, edge cases may exist — always verify your program's behavior when optimization is active. + +c65gm includes a peephole optimizer that removes redundant 6502 instructions from the generated assembly. It operates as a post-pass on the compiler output, scanning instruction windows and applying pattern-matching rules where safe. + +The optimizer only modifies **generated code** — hand-written `ASM`/`ENDASM` blocks, `SCRIPT` output, and inline assembly are left untouched. It can also be configured to skip memory regions used for I/O (e.g., `$D000-$DFFF` on the C64). + +### Optimization Passes + +Five passes run in sequence. All are enabled by default when `--opt` is used. + +| Pass | Pragma | Name | What it removes | +|---|---|---|---| +| 1 | `_P_OPT_STLD` | Store-then-load | `sta X; ...; lda X` → `sta X` when A wasn't modified between | +| 2 | `_P_OPT_LOAD` | Redundant load | `lda X; ...; lda X` → `lda X` when A already holds X's value | +| 3 | `_P_OPT_IMM` | Redundant immediate | `lda #N; ...; lda #N` → `lda #N` when A already holds N | +| 4 | `_P_OPT_JMP` | Jump-to-next | `jmp L; L:` → `L:` when no code between `jmp` and its target | +| 5 | `_P_OPT_SELF` | Self-assignment | `lda X; sta X` → (removed) when same variable stored back to itself | + +### CLI Flags + +Available on the `build` and `compile` subcommands: + +``` +c65gm build -i program.c65 --opt [--opt-debug] [--opt-exclude ...] [--opt-exclude-c64-io] +c65gm compile -i program.c65 --opt [--opt-debug] [--opt-exclude ...] [--opt-exclude-c64-io] +``` + +| Flag | Effect | +|---|---| +| `--opt` | Enable all 5 optimization passes (equivalent to `#PRAGMA _P_OPT_ALL 1`) | +| `--opt-debug` | Annotate the assembly output with `[removed]` markers and a summary line showing how many instructions were saved | +| `--opt-exclude` | Mark an address range as I/O to exclude from store-load optimization. Repeatable. Format: `START:END` with hex (`0x` prefix) or decimal values. | +| `--opt-exclude-c64-io` | Shorthand for `--opt-exclude 0xD000:0xDFFF`. Convenience for C64 targets. | + +### Pragma Equivalents + +All CLI flags have equivalent `#PRAGMA` directives in the source code: + +```c65 +#PRAGMA _P_OPT_ALL 1 ; enable all passes +#PRAGMA _P_OPT_LOAD 1 ; enable redundant load pass only +#PRAGMA _P_OPT_IMM 1 ; enable redundant immediate pass only +#PRAGMA _P_OPT_JMP 1 ; enable jump-to-next pass only +#PRAGMA _P_OPT_SELF 1 ; enable self-assignment pass only +#PRAGMA _P_OPT_STLD 1 ; enable store-then-load pass only +#PRAGMA _P_OPT_DEBUG 1 ; annotate output with [removed] markers +#PRAGMA _P_OPT_IO $D000 $DFFF ; mark C64 I/O range as excluded +``` + +Pragmas and CLI flags are **additive** — both can be used together. + +### Examples + +```bash +# Enable all passes +c65gm compile -i game.c65 --opt -o game.asm + +# Enable passes with debug annotations (see what was removed) +c65gm compile -i game.c65 --opt --opt-debug -o game.asm + +# Enable passes, protect C64 I/O region (recommended for C64 targets) +c65gm build -i game.c65 --opt --opt-exclude-c64-io + +# Protect custom I/O ranges (e.g., multiple VIC-II register blocks) +c65gm compile -i game.c65 --opt --opt-exclude 0xD000:0xDFFF --opt-exclude 0xDC00:0xDC0F + +# Combined: all passes + debug + I/O protection +c65gm build -i game.c65 --opt --opt-debug --opt-exclude-c64-io + +# Command-line equivalent to #PRAGMA _P_OPT_ALL + #PRAGMA _P_OPT_IO $D000 $DFFF +c65gm build -i game.c65 --opt --opt-exclude 0xD000:0xDFFF +``` + +### Verifying Optimizations + +With `--opt-debug`, the assembly output contains: + +``` +; --- peephole: 4311 asm lines → 4072 (239 saved) --- +... +; POKE target_addr , value +; [removed] ldy #0 + lda mem_copy_value + sta (mem_copy_target_addr),y +... +``` + +The header shows the instruction count before and after (`X asm lines → Y`), plus the total saved. Each `[removed]` annotation marks an instruction that was eliminated. The annotation is a comment and does not affect assembly. + +For a full difference view, compile with and without `--opt` and diff the two `.asm` files: + +```bash +c65gm compile -i game.c65 -o baseline.asm +c65gm compile -i game.c65 --opt --opt-debug -o optimized.asm +diff -u baseline.asm optimized.asm | less +``` + +### I/O Safety + +The optimizer never removes **reads or stores** to addresses marked in the I/O exclusion map. Reading an I/O register (like `$D012` raster line or `$DC01` keyboard matrix) may return different values each time, so redundant load elimination is disabled for those addresses. These can be specified via: + +1. **CLI flag**: `--opt-exclude 0xD000:0xDFFF` (repeatable for multiple regions) +2. **CLI shorthand**: `--opt-exclude-c64-io` for the full C64 I/O page +3. **Pragma**: `#PRAGMA _P_OPT_IO $D000 $DFFF` + +Variable names (like `vic2`, `BORDER_COLOR`) are NOT checked against the I/O map — only literal hex addresses are. For `@`-mapped variables that point to I/O registers, use the `_P_OPT_IO` pragma with their address range. + ### Environment Variables - **`C65LIBPATH`**: Search path for `#INCLUDE ` directives ```bash diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index dc5e14c..e4d6037 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -12,9 +12,12 @@ import ( // Compiler orchestrates the compilation process type Compiler struct { - ctx *CompilerContext - registry *CommandRegistry - deferredAsm []string // ASM blocks with _P_ASM_AFTER_VARS pragma + ctx *CompilerContext + registry *CommandRegistry + deferredAsm []string // ASM blocks with _P_ASM_AFTER_VARS pragma + CmdlineOpt bool // --opt enables all passes + CmdlineDebug bool // --opt-debug enables debug output + CmdlineIORegions []optimizer.IORegion // --opt-exclude ranges } // NewCompiler creates a new compiler with initialized context and registry @@ -281,12 +284,18 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { // Remove unused functions with _P_REMOVE_UNUSED pragma codeOutput, removedFuncs := c.removeUnusedFunctions(codeOutput) + // Update peephole header to match actual [removed] count after function removal + codeOutput = updatePeepholeHeader(codeOutput) + // Assemble final output with headers and footers return c.assembleOutput(codeOutput, removedFuncs), nil } // isOptimizing returns true if any peephole optimization pragma is active func (c *Compiler) isOptimizing() bool { + if c.CmdlineOpt { + return true + } if c.ctx == nil || c.ctx.Pragma == nil { return false } @@ -314,13 +323,65 @@ func (c *Compiler) getOptimizerConfig() *optimizer.Config { idx := c.ctx.Pragma.GetCurrentPragmaSetIndex() ps := c.ctx.Pragma.GetPragmaSetByIndex(idx) cfg := optimizer.NewConfig(ps) + + // Command-line overrides + if c.CmdlineOpt { + cfg.EnableLoad = true + cfg.EnableImm = true + cfg.EnableJmp = true + cfg.EnableSelf = true + cfg.EnableStoreLoad = true + } + if c.CmdlineDebug { + cfg.Debug = true + } + if !cfg.Any() { return nil } + + // Apply CLI I/O regions before pragma-based ones + if len(c.CmdlineIORegions) > 0 { + cfg.AddIORegions(c.CmdlineIORegions) + } cfg.BuildIOMap(c.ctx.Pragma) return cfg } +// updatePeepholeHeader recalculates the peephole header saved count +// from the actual [removed] annotations present after removeUnusedFunctions. +func updatePeepholeHeader(codeOutput []string) []string { + headerIdx := -1 + var header string + removedCount := 0 + + for i, line := range codeOutput { + if strings.HasPrefix(line, "; --- peephole:") { + headerIdx = i + header = line + } + if strings.HasPrefix(line, "; [removed]") { + removedCount++ + } + } + + if headerIdx < 0 { + return codeOutput + } + + // Extract orig count from the existing header + var origCount, oldOptCount int + if _, err := fmt.Sscanf(header, "; --- peephole: %d asm lines → %d", + &origCount, &oldOptCount); err != nil { + return codeOutput + } + + newHeader := fmt.Sprintf("; --- peephole: %d asm lines → %d (%d saved) ---", + origCount, origCount-removedCount, removedCount) + codeOutput[headerIdx] = newHeader + return codeOutput +} + func classString(cls CmdClass) string { switch cls { case CmdLinear: diff --git a/internal/optimizer/config.go b/internal/optimizer/config.go index 7fd6ff2..7dec2f2 100644 --- a/internal/optimizer/config.go +++ b/internal/optimizer/config.go @@ -1,6 +1,7 @@ package optimizer import ( + "fmt" "strconv" "strings" @@ -70,3 +71,57 @@ func parsePragmaAddr(s string) int { } return int(v) } + +// IORegion defines an address range to exclude from optimization. +type IORegion struct { + Start, End uint16 +} + +// AddIORegions marks all addresses in the given regions as I/O in the IOMap. +func (c *Config) AddIORegions(regions []IORegion) { + for _, r := range regions { + for a := r.Start; a <= r.End; a++ { + c.IOMap[a] = true + } + } +} + +// ParseExcludeRange parses a string like "0xD000:0xDFFF" or "53248:57343". +// Returns an IORegion or an error. +func ParseExcludeRange(s string) (IORegion, error) { + parts := strings.SplitN(s, ":", 2) + if len(parts) != 2 { + return IORegion{}, fmt.Errorf("expected START:END, got %q", s) + } + + start, err := parseExcludeAddr(parts[0]) + if err != nil { + return IORegion{}, fmt.Errorf("invalid start %q: %w", parts[0], err) + } + end, err := parseExcludeAddr(parts[1]) + if err != nil { + return IORegion{}, fmt.Errorf("invalid end %q: %w", parts[1], err) + } + if start > end { + return IORegion{}, fmt.Errorf("start %s > end %s", parts[0], parts[1]) + } + return IORegion{Start: start, End: end}, nil +} + +func parseExcludeAddr(s string) (uint16, error) { + s = strings.TrimSpace(s) + // Hex: 0x prefix (case-insensitive) + if len(s) > 2 && (s[:2] == "0x" || s[:2] == "0X") { + v, err := strconv.ParseUint(s[2:], 16, 16) + if err != nil { + return 0, err + } + return uint16(v), nil + } + // Decimal + v, err := strconv.ParseUint(s, 10, 16) + if err != nil { + return 0, err + } + return uint16(v), nil +} diff --git a/internal/optimizer/config_test.go b/internal/optimizer/config_test.go index adcbbe7..a14ee53 100644 --- a/internal/optimizer/config_test.go +++ b/internal/optimizer/config_test.go @@ -25,6 +25,132 @@ func TestNewConfigWithAll(t *testing.T) { } } +func TestParseExcludeRange(t *testing.T) { + tests := []struct { + name string + input string + wantOk bool + wantS uint16 + wantE uint16 + }{ + {"hex", "0xD000:0xDFFF", true, 0xD000, 0xDFFF}, + {"hex lowercase", "0xd000:0xdfff", true, 0xD000, 0xDFFF}, + {"hex mixed case", "0XD000:0XdFfF", true, 0xD000, 0xDFFF}, + {"decimal", "53248:57343", true, 53248, 57343}, + {"single address", "0xD020:0xD020", true, 0xD020, 0xD020}, + {"invalid range", "0xFFFF:0x0000", false, 0, 0}, + {"bad format", "garbage", false, 0, 0}, + {"bad hex", "0xGGGG:0x0000", false, 0, 0}, + {"too many colons", "0xD000:0xDFFF:extra", false, 0, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, err := ParseExcludeRange(tt.input) + if tt.wantOk { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if r.Start != tt.wantS || r.End != tt.wantE { + t.Errorf("got %04X:%04X, want %04X:%04X", r.Start, r.End, tt.wantS, tt.wantE) + } + } else { + if err == nil { + t.Errorf("expected error, got %04X:%04X", r.Start, r.End) + } + } + }) + } +} + +func TestAddIORegions(t *testing.T) { + cfg := &Config{} + cfg.AddIORegions([]IORegion{ + {Start: 0xD000, End: 0xDFFF}, + }) + + if !cfg.IOMap[0xD000] { + t.Error("expected 0xD000 to be marked I/O") + } + if !cfg.IOMap[0xDFFF] { + t.Error("expected 0xDFFF to be marked I/O") + } + if cfg.IOMap[0xCFFF] { + t.Error("expected 0xCFFF to NOT be marked I/O") + } + if cfg.IOMap[0xE000] { + t.Error("expected 0xE000 to NOT be marked I/O") + } +} + +func TestAddIORegionsMultiple(t *testing.T) { + cfg := &Config{} + cfg.AddIORegions([]IORegion{ + {Start: 0xD000, End: 0xDFFF}, + {Start: 0xDC00, End: 0xDC0F}, // overlaps — still correct + }) + + if !cfg.IOMap[0xD000] { + t.Error("expected 0xD000 I/O") + } + if !cfg.IOMap[0xDC00] { + t.Error("expected 0xDC00 I/O") + } + if !cfg.IOMap[0xDC0F] { + t.Error("expected 0xDC0F I/O") + } +} + +func TestBuildIOMap(t *testing.T) { + p := preproc.NewPragma() + p.AddPragma("_P_OPT_IO", "$D000 $DFFF") + + // Last pragma set should have _P_OPT_IO + ps := p.GetPragmaSetByIndex(p.GetCurrentPragmaSetIndex()) + cfg := NewConfig(ps) + cfg.BuildIOMap(p) + + if !cfg.IOMap[0xD000] { + t.Error("expected $D000 I/O from pragma") + } + if !cfg.IOMap[0xDFFF] { + t.Error("expected $DFFF I/O from pragma") + } +} + +func TestBuildIOMapMultiple(t *testing.T) { + // Simulate two _P_OPT_IO pragmas + p := preproc.NewPragma() + p.AddPragma("_P_OPT_IO", "$D000 $DFFF") + p.AddPragma("_P_OPT_IO", "$DC00 $DC0F") + p.AddPragma("_P_SOMETHING_ELSE", "1") + + // BuildIOMap scans ALL sets, finding _P_OPT_IO at index 1 and 2 + ps := p.GetPragmaSetByIndex(p.GetCurrentPragmaSetIndex()) + cfg := NewConfig(ps) + cfg.BuildIOMap(p) + + if !cfg.IOMap[0xD000] { + t.Error("expected $D000 I/O") + } + if !cfg.IOMap[0xDC00] { + t.Error("expected $DC00 I/O") + } +} + +func TestBuildIOMapEmpty(t *testing.T) { + p := preproc.NewPragma() + ps := p.GetPragmaSetByIndex(p.GetCurrentPragmaSetIndex()) + cfg := NewConfig(ps) + cfg.BuildIOMap(p) + + for _, addr := range []uint16{0, 0xD000, 0xFFFF} { + if cfg.IOMap[addr] { + t.Errorf("address %04X should not be I/O", addr) + } + } +} + func TestNewConfigPragmasAccumulate(t *testing.T) { // Pragma sets COPY previous values — so last set has ALL pragmas p := preproc.NewPragma() diff --git a/internal/optimizer/debug.go b/internal/optimizer/debug.go index 378e4e5..4edeb9d 100644 --- a/internal/optimizer/debug.go +++ b/internal/optimizer/debug.go @@ -5,45 +5,46 @@ import "fmt" // debugDiff produces annotated output showing original vs optimized code. // Lines that were removed are shown with "[removed]" annotation. func debugDiff(original, optimized []asmLine) []asmLine { + // Count [removed] annotations from the walk, then derive optCount from + // origCount - removedCount. This avoids mismatches when later pipeline + // steps (e.g. removeUnusedFunctions) remove code that the optimizer saw. origCount := countCodeLines(original) - optCount := countCodeLines(optimized) - saved := origCount - optCount - header := fmt.Sprintf("; --- peephole: %d asm lines → %d (%d saved) ---", origCount, optCount, saved) - var result []asmLine - result = append(result, asmLine{text: header, isComment: true}) - - // Walk both arrays in sync. When optimized doesn't have a line - // that original has, it was removed. - oi := 0 // index into original (pre-stripped) - opi := 0 // index into optimized + var body []asmLine + oi := 0 + opi := 0 + removedCount := 0 for oi < len(original) { origLine := original[oi] if opi < len(optimized) && linesMatch(origLine, optimized[opi]) { - result = append(result, optimized[opi]) + body = append(body, optimized[opi]) oi++ opi++ } else if !origLine.isCode { - // Non-code line that doesn't match anything — pass through - result = append(result, origLine) + body = append(body, origLine) oi++ } else { - // Code line in original but not in optimized → removed - result = append(result, asmLine{ + body = append(body, asmLine{ text: fmt.Sprintf("; [removed] %s", origLine.text), isComment: true, }) + removedCount++ oi++ } } - // Append any remaining optimized lines (shouldn't happen in practice) for ; opi < len(optimized); opi++ { - result = append(result, optimized[opi]) + body = append(body, optimized[opi]) } + optCount := origCount - removedCount + header := fmt.Sprintf("; --- peephole: %d asm lines → %d (%d saved) ---", + origCount, optCount, removedCount) + + result := []asmLine{{text: header, isComment: true}} + result = append(result, body...) return result } diff --git a/internal/optimizer/optimizer.go b/internal/optimizer/optimizer.go index e272c0b..935316d 100644 --- a/internal/optimizer/optimizer.go +++ b/internal/optimizer/optimizer.go @@ -15,7 +15,7 @@ func Optimize(lines []string, cfg *Config) []string { parsed = passStoreReload(parsed, cfg) } if cfg.EnableLoad { - parsed = passLoadElimination(parsed) + parsed = passLoadElimination(parsed, cfg) } if cfg.EnableImm { parsed = passImmElimination(parsed) diff --git a/internal/optimizer/optimizer_test.go b/internal/optimizer/optimizer_test.go index 3f21192..2a621c0 100644 --- a/internal/optimizer/optimizer_test.go +++ b/internal/optimizer/optimizer_test.go @@ -47,7 +47,7 @@ func TestPassLoadElimination(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { parsed := parseLines(tt.input) - result := passLoadElimination(parsed) + result := passLoadElimination(parsed, nil) cleaned := stripOptMarkers(result) if got := len(cleaned); got != tt.expected { t.Errorf("got %d lines, want %d\ninput: %v\noutput: %v", got, tt.expected, tt.input, linesToString(cleaned)) @@ -173,6 +173,38 @@ func TestPassSelfAssignment(t *testing.T) { } } +func TestPassLoadIO(t *testing.T) { + cfg := &Config{} + cfg.IOMap[0xD012] = true // raster line register — changes constantly + + t.Run("IO load not eliminated", func(t *testing.T) { + parsed := parseLines(lines("\tlda $D012", "\tlda $D012")) + result := passLoadElimination(parsed, cfg) + cleaned := stripOptMarkers(result) + if len(cleaned) != 2 { + t.Errorf("expected 2 lines (IO skip), got %d", len(cleaned)) + } + }) + + t.Run("non-IO load eliminated", func(t *testing.T) { + parsed := parseLines(lines("\tlda $C000", "\tlda $C000")) + result := passLoadElimination(parsed, cfg) + cleaned := stripOptMarkers(result) + if len(cleaned) != 1 { + t.Errorf("expected 1 line (non-IO), got %d", len(cleaned)) + } + }) + + t.Run("variable name not caught by IO", func(t *testing.T) { + parsed := parseLines(lines("\tlda RASTER_LINE", "\tlda RASTER_LINE")) + result := passLoadElimination(parsed, cfg) + cleaned := stripOptMarkers(result) + if len(cleaned) != 1 { + t.Errorf("expected 1 line (var not IO), got %d", len(cleaned)) + } + }) +} + func TestPassLoadNoCommentReset(t *testing.T) { // Comments should NOT reset register state parsed := parseLines(lines( @@ -181,7 +213,7 @@ func TestPassLoadNoCommentReset(t *testing.T) { "; comment", "\tlda b", // should be redundant )) - result := passLoadElimination(parsed) + result := passLoadElimination(parsed, nil) if len(result) != 3 { t.Errorf("expected 3 lines (comment kept, lda b removed), got %d:\n%v", len(result), linesToString(result)) } @@ -389,13 +421,98 @@ func TestPassStoreReload(t *testing.T) { } func TestPassStoreReloadIO(t *testing.T) { - cfg := &Config{} - cfg.IOMap[0xD020] = true + t.Run("c64 border color", func(t *testing.T) { + cfg := &Config{} + cfg.IOMap[0xD020] = true + parsed := parseLines(lines("\tsta $D020", "\tlda $D020")) + result := passStoreReload(parsed, cfg) + cleaned := stripOptMarkers(result) + if len(cleaned) != 2 { + t.Errorf("expected 2 lines (I/O skip), got %d", len(cleaned)) + } + }) - parsed := parseLines(lines("\tsta $D020", "\tlda $D020")) - result := passStoreReload(parsed, cfg) - cleaned := stripOptMarkers(result) - if len(cleaned) != 2 { - t.Errorf("expected 2 lines (I/O skip), got %d", len(cleaned)) - } + t.Run("non-IO address", func(t *testing.T) { + cfg := &Config{} + cfg.IOMap[0xD020] = true + parsed := parseLines(lines("\tsta $C000", "\tlda $C000")) + result := passStoreReload(parsed, cfg) + cleaned := stripOptMarkers(result) + if len(cleaned) != 1 { + t.Errorf("expected 1 line (non-IO), got %d", len(cleaned)) + } + }) + + t.Run("decimal address in IO range", func(t *testing.T) { + cfg := &Config{} + cfg.IOMap[0xD020] = true + // Decimal 53280 = $D020, but IOMap uses hex lookup + // The pass checks $ prefix only, decimal addresses won't be caught by IOMap + parsed := parseLines(lines("\tsta 53280", "\tlda 53280")) + result := passStoreReload(parsed, cfg) + cleaned := stripOptMarkers(result) + if len(cleaned) != 1 { + t.Errorf("expected 1 line (decimal not caught by I/O), got %d", len(cleaned)) + } + }) + + t.Run("variable name in IO region", func(t *testing.T) { + cfg := &Config{} + cfg.IOMap[0xD020] = true + // Variable names like VIC_BORDER aren't checked against IOMap + parsed := parseLines(lines("\tsta BORDER_COLOR", "\tlda BORDER_COLOR")) + result := passStoreReload(parsed, cfg) + cleaned := stripOptMarkers(result) + if len(cleaned) != 1 { + t.Errorf("expected 1 line (var name not I/O), got %d", len(cleaned)) + } + }) + + t.Run("decimal address not in IO range", func(t *testing.T) { + cfg := &Config{} + cfg.IOMap[0xD020] = true + parsed := parseLines(lines("\tsta 49152", "\tlda 49152")) + result := passStoreReload(parsed, cfg) + cleaned := stripOptMarkers(result) + if len(cleaned) != 1 { + t.Errorf("expected 1 line (decimal not IO), got %d", len(cleaned)) + } + }) + + t.Run("IOMap from AddIORegions", func(t *testing.T) { + cfg := &Config{} + cfg.AddIORegions([]IORegion{{Start: 0xD000, End: 0xDFFF}}) + parsed := parseLines(lines("\tsta $D020", "\tlda $D020")) + result := passStoreReload(parsed, cfg) + cleaned := stripOptMarkers(result) + if len(cleaned) != 2 { + t.Errorf("expected 2 lines (I/O via AddIORegions), got %d", len(cleaned)) + } + }) + + t.Run("multiple IO regions", func(t *testing.T) { + cfg := &Config{} + cfg.AddIORegions([]IORegion{ + {Start: 0xD000, End: 0xDFFF}, + {Start: 0xDC00, End: 0xDC0F}, + }) + tests := []struct{ + addr string + expect int + }{ + {"$D020", 2}, + {"$DC00", 2}, + {"$DC0F", 2}, + {"$C000", 1}, + {"$E000", 1}, + } + for _, tt := range tests { + parsed := parseLines(lines("\tsta " + tt.addr, "\tlda " + tt.addr)) + result := passStoreReload(parsed, cfg) + cleaned := stripOptMarkers(result) + if len(cleaned) != tt.expect { + t.Errorf("addr %s: expected %d lines, got %d", tt.addr, tt.expect, len(cleaned)) + } + } + }) } diff --git a/internal/optimizer/pass_load.go b/internal/optimizer/pass_load.go index 3baa70b..243c153 100644 --- a/internal/optimizer/pass_load.go +++ b/internal/optimizer/pass_load.go @@ -5,7 +5,7 @@ import "strings" // passLoadElimination removes redundant memory-to-register loads. // Pattern: lda X; ... (X not modified) ...; lda X → remove second // Applies to A, X, Y registers identically. -func passLoadElimination(lines []asmLine) []asmLine { +func passLoadElimination(lines []asmLine, cfg *Config) []asmLine { rs := newRegState() var result []asmLine @@ -27,7 +27,10 @@ func passLoadElimination(lines []asmLine) []asmLine { // Check for redundant memory load if line.opcode == "lda" && !isImmediate(line.operand) { if isIndexedOperand(line.operand) { - // Indexed/indirect addressing: address depends on X/Y, can't track by operand text alone + updateRegState(line, rs) + } else if isIOAddr(line.operand, cfg) { + // I/O register reads may return different values — don't track + rs.resetA() updateRegState(line, rs) } else if rs.a.src == line.operand { continue // redundant, remove @@ -37,6 +40,9 @@ func passLoadElimination(lines []asmLine) []asmLine { } else if line.opcode == "ldx" && !isImmediate(line.operand) { if isIndexedOperand(line.operand) { updateRegState(line, rs) + } else if isIOAddr(line.operand, cfg) { + rs.resetX() + updateRegState(line, rs) } else if rs.x.src == line.operand { continue } else { @@ -45,6 +51,9 @@ func passLoadElimination(lines []asmLine) []asmLine { } else if line.opcode == "ldy" && !isImmediate(line.operand) { if isIndexedOperand(line.operand) { updateRegState(line, rs) + } else if isIOAddr(line.operand, cfg) { + rs.resetY() + updateRegState(line, rs) } else if rs.y.src == line.operand { continue } else { @@ -67,6 +76,18 @@ func isIndexedOperand(operand string) bool { return strings.Contains(operand, "(") || strings.Contains(operand, ",") } +// isIOAddr returns true if operand is a hex address marked as I/O in the config. +func isIOAddr(operand string, cfg *Config) bool { + if cfg == nil { + return false + } + if !strings.HasPrefix(operand, "$") { + return false + } + addr := parseHexOrDec(operand) + return addr >= 0 && addr < 65536 && cfg.IOMap[addr] +} + func isImmediate(operand string) bool { return len(operand) > 0 && operand[0] == '#' } diff --git a/main.go b/main.go index bfb58bb..497bc3a 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "c65gm/internal/commands" "c65gm/internal/compiler" + "c65gm/internal/optimizer" "c65gm/internal/preproc" ) @@ -114,20 +115,20 @@ func main() { // Determine mode by output extension if strings.HasSuffix(strings.ToLower(outputFile), ".prg") { // Build mode (compile + assemble) - if err := build(inputFile, outputFile, false, false); err != nil { + if err := build(inputFile, outputFile, false, false, false, false, false, nil); err != nil { handleError(err) } fmt.Println("Build successful.") } else { // Compile mode (assembly only) - if err := compileOnly(inputFile, outputFile); err != nil { + if err := compileOnly(inputFile, outputFile, false, false, false, nil); err != nil { handleError(err) } fmt.Println("Compilation successful.") } } -func compileOnly(inFile, outFile string) error { +func compileOnly(inFile, outFile string, opt, optDebug, optC64 bool, optExcludes []string) error { // Preprocess lines, pragma, err := preproc.PreProcess(inFile) if err != nil { @@ -138,6 +139,20 @@ func compileOnly(inFile, outFile string) error { // Create compiler and register commands comp := compiler.NewCompiler(pragma) + comp.CmdlineOpt = opt + comp.CmdlineDebug = optDebug + // Build I/O regions from CLI flags + if optC64 { + comp.CmdlineIORegions = append(comp.CmdlineIORegions, optimizer.IORegion{Start: 0xD000, End: 0xDFFF}) + } + for _, raw := range optExcludes { + region, err := optimizer.ParseExcludeRange(raw) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: --opt-exclude: %v\n", err) + continue + } + comp.CmdlineIORegions = append(comp.CmdlineIORegions, region) + } registerCommands(comp) // Compile @@ -233,36 +248,56 @@ func printUsage() { fmt.Println(" -V, --version Show version") } +// excludeFlag is a repeatable flag value for --opt-exclude +type excludeFlag []string + +func (e *excludeFlag) String() string { + if len(*e) == 0 { + return "" + } + return strings.Join(*e, ", ") +} + +func (e *excludeFlag) Set(value string) error { + *e = append(*e, value) + return nil +} + func runBuildCommand(args []string) { buildCmd := flag.NewFlagSet("build", flag.ExitOnError) input := buildCmd.String("i", "", "input .c65 file (required)") output := buildCmd.String("o", "", "output .prg file (default: .prg)") keepAsm := buildCmd.Bool("keep-asm", false, "keep intermediate assembly file") noCBM := buildCmd.Bool("no-cbm", false, "don't add -f cbm flag to ACME") - + opt := buildCmd.Bool("opt", false, "enable all optimizations") + optDebug := buildCmd.Bool("opt-debug", false, "show optimization changes in output") + optC64 := buildCmd.Bool("opt-exclude-c64-io", false, "exclude C64 I/O region ($D000-$DFFF) from optimization") + var optExcludes excludeFlag + buildCmd.Var(&optExcludes, "opt-exclude", "exclude address range from optimization (repeatable, format: START:END)") + if err := buildCmd.Parse(args); err != nil { fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) os.Exit(1) } - + if *input == "" { fmt.Fprintln(os.Stderr, "Error: -i flag is required") buildCmd.Usage() os.Exit(1) } - + // Default output filename if *output == "" { base := strings.TrimSuffix(filepath.Base(*input), filepath.Ext(*input)) *output = base + ".prg" } - + // Ensure output has .prg extension if !strings.HasSuffix(strings.ToLower(*output), ".prg") { *output = *output + ".prg" } - - if err := build(*input, *output, *keepAsm, *noCBM); err != nil { + + if err := build(*input, *output, *keepAsm, *noCBM, *opt, *optDebug, *optC64, []string(optExcludes)); err != nil { handleError(err) } fmt.Println("Build successful.") @@ -272,36 +307,41 @@ func runCompileCommand(args []string) { compileCmd := flag.NewFlagSet("compile", flag.ExitOnError) input := compileCmd.String("i", "", "input .c65 file (required)") output := compileCmd.String("o", "", "output .asm file (default: .asm)") - + opt := compileCmd.Bool("opt", false, "enable all optimizations") + optDebug := compileCmd.Bool("opt-debug", false, "show optimization changes in output") + optC64 := compileCmd.Bool("opt-exclude-c64-io", false, "exclude C64 I/O region ($D000-$DFFF) from optimization") + var optExcludes excludeFlag + compileCmd.Var(&optExcludes, "opt-exclude", "exclude address range from optimization (repeatable, format: START:END)") + if err := compileCmd.Parse(args); err != nil { fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) os.Exit(1) } - + if *input == "" { fmt.Fprintln(os.Stderr, "Error: -i flag is required") compileCmd.Usage() os.Exit(1) } - + // Default output filename if *output == "" { base := strings.TrimSuffix(filepath.Base(*input), filepath.Ext(*input)) *output = base + ".asm" } - + // Ensure output has .asm extension if !strings.HasSuffix(strings.ToLower(*output), ".asm") { *output = *output + ".asm" } - - if err := compileOnly(*input, *output); err != nil { + + if err := compileOnly(*input, *output, *opt, *optDebug, *optC64, []string(optExcludes)); err != nil { handleError(err) } fmt.Println("Compilation successful.") } -func build(inputFile, outputFile string, keepAsm, noCBM bool) error { +func build(inputFile, outputFile string, keepAsm, noCBM, opt, optDebug, optC64 bool, optExcludes []string) error { // Check if ACME is available before starting compilation if err := checkACMEAvailable(); err != nil { return err @@ -312,7 +352,7 @@ func build(inputFile, outputFile string, keepAsm, noCBM bool) error { asmFile := base + ".asm" // Compile to assembly - if err := compileOnly(inputFile, asmFile); err != nil { + if err := compileOnly(inputFile, asmFile, opt, optDebug, optC64, optExcludes); err != nil { return fmt.Errorf("compilation failed: %w", err) }