From 97c976c2180c93216f1686abe50bb80d9ab3a879 Mon Sep 17 00:00:00 2001 From: Mattias Hansson Date: Fri, 21 Aug 2026 21:42:55 +0200 Subject: [PATCH] Improved peephole optimizer. Sending an output asm format to optimizer with kept info about asm source (generated, script or asm block) --- README.md | 2 +- internal/compiler/compiler.go | 119 +++++--- internal/compiler/funchandler.go | 68 ++--- internal/compiler/funchandler_test.go | 28 +- internal/optimizer/optimizer.go | 5 +- internal/optimizer/optimizer_test.go | 400 +++++++++++++++++++++----- internal/optimizer/pass_load.go | 6 +- internal/optimizer/pass_stld.go | 5 +- internal/optimizer/pass_transfer.go | 67 +++++ internal/optimizer/scanner.go | 55 +++- 10 files changed, 576 insertions(+), 179 deletions(-) create mode 100644 internal/optimizer/pass_transfer.go diff --git a/README.md b/README.md index 14db33a..ff9df30 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ The optimizer never removes **reads or stores** to addresses marked in the I/O e 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. +Variable names (like `vic2`, `BORDER_COLOR`) are NOT checked against the I/O map — only literal numeric addresses (hex `$D020` or decimal `53280`) 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 diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 4b08ac7..08cb8fd 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -14,11 +14,11 @@ import ( type Compiler struct { ctx *CompilerContext registry *CommandRegistry - deferredAsm []string // ASM blocks with _P_ASM_AFTER_VARS pragma - dissolvedVars map[string]bool // REGISTER vars dissolved by optimizer - CmdlineOpt bool // --opt enables all passes - CmdlineDebug bool // --opt-debug enables debug output - CmdlineIORegions []optimizer.IORegion // --opt-exclude ranges + deferredAsm []optimizer.SourceLine // ASM blocks with _P_ASM_AFTER_VARS pragma + dissolvedVars map[string]bool // REGISTER vars dissolved by optimizer + 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 @@ -39,9 +39,51 @@ func (c *Compiler) Registry() *CommandRegistry { return c.registry } +// generatedLine tags a line as compiler-generated (optimizable). +func generatedLine(text string) optimizer.SourceLine { + return optimizer.SourceLine{Text: text, Origin: optimizer.OriginGenerated} +} + +// generatedLines tags a batch of compiler-generated lines. +func generatedLines(lines []string) []optimizer.SourceLine { + out := make([]optimizer.SourceLine, len(lines)) + for i, l := range lines { + out[i] = generatedLine(l) + } + return out +} + +// asmSourceLine tags a line as verbatim ASM block content. +func asmSourceLine(text string) optimizer.SourceLine { + return optimizer.SourceLine{Text: text, Origin: optimizer.OriginAsm} +} + +// scriptSourceLine tags a line as verbatim SCRIPT print() output. +func scriptSourceLine(text string) optimizer.SourceLine { + return optimizer.SourceLine{Text: text, Origin: optimizer.OriginScript} +} + +// scriptSourceLines tags a batch of SCRIPT print() output lines. +func scriptSourceLines(lines []string) []optimizer.SourceLine { + out := make([]optimizer.SourceLine, len(lines)) + for i, l := range lines { + out[i] = scriptSourceLine(l) + } + return out +} + +// asmSourceLines tags a batch of verbatim ASM block lines. +func asmSourceLines(lines []string) []optimizer.SourceLine { + out := make([]optimizer.SourceLine, len(lines)) + for i, l := range lines { + out[i] = asmSourceLine(l) + } + return out +} + // Compile processes preprocessed lines and generates assembly output func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { - var codeOutput []string + var codeOutput []optimizer.SourceLine var lastKind = preproc.Source var scriptBuffer []preproc.Line var scriptIsLibrary bool @@ -50,7 +92,7 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { var currentMacroParams []string var currentMacroSourceFile string var currentMacroStartLine int - var currentAsmTarget *[]string // nil = no active ASM block, or points to target slice + var currentAsmTarget *[]optimizer.SourceLine // nil = no active ASM block, or points to target slice // Reset deferred ASM storage for this compilation c.deferredAsm = nil @@ -64,12 +106,12 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { if err != nil { return nil, fmt.Errorf("script execution failed: %w", err) } - codeOutput = append(codeOutput, scriptOutput...) + codeOutput = append(codeOutput, scriptSourceLines(scriptOutput)...) scriptBuffer = nil if scriptIsLibrary { - codeOutput = append(codeOutput, "; ENDSCRIPT LIBRARY") + codeOutput = append(codeOutput, generatedLine("; ENDSCRIPT LIBRARY")) } else { - codeOutput = append(codeOutput, "; ENDSCRIPT") + codeOutput = append(codeOutput, generatedLine("; ENDSCRIPT")) } } @@ -83,7 +125,7 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { SourceFile: currentMacroSourceFile, StartLine: currentMacroStartLine, } - codeOutput = append(codeOutput, fmt.Sprintf("; ENDSCRIPT MACRO %s", currentMacroName)) + codeOutput = append(codeOutput, generatedLine(fmt.Sprintf("; ENDSCRIPT MACRO %s", currentMacroName))) } macroBuffer = nil currentMacroName = "" @@ -94,7 +136,7 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { // Close previous Assembler block if lastKind == preproc.Assembler && currentAsmTarget != nil { - *currentAsmTarget = append(*currentAsmTarget, "; ENDASM") + *currentAsmTarget = append(*currentAsmTarget, asmSourceLine("; ENDASM")) currentAsmTarget = nil } @@ -102,25 +144,25 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { if line.Kind == preproc.Assembler { // Check if ASM block should be deferred to end pragmaSet := c.ctx.Pragma.GetPragmaSetByIndex(line.PragmaSetIndex) - asmAfterVars := pragmaSet.GetPragma("_P_ASM_AFTER_VARS") != "" && - pragmaSet.GetPragma("_P_ASM_AFTER_VARS") != "0" - + asmAfterVars := pragmaSet.GetPragma("_P_ASM_AFTER_VARS") != "" && + pragmaSet.GetPragma("_P_ASM_AFTER_VARS") != "0" + if asmAfterVars { // Add inline comment and defer ASM block - codeOutput = append(codeOutput, "; ASM block deferred to end of source") - c.deferredAsm = append(c.deferredAsm, - fmt.Sprintf("; ASM Block from %s, Line %d", line.Filename, line.LineNo)) + codeOutput = append(codeOutput, generatedLine("; ASM block deferred to end of source")) + c.deferredAsm = append(c.deferredAsm, + asmSourceLine(fmt.Sprintf("; ASM Block from %s, Line %d", line.Filename, line.LineNo))) currentAsmTarget = &c.deferredAsm } else { // Normal ASM block - codeOutput = append(codeOutput, "; ASM") + codeOutput = append(codeOutput, generatedLine("; ASM")) currentAsmTarget = &codeOutput } } else if line.Kind == preproc.Script { - codeOutput = append(codeOutput, "; SCRIPT") + codeOutput = append(codeOutput, generatedLine("; SCRIPT")) scriptIsLibrary = false } else if line.Kind == preproc.ScriptLibrary { - codeOutput = append(codeOutput, "; SCRIPT LIBRARY") + codeOutput = append(codeOutput, generatedLine("; SCRIPT LIBRARY")) scriptIsLibrary = true } else if line.Kind == preproc.ScriptMacroDef { // First line is the header - parse it @@ -131,7 +173,7 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { } currentMacroName = name currentMacroParams = params - codeOutput = append(codeOutput, fmt.Sprintf("; %s", line.Text)) + codeOutput = append(codeOutput, generatedLine(fmt.Sprintf("; %s", line.Text))) } lastKind = line.Kind @@ -145,7 +187,7 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { c.printErrorWithContext(lines, i, fmt.Errorf("internal error: ASM line without active ASM block")) return nil, fmt.Errorf("compilation failed") } - + text := line.Text // Find comment boundary - only process |...| patterns in the code portion @@ -177,9 +219,9 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { } // Emit with comments showing invocation - *currentAsmTarget = append(*currentAsmTarget, fmt.Sprintf("; %s", text)) - *currentAsmTarget = append(*currentAsmTarget, macroOutput...) - *currentAsmTarget = append(*currentAsmTarget, fmt.Sprintf("; end @%s", macroName)) + *currentAsmTarget = append(*currentAsmTarget, asmSourceLine(fmt.Sprintf("; %s", text))) + *currentAsmTarget = append(*currentAsmTarget, asmSourceLines(macroOutput)...) + *currentAsmTarget = append(*currentAsmTarget, asmSourceLine(fmt.Sprintf("; end @%s", macroName))) continue } } @@ -208,7 +250,7 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { // Continue searching after the replacement searchFrom = start + len(expandedName) } - *currentAsmTarget = append(*currentAsmTarget, codePart+commentPart) + *currentAsmTarget = append(*currentAsmTarget, asmSourceLine(codePart+commentPart)) } else if line.Kind == preproc.Script || line.Kind == preproc.ScriptLibrary { // Collect script lines for execution scriptBuffer = append(scriptBuffer, line) @@ -253,18 +295,18 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { return nil, fmt.Errorf("compilation failed") } - codeOutput = append(codeOutput, fmt.Sprintf("; %s", line.Text)) + codeOutput = append(codeOutput, generatedLine(fmt.Sprintf("; %s", line.Text))) if len(asmLines) > 0 && c.isMarkersEnabled() { - codeOutput = append(codeOutput, fmt.Sprintf("; @@OPT:%s:%s", classString(cmd.GetClass()), cmd.GetName())) + codeOutput = append(codeOutput, generatedLine(fmt.Sprintf("; @@OPT:%s:%s", classString(cmd.GetClass()), cmd.GetName()))) } - codeOutput = append(codeOutput, asmLines...) + codeOutput = append(codeOutput, generatedLines(asmLines)...) } // Close any open block if lastKind == preproc.Assembler { // Close the final ASM block if still open if currentAsmTarget != nil { - *currentAsmTarget = append(*currentAsmTarget, "; ENDASM") + *currentAsmTarget = append(*currentAsmTarget, asmSourceLine("; ENDASM")) } return nil, fmt.Errorf("Unclosed ASM block.") } else if lastKind == preproc.Script { @@ -276,10 +318,13 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { } // Peephole optimization pass + var codeStrings []string if cfg := c.getOptimizerConfig(); cfg != nil { var dissolved map[string]bool - codeOutput, dissolved = optimizer.Optimize(codeOutput, cfg) + codeStrings, dissolved = optimizer.Optimize(codeOutput, cfg) c.dissolvedVars = dissolved + } else { + codeStrings = optimizer.SourceLineTexts(codeOutput) } // Analyze for overlapping absolute addresses in function call chains @@ -287,7 +332,7 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { // Get functions with _P_REMOVE_UNUSED pragma (for suppressing variable warnings) funcsWithRemovePragma := c.ctx.FunctionHandler.GetFunctionsWithRemovePragma() - + // Check for unused variables and print warnings (skip variables in functions with remove pragma) warnings := c.ctx.SymbolTable.CheckUnused(funcsWithRemovePragma, c.dissolvedVars) for _, warning := range warnings { @@ -301,13 +346,13 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { } // Remove unused functions with _P_REMOVE_UNUSED pragma - codeOutput, removedFuncs := c.removeUnusedFunctions(codeOutput) + codeStrings, removedFuncs := c.removeUnusedFunctions(codeStrings) // Update peephole header to match actual [removed] count after function removal - codeOutput = updatePeepholeHeader(codeOutput) + codeStrings = updatePeepholeHeader(codeStrings) // Assemble final output with headers and footers - return c.assembleOutput(codeOutput, removedFuncs), nil + return c.assembleOutput(codeStrings, removedFuncs), nil } // isOptimizing returns true if any peephole optimization pragma is active @@ -757,7 +802,7 @@ func (c *Compiler) assembleOutput(codeLines []string, removedFuncs map[string]bo if len(c.deferredAsm) > 0 { output = append(output, "; Deferred ASM blocks (after variables)") output = append(output, "") - output = append(output, c.deferredAsm...) + output = append(output, optimizer.SourceLineTexts(c.deferredAsm)...) output = append(output, "") } diff --git a/internal/compiler/funchandler.go b/internal/compiler/funchandler.go index 76695d9..e9c7cee 100644 --- a/internal/compiler/funchandler.go +++ b/internal/compiler/funchandler.go @@ -52,7 +52,7 @@ type FunctionHandler struct { // Absolute address tracking for overlap detection absoluteAddrs map[string]map[uint16]bool // funcName -> set of absolute addresses used callGraph map[string][]string // funcName -> list of functions it calls - + // Function usage tracking for unused function warnings calledFunctions map[string]bool // funcName -> true if function is called @@ -403,7 +403,7 @@ func (fh *FunctionHandler) HandleFuncCall(line preproc.Line) ([]string, error) { // Generate final assembly asmLines = append(asmLines, inAssigns...) - asmLines = append(asmLines, fmt.Sprintf(" jsr %s", funcName)) + asmLines = append(asmLines, fmt.Sprintf("\tjsr %s", funcName)) asmLines = append(asmLines, outAssigns...) return asmLines, nil @@ -451,10 +451,10 @@ func (fh *FunctionHandler) processLabelArg(arg string, param *FuncParam, funcNam } *inAssigns = append(*inAssigns, - fmt.Sprintf(" lda #<%s", labelName), - fmt.Sprintf(" sta %s", param.Symbol.FullName()), - fmt.Sprintf(" lda #>%s", labelName), - fmt.Sprintf(" sta %s+1", param.Symbol.FullName()), + fmt.Sprintf("\tlda #<%s", labelName), + fmt.Sprintf("\tsta %s", param.Symbol.FullName()), + fmt.Sprintf("\tlda #>%s", labelName), + fmt.Sprintf("\tsta %s+1", param.Symbol.FullName()), ) return nil @@ -475,10 +475,10 @@ func (fh *FunctionHandler) processStringArg(arg string, param *FuncParam, funcNa actualLabel := fh.constStrHandler.AddConstStr(labelName, arg, true, pragmaSet) *inAssigns = append(*inAssigns, - fmt.Sprintf(" lda #<%s", actualLabel), - fmt.Sprintf(" sta %s", param.Symbol.FullName()), - fmt.Sprintf(" lda #>%s", actualLabel), - fmt.Sprintf(" sta %s+1", param.Symbol.FullName()), + fmt.Sprintf("\tlda #<%s", actualLabel), + fmt.Sprintf("\tsta %s", param.Symbol.FullName()), + fmt.Sprintf("\tlda #>%s", actualLabel), + fmt.Sprintf("\tsta %s+1", param.Symbol.FullName()), ) return nil @@ -492,20 +492,20 @@ func (fh *FunctionHandler) processVarArg(sym *Symbol, param *FuncParam, funcName // Generate IN assignments (sym -> param) if param.Direction.Has(DirIn) { *inAssigns = append(*inAssigns, - fmt.Sprintf(" lda %s", sym.FullName()), - fmt.Sprintf(" sta %s", param.Symbol.FullName()), + fmt.Sprintf("\tlda %s", sym.FullName()), + fmt.Sprintf("\tsta %s", param.Symbol.FullName()), ) if param.Symbol.IsWord() { if sym.IsWord() { *inAssigns = append(*inAssigns, - fmt.Sprintf(" lda %s+1", sym.FullName()), - fmt.Sprintf(" sta %s+1", param.Symbol.FullName()), + fmt.Sprintf("\tlda %s+1", sym.FullName()), + fmt.Sprintf("\tsta %s+1", param.Symbol.FullName()), ) } else { // byte -> word: zero extend *inAssigns = append(*inAssigns, - " lda #0", - fmt.Sprintf(" sta %s+1", param.Symbol.FullName()), + "\tlda #0", + fmt.Sprintf("\tsta %s+1", param.Symbol.FullName()), ) } } else if sym.IsWord() { @@ -518,20 +518,20 @@ func (fh *FunctionHandler) processVarArg(sym *Symbol, param *FuncParam, funcName // Generate OUT assignments (param -> sym) if param.Direction.Has(DirOut) { *outAssigns = append(*outAssigns, - fmt.Sprintf(" lda %s", param.Symbol.FullName()), - fmt.Sprintf(" sta %s", sym.FullName()), + fmt.Sprintf("\tlda %s", param.Symbol.FullName()), + fmt.Sprintf("\tsta %s", sym.FullName()), ) if sym.IsWord() { if param.Symbol.IsWord() { *outAssigns = append(*outAssigns, - fmt.Sprintf(" lda %s+1", param.Symbol.FullName()), - fmt.Sprintf(" sta %s+1", sym.FullName()), + fmt.Sprintf("\tlda %s+1", param.Symbol.FullName()), + fmt.Sprintf("\tsta %s+1", sym.FullName()), ) } else { // byte -> word: zero extend *outAssigns = append(*outAssigns, - " lda #0", - fmt.Sprintf(" sta %s+1", sym.FullName()), + "\tlda #0", + fmt.Sprintf("\tsta %s+1", sym.FullName()), ) } } else if param.Symbol.IsWord() { @@ -576,16 +576,16 @@ func (fh *FunctionHandler) processConstArg(arg string, param *FuncParam, funcNam highByte := uint8((value >> 8) & 0xFF) *inAssigns = append(*inAssigns, - fmt.Sprintf(" lda #%d", lowByte), - fmt.Sprintf(" sta %s", param.Symbol.FullName()), + fmt.Sprintf("\tlda #%d", lowByte), + fmt.Sprintf("\tsta %s", param.Symbol.FullName()), ) if param.Symbol.IsWord() { // Optimize: only reload A if high byte differs if highByte != lowByte { - *inAssigns = append(*inAssigns, fmt.Sprintf(" lda #%d", highByte)) + *inAssigns = append(*inAssigns, fmt.Sprintf("\tlda #%d", highByte)) } - *inAssigns = append(*inAssigns, fmt.Sprintf(" sta %s+1", param.Symbol.FullName())) + *inAssigns = append(*inAssigns, fmt.Sprintf("\tsta %s+1", param.Symbol.FullName())) } return nil @@ -604,16 +604,16 @@ func (fh *FunctionHandler) processConstValue(value uint16, param *FuncParam, fun highByte := uint8((value >> 8) & 0xFF) *inAssigns = append(*inAssigns, - fmt.Sprintf(" lda #%d", lowByte), - fmt.Sprintf(" sta %s", param.Symbol.FullName()), + fmt.Sprintf("\tlda #%d", lowByte), + fmt.Sprintf("\tsta %s", param.Symbol.FullName()), ) if param.Symbol.IsWord() { // Optimize: only reload A if high byte differs if highByte != lowByte { - *inAssigns = append(*inAssigns, fmt.Sprintf(" lda #%d", highByte)) + *inAssigns = append(*inAssigns, fmt.Sprintf("\tlda #%d", highByte)) } - *inAssigns = append(*inAssigns, fmt.Sprintf(" sta %s+1", param.Symbol.FullName())) + *inAssigns = append(*inAssigns, fmt.Sprintf("\tsta %s+1", param.Symbol.FullName())) } return nil @@ -917,10 +917,10 @@ func parseParamSpec(spec string) (ParamDirection, string, bool, string, error) { // AbsoluteOverlap represents a detected overlap in absolute addresses type AbsoluteOverlap struct { - Func1 string // First function using the address - Func2 string // Second function using the address - Address uint16 // Overlapping address - CallChain []string // Call chain from Func1 to Func2 + Func1 string // First function using the address + Func2 string // Second function using the address + Address uint16 // Overlapping address + CallChain []string // Call chain from Func1 to Func2 } // AnalyzeAbsoluteOverlaps checks for overlapping absolute addresses in call chains diff --git a/internal/compiler/funchandler_test.go b/internal/compiler/funchandler_test.go index af2babe..a832eea 100644 --- a/internal/compiler/funchandler_test.go +++ b/internal/compiler/funchandler_test.go @@ -399,13 +399,13 @@ func TestHandleFuncCall_VarArgs(t *testing.T) { // Check generated assembly expectedLines := []string{ - " lda var_a", - " sta test_func_param_a", - " lda var_b", - " sta test_func_param_b", - " lda var_b+1", - " sta test_func_param_b+1", - " jsr test_func", + "\tlda var_a", + "\tsta test_func_param_a", + "\tlda var_b", + "\tsta test_func_param_b", + "\tlda var_b+1", + "\tsta test_func_param_b+1", + "\tjsr test_func", } if len(asm) != len(expectedLines) { @@ -928,13 +928,13 @@ func TestHandleFuncCall_AbsoluteParams(t *testing.T) { // Check generated assembly uses correct names expectedLines := []string{ - " lda var_a", - " sta test_abs_param_a", - " lda var_b", - " sta test_abs_param_b", - " lda var_b+1", - " sta test_abs_param_b+1", - " jsr test_abs", + "\tlda var_a", + "\tsta test_abs_param_a", + "\tlda var_b", + "\tsta test_abs_param_b", + "\tlda var_b+1", + "\tsta test_abs_param_b+1", + "\tjsr test_abs", } if len(asm) != len(expectedLines) { diff --git a/internal/optimizer/optimizer.go b/internal/optimizer/optimizer.go index 7fe04dc..ce18307 100644 --- a/internal/optimizer/optimizer.go +++ b/internal/optimizer/optimizer.go @@ -7,11 +7,11 @@ import "strings" // @@OPT markers are stripped from output. // Returns optimized lines and the set of dissolved REGISTER variable names // (variables that had all their stores/loads eliminated). -func Optimize(lines []string, cfg *Config) ([]string, map[string]bool) { +func Optimize(lines []SourceLine, cfg *Config) ([]string, map[string]bool) { dissolved := map[string]bool{} if cfg == nil || !cfg.Any() { - return lines, dissolved + return SourceLineTexts(lines), dissolved } parsed := parseLines(lines) @@ -19,6 +19,7 @@ func Optimize(lines []string, cfg *Config) ([]string, map[string]bool) { if cfg.EnableStoreLoad { parsed = passStoreReload(parsed, cfg) + parsed = passStoreTransfer(parsed, cfg) } if cfg.EnableLoad { parsed = passLoadElimination(parsed, cfg) diff --git a/internal/optimizer/optimizer_test.go b/internal/optimizer/optimizer_test.go index c62e542..212050a 100644 --- a/internal/optimizer/optimizer_test.go +++ b/internal/optimizer/optimizer_test.go @@ -5,12 +5,26 @@ import ( "testing" ) -func lines(s ...string) []string { return s } +func lines(s ...string) []SourceLine { + out := make([]SourceLine, len(s)) + for i, t := range s { + out[i] = SourceLine{Text: t, Origin: OriginGenerated} + } + return out +} + +func verbatimLines(s ...string) []SourceLine { + out := make([]SourceLine, len(s)) + for i, t := range s { + out[i] = SourceLine{Text: t, Origin: OriginAsm} + } + return out +} func TestPassLoadElimination(t *testing.T) { tests := []struct { name string - input []string + input []SourceLine expected int // expected number of lines after optimization }{ { @@ -60,7 +74,7 @@ func TestPassLoadElimination(t *testing.T) { func TestPassImmElimination(t *testing.T) { tests := []struct { name string - input []string + input []SourceLine expected int }{ { @@ -102,7 +116,7 @@ func TestPassImmElimination(t *testing.T) { func TestPassJmpNext(t *testing.T) { tests := []struct { name string - input []string + input []SourceLine expected int }{ { @@ -137,7 +151,7 @@ func TestPassJmpNext(t *testing.T) { func TestPassSelfAssignment(t *testing.T) { tests := []struct { name string - input []string + input []SourceLine expected int }{ { @@ -196,6 +210,15 @@ func TestPassLoadIO(t *testing.T) { } }) + t.Run("decimal IO load not eliminated", func(t *testing.T) { + parsed := parseLines(lines("\tlda 53266", "\tlda 53266")) + result := passLoadElimination(parsed, cfg) + cleaned := stripOptMarkers(result) + if len(cleaned) != 2 { + t.Errorf("expected 2 lines (decimal IO skip), 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) @@ -241,6 +264,25 @@ func TestOptimizeIntegration(t *testing.T) { } } +func TestOptimizeStoreTransferIntegration(t *testing.T) { + input := lines( + "\tlda #$05", + "\tsta x", + "\tldy x", + "\tlda (zp),y", + ) + + cfg := &Config{EnableStoreLoad: true} + output, _ := Optimize(input, cfg) + + if len(output) != 4 { + t.Fatalf("expected 4 lines, got %d:\n%v", len(output), output) + } + if output[2] != "\ttay" { + t.Errorf("expected ldy x to become tay via Optimize, got %q", output[2]) + } +} + func TestOptimizeWithDebug(t *testing.T) { input := lines( "\tlda x", @@ -289,7 +331,7 @@ func TestPassStoreReload(t *testing.T) { tests := []struct { name string - input []string + input []SourceLine expected int }{ { @@ -447,13 +489,12 @@ func TestPassStoreReloadIO(t *testing.T) { 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 + // Decimal 53280 = $D020, now caught by IOMap regardless of base 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)) + if len(cleaned) != 2 { + t.Errorf("expected 2 lines (decimal I/O protected), got %d", len(cleaned)) } }) @@ -497,8 +538,8 @@ func TestPassStoreReloadIO(t *testing.T) { {Start: 0xD000, End: 0xDFFF}, {Start: 0xDC00, End: 0xDC0F}, }) - tests := []struct{ - addr string + tests := []struct { + addr string expect int }{ {"$D020", 2}, @@ -508,7 +549,7 @@ func TestPassStoreReloadIO(t *testing.T) { {"$E000", 1}, } for _, tt := range tests { - parsed := parseLines(lines("\tsta " + tt.addr, "\tlda " + tt.addr)) + parsed := parseLines(lines("\tsta "+tt.addr, "\tlda "+tt.addr)) result := passStoreReload(parsed, cfg) cleaned := stripOptMarkers(result) if len(cleaned) != tt.expect { @@ -518,12 +559,117 @@ func TestPassStoreReloadIO(t *testing.T) { }) } +func TestPassStoreTransfer(t *testing.T) { + cfg := &Config{} + + t.Run("sta x then ldy x becomes tay", func(t *testing.T) { + parsed := parseLines(lines("\tsta x", "\tldy x")) + result := passStoreTransfer(parsed, cfg) + out := linesToString(stripOptMarkers(result)) + if len(out) != 2 || out[0] != "\tsta x" || out[1] != "\ttay" { + t.Errorf("got %v", out) + } + }) + + t.Run("sta x then ldx x becomes tax", func(t *testing.T) { + parsed := parseLines(lines("\tsta x", "\tldx x")) + result := passStoreTransfer(parsed, cfg) + out := linesToString(stripOptMarkers(result)) + if len(out) != 2 || out[0] != "\tsta x" || out[1] != "\ttax" { + t.Errorf("got %v", out) + } + }) + + t.Run("comment between is skipped", func(t *testing.T) { + parsed := parseLines(lines("\tsta x", "; source", "\tldy x")) + result := passStoreTransfer(parsed, cfg) + out := linesToString(stripOptMarkers(result)) + if len(out) != 3 || out[2] != "\ttay" { + t.Errorf("got %v", out) + } + }) + + t.Run("label between blocks transfer", func(t *testing.T) { + parsed := parseLines(lines("\tsta x", "label", "\tldy x")) + result := passStoreTransfer(parsed, cfg) + out := linesToString(stripOptMarkers(result)) + if len(out) != 3 || out[2] != "\tldy x" { + t.Errorf("got %v", out) + } + }) + + t.Run("IO address not transferred", func(t *testing.T) { + ioCfg := &Config{} + ioCfg.IOMap[0xD020] = true + parsed := parseLines(lines("\tsta $D020", "\tldy $D020")) + result := passStoreTransfer(parsed, ioCfg) + out := linesToString(stripOptMarkers(result)) + if len(out) != 2 || out[1] != "\tldy $D020" { + t.Errorf("got %v", out) + } + }) + + t.Run("operand mismatch no transfer", func(t *testing.T) { + parsed := parseLines(lines("\tsta x", "\tldy y")) + result := passStoreTransfer(parsed, cfg) + out := linesToString(stripOptMarkers(result)) + if len(out) != 2 || out[1] != "\tldy y" { + t.Errorf("got %v", out) + } + }) + + t.Run("inline comment on load blocks transfer", func(t *testing.T) { + parsed := parseLines(lines("\tsta x", "\tldy x ; note")) + result := passStoreTransfer(parsed, cfg) + out := linesToString(stripOptMarkers(result)) + if len(out) != 2 || out[1] != "\tldy x ; note" { + t.Errorf("got %v", out) + } + }) + + t.Run("indexed store not transferred", func(t *testing.T) { + parsed := parseLines(lines("\tsta (zp),y", "\tldy (zp),y")) + result := passStoreTransfer(parsed, cfg) + out := linesToString(stripOptMarkers(result)) + if len(out) != 2 || out[1] != "\tldy (zp),y" { + t.Errorf("got %v", out) + } + }) + + t.Run("lda between blocks transfer", func(t *testing.T) { + parsed := parseLines(lines("\tsta x", "\tlda y", "\tldy x")) + result := passStoreTransfer(parsed, cfg) + out := linesToString(stripOptMarkers(result)) + if len(out) != 3 || out[2] != "\tldy x" { + t.Errorf("got %v", out) + } + }) + + t.Run("sta x then lda x not transferred", func(t *testing.T) { + parsed := parseLines(lines("\tsta x", "\tlda x")) + result := passStoreTransfer(parsed, cfg) + out := linesToString(stripOptMarkers(result)) + if len(out) != 2 || out[1] != "\tlda x" { + t.Errorf("got %v", out) + } + }) + + t.Run("sta as last line no transfer", func(t *testing.T) { + parsed := parseLines(lines("\tlda #1", "\tsta x")) + result := passStoreTransfer(parsed, cfg) + out := linesToString(stripOptMarkers(result)) + if len(out) != 2 || out[1] != "\tsta x" { + t.Errorf("got %v", out) + } + }) +} + func TestPassRegDead_DeadStore(t *testing.T) { - input := []string{ + input := lines( "\tlda #$05", "\tsta myFunc_temp", "\tsta $d020", - } + ) cfg := &Config{ EnableRegisterVars: true, @@ -545,12 +691,12 @@ func TestPassRegDead_DeadStore(t *testing.T) { } func TestPassRegDead_KeptStoreWithLoad(t *testing.T) { - input := []string{ + input := lines( "\tlda #$05", "\tsta myFunc_temp", "\tlda myFunc_temp", "\tsta $d020", - } + ) cfg := &Config{ EnableRegisterVars: true, @@ -573,12 +719,12 @@ func TestPassRegDead_KeptStoreWithLoad(t *testing.T) { func TestPassRegDead_KeptStoreAtLabel(t *testing.T) { // sta regVar before a label — no lda regVar anywhere → globally dead - input := []string{ + input := lines( "\tlda #$05", "\tsta myFunc_temp", "myskip:", "\tnop", - } + ) cfg := &Config{ EnableRegisterVars: true, @@ -600,15 +746,15 @@ func TestPassRegDead_KeptStoreAtLabel(t *testing.T) { } func TestPassRegDead_NonRegisterVarUnchanged(t *testing.T) { - input := []string{ + input := lines( "\tlda #$05", "\tsta normalVar", "\tsta $d020", - } + ) cfg := &Config{ EnableRegisterVars: true, - RegisterVars: map[string]bool{}, + RegisterVars: map[string]bool{}, } output, _ := Optimize(input, cfg) @@ -620,12 +766,12 @@ func TestPassRegDead_NonRegisterVarUnchanged(t *testing.T) { } func TestPassRegDead_DeadStoreBeforeRts(t *testing.T) { - input := []string{ + input := lines( "\tlda #$05", "\tsta myFunc_temp", "\tsta $d020", "\trts", - } + ) cfg := &Config{ EnableRegisterVars: true, @@ -648,7 +794,7 @@ func TestPassRegDead_DeadStoreBeforeRts(t *testing.T) { func TestPassRegDead_StoreNeededAfterAClobber(t *testing.T) { // sta regVar; lda other → A clobbered, but regVar is reloaded later → keep store - input := []string{ + input := lines( "\tlda 53281", "\tsta spill_me_bg", "\tlda 53282", @@ -656,7 +802,7 @@ func TestPassRegDead_StoreNeededAfterAClobber(t *testing.T) { "\tlda spill_me_bg", "\tsta 53282", "\trts", - } + ) cfg := &Config{ EnableRegisterVars: true, @@ -680,14 +826,14 @@ func TestPassRegDead_StoreNeededAfterAClobber(t *testing.T) { func TestPassRegDead_DeadStoreNeverReloaded(t *testing.T) { // sta → and (A clobbered) → POKE (uses A directly) → rts. Store dead. - input := []string{ + input := lines( "\tlda 56576", "\tsta dissolve_me_temp", "\tand #$fc", "\tsta dissolve_me_temp", "\tsta 56576", "\trts", - } + ) cfg := &Config{ EnableRegisterVars: true, @@ -711,12 +857,12 @@ func TestPassRegDead_DeadStoreNeverReloaded(t *testing.T) { func TestPassRegDead_GloballyDeadBeforeJsr(t *testing.T) { // sta regVar; jsr foo; no lda regVar anywhere → globally dead // jsr does not protect a store with zero readers. - input := []string{ + input := lines( "\tlda 53281", "\tsta call_val", "\tjsr helper", "\trts", - } + ) cfg := &Config{ EnableRegisterVars: true, @@ -741,14 +887,14 @@ func TestPassRegDead_JsrProtectsStoreWhenReloaded(t *testing.T) { // sta regVar; jsr foo; lda regVar → store kept // lda exists (pre-scan passes), but jsr blocks local scan before reaching it. // The callee may modify A, so the reload after jsr is genuine. - input := []string{ + input := lines( "\tlda 53281", "\tsta call_val", "\tjsr helper", "\tlda call_val", "\tsta 53280", "\trts", - } + ) cfg := &Config{ EnableRegisterVars: true, @@ -771,13 +917,13 @@ func TestPassRegDead_JsrProtectsStoreWhenReloaded(t *testing.T) { func TestPassRegDead_InitValueFlowsThrough(t *testing.T) { // BYTE REGISTER x = 42; POKE $d020, x → value flows through A, never touches RAM - input := []string{ + input := lines( "\tlda #$2a", "\tsta test_x", "\tlda test_x", "\tsta 53280", "\trts", - } + ) cfg := &Config{ EnableRegisterVars: true, @@ -805,7 +951,7 @@ func TestPassRegDead_InitValueFlowsThrough(t *testing.T) { func TestPassRegDead_GloballyDeadBeforeLabel(t *testing.T) { // sta regVar before a label, no lda regVar anywhere → globally dead // Typical copy-loop pattern: the value flows through A, never reloaded. - input := []string{ + input := lines( "_LOOPSTART", "\tlda (src),y", "\tsta loop_val", @@ -814,7 +960,7 @@ func TestPassRegDead_GloballyDeadBeforeLabel(t *testing.T) { "_SKIP:", "\tjmp _LOOPSTART", "\trts", - } + ) cfg := &Config{ EnableRegisterVars: true, @@ -838,7 +984,7 @@ func TestPassRegDead_GloballyDeadBeforeLabel(t *testing.T) { func TestPassRegDead_NotGloballyDeadWithLda(t *testing.T) { // sta regVar before a label, but lda regVar exists elsewhere → not globally dead // Falls through to local scan. With jsr before the lda, store is kept. - input := []string{ + input := lines( "\tsta spill_me_bg", "\tlda 53282", "_SKIP:", @@ -846,7 +992,7 @@ func TestPassRegDead_NotGloballyDeadWithLda(t *testing.T) { "\tlda spill_me_bg", "\tsta 53282", "\trts", - } + ) cfg := &Config{ EnableRegisterVars: true, @@ -869,181 +1015,181 @@ func TestPassRegDead_NotGloballyDeadWithLda(t *testing.T) { func TestPassRegDead_ReadModifyWrite(t *testing.T) { tests := []struct { - name string - opcode string - lines []string + name string + opcode string + lines []SourceLine }{ { name: "dec reads stored value", opcode: "dec", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tdec rmw_var", "\trts", - }, + ), }, { name: "inc reads stored value", opcode: "inc", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tinc rmw_var", "\trts", - }, + ), }, { name: "adc reads stored value", opcode: "adc", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tclc", "\tadc rmw_var", "\trts", - }, + ), }, { name: "sbc reads stored value", opcode: "sbc", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tsec", "\tsbc rmw_var", "\trts", - }, + ), }, { name: "and reads stored value", opcode: "and", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tand rmw_var", "\trts", - }, + ), }, { name: "ora reads stored value", opcode: "ora", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tora rmw_var", "\trts", - }, + ), }, { name: "eor reads stored value", opcode: "eor", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\teor rmw_var", "\trts", - }, + ), }, { name: "cmp reads stored value", opcode: "cmp", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tcmp rmw_var", "\trts", - }, + ), }, { name: "ldx reads stored value", opcode: "ldx", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tldx rmw_var", "\trts", - }, + ), }, { name: "ldy reads stored value", opcode: "ldy", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tldy rmw_var", "\trts", - }, + ), }, { name: "asl reads stored value", opcode: "asl", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tasl rmw_var", "\trts", - }, + ), }, { name: "lsr reads stored value", opcode: "lsr", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tlsr rmw_var", "\trts", - }, + ), }, { name: "rol reads stored value", opcode: "rol", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\trol rmw_var", "\trts", - }, + ), }, { name: "ror reads stored value", opcode: "ror", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tror rmw_var", "\trts", - }, + ), }, { name: "bit reads stored value", opcode: "bit", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tbit rmw_var", "\trts", - }, + ), }, { name: "cpx reads stored value", opcode: "cpx", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tcpx rmw_var", "\trts", - }, + ), }, { name: "cpy reads stored value", opcode: "cpy", - lines: []string{ + lines: lines( "\tlda #$05", "\tsta rmw_var", "\tcpy rmw_var", "\trts", - }, + ), }, } @@ -1074,7 +1220,7 @@ func TestPassRegDead_JmpDoesNotKillStore(t *testing.T) { // sta regVar; ...(then body)...; jmp _END; _ELSE:; ...; _END:; ldy regVar // The jmp in the THEN body should NOT kill the store because // the jump target _END reaches code that reads regVar. - input := []string{ + input := lines( "\tldy #0", "\tlda (zp),y", "\tsta if_val", @@ -1092,7 +1238,7 @@ func TestPassRegDead_JmpDoesNotKillStore(t *testing.T) { "\tldy if_val", "\tlda (zp),y", "\trts", - } + ) cfg := &Config{ EnableRegisterVars: true, @@ -1112,3 +1258,103 @@ func TestPassRegDead_JmpDoesNotKillStore(t *testing.T) { t.Errorf("expected sta if_val to be kept (jmp in THEN should not kill it), got:\n%s", joined) } } + +func TestParseLinesOrigin(t *testing.T) { + t.Run("generated tab line is code", func(t *testing.T) { + parsed := parseLines([]SourceLine{{Text: "\tlda x", Origin: OriginGenerated}}) + if len(parsed) != 1 || !parsed[0].isCode { + t.Errorf("generated tab line should be code, got %+v", parsed) + } + }) + + t.Run("generated space line is code", func(t *testing.T) { + parsed := parseLines([]SourceLine{{Text: " lda x", Origin: OriginGenerated}}) + if len(parsed) != 1 || !parsed[0].isCode { + t.Errorf("generated space-indented line should be code, got %+v", parsed) + } + }) + + t.Run("verbatim ASM tab line is a barrier, not code", func(t *testing.T) { + parsed := parseLines([]SourceLine{{Text: "\tlda x", Origin: OriginAsm}}) + if len(parsed) != 1 || parsed[0].isCode || !parsed[0].isLabel { + t.Errorf("verbatim ASM line should be a barrier, got %+v", parsed) + } + }) + + t.Run("verbatim SCRIPT tab line is a barrier", func(t *testing.T) { + parsed := parseLines([]SourceLine{{Text: "\tsta $d020", Origin: OriginScript}}) + if len(parsed) != 1 || parsed[0].isCode || !parsed[0].isLabel { + t.Errorf("verbatim SCRIPT line should be a barrier, got %+v", parsed) + } + }) + + t.Run("verbatim ASM comment stays a comment", func(t *testing.T) { + parsed := parseLines([]SourceLine{{Text: "; note", Origin: OriginAsm}}) + if len(parsed) != 1 || !parsed[0].isComment { + t.Errorf("verbatim ASM comment should be a comment, got %+v", parsed) + } + }) + + t.Run("verbatim ASM label stays a barrier", func(t *testing.T) { + parsed := parseLines([]SourceLine{{Text: "h_dispatch:", Origin: OriginAsm}}) + if len(parsed) != 1 || parsed[0].isCode || !parsed[0].isLabel { + t.Errorf("verbatim ASM label should be a barrier, got %+v", parsed) + } + }) +} + +func TestOptimizeSkipsVerbatimTransfer(t *testing.T) { + // A tab-indented ASM block line must NOT be rewritten, even though it + // looks like generated code: provenance trumps indentation. + input := []SourceLine{ + {Text: "\tsta x", Origin: OriginAsm}, + {Text: "\tldy x", Origin: OriginAsm}, + } + + cfg := &Config{EnableStoreLoad: true} + output, _ := Optimize(input, cfg) + + if len(output) != 2 || output[0] != "\tsta x" || output[1] != "\tldy x" { + t.Errorf("verbatim ASM lines must not be optimized, got %v", output) + } +} + +func TestPassRegDead_OriginAffectsReadDetection(t *testing.T) { + regVars := map[string]bool{"bg": true} + + t.Run("generated read keeps store", func(t *testing.T) { + input := []SourceLine{ + {Text: "\tlda 53281", Origin: OriginGenerated}, + {Text: "\tsta bg", Origin: OriginGenerated}, + {Text: "\tlda bg", Origin: OriginGenerated}, + {Text: "\tsta 53280", Origin: OriginGenerated}, + {Text: "\trts", Origin: OriginGenerated}, + } + out, dissolved := Optimize(input, &Config{EnableRegisterVars: true, RegisterVars: regVars}) + if dissolved["bg"] { + t.Error("bg should NOT dissolve: a generated read exists") + } + if !strings.Contains(strings.Join(out, "\n"), "sta bg") { + t.Errorf("expected sta bg kept (generated read), got:\n%s", strings.Join(out, "\n")) + } + }) + + t.Run("verbatim ASM read does not keep store", func(t *testing.T) { + input := []SourceLine{ + {Text: "\tlda 53281", Origin: OriginGenerated}, + {Text: "\tsta bg", Origin: OriginGenerated}, + {Text: "\tlda bg", Origin: OriginAsm}, + {Text: "\tsta 53280", Origin: OriginGenerated}, + {Text: "\trts", Origin: OriginGenerated}, + } + out, _ := Optimize(input, &Config{EnableRegisterVars: true, RegisterVars: regVars}) + joined := strings.Join(out, "\n") + if strings.Contains(joined, "sta bg") { + t.Errorf("expected sta bg removed (verbatim ASM read is not a generated read), got:\n%s", joined) + } + // The verbatim ASM line itself must be preserved untouched. + if !strings.Contains(joined, "\tlda bg") { + t.Errorf("expected verbatim lda bg preserved, got:\n%s", joined) + } + }) +} diff --git a/internal/optimizer/pass_load.go b/internal/optimizer/pass_load.go index 243c153..1662217 100644 --- a/internal/optimizer/pass_load.go +++ b/internal/optimizer/pass_load.go @@ -76,14 +76,12 @@ 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. +// isIOAddr returns true if operand is a numeric address (hex or decimal) marked +// as I/O in the config. Symbolic names parse to -1 and are never treated as I/O. 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] } diff --git a/internal/optimizer/pass_stld.go b/internal/optimizer/pass_stld.go index 2871a02..8f160f1 100644 --- a/internal/optimizer/pass_stld.go +++ b/internal/optimizer/pass_stld.go @@ -79,8 +79,9 @@ func isSafeStldOperand(operand string, cfg *Config) bool { return false } - // Check if operand is a direct hex address in an I/O region - if strings.HasPrefix(operand, "$") { + // Check if operand is a direct numeric address (hex or decimal) in an I/O region. + // Symbolic names parse to -1 and are never treated as I/O. + if cfg != nil { addr := parseHexOrDec(operand) if addr >= 0 && addr < 65536 && cfg.IOMap[addr] { return false diff --git a/internal/optimizer/pass_transfer.go b/internal/optimizer/pass_transfer.go new file mode 100644 index 0000000..50a286d --- /dev/null +++ b/internal/optimizer/pass_transfer.go @@ -0,0 +1,67 @@ +package optimizer + +import "strings" + +// passStoreTransfer converts a store immediately followed by a reload of the +// same value into a different register, into a register-transfer instruction. +// +// sta M; ldy M → sta M; tay +// sta M; ldx M → sta M; tax +// +// A already holds M after the store, so the reload from memory is redundant. +// Only comments and @@OPT markers may separate the two instructions; a label +// or any other code line blocks the transform. +func passStoreTransfer(lines []asmLine, cfg *Config) []asmLine { + var result []asmLine + + for i := 0; i < len(lines); i++ { + line := lines[i] + + if line.isCode && line.opcode == "sta" && isSafeStldOperand(line.operand, cfg) { + if j, transfer := findTransferTarget(lines, i+1, line.operand); transfer != "" { + result = append(result, line) + for k := i + 1; k < j; k++ { + result = append(result, lines[k]) + } + result = append(result, asmLine{ + text: "\t" + transfer, + isCode: true, + opcode: transfer, + }) + i = j + continue + } + } + + result = append(result, line) + } + + return result +} + +// findTransferTarget scans forward from start, skipping comments and @@OPT +// markers, for an ldy/ldx of the given operand. Returns the index of that line +// and the transfer opcode to use, or "" if no safe match is found. +func findTransferTarget(lines []asmLine, start int, operand string) (int, string) { + i := start + for i < len(lines) && (lines[i].isComment || lines[i].optMarker) { + i++ + } + if i >= len(lines) || !lines[i].isCode { + return 0, "" + } + + next := lines[i] + if next.operand != operand || len(strings.Fields(next.text)) != 2 { + return 0, "" + } + + switch next.opcode { + case "ldy": + return i, "tay" + case "ldx": + return i, "tax" + default: + return 0, "" + } +} diff --git a/internal/optimizer/scanner.go b/internal/optimizer/scanner.go index 1217ea3..efc2c8f 100644 --- a/internal/optimizer/scanner.go +++ b/internal/optimizer/scanner.go @@ -41,9 +41,36 @@ type asmLine struct { operand string } -func parseLines(lines []string) []asmLine { +// Origin identifies the source of an output line. The optimizer must know +// whether a line was produced by the compiler itself (and is therefore safe +// to optimize) or emitted verbatim from an ASM block or SCRIPT output. +type Origin int + +const ( + OriginGenerated Origin = iota // compiler-generated code (optimizable) + OriginAsm // handwritten ASM block content (verbatim) + OriginScript // SCRIPT print() output (verbatim) +) + +// SourceLine is a single output line together with its provenance. +type SourceLine struct { + Text string + Origin Origin +} + +// SourceLineTexts extracts just the text from a slice of source lines. +func SourceLineTexts(lines []SourceLine) []string { + out := make([]string, len(lines)) + for i, l := range lines { + out[i] = l.Text + } + return out +} + +func parseLines(lines []SourceLine) []asmLine { var result []asmLine - for _, l := range lines { + for _, sl := range lines { + l := sl.Text al := asmLine{text: l} if l == "" { @@ -64,14 +91,21 @@ func parseLines(lines []string) []asmLine { continue } - if l[0] == '\t' { - al.isCode = true + // Only compiler-generated, indented lines are treated as optimizable + // instructions. ACME requires labels at column 0, so any leading + // whitespace means "not a label" — the exact character (tab or space) + // is irrelevant. Generated labels and any verbatim ASM/SCRIPT line + // become barriers. + if sl.Origin == OriginGenerated && isIndented(l) { parts := strings.Fields(l) if len(parts) > 0 { + al.isCode = true al.opcode = strings.ToLower(parts[0]) - } - if len(parts) > 1 { - al.operand = parts[1] + if len(parts) > 1 { + al.operand = parts[1] + } + } else { + al.isLabel = true } } else { al.isLabel = true @@ -82,6 +116,12 @@ func parseLines(lines []string) []asmLine { return result } +// isIndented reports whether a line has leading whitespace (an instruction), +// as opposed to a label which starts at column 0. +func isIndented(l string) bool { + return len(l) > 0 && (l[0] == ' ' || l[0] == '\t') +} + // stripOptMarkers removes @@OPT comment lines from the output func stripOptMarkers(lines []asmLine) []asmLine { var result []asmLine @@ -106,4 +146,3 @@ func linesToString(lines []asmLine) []string { func skipJmpMarker(line asmLine) bool { return line.optMarker } -