From c6b67f8044a1e04217c17d0d8faf116430a46ca9 Mon Sep 17 00:00:00 2001 From: Mattias Hansson Date: Wed, 11 Feb 2026 13:25:07 +0100 Subject: [PATCH] Added SCRIPT LIBRARY block for reusable StarLark code. --- examples/script_library_demo/cm.sh | 20 ++ .../script_library_demo.c65 | 85 ++++++++ examples/script_library_demo/start_in_vice.sh | 1 + internal/compiler/compiler.go | 23 ++- internal/compiler/compiler_test.go | 184 ++++++++++++++++++ internal/compiler/context.go | 26 ++- internal/compiler/scriptexec.go | 36 +++- internal/preproc/preproc.go | 38 ++-- internal/preproc/preproc_test.go | 74 +++++++ 9 files changed, 448 insertions(+), 39 deletions(-) create mode 100755 examples/script_library_demo/cm.sh create mode 100644 examples/script_library_demo/script_library_demo.c65 create mode 100644 examples/script_library_demo/start_in_vice.sh diff --git a/examples/script_library_demo/cm.sh b/examples/script_library_demo/cm.sh new file mode 100755 index 0000000..58e35f4 --- /dev/null +++ b/examples/script_library_demo/cm.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Define filename as variable +PROGNAME="script_library_demo" +# Only set C65LIBPATH if not already defined +if [ -z "$C65LIBPATH" ]; then + export C65LIBPATH=$(readlink -f "../../lib") +fi +# Compile +c65gm -in ${PROGNAME}.c65 -out ${PROGNAME}.s +if [ $? -ne 0 ]; then + echo "Compilation terminated" + exit 1 +fi +echo assemble. +acme ${PROGNAME}.s +if [ -f ${PROGNAME}.prg ]; then + rm ${PROGNAME}.prg +fi +# main.bin ${PROGNAME}.prg +mv main.bin main.prg diff --git a/examples/script_library_demo/script_library_demo.c65 b/examples/script_library_demo/script_library_demo.c65 new file mode 100644 index 0000000..aace2ba --- /dev/null +++ b/examples/script_library_demo/script_library_demo.c65 @@ -0,0 +1,85 @@ +//----------------------------------------------------------- +// SCRIPT LIBRARY Demo +// Demonstrates reusable Starlark functions defined in +// SCRIPT LIBRARY blocks and called from SCRIPT blocks +//----------------------------------------------------------- + +#INCLUDE + +GOTO start + +//----------------------------------------------------------- +// SCRIPT LIBRARY: Define reusable code generation functions +//----------------------------------------------------------- +SCRIPT LIBRARY +def emit_nops(count): + for i in range(count): + print(" nop") + +def emit_delay(cycles): + # 4 cycles per iteration (2x nop) + for i in range(cycles // 4): + print(" nop") + print(" nop") + remainder = cycles % 4 + if remainder >= 3: + print(" bit $ea") + remainder -= 3 + # 2 cycles per nop + for i in range(remainder // 2): + print(" nop") +ENDSCRIPT + +//----------------------------------------------------------- +// Second SCRIPT LIBRARY: Functions accumulate +//----------------------------------------------------------- +SCRIPT LIBRARY +def emit_border_flash(color1, color2): + print(" lda #%d" % color1) + print(" sta $d020") + print(" lda #%d" % color2) + print(" sta $d020") + +def emit_load_store(value, addr): + print(" lda #%d" % value) + print(" sta %d" % addr) +ENDSCRIPT + +//----------------------------------------------------------- +// Main code using library functions +//----------------------------------------------------------- +LABEL start + + // Use emit_nops from first library + SCRIPT + print("; 5 nops from library") + emit_nops(5) + ENDSCRIPT + + // Use emit_delay from first library + SCRIPT + print("; 10 cycle delay") + emit_delay(10) + ENDSCRIPT + + // Use emit_border_flash from second library + SCRIPT + print("; border flash red/blue") + emit_border_flash(2, 6) + ENDSCRIPT + + // Combine multiple library functions + SCRIPT + print("; combined: delay + flash + nops") + emit_delay(8) + emit_border_flash(0, 1) + emit_nops(3) + ENDSCRIPT + + // Use emit_load_store + SCRIPT + print("; load/store to border") + emit_load_store(5, 0xd020) + ENDSCRIPT + + SUBEND diff --git a/examples/script_library_demo/start_in_vice.sh b/examples/script_library_demo/start_in_vice.sh new file mode 100644 index 0000000..865c48e --- /dev/null +++ b/examples/script_library_demo/start_in_vice.sh @@ -0,0 +1 @@ +x64 -autostartprgmode 1 main.prg diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index c09f671..8015494 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -37,19 +37,24 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { var codeOutput []string var lastKind = preproc.Source var scriptBuffer []string + var scriptIsLibrary bool for i, line := range lines { // Detect kind transitions and emit markers if line.Kind != lastKind { - // Execute and close previous Script block - if lastKind == preproc.Script { - scriptOutput, err := executeScript(scriptBuffer, c.ctx) + // Execute and close previous Script or ScriptLibrary block + if lastKind == preproc.Script || lastKind == preproc.ScriptLibrary { + scriptOutput, err := executeScript(scriptBuffer, c.ctx, scriptIsLibrary) if err != nil { return nil, fmt.Errorf("script execution failed: %w", err) } codeOutput = append(codeOutput, scriptOutput...) scriptBuffer = nil - codeOutput = append(codeOutput, "; ENDSCRIPT") + if scriptIsLibrary { + codeOutput = append(codeOutput, "; ENDSCRIPT LIBRARY") + } else { + codeOutput = append(codeOutput, "; ENDSCRIPT") + } } // Close previous Assembler block @@ -62,6 +67,10 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { codeOutput = append(codeOutput, "; ASM") } else if line.Kind == preproc.Script { codeOutput = append(codeOutput, "; SCRIPT") + scriptIsLibrary = false + } else if line.Kind == preproc.ScriptLibrary { + codeOutput = append(codeOutput, "; SCRIPT LIBRARY") + scriptIsLibrary = true } lastKind = line.Kind @@ -89,7 +98,7 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { text = text[:start] + expandedName + text[end+1:] } codeOutput = append(codeOutput, text) - } else if line.Kind == preproc.Script { + } else if line.Kind == preproc.Script || line.Kind == preproc.ScriptLibrary { // Collect script lines for execution scriptBuffer = append(scriptBuffer, line.Text) } @@ -127,11 +136,11 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) { // Close any open block if lastKind == preproc.Assembler { - //codeOutput = append(codeOutput, "; ENDASM") return nil, fmt.Errorf("Unclosed ASM block.") } else if lastKind == preproc.Script { - //codeOutput = append(codeOutput, "; ENDSCRIPT") return nil, fmt.Errorf("Unclosed SCRIPT block.") + } else if lastKind == preproc.ScriptLibrary { + return nil, fmt.Errorf("Unclosed SCRIPT LIBRARY block.") } // Analyze for overlapping absolute addresses in function call chains diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go index 17b43aa..4b93e0a 100644 --- a/internal/compiler/compiler_test.go +++ b/internal/compiler/compiler_test.go @@ -139,6 +139,9 @@ func TestCompilerContext(t *testing.T) { if ctx.Pragma == nil { t.Error("Pragma not initialized") } + if ctx.ScriptLibraryGlobals == nil { + t.Error("ScriptLibraryGlobals not initialized") + } // Test CurrentScope scope := ctx.CurrentScope() @@ -146,3 +149,184 @@ func TestCompilerContext(t *testing.T) { t.Errorf("Expected nil scope in global context, got %v", scope) } } + +func TestExecuteScript_BasicPrint(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + scriptLines := []string{ + "for i in range(3):", + " print(' nop')", + } + + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("executeScript failed: %v", err) + } + + if len(output) != 3 { + t.Fatalf("expected 3 output lines, got %d: %v", len(output), output) + } + + for i, line := range output { + if line != " nop" { + t.Errorf("line %d: expected ' nop', got %q", i, line) + } + } +} + +func TestExecuteScript_EmptyOutput(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + scriptLines := []string{ + "x = 1 + 1", + } + + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("executeScript failed: %v", err) + } + + if len(output) != 0 { + t.Errorf("expected 0 output lines, got %d: %v", len(output), output) + } +} + +func TestExecuteScript_Library_DefinesFunction(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + // Define a function in library mode + libraryLines := []string{ + "def emit_nops(count):", + " for i in range(count):", + " print(' nop')", + } + + _, err := executeScript(libraryLines, ctx, true) + if err != nil { + t.Fatalf("library executeScript failed: %v", err) + } + + // Verify function is in globals + if _, ok := ctx.ScriptLibraryGlobals["emit_nops"]; !ok { + t.Fatal("expected emit_nops to be defined in ScriptLibraryGlobals") + } +} + +func TestExecuteScript_Library_FunctionCallableFromScript(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + // First: define function in library + libraryLines := []string{ + "def emit_nops(count):", + " for i in range(count):", + " print(' nop')", + } + + _, err := executeScript(libraryLines, ctx, true) + if err != nil { + t.Fatalf("library executeScript failed: %v", err) + } + + // Second: call function from regular script + scriptLines := []string{ + "emit_nops(2)", + } + + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("script executeScript failed: %v", err) + } + + if len(output) != 2 { + t.Fatalf("expected 2 output lines, got %d: %v", len(output), output) + } + + for i, line := range output { + if line != " nop" { + t.Errorf("line %d: expected ' nop', got %q", i, line) + } + } +} + +func TestExecuteScript_MultipleLibraries_Accumulate(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + // First library: define func_a + lib1 := []string{ + "def func_a():", + " print(' ; from a')", + } + _, err := executeScript(lib1, ctx, true) + if err != nil { + t.Fatalf("lib1 failed: %v", err) + } + + // Second library: define func_b (should still have func_a) + lib2 := []string{ + "def func_b():", + " print(' ; from b')", + } + _, err = executeScript(lib2, ctx, true) + if err != nil { + t.Fatalf("lib2 failed: %v", err) + } + + // Both functions should be available + if _, ok := ctx.ScriptLibraryGlobals["func_a"]; !ok { + t.Error("func_a missing after second library") + } + if _, ok := ctx.ScriptLibraryGlobals["func_b"]; !ok { + t.Error("func_b missing after second library") + } + + // Call both from a script + scriptLines := []string{ + "func_a()", + "func_b()", + } + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("script failed: %v", err) + } + + if len(output) != 2 { + t.Fatalf("expected 2 lines, got %d: %v", len(output), output) + } + if output[0] != " ; from a" { + t.Errorf("expected ' ; from a', got %q", output[0]) + } + if output[1] != " ; from b" { + t.Errorf("expected ' ; from b', got %q", output[1]) + } +} + +func TestExecuteScript_RegularScript_DoesNotPersist(t *testing.T) { + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + + // Define function in regular script (not library) + scriptLines := []string{ + "def local_func():", + " print('hello')", + "local_func()", + } + + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("script failed: %v", err) + } + + if len(output) != 1 || output[0] != "hello" { + t.Errorf("unexpected output: %v", output) + } + + // Function should NOT be in globals (it was in regular script) + if _, ok := ctx.ScriptLibraryGlobals["local_func"]; ok { + t.Error("local_func should not persist from regular script") + } +} diff --git a/internal/compiler/context.go b/internal/compiler/context.go index 86046a9..5115cf6 100644 --- a/internal/compiler/context.go +++ b/internal/compiler/context.go @@ -2,6 +2,8 @@ package compiler import ( "c65gm/internal/preproc" + + "go.starlark.net/starlark" ) // CompilerContext holds all shared resources needed by commands during compilation @@ -26,6 +28,9 @@ type CompilerContext struct { // Pragma access for per-line pragma lookup Pragma *preproc.Pragma + + // ScriptLibraryGlobals holds persisted Starlark globals from SCRIPT LIBRARY blocks + ScriptLibraryGlobals starlark.StringDict } // NewCompilerContext creates a new compiler context with initialized resources @@ -35,16 +40,17 @@ func NewCompilerContext(pragma *preproc.Pragma) *CompilerContext { generalStack := NewLabelStack("_L") ctx := &CompilerContext{ - SymbolTable: symTable, - ConstStrHandler: constStrHandler, - LoopStartStack: NewLabelStack("_LOOPSTART"), - LoopEndStack: NewLabelStack("_LOOPEND"), - IfStack: NewLabelStack("_I"), - GeneralStack: generalStack, - ForStack: NewForStack(), - SwitchStack: NewSwitchStack(), - CaseSkipStack: NewLabelStack("_SKIPCASE"), - Pragma: pragma, + SymbolTable: symTable, + ConstStrHandler: constStrHandler, + LoopStartStack: NewLabelStack("_LOOPSTART"), + LoopEndStack: NewLabelStack("_LOOPEND"), + IfStack: NewLabelStack("_I"), + GeneralStack: generalStack, + ForStack: NewForStack(), + SwitchStack: NewSwitchStack(), + CaseSkipStack: NewLabelStack("_SKIPCASE"), + Pragma: pragma, + ScriptLibraryGlobals: make(starlark.StringDict), } // FunctionHandler needs references to other components diff --git a/internal/compiler/scriptexec.go b/internal/compiler/scriptexec.go index 3bb3ad4..894f858 100644 --- a/internal/compiler/scriptexec.go +++ b/internal/compiler/scriptexec.go @@ -8,20 +8,28 @@ import ( "go.starlark.net/starlark" ) -// executeScript runs a Starlark script and returns the output lines -func executeScript(scriptLines []string, ctx *CompilerContext) ([]string, error) { +// executeScript runs a Starlark script and returns the output lines. +// If isLibrary is true, the script is executed at top level (no _main wrapper) +// and resulting globals are persisted to ctx.ScriptLibraryGlobals. +func executeScript(scriptLines []string, ctx *CompilerContext, isLibrary bool) ([]string, error) { // Join script lines scriptText := strings.Join(scriptLines, "\n") // Expand |varname| -> actual variable names scriptText = expandVariables(scriptText, ctx) - // Wrap in function (Starlark requires control flow inside functions) - wrappedScript := "def _main():\n" - for _, line := range strings.Split(scriptText, "\n") { - wrappedScript += " " + line + "\n" + var finalScript string + if isLibrary { + // LIBRARY: execute at top level so defs become globals + finalScript = scriptText + } else { + // Regular SCRIPT: wrap in function (Starlark requires control flow inside functions) + finalScript = "def _main():\n" + for _, line := range strings.Split(scriptText, "\n") { + finalScript += " " + line + "\n" + } + finalScript += "_main()\n" } - wrappedScript += "_main()\n" // Capture print output var output bytes.Buffer @@ -35,17 +43,27 @@ func executeScript(scriptLines []string, ctx *CompilerContext) ([]string, error) // Set execution limit (prevent infinite loops) thread.SetMaxExecutionSteps(1000000) // 1M steps - // Predeclared functions (math module) + // Build predeclared: math module + library globals predeclared := starlark.StringDict{ "math": math.Module, } + for k, v := range ctx.ScriptLibraryGlobals { + predeclared[k] = v + } // Execute - _, err := starlark.ExecFile(thread, "script.star", wrappedScript, predeclared) + globals, err := starlark.ExecFile(thread, "script.star", finalScript, predeclared) if err != nil { return nil, err } + // For LIBRARY: persist new globals (functions, variables defined at top level) + if isLibrary { + for k, v := range globals { + ctx.ScriptLibraryGlobals[k] = v + } + } + // Split output into lines for assembly outputStr := output.String() if outputStr == "" { diff --git a/internal/preproc/preproc.go b/internal/preproc/preproc.go index e96fbc7..34bc094 100644 --- a/internal/preproc/preproc.go +++ b/internal/preproc/preproc.go @@ -12,6 +12,7 @@ const ( Source LineKind = iota Assembler Script + ScriptLibrary ) // Line represents one post-processed source line and its provenance. @@ -46,12 +47,13 @@ func PreProcess(rootFilename string, reader ...FileReader) ([]Line, *Pragma, err // -------------------- internal -------------------- type preproc struct { - defs *DefineList // from definelist.go - pragma *Pragma // pragma handler - cond []bool // conditional stack; a line is active if all are true - inAsm bool // true when inside ASM/ENDASM block - inScript bool // true when inside SCRIPT/ENDSCRIPT block - reader FileReader // file reader abstraction + defs *DefineList // from definelist.go + pragma *Pragma // pragma handler + cond []bool // conditional stack; a line is active if all are true + inAsm bool // true when inside ASM/ENDASM block + inScript bool // true when inside SCRIPT/ENDSCRIPT block + inScriptLibrary bool // true when inside SCRIPT LIBRARY/ENDSCRIPT block + reader FileReader // file reader abstraction } func newPreproc(reader FileReader) *preproc { @@ -116,15 +118,19 @@ func (p *preproc) run(root string) ([]Line, error) { tokens := strings.Fields(raw) // ASM mode handling - if !p.inAsm && !p.inScript { + if !p.inAsm && !p.inScript && !p.inScriptLibrary { // Check for ASM entry if includeSource && len(tokens) > 0 && tokens[0] == "ASM" { p.inAsm = true continue // don't emit ASM marker } - // Check for SCRIPT entry + // Check for SCRIPT entry (SCRIPT LIBRARY or plain SCRIPT) if includeSource && len(tokens) > 0 && tokens[0] == "SCRIPT" { - p.inScript = true + if len(tokens) > 1 && tokens[1] == "LIBRARY" { + p.inScriptLibrary = true + } else { + p.inScript = true + } continue // don't emit SCRIPT marker } } else if p.inAsm { @@ -144,20 +150,26 @@ func (p *preproc) run(root string) ([]Line, error) { PragmaSetIndex: p.pragma.GetCurrentPragmaSetIndex(), }) continue - } else if p.inScript { - // We're in SCRIPT mode + } else if p.inScript || p.inScriptLibrary { + // We're in SCRIPT or SCRIPT LIBRARY mode // Check for ENDSCRIPT if len(tokens) > 0 && tokens[0] == "ENDSCRIPT" { p.inScript = false + p.inScriptLibrary = false continue // don't emit ENDSCRIPT marker } - // Otherwise emit line verbatim as Script + // Determine the kind based on which mode we're in + kind := Script + if p.inScriptLibrary { + kind = ScriptLibrary + } + // Emit line verbatim with appropriate kind out = append(out, Line{ RawText: raw, Text: raw, Filename: currFrame.path, LineNo: currFrame.line, - Kind: Script, + Kind: kind, PragmaSetIndex: p.pragma.GetCurrentPragmaSetIndex(), }) continue diff --git a/internal/preproc/preproc_test.go b/internal/preproc/preproc_test.go index e94a908..aeb02f7 100644 --- a/internal/preproc/preproc_test.go +++ b/internal/preproc/preproc_test.go @@ -947,6 +947,80 @@ func TestPreProcess_EmptyScriptBlock(t *testing.T) { } } +func TestPreProcess_ScriptLibraryBlock(t *testing.T) { + files := map[string][]string{ + "test.c65": { + "SCRIPT LIBRARY", + "def my_func():", + " print('nop')", + "ENDSCRIPT", + "NOP", + }, + } + reader := NewMockFileReader(files) + lines, _, err := PreProcess("test.c65", reader) + if err != nil { + t.Fatalf("PreProcess failed: %v", err) + } + + // Should have 2 script lines + 1 source line + if len(lines) != 3 { + t.Fatalf("expected 3 lines, got %d", len(lines)) + } + + // Script library lines should have ScriptLibrary kind + if lines[0].Kind != ScriptLibrary { + t.Errorf("expected Kind=ScriptLibrary, got %v", lines[0].Kind) + } + if lines[0].Text != "def my_func():" { + t.Errorf("expected 'def my_func():', got %q", lines[0].Text) + } + + if lines[1].Kind != ScriptLibrary { + t.Errorf("expected Kind=ScriptLibrary, got %v", lines[1].Kind) + } + + // Source line after ENDSCRIPT + if lines[2].Kind != Source { + t.Errorf("expected Kind=Source, got %v", lines[2].Kind) + } + if lines[2].Text != "NOP" { + t.Errorf("expected 'NOP', got %q", lines[2].Text) + } +} + +func TestPreProcess_ScriptVsScriptLibrary(t *testing.T) { + files := map[string][]string{ + "test.c65": { + "SCRIPT LIBRARY", + "def foo(): pass", + "ENDSCRIPT", + "SCRIPT", + "foo()", + "ENDSCRIPT", + }, + } + reader := NewMockFileReader(files) + lines, _, err := PreProcess("test.c65", reader) + if err != nil { + t.Fatalf("PreProcess failed: %v", err) + } + + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + + // First line is from SCRIPT LIBRARY + if lines[0].Kind != ScriptLibrary { + t.Errorf("line 0: expected ScriptLibrary, got %v", lines[0].Kind) + } + + // Second line is from regular SCRIPT + if lines[1].Kind != Script { + t.Errorf("line 1: expected Script, got %v", lines[1].Kind) + } +} + func TestPreProcess_DollarEscapeExpansion(t *testing.T) { files := map[string][]string{ "test.c65": {