From 037d232c558ba1db24d7df62086e4ffe29a8dbb1 Mon Sep 17 00:00:00 2001 From: Mattias Hansson Date: Wed, 3 Jun 2026 21:51:29 +0200 Subject: [PATCH 1/4] First naive version of optimizer --- internal/commands/add.go | 3 + internal/commands/and.go | 3 + internal/commands/break.go | 3 + internal/commands/byte.go | 3 + internal/commands/call.go | 3 + internal/commands/case.go | 3 + internal/commands/decr.go | 3 + internal/commands/default.go | 3 + internal/commands/else.go | 3 + internal/commands/endif.go | 3 + internal/commands/endswitch.go | 3 + internal/commands/fend.go | 3 + internal/commands/for.go | 3 + internal/commands/func.go | 3 + internal/commands/gosub.go | 3 + internal/commands/goto.go | 3 + internal/commands/if.go | 3 + internal/commands/incr.go | 3 + internal/commands/label.go | 3 + internal/commands/let.go | 3 + internal/commands/macro.go | 3 + internal/commands/next.go | 3 + internal/commands/or.go | 3 + internal/commands/origin.go | 3 + internal/commands/peek.go | 11 ++ internal/commands/peekw.go | 11 ++ internal/commands/point.go | 3 + internal/commands/poke.go | 11 ++ internal/commands/pokew.go | 11 ++ internal/commands/shiftl.go | 15 +- internal/commands/shiftr.go | 10 +- internal/commands/subend.go | 3 + internal/commands/subtr.go | 3 + internal/commands/switch.go | 3 + internal/commands/wend.go | 3 + internal/commands/while.go | 3 + internal/commands/word.go | 3 + internal/commands/xor.go | 3 + internal/compiler/command.go | 15 ++ internal/compiler/compiler.go | 47 ++++++ internal/compiler/compiler_test.go | 3 + internal/optimizer/config.go | 29 ++++ internal/optimizer/debug.go | 11 ++ internal/optimizer/optimizer.go | 36 +++++ internal/optimizer/optimizer_test.go | 209 +++++++++++++++++++++++++++ internal/optimizer/pass_imm.go | 67 +++++++++ internal/optimizer/pass_jmp.go | 38 +++++ internal/optimizer/pass_load.go | 56 +++++++ internal/optimizer/pass_self.go | 38 +++++ internal/optimizer/regstate.go | 140 ++++++++++++++++++ internal/optimizer/scanner.go | 97 +++++++++++++ 51 files changed, 941 insertions(+), 10 deletions(-) create mode 100644 internal/optimizer/config.go create mode 100644 internal/optimizer/debug.go create mode 100644 internal/optimizer/optimizer.go create mode 100644 internal/optimizer/optimizer_test.go create mode 100644 internal/optimizer/pass_imm.go create mode 100644 internal/optimizer/pass_jmp.go create mode 100644 internal/optimizer/pass_load.go create mode 100644 internal/optimizer/pass_self.go create mode 100644 internal/optimizer/regstate.go create mode 100644 internal/optimizer/scanner.go diff --git a/internal/commands/add.go b/internal/commands/add.go index e824435..cf27148 100644 --- a/internal/commands/add.go +++ b/internal/commands/add.go @@ -231,3 +231,6 @@ func (c *AddCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { return asm, nil } + +func (c *AddCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *AddCommand) GetName() string { return "ADD" } diff --git a/internal/commands/and.go b/internal/commands/and.go index 6c63eab..27867f0 100644 --- a/internal/commands/and.go +++ b/internal/commands/and.go @@ -229,3 +229,6 @@ func (c *AndCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { return asm, nil } + +func (c *AndCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *AndCommand) GetName() string { return "AND" } diff --git a/internal/commands/break.go b/internal/commands/break.go index f5e8835..e0ee485 100644 --- a/internal/commands/break.go +++ b/internal/commands/break.go @@ -49,3 +49,6 @@ func (c *BreakCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { fmt.Sprintf("\tjmp %s", c.skipLabel), }, nil } + +func (c *BreakCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *BreakCommand) GetName() string { return "BREAK" } diff --git a/internal/commands/byte.go b/internal/commands/byte.go index 124232e..02c6bb6 100644 --- a/internal/commands/byte.go +++ b/internal/commands/byte.go @@ -149,3 +149,6 @@ func (c *ByteCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { // Variables are rendered by assembleOutput, not by individual commands return nil, nil } + +func (c *ByteCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *ByteCommand) GetName() string { return "BYTE" } diff --git a/internal/commands/call.go b/internal/commands/call.go index d3fb175..97b020c 100644 --- a/internal/commands/call.go +++ b/internal/commands/call.go @@ -75,3 +75,6 @@ func (c *CallCommand) Interpret(line preproc.Line, ctx *compiler.CompilerContext func (c *CallCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { return c.asmOutput, nil } + +func (c *CallCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *CallCommand) GetName() string { return "CALL" } diff --git a/internal/commands/case.go b/internal/commands/case.go index febb002..ebc555a 100644 --- a/internal/commands/case.go +++ b/internal/commands/case.go @@ -148,3 +148,6 @@ func (c *CaseCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) return asm, nil } + +func (c *CaseCommand) GetClass() compiler.CmdClass { return compiler.CmdFlowCtrl } +func (c *CaseCommand) GetName() string { return "CASE" } diff --git a/internal/commands/decr.go b/internal/commands/decr.go index 6c393f9..2c55718 100644 --- a/internal/commands/decr.go +++ b/internal/commands/decr.go @@ -139,3 +139,6 @@ func (c *DecrCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) return asm, nil } + +func (c *DecrCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *DecrCommand) GetName() string { return "DEC" } diff --git a/internal/commands/default.go b/internal/commands/default.go index 711ed78..f83f228 100644 --- a/internal/commands/default.go +++ b/internal/commands/default.go @@ -76,3 +76,6 @@ func (c *DefaultCommand) Generate(ctx *compiler.CompilerContext) ([]string, erro return asm, nil } + +func (c *DefaultCommand) GetClass() compiler.CmdClass { return compiler.CmdFlowCtrl } +func (c *DefaultCommand) GetName() string { return "DEFAULT" } diff --git a/internal/commands/else.go b/internal/commands/else.go index e920a11..cb8a623 100644 --- a/internal/commands/else.go +++ b/internal/commands/else.go @@ -54,3 +54,6 @@ func (c *ElseCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { c.skipLabel, }, nil } + +func (c *ElseCommand) GetClass() compiler.CmdClass { return compiler.CmdFlowCtrl } +func (c *ElseCommand) GetName() string { return "ELSE" } diff --git a/internal/commands/endif.go b/internal/commands/endif.go index b197077..c0231eb 100644 --- a/internal/commands/endif.go +++ b/internal/commands/endif.go @@ -47,3 +47,6 @@ func (c *EndIfCommand) Interpret(line preproc.Line, ctx *compiler.CompilerContex func (c *EndIfCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { return []string{c.endLabel}, nil } + +func (c *EndIfCommand) GetClass() compiler.CmdClass { return compiler.CmdFlowCtrl } +func (c *EndIfCommand) GetName() string { return "ENDIF" } diff --git a/internal/commands/endswitch.go b/internal/commands/endswitch.go index 5265d88..b51f9b8 100644 --- a/internal/commands/endswitch.go +++ b/internal/commands/endswitch.go @@ -58,3 +58,6 @@ func (c *EndSwitchCommand) Generate(ctx *compiler.CompilerContext) ([]string, er return asm, nil } + +func (c *EndSwitchCommand) GetClass() compiler.CmdClass { return compiler.CmdFlowCtrl } +func (c *EndSwitchCommand) GetName() string { return "ENDSWITCH" } diff --git a/internal/commands/fend.go b/internal/commands/fend.go index 9bebb47..115629a 100644 --- a/internal/commands/fend.go +++ b/internal/commands/fend.go @@ -47,3 +47,6 @@ func (c *FendCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) } return lines, nil } + +func (c *FendCommand) GetClass() compiler.CmdClass { return compiler.CmdBoundary } +func (c *FendCommand) GetName() string { return "FEND" } diff --git a/internal/commands/for.go b/internal/commands/for.go index 6955872..85523b1 100644 --- a/internal/commands/for.go +++ b/internal/commands/for.go @@ -243,3 +243,6 @@ func (c *ForCommand) generateAssignment() []string { return asm } + +func (c *ForCommand) GetClass() compiler.CmdClass { return compiler.CmdFlowCtrl } +func (c *ForCommand) GetName() string { return "FOR" } diff --git a/internal/commands/func.go b/internal/commands/func.go index 0733efb..7091d77 100644 --- a/internal/commands/func.go +++ b/internal/commands/func.go @@ -48,3 +48,6 @@ func (c *FuncCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { marker := fmt.Sprintf("; @@FUNC_BEGIN %s", c.funcName) return []string{marker, c.funcName}, nil } + +func (c *FuncCommand) GetClass() compiler.CmdClass { return compiler.CmdBoundary } +func (c *FuncCommand) GetName() string { return "FUNC" } diff --git a/internal/commands/gosub.go b/internal/commands/gosub.go index ee4833c..962f119 100644 --- a/internal/commands/gosub.go +++ b/internal/commands/gosub.go @@ -222,6 +222,9 @@ func (c *GosubCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) return asm, nil } +func (c *GosubCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *GosubCommand) GetName() string { return "GOSUB" } + func (c *GosubCommand) generatePrePassing() []string { var asm []string diff --git a/internal/commands/goto.go b/internal/commands/goto.go index dccc286..3352f58 100644 --- a/internal/commands/goto.go +++ b/internal/commands/goto.go @@ -98,3 +98,6 @@ func (c *GotoCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { // Jump to computed address return []string{fmt.Sprintf("\tjmp $%04x", c.address)}, nil } + +func (c *GotoCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *GotoCommand) GetName() string { return "GOTO" } diff --git a/internal/commands/if.go b/internal/commands/if.go index cfcb810..87ab0fb 100644 --- a/internal/commands/if.go +++ b/internal/commands/if.go @@ -119,3 +119,6 @@ func (c *IfCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) { return cmpAsm, nil } + +func (c *IfCommand) GetClass() compiler.CmdClass { return compiler.CmdFlowCtrl } +func (c *IfCommand) GetName() string { return "IF" } diff --git a/internal/commands/incr.go b/internal/commands/incr.go index 0752811..f112eb3 100644 --- a/internal/commands/incr.go +++ b/internal/commands/incr.go @@ -138,3 +138,6 @@ func (c *IncrCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) return asm, nil } + +func (c *IncrCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *IncrCommand) GetName() string { return "INC" } diff --git a/internal/commands/label.go b/internal/commands/label.go index c6f9663..f420d20 100644 --- a/internal/commands/label.go +++ b/internal/commands/label.go @@ -46,3 +46,6 @@ func (c *LabelCommand) Interpret(line preproc.Line, _ *compiler.CompilerContext) func (c *LabelCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { return []string{c.labelName}, nil } + +func (c *LabelCommand) GetClass() compiler.CmdClass { return compiler.CmdBoundary } +func (c *LabelCommand) GetName() string { return "LABEL" } diff --git a/internal/commands/let.go b/internal/commands/let.go index eaee451..9dd57c3 100644 --- a/internal/commands/let.go +++ b/internal/commands/let.go @@ -182,3 +182,6 @@ func (c *LetCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { return asm, nil } + +func (c *LetCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *LetCommand) GetName() string { return "LET" } diff --git a/internal/commands/macro.go b/internal/commands/macro.go index 5c44895..19e68fd 100644 --- a/internal/commands/macro.go +++ b/internal/commands/macro.go @@ -44,3 +44,6 @@ func (c *MacroCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) result = append(result, fmt.Sprintf("; end @%s", c.macroName)) return result, nil } + +func (c *MacroCommand) GetClass() compiler.CmdClass { return compiler.CmdBoundary } +func (c *MacroCommand) GetName() string { return "MACRO" } diff --git a/internal/commands/next.go b/internal/commands/next.go index 7442309..1ea97e9 100644 --- a/internal/commands/next.go +++ b/internal/commands/next.go @@ -74,6 +74,9 @@ func (c *NextCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) return asm, nil } +func (c *NextCommand) GetClass() compiler.CmdClass { return compiler.CmdFlowCtrl } +func (c *NextCommand) GetName() string { return "NEXT" } + // generateEndCheck generates code to exit if var == end. func (c *NextCommand) generateEndCheck(ctx *compiler.CompilerContext) []string { var asm []string diff --git a/internal/commands/or.go b/internal/commands/or.go index c94869b..a03a406 100644 --- a/internal/commands/or.go +++ b/internal/commands/or.go @@ -229,3 +229,6 @@ func (c *OrCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { return asm, nil } + +func (c *OrCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *OrCommand) GetName() string { return "OR" } diff --git a/internal/commands/origin.go b/internal/commands/origin.go index 1a069c9..29d39e7 100644 --- a/internal/commands/origin.go +++ b/internal/commands/origin.go @@ -54,3 +54,6 @@ func (c *OriginCommand) Interpret(line preproc.Line, ctx *compiler.CompilerConte func (c *OriginCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { return []string{fmt.Sprintf("*= $%04x", c.address)}, nil } + +func (c *OriginCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *OriginCommand) GetName() string { return "ORIGIN" } diff --git a/internal/commands/peek.go b/internal/commands/peek.go index 6ae3848..0024709 100644 --- a/internal/commands/peek.go +++ b/internal/commands/peek.go @@ -280,6 +280,17 @@ func (c *PeekCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) return asm, nil } +func (c *PeekCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *PeekCommand) GetName() string { + if c.isZPPointer { + return "PEEK:ZP" + } + if c.isAddrVar { + return "PEEK:SM" + } + return "PEEK" +} + // parseOffset extracts offset from syntax like "var[offset]" // Returns (base, offset) where offset is empty if no brackets found func parseOffset(param string) (base string, offset string) { diff --git a/internal/commands/peekw.go b/internal/commands/peekw.go index 35bd69e..6e43439 100644 --- a/internal/commands/peekw.go +++ b/internal/commands/peekw.go @@ -281,6 +281,17 @@ func (c *PeekWCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) return asm, nil } +func (c *PeekWCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *PeekWCommand) GetName() string { + if c.isZPPointer { + return "PEEKW:ZP" + } + if c.isAddrVar { + return "PEEKW:SM" + } + return "PEEKW" +} + // parseOffsetW extracts offset from syntax like "var[offset]" // Returns (base, offset) where offset is empty if no brackets found func parseOffsetW(param string) (base string, offset string) { diff --git a/internal/commands/point.go b/internal/commands/point.go index 3118ab9..a4e83a2 100644 --- a/internal/commands/point.go +++ b/internal/commands/point.go @@ -132,3 +132,6 @@ func (c *PointerCommand) Generate(ctx *compiler.CompilerContext) ([]string, erro return asm, nil } + +func (c *PointerCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *PointerCommand) GetName() string { return "POINT" } diff --git a/internal/commands/poke.go b/internal/commands/poke.go index 49e913e..8a7f55d 100644 --- a/internal/commands/poke.go +++ b/internal/commands/poke.go @@ -258,6 +258,17 @@ func (c *PokeCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) return asm, nil } +func (c *PokeCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *PokeCommand) GetName() string { + if c.isZPPointer { + return "POKE:ZP" + } + if c.isAddrVar { + return "POKE:SM" + } + return "POKE" +} + // parsePOKEOffset extracts offset from syntax like "var[offset]" // Returns (base, offset) where offset is empty if no brackets found func parsePOKEOffset(param string) (base string, offset string) { diff --git a/internal/commands/pokew.go b/internal/commands/pokew.go index 2767712..02dab72 100644 --- a/internal/commands/pokew.go +++ b/internal/commands/pokew.go @@ -294,6 +294,17 @@ func (c *PokeWCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) return asm, nil } +func (c *PokeWCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *PokeWCommand) GetName() string { + if c.isZPPointer { + return "POKEW:ZP" + } + if c.isAddrVar { + return "POKEW:SM" + } + return "POKEW" +} + // parsePOKEWOffset extracts offset from syntax like "var[offset]" // Returns (base, offset) where offset is empty if no brackets found func parsePOKEWOffset(param string) (base string, offset string) { diff --git a/internal/commands/shiftl.go b/internal/commands/shiftl.go index 5ed6ca5..a4a3dce 100644 --- a/internal/commands/shiftl.go +++ b/internal/commands/shiftl.go @@ -209,9 +209,9 @@ func (c *ShiftLCommand) generateByteShift(ctx *compiler.CompilerContext) ([]stri asm = append(asm, "\tlda #0") asm = append(asm, fmt.Sprintf("\tsta %s", c.destVarName)) } - return asm, nil - } - + return asm, nil +} + // Shift 0 -> copy source if amount == 0 { return c.generateByteCopy(), nil @@ -311,10 +311,10 @@ func (c *ShiftLCommand) generateByteShiftVar(ctx *compiler.CompilerContext) ([]s // Store result back asm = append(asm, fmt.Sprintf("\tsta %s", c.destVarName)) - - return asm, nil - } + return asm, nil +} + // Different variables or literal: shift in accumulator (faster) // Generate labels loopLabel := ctx.GeneralStack.Push() @@ -510,3 +510,6 @@ func (c *ShiftLCommand) generateWordShiftVar(ctx *compiler.CompilerContext) ([]s return asm, nil } +func (c *ShiftLCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *ShiftLCommand) GetName() string { return "SHL" } + diff --git a/internal/commands/shiftr.go b/internal/commands/shiftr.go index f62770c..23e3ee7 100644 --- a/internal/commands/shiftr.go +++ b/internal/commands/shiftr.go @@ -415,10 +415,10 @@ func (c *ShiftRCommand) generateWordToByteShift(ctx *compiler.CompilerContext) ( for i := uint16(0); i < remaining; i++ { asm = append(asm, fmt.Sprintf("\tlsr %s", c.destVarName)) } - - return asm, nil - } - + + return asm, nil +} + // Shift 1-7: need full WORD shift, then take low byte // We'll use the destination as a temporary for the low byte // and handle high byte in A register @@ -653,3 +653,5 @@ func (c *ShiftRCommand) generateWordShiftVar(ctx *compiler.CompilerContext) ([]s return asm, nil } +func (c *ShiftRCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *ShiftRCommand) GetName() string { return "SHR" } diff --git a/internal/commands/subend.go b/internal/commands/subend.go index 61b011c..7b41011 100644 --- a/internal/commands/subend.go +++ b/internal/commands/subend.go @@ -42,3 +42,6 @@ func (c *SubEndCommand) Interpret(line preproc.Line, _ *compiler.CompilerContext func (c *SubEndCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { return []string{"\trts"}, nil } + +func (c *SubEndCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *SubEndCommand) GetName() string { return "SUBEND" } diff --git a/internal/commands/subtr.go b/internal/commands/subtr.go index 4ed8914..163983e 100644 --- a/internal/commands/subtr.go +++ b/internal/commands/subtr.go @@ -251,3 +251,6 @@ func (c *SubtractCommand) Generate(_ *compiler.CompilerContext) ([]string, error return asm, nil } + +func (c *SubtractCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *SubtractCommand) GetName() string { return "SUB" } diff --git a/internal/commands/switch.go b/internal/commands/switch.go index 56abba4..879a546 100644 --- a/internal/commands/switch.go +++ b/internal/commands/switch.go @@ -74,3 +74,6 @@ func (c *SwitchCommand) Generate(ctx *compiler.CompilerContext) ([]string, error // The variable will be loaded and compared by each CASE return []string{}, nil } + +func (c *SwitchCommand) GetClass() compiler.CmdClass { return compiler.CmdFlowCtrl } +func (c *SwitchCommand) GetName() string { return "SWITCH" } diff --git a/internal/commands/wend.go b/internal/commands/wend.go index d4be077..8bc64a6 100644 --- a/internal/commands/wend.go +++ b/internal/commands/wend.go @@ -57,3 +57,6 @@ func (c *WendCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { c.skipLabel, }, nil } + +func (c *WendCommand) GetClass() compiler.CmdClass { return compiler.CmdFlowCtrl } +func (c *WendCommand) GetName() string { return "WEND" } diff --git a/internal/commands/while.go b/internal/commands/while.go index 5278867..50939a2 100644 --- a/internal/commands/while.go +++ b/internal/commands/while.go @@ -125,3 +125,6 @@ func (c *WhileCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) asm = append(asm, cmpAsm...) return asm, nil } + +func (c *WhileCommand) GetClass() compiler.CmdClass { return compiler.CmdFlowCtrl } +func (c *WhileCommand) GetName() string { return "WHILE" } diff --git a/internal/commands/word.go b/internal/commands/word.go index 78ac897..2ada2d7 100644 --- a/internal/commands/word.go +++ b/internal/commands/word.go @@ -204,3 +204,6 @@ func (c *WordCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { // Variables are rendered by assembleOutput, not by individual commands return nil, nil } + +func (c *WordCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *WordCommand) GetName() string { return "WORD" } diff --git a/internal/commands/xor.go b/internal/commands/xor.go index 0431c33..f925a1d 100644 --- a/internal/commands/xor.go +++ b/internal/commands/xor.go @@ -267,3 +267,6 @@ func (c *XorCommand) Generate(_ *compiler.CompilerContext) ([]string, error) { return asm, nil } + +func (c *XorCommand) GetClass() compiler.CmdClass { return compiler.CmdLinear } +func (c *XorCommand) GetName() string { return "XOR" } diff --git a/internal/compiler/command.go b/internal/compiler/command.go index d7ac6da..fbc4743 100644 --- a/internal/compiler/command.go +++ b/internal/compiler/command.go @@ -7,6 +7,15 @@ import ( "c65gm/internal/preproc" ) +// CmdClass categorizes commands for the peephole optimizer +type CmdClass int + +const ( + CmdLinear CmdClass = iota // straight-line code, no visible labels + CmdFlowCtrl // IF/WHILE/FOR/SWITCH: internal labels, bodies still optimizable + CmdBoundary // FUNC/FEND/LABEL/MACRO: reset all optimizer state +) + // Command represents a script command that can interpret source lines and generate assembly type Command interface { // WillHandle checks if this command can handle the given line @@ -20,6 +29,12 @@ type Command interface { // Generate produces assembly output based on previously interpreted line // Returns assembly lines and error if generation fails Generate(ctx *CompilerContext) ([]string, error) + + // GetClass returns the command's optimizer class + GetClass() CmdClass + + // GetName returns a short command name for @@OPT annotations (e.g. "LET", "ADD", "POKE") + GetName() string } // CommandRegistry manages registered commands and dispatches lines to appropriate handlers diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 815d51c..d92b324 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "c65gm/internal/optimizer" "c65gm/internal/preproc" ) @@ -233,6 +234,9 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { } codeOutput = append(codeOutput, fmt.Sprintf("; %s", line.Text)) + if len(asmLines) > 0 && c.isOptimizing() { + codeOutput = append(codeOutput, fmt.Sprintf("; @@OPT:%s:%s", classString(cmd.GetClass()), cmd.GetName())) + } codeOutput = append(codeOutput, asmLines...) } @@ -251,6 +255,11 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { return nil, fmt.Errorf("Unclosed SCRIPT MACRO block.") } + // Peephole optimization pass + if cfg := c.getOptimizerConfig(); cfg != nil { + codeOutput = optimizer.Optimize(codeOutput, cfg) + } + // Analyze for overlapping absolute addresses in function call chains c.checkAbsoluteOverlaps() @@ -276,6 +285,44 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { return c.assembleOutput(codeOutput, removedFuncs), nil } +// isOptimizing returns true if any peephole optimization pragma is active +func (c *Compiler) isOptimizing() bool { + if c.ctx == nil || c.ctx.Pragma == nil { + return false + } + idx := c.ctx.Pragma.GetCurrentPragmaSetIndex() + ps := c.ctx.Pragma.GetPragmaSetByIndex(idx) + cfg := optimizer.NewConfig(ps) + return cfg.Any() +} + +// getOptimizerConfig returns optimizer config if optimization is enabled, nil otherwise +func (c *Compiler) getOptimizerConfig() *optimizer.Config { + if c.ctx == nil || c.ctx.Pragma == nil { + return nil + } + idx := c.ctx.Pragma.GetCurrentPragmaSetIndex() + ps := c.ctx.Pragma.GetPragmaSetByIndex(idx) + cfg := optimizer.NewConfig(ps) + if !cfg.Any() { + return nil + } + return cfg +} + +func classString(cls CmdClass) string { + switch cls { + case CmdLinear: + return "LINEAR" + case CmdFlowCtrl: + return "FLOWCTRL" + case CmdBoundary: + return "BOUNDARY" + default: + return "UNKNOWN" + } +} + // printErrorWithContext prints an error with source code context to stderr func (c *Compiler) printErrorWithContext(lines []preproc.Line, lineIndex int, err error) { if lineIndex < 0 || lineIndex >= len(lines) { diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go index af43c1b..f5e4276 100644 --- a/internal/compiler/compiler_test.go +++ b/internal/compiler/compiler_test.go @@ -49,6 +49,9 @@ func (c *TestBreakCommand) Generate(ctx *CompilerContext) ([]string, error) { }, nil } +func (c *TestBreakCommand) GetClass() CmdClass { return CmdLinear } +func (c *TestBreakCommand) GetName() string { return "BREAK" } + func TestCompilerArchitecture(t *testing.T) { // Create pragma pragma := preproc.NewPragma() diff --git a/internal/optimizer/config.go b/internal/optimizer/config.go new file mode 100644 index 0000000..49cf2e6 --- /dev/null +++ b/internal/optimizer/config.go @@ -0,0 +1,29 @@ +package optimizer + +import ( + "c65gm/internal/preproc" +) + +type Config struct { + EnableLoad bool + EnableImm bool + EnableJmp bool + EnableSelf bool + Debug bool +} + +func NewConfig(ps preproc.PragmaSet) *Config { + all := ps.GetPragma("_P_OPT_ALL") != "" && ps.GetPragma("_P_OPT_ALL") != "0" + + return &Config{ + EnableLoad: all || (ps.GetPragma("_P_OPT_LOAD") != "" && ps.GetPragma("_P_OPT_LOAD") != "0"), + EnableImm: all || (ps.GetPragma("_P_OPT_IMM") != "" && ps.GetPragma("_P_OPT_IMM") != "0"), + EnableJmp: all || (ps.GetPragma("_P_OPT_JMP") != "" && ps.GetPragma("_P_OPT_JMP") != "0"), + EnableSelf: all || (ps.GetPragma("_P_OPT_SELF") != "" && ps.GetPragma("_P_OPT_SELF") != "0"), + Debug: (ps.GetPragma("_P_OPT_DEBUG") != "" && ps.GetPragma("_P_OPT_DEBUG") != "0"), + } +} + +func (c *Config) Any() bool { + return c.EnableLoad || c.EnableImm || c.EnableJmp || c.EnableSelf +} diff --git a/internal/optimizer/debug.go b/internal/optimizer/debug.go new file mode 100644 index 0000000..629e1e8 --- /dev/null +++ b/internal/optimizer/debug.go @@ -0,0 +1,11 @@ +package optimizer + +import "fmt" + +// debugWithOriginals wraps the current state with a debug comment header +func debugWithOriginals(lines []asmLine, phase string) []asmLine { + header := fmt.Sprintf("; --- %s ---", phase) + result := []asmLine{{text: header, isComment: true}} + result = append(result, lines...) + return result +} diff --git a/internal/optimizer/optimizer.go b/internal/optimizer/optimizer.go new file mode 100644 index 0000000..ce96937 --- /dev/null +++ b/internal/optimizer/optimizer.go @@ -0,0 +1,36 @@ +package optimizer + +// Optimize applies all enabled peephole passes to the generated ASM lines. +// Each pass runs sequentially on a parsed representation of the lines. +// @@OPT markers are stripped from output. +func Optimize(lines []string, cfg *Config) []string { + if cfg == nil || !cfg.Any() { + return lines + } + + parsed := parseLines(lines) + + if cfg.Debug { + parsed = debugWithOriginals(parsed, "before peephole") + } + + if cfg.EnableLoad { + parsed = passLoadElimination(parsed) + } + if cfg.EnableImm { + parsed = passImmElimination(parsed) + } + if cfg.EnableJmp { + parsed = passJmpNext(parsed) + } + if cfg.EnableSelf { + parsed = passSelfAssignment(parsed) + } + + if cfg.Debug { + parsed = debugWithOriginals(parsed, "after peephole") + } + + parsed = stripOptMarkers(parsed) + return linesToString(parsed) +} diff --git a/internal/optimizer/optimizer_test.go b/internal/optimizer/optimizer_test.go new file mode 100644 index 0000000..b377194 --- /dev/null +++ b/internal/optimizer/optimizer_test.go @@ -0,0 +1,209 @@ +package optimizer + +import ( + "testing" +) + +func lines(s ...string) []string { return s } + +func TestPassLoadElimination(t *testing.T) { + tests := []struct { + name string + input []string + expected int // expected number of lines after optimization + }{ + { + name: "redundant same variable", + input: lines("\tlda x", "\tsta y", "\tlda x"), + expected: 2, // third lda removed + }, + { + name: "not redundant - store to same variable", + input: lines("\tlda x", "\tsta x", "\tlda x"), + expected: 3, // X was stored, so A is stale + }, + { + name: "not redundant - arithmetic in between", + input: lines("\tlda x", "\tadc #1", "\tlda x"), + expected: 3, // A was modified + }, + { + name: "redundant - label resets state", + input: lines("\tlda x", "label", "\tlda x"), + expected: 3, // label resets state, second lda not redundant + }, + { + name: "x register redundant", + input: lines("\tldx counter", "\tldy counter", "\tldx counter"), + expected: 2, // third ldx is redundant (X still holds counter) + }, + { + name: "immediate loads not affected", + input: lines("\tlda #5", "\tsta x", "\tlda #5"), + expected: 3, // handled by imm pass + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed := parseLines(tt.input) + result := passLoadElimination(parsed) + 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)) + } + }) + } +} + +func TestPassImmElimination(t *testing.T) { + tests := []struct { + name string + input []string + expected int + }{ + { + name: "redundant same immediate", + input: lines("\tlda #$05", "\tsta y", "\tlda #$05"), + expected: 2, // third removed + }, + { + name: "different immediate values", + input: lines("\tlda #5", "\tsta y", "\tlda #3"), + expected: 3, // different values + }, + { + name: "arithmetic invalidates", + input: lines("\tlda #5", "\tadc #1", "\tlda #5"), + expected: 3, // adc modified A + }, + { + name: "x and y independent", + input: lines("\tldx #$00", "\tldy #$00", "\tldx #$00"), + expected: 2, // first ldx removed? No, let me check: ldx #0 then ldy #0, X still 0, so third redundant + // Actually: ldx#0(X=0), ldy#0(Y=0,X=0), ldx#0(X=0,redundant) + // Result: ldx#0, ldy#0 → 2 lines + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed := parseLines(tt.input) + result := passImmElimination(parsed) + 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)) + } + }) + } +} + +func TestPassJmpNext(t *testing.T) { + tests := []struct { + name string + input []string + expected int + }{ + { + name: "jmp to next label", + input: lines("\tjmp _L1", "_L1"), + expected: 1, // jmp removed + }, + { + name: "jmp not to next label", + input: lines("\tjmp _L1", "\tlda x", "_L1"), + expected: 3, // not immediately followed by label + }, + { + name: "label different from jmp target", + input: lines("\tjmp _L1", "_L2"), + expected: 2, // different labels + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed := parseLines(tt.input) + result := passJmpNext(parsed) + 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)) + } + }) + } +} + +func TestPassSelfAssignment(t *testing.T) { + tests := []struct { + name string + input []string + expected int + }{ + { + name: "self assignment", + input: lines("\tlda x", "\tsta x"), + expected: 0, // both removed + }, + { + name: "not self - different variables", + input: lines("\tlda x", "\tsta y"), + expected: 2, // different + }, + { + name: "not self - immediate load", + input: lines("\tlda #$05", "\tsta #$05"), + expected: 2, // immediate + immediate doesn't match + }, + { + name: "not self - SM code pattern (+ offset)", + input: lines("\tlda x", "\tsta _L1+1"), + expected: 2, // SM code pattern + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed := parseLines(tt.input) + result := passSelfAssignment(parsed) + 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)) + } + }) + } +} + +func TestPassLoadNoCommentReset(t *testing.T) { + // Comments should NOT reset register state + parsed := parseLines(lines( + "\tlda b", + "\tsta a", + "; comment", + "\tlda b", // should be redundant + )) + result := passLoadElimination(parsed) + if len(result) != 3 { + t.Errorf("expected 3 lines (comment kept, lda b removed), got %d:\n%v", len(result), linesToString(result)) + } +} + +func TestOptimizeIntegration(t *testing.T) { + input := lines( + "; a = b", + "; @@OPT:LINEAR:LET", + "\tlda b", + "\tsta a", + "; second use of b (redundant load)", + "; @@OPT:LINEAR:LET", + "\tlda b", + "\tsta c", + ) + + cfg := &Config{EnableLoad: true} + output := Optimize(input, cfg) + + // 2 source comments + 3 asm lines (lda b removed) = 5 + if len(output) != 5 { + t.Errorf("expected 5 lines, got %d:\n%v", len(output), output) + } +} diff --git a/internal/optimizer/pass_imm.go b/internal/optimizer/pass_imm.go new file mode 100644 index 0000000..21af871 --- /dev/null +++ b/internal/optimizer/pass_imm.go @@ -0,0 +1,67 @@ +package optimizer + +// passImmElimination removes redundant immediate loads. +// Pattern: lda #N; ... (no A modification) ...; lda #N → remove second +// Applies to A, X, Y. +func passImmElimination(lines []asmLine) []asmLine { + rs := newRegState() + var result []asmLine + + for _, line := range lines { + if !line.isCode { + if line.optMarker { + continue + } + if line.isLabel { + rs.reset() + } + result = append(result, line) + continue + } + + if isChainBreaker(line.opcode) { + rs.reset() + result = append(result, line) + continue + } + + if isRedundantImm(line, rs) { + continue // remove line + } + + updateRegState(line, rs) + result = append(result, line) + } + + return result +} + +func isRedundantImm(line asmLine, rs *regState) bool { + switch line.opcode { + case "lda": + if isImmediate(line.operand) { + val := parseHexOrDec(line.operand[1:]) + if rs.a.val == val { + return true + } + rs.loadImm("a", val) + } + case "ldx": + if isImmediate(line.operand) { + val := parseHexOrDec(line.operand[1:]) + if rs.x.val == val { + return true + } + rs.loadImm("x", val) + } + case "ldy": + if isImmediate(line.operand) { + val := parseHexOrDec(line.operand[1:]) + if rs.y.val == val { + return true + } + rs.loadImm("y", val) + } + } + return false +} diff --git a/internal/optimizer/pass_jmp.go b/internal/optimizer/pass_jmp.go new file mode 100644 index 0000000..5a7fe58 --- /dev/null +++ b/internal/optimizer/pass_jmp.go @@ -0,0 +1,38 @@ +package optimizer + +import "strings" + +// passJmpNext removes unconditional jumps to the immediately following label. +// Pattern: jmp L; L: → (remove jmp) +func passJmpNext(lines []asmLine) []asmLine { + var result []asmLine + skipNext := false + + for i := 0; i < len(lines); i++ { + if skipNext { + skipNext = false + continue + } + + line := lines[i] + + if skipJmpMarker(line) { + continue + } + + // Check for jmp L followed by label L: + if line.isCode && line.opcode == "jmp" && i+1 < len(lines) { + next := lines[i+1] + if next.isLabel && strings.TrimSpace(next.text) == strings.TrimSpace(line.operand) { + // Remove the jmp, keep the label + result = append(result, next) + skipNext = true + continue + } + } + + result = append(result, line) + } + + return result +} diff --git a/internal/optimizer/pass_load.go b/internal/optimizer/pass_load.go new file mode 100644 index 0000000..b4164d5 --- /dev/null +++ b/internal/optimizer/pass_load.go @@ -0,0 +1,56 @@ +package optimizer + +// 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 { + rs := newRegState() + var result []asmLine + + for _, line := range lines { + if !line.isCode { + if line.optMarker { + continue + } + if line.isLabel { + rs.reset() + } + result = append(result, line) + continue + } + + if isChainBreaker(line.opcode) { + rs.reset() + result = append(result, line) + continue + } + + // Check for redundant memory load + if line.opcode == "lda" && !isImmediate(line.operand) { + if rs.a.src == line.operand { + continue // redundant, remove + } + rs.a.src = line.operand + } else if line.opcode == "ldx" && !isImmediate(line.operand) { + if rs.x.src == line.operand { + continue + } + rs.x.src = line.operand + } else if line.opcode == "ldy" && !isImmediate(line.operand) { + if rs.y.src == line.operand { + continue + } + rs.y.src = line.operand + } else { + updateRegState(line, rs) + } + + result = append(result, line) + } + + return result +} + +func isImmediate(operand string) bool { + return len(operand) > 0 && operand[0] == '#' +} diff --git a/internal/optimizer/pass_self.go b/internal/optimizer/pass_self.go new file mode 100644 index 0000000..0e09552 --- /dev/null +++ b/internal/optimizer/pass_self.go @@ -0,0 +1,38 @@ +package optimizer + +import "strings" + +// passSelfAssignment removes lda X; sta X pairs where the store immediately +// follows the load and neither line is a branch target. +func passSelfAssignment(lines []asmLine) []asmLine { + var result []asmLine + skipNext := false + + for i := 0; i < len(lines); i++ { + if skipNext { + skipNext = false + continue + } + + line := lines[i] + + if skipJmpMarker(line) { + continue + } + + // Check for lda X; sta X + if line.isCode && line.opcode == "lda" && !isImmediate(line.operand) && i+1 < len(lines) { + next := lines[i+1] + if next.isCode && next.opcode == "sta" && next.operand == line.operand { + if !strings.Contains(line.operand, "+") { + skipNext = true // skip sta on next iteration + continue // skip lda + } + } + } + + result = append(result, line) + } + + return result +} diff --git a/internal/optimizer/regstate.go b/internal/optimizer/regstate.go new file mode 100644 index 0000000..6687c7e --- /dev/null +++ b/internal/optimizer/regstate.go @@ -0,0 +1,140 @@ +package optimizer + +import "strings" + +type regInfo struct { + val int // -1 = unknown, else the immediate value known to be in reg + src string // variable name if loaded from memory (or "" for immediates) +} + +type regState struct { + a regInfo + x regInfo + y regInfo +} + +func newRegState() *regState { + return ®State{ + a: regInfo{val: -1}, + x: regInfo{val: -1}, + y: regInfo{val: -1}, + } +} + +func (rs *regState) reset() { + rs.a = regInfo{val: -1} + rs.x = regInfo{val: -1} + rs.y = regInfo{val: -1} +} + +func (rs *regState) resetA() { rs.a = regInfo{val: -1} } +func (rs *regState) resetX() { rs.x = regInfo{val: -1} } +func (rs *regState) resetY() { rs.y = regInfo{val: -1} } + +// loadImm records that a register was loaded with an immediate value +func (rs *regState) loadImm(reg string, val int) { + r := regInfo{val: val} + switch reg { + case "a": + rs.a = r + case "x": + rs.x = r + case "y": + rs.y = r + } +} + +// loadMem records that a register was loaded from a memory location +func (rs *regState) loadMem(reg, varName string) { + r := regInfo{val: -1, src: varName} + switch reg { + case "a": + rs.a = r + case "x": + rs.x = r + case "y": + rs.y = r + } +} + +// storeInvalidate marks that a memory location was written. +// If any register's src matches, that register becomes "unknown" (value gone). +func (rs *regState) storeInvalidate(varName string) { + if rs.a.src == varName { + rs.a.src = "" + rs.a.val = -1 + } + if rs.x.src == varName { + rs.x.src = "" + rs.x.val = -1 + } + if rs.y.src == varName { + rs.y.src = "" + rs.y.val = -1 + } +} + +// isChainBreaker returns true for instructions that reset all register tracking +func isChainBreaker(opcode string) bool { + switch opcode { + case "jmp", "jsr", "rts", "rti", "brk": + return true + } + return false +} + +// updateRegState updates the register state machine for a code line +func updateRegState(line asmLine, rs *regState) { + if !line.isCode { + return + } + + switch line.opcode { + case "lda": + if strings.HasPrefix(line.operand, "#") { + rs.loadImm("a", parseHexOrDec(line.operand[1:])) + } else { + rs.loadMem("a", line.operand) + } + case "ldx": + if strings.HasPrefix(line.operand, "#") { + rs.loadImm("x", parseHexOrDec(line.operand[1:])) + } else { + rs.loadMem("x", line.operand) + } + case "ldy": + if strings.HasPrefix(line.operand, "#") { + rs.loadImm("y", parseHexOrDec(line.operand[1:])) + } else { + rs.loadMem("y", line.operand) + } + case "sta": + rs.storeInvalidate(line.operand) + case "stx": + rs.storeInvalidate(line.operand) + case "sty": + rs.storeInvalidate(line.operand) + case "inc", "dec": + rs.storeInvalidate(line.operand) + case "tax": + rs.x = regInfo{val: -1, src: rs.a.src} + case "tay": + rs.y = regInfo{val: -1, src: rs.a.src} + case "txa": + rs.a = regInfo{val: -1, src: rs.x.src} + case "tya": + rs.a = regInfo{val: -1, src: rs.y.src} + case "inx", "dex": + rs.resetX() + case "iny", "dey": + rs.resetY() + case "asl", "lsr", "rol", "ror", "adc", "sbc", "and", "ora", "eor", "pla": + rs.resetA() + case "plp": + rs.reset() + case "tsx": + rs.resetX() + case "txs": + // SP doesn't affect register tracking + } +} diff --git a/internal/optimizer/scanner.go b/internal/optimizer/scanner.go new file mode 100644 index 0000000..6af88bd --- /dev/null +++ b/internal/optimizer/scanner.go @@ -0,0 +1,97 @@ +package optimizer + +import ( + "strconv" + "strings" +) + +// parseHexOrDec parses an ACME-style number: $ff or 255 +func parseHexOrDec(s string) int { + if strings.HasPrefix(s, "$") { + v, _ := strconv.ParseInt(s[1:], 16, 16) + return int(v) + } + // Strip trailing characters after the number (e.g., ",y" in "5,y") + if idx := strings.IndexAny(s, ",)"); idx >= 0 { + s = s[:idx] + } + v, _ := strconv.ParseInt(s, 10, 16) + return int(v) +} + +type asmLine struct { + text string + isLabel bool + isCode bool + isComment bool + optMarker bool + opcode string + operand string +} + +func parseLines(lines []string) []asmLine { + var result []asmLine + for _, l := range lines { + al := asmLine{text: l} + + if l == "" { + result = append(result, al) + continue + } + + if strings.HasPrefix(l, "; @@OPT:") { + al.optMarker = true + al.isComment = true + result = append(result, al) + continue + } + + if strings.HasPrefix(l, ";") { + al.isComment = true + result = append(result, al) + continue + } + + if l[0] == '\t' { + al.isCode = true + parts := strings.Fields(l) + if len(parts) > 0 { + al.opcode = strings.ToLower(parts[0]) + } + if len(parts) > 1 { + al.operand = parts[1] + } + } else { + al.isLabel = true + } + + result = append(result, al) + } + return result +} + +// stripOptMarkers removes @@OPT comment lines from the output +func stripOptMarkers(lines []asmLine) []asmLine { + var result []asmLine + for _, l := range lines { + if l.optMarker { + continue + } + result = append(result, l) + } + return result +} + +func linesToString(lines []asmLine) []string { + var result []string + for _, l := range lines { + result = append(result, l.text) + } + return result +} + +// skipJmpMarker returns true if the line is an @@OPT comment (to be skipped in pass logic) +func skipJmpMarker(line asmLine) bool { + return line.optMarker +} + From 89bd30bfbddd9999cb5f83e4d2981a583f16463c Mon Sep 17 00:00:00 2001 From: Mattias Hansson Date: Wed, 3 Jun 2026 23:23:54 +0200 Subject: [PATCH 2/4] Fixed optimizer bugs --- internal/compiler/compiler.go | 12 +++++- internal/optimizer/config.go | 22 +++++----- internal/optimizer/config_test.go | 54 ++++++++++++++++++++++++ internal/optimizer/debug.go | 62 +++++++++++++++++++++++++--- internal/optimizer/optimizer.go | 13 +++--- internal/optimizer/optimizer_test.go | 43 +++++++++++++++++++ internal/optimizer/pass_imm.go | 52 +++++++++++++---------- internal/optimizer/pass_jmp.go | 4 -- internal/optimizer/pass_load.go | 34 +++++++++++---- internal/optimizer/pass_self.go | 4 -- internal/optimizer/regstate.go | 18 ++++++-- internal/optimizer/scanner.go | 18 ++++++-- 12 files changed, 268 insertions(+), 68 deletions(-) create mode 100644 internal/optimizer/config_test.go diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index d92b324..40efdc2 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -234,7 +234,7 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { } codeOutput = append(codeOutput, fmt.Sprintf("; %s", line.Text)) - if len(asmLines) > 0 && c.isOptimizing() { + if len(asmLines) > 0 && c.isMarkersEnabled() { codeOutput = append(codeOutput, fmt.Sprintf("; @@OPT:%s:%s", classString(cmd.GetClass()), cmd.GetName())) } codeOutput = append(codeOutput, asmLines...) @@ -296,6 +296,16 @@ func (c *Compiler) isOptimizing() bool { return cfg.Any() } +// isMarkersEnabled returns true if _P_OPT_MARKERS is active +func (c *Compiler) isMarkersEnabled() bool { + if c.ctx == nil || c.ctx.Pragma == nil { + return false + } + idx := c.ctx.Pragma.GetCurrentPragmaSetIndex() + ps := c.ctx.Pragma.GetPragmaSetByIndex(idx) + return ps.GetPragma("_P_OPT_MARKERS") != "" && ps.GetPragma("_P_OPT_MARKERS") != "0" +} + // getOptimizerConfig returns optimizer config if optimization is enabled, nil otherwise func (c *Compiler) getOptimizerConfig() *optimizer.Config { if c.ctx == nil || c.ctx.Pragma == nil { diff --git a/internal/optimizer/config.go b/internal/optimizer/config.go index 49cf2e6..a6d7ef0 100644 --- a/internal/optimizer/config.go +++ b/internal/optimizer/config.go @@ -5,22 +5,24 @@ import ( ) type Config struct { - EnableLoad bool - EnableImm bool - EnableJmp bool - EnableSelf bool - Debug bool + EnableLoad bool + EnableImm bool + EnableJmp bool + EnableSelf bool + Debug bool + ShowMarkers bool } func NewConfig(ps preproc.PragmaSet) *Config { all := ps.GetPragma("_P_OPT_ALL") != "" && ps.GetPragma("_P_OPT_ALL") != "0" return &Config{ - EnableLoad: all || (ps.GetPragma("_P_OPT_LOAD") != "" && ps.GetPragma("_P_OPT_LOAD") != "0"), - EnableImm: all || (ps.GetPragma("_P_OPT_IMM") != "" && ps.GetPragma("_P_OPT_IMM") != "0"), - EnableJmp: all || (ps.GetPragma("_P_OPT_JMP") != "" && ps.GetPragma("_P_OPT_JMP") != "0"), - EnableSelf: all || (ps.GetPragma("_P_OPT_SELF") != "" && ps.GetPragma("_P_OPT_SELF") != "0"), - Debug: (ps.GetPragma("_P_OPT_DEBUG") != "" && ps.GetPragma("_P_OPT_DEBUG") != "0"), + EnableLoad: all || (ps.GetPragma("_P_OPT_LOAD") != "" && ps.GetPragma("_P_OPT_LOAD") != "0"), + EnableImm: all || (ps.GetPragma("_P_OPT_IMM") != "" && ps.GetPragma("_P_OPT_IMM") != "0"), + EnableJmp: all || (ps.GetPragma("_P_OPT_JMP") != "" && ps.GetPragma("_P_OPT_JMP") != "0"), + EnableSelf: all || (ps.GetPragma("_P_OPT_SELF") != "" && ps.GetPragma("_P_OPT_SELF") != "0"), + Debug: (ps.GetPragma("_P_OPT_DEBUG") != "" && ps.GetPragma("_P_OPT_DEBUG") != "0"), + ShowMarkers: (ps.GetPragma("_P_OPT_MARKERS") != "" && ps.GetPragma("_P_OPT_MARKERS") != "0"), } } diff --git a/internal/optimizer/config_test.go b/internal/optimizer/config_test.go new file mode 100644 index 0000000..adcbbe7 --- /dev/null +++ b/internal/optimizer/config_test.go @@ -0,0 +1,54 @@ +package optimizer + +import ( + "testing" + "c65gm/internal/preproc" +) + +func TestNewConfigWithAll(t *testing.T) { + ps := preproc.NewPragmaSet(map[string]string{ + "_P_OPT_ALL": "1", + "_P_OPT_DEBUG": "1", + }) + cfg := NewConfig(ps) + if !cfg.Any() { + t.Error("expected Any()=true") + } + if !cfg.EnableLoad { + t.Error("expected EnableLoad=true from _P_OPT_ALL") + } + if !cfg.EnableImm { + t.Error("expected EnableImm=true from _P_OPT_ALL") + } + if !cfg.Debug { + t.Error("expected Debug=true") + } +} + +func TestNewConfigPragmasAccumulate(t *testing.T) { + // Pragma sets COPY previous values — so last set has ALL pragmas + p := preproc.NewPragma() + p.AddPragma("_P_OPT_ALL", "1") + p.AddPragma("_P_OPT_DEBUG", "1") + p.AddPragma("_P_SOMETHING_ELSE", "1") + + // Last set should have all three + ps := p.GetPragmaSetByIndex(p.GetCurrentPragmaSetIndex()) + if v := ps.GetPragma("_P_OPT_ALL"); v != "1" { + t.Errorf("expected _P_OPT_ALL=1, got %q", v) + } + if v := ps.GetPragma("_P_OPT_DEBUG"); v != "1" { + t.Errorf("expected _P_OPT_DEBUG=1, got %q", v) + } + if v := ps.GetPragma("_P_SOMETHING_ELSE"); v != "1" { + t.Errorf("expected _P_SOMETHING_ELSE=1, got %q", v) + } + + cfg := NewConfig(ps) + if !cfg.Any() { + t.Error("expected Any()=true") + } + if !cfg.Debug { + t.Error("expected Debug=true") + } +} diff --git a/internal/optimizer/debug.go b/internal/optimizer/debug.go index 629e1e8..378e4e5 100644 --- a/internal/optimizer/debug.go +++ b/internal/optimizer/debug.go @@ -2,10 +2,62 @@ package optimizer import "fmt" -// debugWithOriginals wraps the current state with a debug comment header -func debugWithOriginals(lines []asmLine, phase string) []asmLine { - header := fmt.Sprintf("; --- %s ---", phase) - result := []asmLine{{text: header, isComment: true}} - result = append(result, lines...) +// debugDiff produces annotated output showing original vs optimized code. +// Lines that were removed are shown with "[removed]" annotation. +func debugDiff(original, optimized []asmLine) []asmLine { + 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 + + for oi < len(original) { + origLine := original[oi] + + if opi < len(optimized) && linesMatch(origLine, optimized[opi]) { + result = append(result, optimized[opi]) + oi++ + opi++ + } else if !origLine.isCode { + // Non-code line that doesn't match anything — pass through + result = append(result, origLine) + oi++ + } else { + // Code line in original but not in optimized → removed + result = append(result, asmLine{ + text: fmt.Sprintf("; [removed] %s", origLine.text), + isComment: true, + }) + oi++ + } + } + + // Append any remaining optimized lines (shouldn't happen in practice) + for ; opi < len(optimized); opi++ { + result = append(result, optimized[opi]) + } + return result } + +// linesMatch returns true if two asmLines represent the same instruction. +func linesMatch(a, b asmLine) bool { + return a.isCode == b.isCode && a.isLabel == b.isLabel && a.isComment == b.isComment && a.text == b.text +} + +func countCodeLines(lines []asmLine) int { + n := 0 + for _, l := range lines { + if l.isCode { + n++ + } + } + return n +} diff --git a/internal/optimizer/optimizer.go b/internal/optimizer/optimizer.go index ce96937..770fd68 100644 --- a/internal/optimizer/optimizer.go +++ b/internal/optimizer/optimizer.go @@ -9,10 +9,7 @@ func Optimize(lines []string, cfg *Config) []string { } parsed := parseLines(lines) - - if cfg.Debug { - parsed = debugWithOriginals(parsed, "before peephole") - } + original := parsed if cfg.EnableLoad { parsed = passLoadElimination(parsed) @@ -27,10 +24,10 @@ func Optimize(lines []string, cfg *Config) []string { parsed = passSelfAssignment(parsed) } - if cfg.Debug { - parsed = debugWithOriginals(parsed, "after peephole") - } - parsed = stripOptMarkers(parsed) + if cfg.Debug { + original = stripOptMarkers(original) + parsed = debugDiff(original, parsed) + } return linesToString(parsed) } diff --git a/internal/optimizer/optimizer_test.go b/internal/optimizer/optimizer_test.go index b377194..a0b35d8 100644 --- a/internal/optimizer/optimizer_test.go +++ b/internal/optimizer/optimizer_test.go @@ -207,3 +207,46 @@ func TestOptimizeIntegration(t *testing.T) { t.Errorf("expected 5 lines, got %d:\n%v", len(output), output) } } + +func TestOptimizeWithDebug(t *testing.T) { + input := lines( + "\tlda x", + "; @@OPT:LINEAR:LET", + "\tlda x", + "\tsta y", + ) + + cfg := &Config{EnableLoad: true, Debug: true} + output := Optimize(input, cfg) + + // Header + 2 kept lines + 1 removed annotation = 4 + if len(output) != 4 { + t.Errorf("expected 4 lines, got %d:\n%v", len(output), output) + } + if !containsPrefix(output, "; --- peephole:") { + t.Errorf("expected peephole summary line:\n%v", output) + } + if !containsSubstring(output, "[removed]") { + t.Errorf("expected [removed] annotation:\n%v", output) + } +} + +func containsPrefix(lines []string, prefix string) bool { + for _, l := range lines { + if len(l) >= len(prefix) && l[:len(prefix)] == prefix { + return true + } + } + return false +} + +func containsSubstring(lines []string, sub string) bool { + for _, l := range lines { + for i := 0; i <= len(l)-len(sub); i++ { + if l[i:i+len(sub)] == sub { + return true + } + } + } + return false +} diff --git a/internal/optimizer/pass_imm.go b/internal/optimizer/pass_imm.go index 21af871..1539184 100644 --- a/internal/optimizer/pass_imm.go +++ b/internal/optimizer/pass_imm.go @@ -1,5 +1,7 @@ package optimizer +import "strings" + // passImmElimination removes redundant immediate loads. // Pattern: lda #N; ... (no A modification) ...; lda #N → remove second // Applies to A, X, Y. @@ -9,9 +11,6 @@ func passImmElimination(lines []asmLine) []asmLine { for _, line := range lines { if !line.isCode { - if line.optMarker { - continue - } if line.isLabel { rs.reset() } @@ -37,31 +36,42 @@ func passImmElimination(lines []asmLine) []asmLine { } func isRedundantImm(line asmLine, rs *regState) bool { + if !isImmediate(line.operand) { + return false + } + val := parseValueAfterHash(line.operand) + if val < 0 { + return false // unparseable (label ref, label) + } switch line.opcode { case "lda": - if isImmediate(line.operand) { - val := parseHexOrDec(line.operand[1:]) - if rs.a.val == val { - return true - } - rs.loadImm("a", val) + if rs.a.val == val { + return true } + rs.loadImm("a", val) case "ldx": - if isImmediate(line.operand) { - val := parseHexOrDec(line.operand[1:]) - if rs.x.val == val { - return true - } - rs.loadImm("x", val) + if rs.x.val == val { + return true } + rs.loadImm("x", val) case "ldy": - if isImmediate(line.operand) { - val := parseHexOrDec(line.operand[1:]) - if rs.y.val == val { - return true - } - rs.loadImm("y", val) + if rs.y.val == val { + return true } + rs.loadImm("y", val) } return false } + +// parseValueAfterHash extracts the numeric value after '#'. Returns -1 for unparseable. +func parseValueAfterHash(operand string) int { + if len(operand) < 2 { + return -1 + } + s := operand[1:] + // ACME high/low byte operators are not evaluable at optimize time + if strings.HasPrefix(s, "<") || strings.HasPrefix(s, ">") { + return -1 + } + return parseHexOrDec(s) +} diff --git a/internal/optimizer/pass_jmp.go b/internal/optimizer/pass_jmp.go index 5a7fe58..a6697a9 100644 --- a/internal/optimizer/pass_jmp.go +++ b/internal/optimizer/pass_jmp.go @@ -16,10 +16,6 @@ func passJmpNext(lines []asmLine) []asmLine { line := lines[i] - if skipJmpMarker(line) { - continue - } - // Check for jmp L followed by label L: if line.isCode && line.opcode == "jmp" && i+1 < len(lines) { next := lines[i+1] diff --git a/internal/optimizer/pass_load.go b/internal/optimizer/pass_load.go index b4164d5..3baa70b 100644 --- a/internal/optimizer/pass_load.go +++ b/internal/optimizer/pass_load.go @@ -1,5 +1,7 @@ package optimizer +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. @@ -9,9 +11,6 @@ func passLoadElimination(lines []asmLine) []asmLine { for _, line := range lines { if !line.isCode { - if line.optMarker { - continue - } if line.isLabel { rs.reset() } @@ -27,20 +26,30 @@ func passLoadElimination(lines []asmLine) []asmLine { // Check for redundant memory load if line.opcode == "lda" && !isImmediate(line.operand) { - if rs.a.src == 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 rs.a.src == line.operand { continue // redundant, remove + } else { + rs.a.src = line.operand } - rs.a.src = line.operand } else if line.opcode == "ldx" && !isImmediate(line.operand) { - if rs.x.src == line.operand { + if isIndexedOperand(line.operand) { + updateRegState(line, rs) + } else if rs.x.src == line.operand { continue + } else { + rs.x.src = line.operand } - rs.x.src = line.operand } else if line.opcode == "ldy" && !isImmediate(line.operand) { - if rs.y.src == line.operand { + if isIndexedOperand(line.operand) { + updateRegState(line, rs) + } else if rs.y.src == line.operand { continue + } else { + rs.y.src = line.operand } - rs.y.src = line.operand } else { updateRegState(line, rs) } @@ -51,6 +60,13 @@ func passLoadElimination(lines []asmLine) []asmLine { return result } +// isIndexedOperand returns true for addressing modes where the effective address +// depends on X or Y registers (e.g. (zp),y, $xxxx,x, zp,x). +// Such loads can't be tracked by operand text alone since the register may change. +func isIndexedOperand(operand string) bool { + return strings.Contains(operand, "(") || strings.Contains(operand, ",") +} + func isImmediate(operand string) bool { return len(operand) > 0 && operand[0] == '#' } diff --git a/internal/optimizer/pass_self.go b/internal/optimizer/pass_self.go index 0e09552..943c77f 100644 --- a/internal/optimizer/pass_self.go +++ b/internal/optimizer/pass_self.go @@ -16,10 +16,6 @@ func passSelfAssignment(lines []asmLine) []asmLine { line := lines[i] - if skipJmpMarker(line) { - continue - } - // Check for lda X; sta X if line.isCode && line.opcode == "lda" && !isImmediate(line.operand) && i+1 < len(lines) { next := lines[i+1] diff --git a/internal/optimizer/regstate.go b/internal/optimizer/regstate.go index 6687c7e..da2adfb 100644 --- a/internal/optimizer/regstate.go +++ b/internal/optimizer/regstate.go @@ -92,19 +92,31 @@ func updateRegState(line asmLine, rs *regState) { switch line.opcode { case "lda": if strings.HasPrefix(line.operand, "#") { - rs.loadImm("a", parseHexOrDec(line.operand[1:])) + if val := parseHexOrDec(line.operand[1:]); val >= 0 { + rs.loadImm("a", val) + } else { + rs.resetA() + } } else { rs.loadMem("a", line.operand) } case "ldx": if strings.HasPrefix(line.operand, "#") { - rs.loadImm("x", parseHexOrDec(line.operand[1:])) + if val := parseHexOrDec(line.operand[1:]); val >= 0 { + rs.loadImm("x", val) + } else { + rs.resetX() + } } else { rs.loadMem("x", line.operand) } case "ldy": if strings.HasPrefix(line.operand, "#") { - rs.loadImm("y", parseHexOrDec(line.operand[1:])) + if val := parseHexOrDec(line.operand[1:]); val >= 0 { + rs.loadImm("y", val) + } else { + rs.resetY() + } } else { rs.loadMem("y", line.operand) } diff --git a/internal/optimizer/scanner.go b/internal/optimizer/scanner.go index 6af88bd..6dbe879 100644 --- a/internal/optimizer/scanner.go +++ b/internal/optimizer/scanner.go @@ -5,20 +5,32 @@ import ( "strings" ) -// parseHexOrDec parses an ACME-style number: $ff or 255 +// parseHexOrDec parses an ACME-style number: $ff or 255. +// Returns -1 if s is not a parseable simple number (e.g. label references, label). func parseHexOrDec(s string) int { if strings.HasPrefix(s, "$") { - v, _ := strconv.ParseInt(s[1:], 16, 16) + v, err := strconv.ParseInt(s[1:], 16, 16) + if err != nil { + return -1 + } return int(v) } // Strip trailing characters after the number (e.g., ",y" in "5,y") if idx := strings.IndexAny(s, ",)"); idx >= 0 { s = s[:idx] } - v, _ := strconv.ParseInt(s, 10, 16) + v, err := strconv.ParseInt(s, 10, 16) + if err != nil { + return -1 + } return int(v) } +// isSimpleNumber returns true if s is a plain hex/dec number (no label refs, <, >, etc.) +func isSimpleNumber(s string) bool { + return parseHexOrDec(s) >= 0 +} + type asmLine struct { text string isLabel bool From 28723878b11db880ea769aa6dc6565cf5c24d147 Mon Sep 17 00:00:00 2001 From: Mattias Hansson Date: Thu, 4 Jun 2026 00:05:24 +0200 Subject: [PATCH 3/4] Optimizer removed redundant lda's --- internal/compiler/compiler.go | 1 + internal/optimizer/config.go | 67 +++++++++--- internal/optimizer/optimizer.go | 3 + internal/optimizer/optimizer_test.go | 149 +++++++++++++++++++++++++++ internal/optimizer/pass_stld.go | 91 ++++++++++++++++ internal/optimizer/scanner.go | 4 +- 6 files changed, 300 insertions(+), 15 deletions(-) create mode 100644 internal/optimizer/pass_stld.go diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 40efdc2..dc5e14c 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -317,6 +317,7 @@ func (c *Compiler) getOptimizerConfig() *optimizer.Config { if !cfg.Any() { return nil } + cfg.BuildIOMap(c.ctx.Pragma) return cfg } diff --git a/internal/optimizer/config.go b/internal/optimizer/config.go index a6d7ef0..7fd6ff2 100644 --- a/internal/optimizer/config.go +++ b/internal/optimizer/config.go @@ -1,31 +1,72 @@ package optimizer import ( + "strconv" + "strings" + "c65gm/internal/preproc" ) type Config struct { - EnableLoad bool - EnableImm bool - EnableJmp bool - EnableSelf bool - Debug bool - ShowMarkers bool + EnableLoad bool + EnableImm bool + EnableJmp bool + EnableSelf bool + EnableStoreLoad bool + Debug bool + ShowMarkers bool + IOMap [65536]bool } func NewConfig(ps preproc.PragmaSet) *Config { all := ps.GetPragma("_P_OPT_ALL") != "" && ps.GetPragma("_P_OPT_ALL") != "0" return &Config{ - EnableLoad: all || (ps.GetPragma("_P_OPT_LOAD") != "" && ps.GetPragma("_P_OPT_LOAD") != "0"), - EnableImm: all || (ps.GetPragma("_P_OPT_IMM") != "" && ps.GetPragma("_P_OPT_IMM") != "0"), - EnableJmp: all || (ps.GetPragma("_P_OPT_JMP") != "" && ps.GetPragma("_P_OPT_JMP") != "0"), - EnableSelf: all || (ps.GetPragma("_P_OPT_SELF") != "" && ps.GetPragma("_P_OPT_SELF") != "0"), - Debug: (ps.GetPragma("_P_OPT_DEBUG") != "" && ps.GetPragma("_P_OPT_DEBUG") != "0"), - ShowMarkers: (ps.GetPragma("_P_OPT_MARKERS") != "" && ps.GetPragma("_P_OPT_MARKERS") != "0"), + EnableLoad: all || (ps.GetPragma("_P_OPT_LOAD") != "" && ps.GetPragma("_P_OPT_LOAD") != "0"), + EnableImm: all || (ps.GetPragma("_P_OPT_IMM") != "" && ps.GetPragma("_P_OPT_IMM") != "0"), + EnableJmp: all || (ps.GetPragma("_P_OPT_JMP") != "" && ps.GetPragma("_P_OPT_JMP") != "0"), + EnableSelf: all || (ps.GetPragma("_P_OPT_SELF") != "" && ps.GetPragma("_P_OPT_SELF") != "0"), + EnableStoreLoad: all || (ps.GetPragma("_P_OPT_STLD") != "" && ps.GetPragma("_P_OPT_STLD") != "0"), + Debug: (ps.GetPragma("_P_OPT_DEBUG") != "" && ps.GetPragma("_P_OPT_DEBUG") != "0"), + ShowMarkers: (ps.GetPragma("_P_OPT_MARKERS") != "" && ps.GetPragma("_P_OPT_MARKERS") != "0"), } } func (c *Config) Any() bool { - return c.EnableLoad || c.EnableImm || c.EnableJmp || c.EnableSelf + return c.EnableLoad || c.EnableImm || c.EnableJmp || c.EnableSelf || c.EnableStoreLoad +} + +// BuildIOMap scans all pragma sets for _P_OPT_IO and marks I/O regions. +func (c *Config) BuildIOMap(pragma *preproc.Pragma) { + for i := 0; i <= pragma.GetCurrentPragmaSetIndex(); i++ { + val := pragma.GetPragmaSetByIndex(i).GetPragma("_P_OPT_IO") + if val == "" { + continue + } + parts := strings.Fields(val) + if len(parts) >= 2 { + start := parsePragmaAddr(parts[0]) + end := parsePragmaAddr(parts[1]) + if start >= 0 && end >= 0 && start <= end && start < 65536 { + for a := start; a <= end && a < 65536; a++ { + c.IOMap[a] = true + } + } + } + } +} + +func parsePragmaAddr(s string) int { + if strings.HasPrefix(s, "$") { + v, err := strconv.ParseUint(s[1:], 16, 16) + if err != nil { + return -1 + } + return int(v) + } + v, err := strconv.ParseUint(s, 10, 16) + if err != nil { + return -1 + } + return int(v) } diff --git a/internal/optimizer/optimizer.go b/internal/optimizer/optimizer.go index 770fd68..e272c0b 100644 --- a/internal/optimizer/optimizer.go +++ b/internal/optimizer/optimizer.go @@ -11,6 +11,9 @@ func Optimize(lines []string, cfg *Config) []string { parsed := parseLines(lines) original := parsed + if cfg.EnableStoreLoad { + parsed = passStoreReload(parsed, cfg) + } if cfg.EnableLoad { parsed = passLoadElimination(parsed) } diff --git a/internal/optimizer/optimizer_test.go b/internal/optimizer/optimizer_test.go index a0b35d8..3f21192 100644 --- a/internal/optimizer/optimizer_test.go +++ b/internal/optimizer/optimizer_test.go @@ -250,3 +250,152 @@ func containsSubstring(lines []string, sub string) bool { } return false } + +func TestPassStoreReload(t *testing.T) { + cfg := &Config{} + + tests := []struct { + name string + input []string + expected int + }{ + { + name: "sta x then lda x", + input: lines("\tsta x", "\tlda x"), + expected: 1, + }, + { + name: "different variables", + input: lines("\tsta x", "\tlda y"), + expected: 2, + }, + { + name: "ldy between sta and lda", + input: lines("\tsta x", "\tldy #0", "\tlda x"), + expected: 2, + }, + { + name: "adc between — modifies A", + input: lines("\tsta x", "\tadc #1", "\tlda x"), + expected: 3, + }, + { + name: "label between — barrier", + input: lines("\tsta x", "label", "\tlda x"), + expected: 3, + }, + { + name: "different registers", + input: lines("\tsta x", "\tldx x"), + expected: 2, + }, + { + name: "comment between", + input: lines("\tsta x", "; source line", "\tlda x"), + expected: 2, + }, + { + name: "comment and ldy between", + input: lines("\tsta x", "; POKE addr", "\tldy #5", "\tlda x"), + expected: 3, + }, + { + name: "SM code — operand with +", + input: lines("\tsta _L1+1", "\tlda _L1+1"), + expected: 2, + }, + { + name: "indexed store — operand with (", + input: lines("\tsta (zp),y", "\tlda (zp),y"), + expected: 2, + }, + { + name: "indexed store — operand with ,", + input: lines("\tsta $D020,x", "\tlda $D020,x"), + expected: 2, + }, + { + name: "different operand offsets", + input: lines("\tsta x", "\tlda x+1"), + expected: 2, + }, + { + name: "inx between — does not modify A", + input: lines("\tsta x", "\tinx", "\tlda x"), + expected: 2, + }, + { + name: "dex between — does not modify A", + input: lines("\tsta x", "\tdex", "\tlda x"), + expected: 2, + }, + { + name: "iny between — does not modify A", + input: lines("\tsta x", "\tiny", "\tlda x"), + expected: 2, + }, + { + name: "dey between — does not modify A", + input: lines("\tsta x", "\tdey", "\tlda x"), + expected: 2, + }, + { + name: "tax between — does not modify A", + input: lines("\tsta x", "\ttax", "\tlda x"), + expected: 2, + }, + { + name: "tay between — does not modify A", + input: lines("\tsta x", "\ttay", "\tlda x"), + expected: 2, + }, + { + name: "txa between — modifies A, keep lda", + input: lines("\tsta x", "\ttxa", "\tlda x"), + expected: 3, + }, + { + name: "tya between — modifies A, keep lda", + input: lines("\tsta x", "\ttya", "\tlda x"), + expected: 3, + }, + { + name: "pla between — modifies A, keep lda", + input: lines("\tsta x", "\tpla", "\tlda x"), + expected: 3, + }, + { + name: "multiple stores — pick the last sta", + input: lines("\tsta x", "\tsta y", "\tlda x"), + expected: 2, // sta x kept, sta y kept, lda x removed + }, + { + name: "@@OPT marker between — skip, lda removed", + input: lines("\tsta x", "; @@OPT:LINEAR:LET", "\tlda x"), + expected: 1, // @@OPT stripped by stripOptMarkers + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed := parseLines(tt.input) + result := passStoreReload(parsed, cfg) + 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)) + } + }) + } +} + +func TestPassStoreReloadIO(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)) + } +} diff --git a/internal/optimizer/pass_stld.go b/internal/optimizer/pass_stld.go new file mode 100644 index 0000000..2871a02 --- /dev/null +++ b/internal/optimizer/pass_stld.go @@ -0,0 +1,91 @@ +package optimizer + +import "strings" + +// passStoreReload removes redundant reloads immediately after stores. +// Pattern: sta X; ...(non-A-modifying)...; lda X → remove lda +func passStoreReload(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) { + res := findMatchingLoad(lines, i+1, line.operand, cfg) + if res.found { + result = append(result, line) + for j := i + 1; j < res.idx; j++ { + result = append(result, lines[j]) + } + i = res.idx // skip the lda on next iteration + continue + } + } + + result = append(result, line) + } + + return result +} + +type matchResult struct { + idx int + found bool +} + +// findMatchingLoad scans forward from `start` for `lda operand`. +// Only passes through comments and non-A-modifying code. +func findMatchingLoad(lines []asmLine, start int, operand string, cfg *Config) matchResult { + for i := start; i < len(lines); i++ { + l := lines[i] + + if l.optMarker || l.isComment { + continue + } + if l.isLabel { + return matchResult{} + } + if l.isCode && l.opcode == "lda" && l.operand == operand && isSafeStldOperand(l.operand, cfg) { + return matchResult{i, true} + } + if modifiesA(l) { + return matchResult{} + } + } + return matchResult{} +} + +// modifiesA returns true if the instruction puts a new value into A. +func modifiesA(line asmLine) bool { + if !line.isCode { + return false + } + switch line.opcode { + case "lda", "adc", "sbc", "and", "ora", "eor", "pla", "txa", "tya": + return true + case "asl", "lsr", "rol", "ror": + return line.operand == "" || line.operand == "a" + case "inc", "dec": + return false + default: + return false + } +} + +// isSafeStldOperand returns true if the operand is safe for store-then-reload optimization. +// Unsafe: I/O addresses (in cfg.IOMap), self-modifying code pattern (+N), indexed/indirect. +func isSafeStldOperand(operand string, cfg *Config) bool { + if strings.ContainsAny(operand, "(,+") { + return false + } + + // Check if operand is a direct hex address in an I/O region + if strings.HasPrefix(operand, "$") { + addr := parseHexOrDec(operand) + if addr >= 0 && addr < 65536 && cfg.IOMap[addr] { + return false + } + } + + return true +} diff --git a/internal/optimizer/scanner.go b/internal/optimizer/scanner.go index 6dbe879..1217ea3 100644 --- a/internal/optimizer/scanner.go +++ b/internal/optimizer/scanner.go @@ -9,7 +9,7 @@ import ( // Returns -1 if s is not a parseable simple number (e.g. label references, label). func parseHexOrDec(s string) int { if strings.HasPrefix(s, "$") { - v, err := strconv.ParseInt(s[1:], 16, 16) + v, err := strconv.ParseUint(s[1:], 16, 16) if err != nil { return -1 } @@ -19,7 +19,7 @@ func parseHexOrDec(s string) int { if idx := strings.IndexAny(s, ",)"); idx >= 0 { s = s[:idx] } - v, err := strconv.ParseInt(s, 10, 16) + v, err := strconv.ParseUint(s, 10, 16) if err != nil { return -1 } From 1abad8bb925544185d1f336cf5e4c4d8d3c8b36d Mon Sep 17 00:00:00 2001 From: Mattias Hansson Date: Thu, 4 Jun 2026 01:21:10 +0200 Subject: [PATCH 4/4] Add experimental peephole optimizer with 5 passes, CLI flags, pragmas, and I/O region protection. --- README.md | 109 +++++++++++++++++++++ internal/compiler/compiler.go | 67 ++++++++++++- internal/optimizer/config.go | 55 +++++++++++ internal/optimizer/config_test.go | 126 ++++++++++++++++++++++++ internal/optimizer/debug.go | 35 +++---- internal/optimizer/optimizer.go | 2 +- internal/optimizer/optimizer_test.go | 137 +++++++++++++++++++++++++-- internal/optimizer/pass_load.go | 25 ++++- main.go | 74 +++++++++++---- 9 files changed, 580 insertions(+), 50 deletions(-) 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) }