From b2e9e3dc176bfea752893d3a3360592a320a943a Mon Sep 17 00:00:00 2001 From: Mattias Hansson Date: Fri, 12 Jun 2026 14:48:33 +0200 Subject: [PATCH] Added file loading (bin, text files) into SCRIPTS --- examples/load_binary_demo/cm.sh | 5 + .../load_binary_demo/load_binary_demo.c65 | 50 +++ examples/load_binary_demo/screendata.bin | 2 + examples/load_binary_demo/start_in_vice.sh | 1 + internal/compiler/context.go | 5 + internal/compiler/scriptexec.go | 128 +++++- internal/compiler/scriptexec_test.go | 405 ++++++++++++++++++ language.md | 52 +++ main.go | 5 + syntax.md | 45 ++ 10 files changed, 694 insertions(+), 4 deletions(-) create mode 100755 examples/load_binary_demo/cm.sh create mode 100644 examples/load_binary_demo/load_binary_demo.c65 create mode 100644 examples/load_binary_demo/screendata.bin create mode 100644 examples/load_binary_demo/start_in_vice.sh diff --git a/examples/load_binary_demo/cm.sh b/examples/load_binary_demo/cm.sh new file mode 100755 index 0000000..c0fc561 --- /dev/null +++ b/examples/load_binary_demo/cm.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# Define filename as variable +PROGNAME="load_binary_demo" +# Compile and assemble directly +c65gm ${PROGNAME}.c65 diff --git a/examples/load_binary_demo/load_binary_demo.c65 b/examples/load_binary_demo/load_binary_demo.c65 new file mode 100644 index 0000000..8536f93 --- /dev/null +++ b/examples/load_binary_demo/load_binary_demo.c65 @@ -0,0 +1,50 @@ +//----------------------------------------------------------- +// load_binary Demo +// Demonstrates loading binary data from a file at compile time +// using the Starlark scripting built-in load_binary(). +// +// The SCRIPT block reads screendata.bin (40 bytes of C64 +// screen codes) and emits them as assembler data. At runtime, +// a c65gm FOR loop copies the data to screen memory ($0400) +// using WORD pointers with self-modifying code. +// +// Build: c65gm load_binary_demo.c65 +//----------------------------------------------------------- + +#INCLUDE + +GOTO start + +//----------------------------------------------------------- +// Load binary data at compile time using Starlark load_binary() +// and emit it as data statements in the assembly output. +//----------------------------------------------------------- +SCRIPT + data = load_binary("screendata.bin") + print("; Loaded %d bytes from screendata.bin" % len(data)) + print("scrdata:") + for i in range(0, len(data), 8): + row = ", ".join(["%d" % b for b in data[i:i+8]]) + print(" !8 " + row) +ENDSCRIPT + +LABEL start + + // Pointers to source data and screen memory + WORD src + WORD dst + BYTE i + BYTE val + + POINTER src TO scrdata + POINTER dst TO $0400 + + // Copy 40 bytes from scrdata to screen memory + FOR i = 0 TO 39 + val = PEEK src + POKE dst WITH val + src++ + dst++ + NEXT + + SUBEND diff --git a/examples/load_binary_demo/screendata.bin b/examples/load_binary_demo/screendata.bin new file mode 100644 index 0000000..308422a --- /dev/null +++ b/examples/load_binary_demo/screendata.bin @@ -0,0 +1,2 @@ + +  !"#$%&'( \ No newline at end of file diff --git a/examples/load_binary_demo/start_in_vice.sh b/examples/load_binary_demo/start_in_vice.sh new file mode 100644 index 0000000..c164667 --- /dev/null +++ b/examples/load_binary_demo/start_in_vice.sh @@ -0,0 +1 @@ +x64 -autostartprgmode 1 load_binary_demo.prg diff --git a/internal/compiler/context.go b/internal/compiler/context.go index 8bcf83a..852115f 100644 --- a/internal/compiler/context.go +++ b/internal/compiler/context.go @@ -43,6 +43,11 @@ type CompilerContext struct { // ScriptMacros holds named macro definitions from SCRIPT MACRO blocks ScriptMacros map[string]*ScriptMacro + + // ProjectRoot is the absolute path of the directory containing the main input .c65 file. + // Used by scripting built-ins (load_binary, load_text) to resolve relative file paths + // and enforce security (no access outside project root). + ProjectRoot string } // NewCompilerContext creates a new compiler context with initialized resources diff --git a/internal/compiler/scriptexec.go b/internal/compiler/scriptexec.go index c9a01c4..b943923 100644 --- a/internal/compiler/scriptexec.go +++ b/internal/compiler/scriptexec.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strings" "c65gm/internal/preproc" @@ -213,9 +214,11 @@ func executeScript(scriptLines []preproc.Line, ctx *CompilerContext, isLibrary b // Set execution limit (prevent infinite loops) thread.SetMaxExecutionSteps(1000000) // 1M steps - // Build predeclared: math module + library globals + // Build predeclared: math module + library globals + file I/O builtins predeclared := starlark.StringDict{ - "math": math.Module, + "math": math.Module, + "load_binary": makeLoadBinary(ctx.ProjectRoot), + "load_text": makeLoadText(ctx.ProjectRoot), } for k, v := range ctx.ScriptLibraryGlobals { predeclared[k] = v @@ -280,6 +283,121 @@ func expandVariables(text string, ctx *CompilerContext) string { return result } +// validateScriptFilePath checks that path is safe and resolves it within the project root. +// It rejects absolute paths and path traversal (..). +func validateScriptFilePath(projectRoot, path string) (string, error) { + if projectRoot == "" { + return "", fmt.Errorf("project root not set (internal error)") + } + if path == "" { + return "", fmt.Errorf("file path must not be empty") + } + if filepath.IsAbs(path) { + return "", fmt.Errorf("absolute paths are not allowed: %s", path) + } + + // Reject path traversal components + cleaned := filepath.Clean(path) + for _, component := range strings.Split(cleaned, string(filepath.Separator)) { + if component == ".." { + return "", fmt.Errorf("path traversal is not allowed: %s", path) + } + } + + // Resolve against project root + resolved := filepath.Join(projectRoot, cleaned) + + // Verify containment within project root + projectRootWithSep := projectRoot + string(filepath.Separator) + if !strings.HasPrefix(resolved, projectRootWithSep) && resolved != projectRoot { + return "", fmt.Errorf("file access denied: path resolves outside project folder") + } + + return resolved, nil +} + +// makeLoadBinary creates a Starlark builtin function load_binary(path, offset?, length?) +// that reads a binary file relative to the project root and returns a list of ints (0-255). +func makeLoadBinary(projectRoot string) *starlark.Builtin { + return starlark.NewBuiltin("load_binary", func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var path string + var offset, length int + if err := starlark.UnpackPositionalArgs("load_binary", args, kwargs, 1, &path, &offset, &length); err != nil { + return nil, err + } + + resolvedPath, err := validateScriptFilePath(projectRoot, path) + if err != nil { + return nil, fmt.Errorf("load_binary: %w", err) + } + + data, err := os.ReadFile(resolvedPath) + if err != nil { + return nil, fmt.Errorf("load_binary: cannot read %s: %w", path, err) + } + + if offset < 0 { + return nil, fmt.Errorf("load_binary: offset must be non-negative, got %d", offset) + } + if offset > len(data) { + return nil, fmt.Errorf("load_binary: offset %d exceeds file size %d", offset, len(data)) + } + data = data[offset:] + + if length > 0 { + if length > len(data) { + return nil, fmt.Errorf("load_binary: length %d exceeds available data %d", length, len(data)) + } + data = data[:length] + } + + result := make([]starlark.Value, len(data)) + for i, b := range data { + result[i] = starlark.MakeInt(int(b)) + } + + return starlark.NewList(result), nil + }) +} + +// makeLoadText creates a Starlark builtin function load_text(path) +// that reads a text file relative to the project root and returns a list of strings (lines). +func makeLoadText(projectRoot string) *starlark.Builtin { + return starlark.NewBuiltin("load_text", func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var path string + if err := starlark.UnpackPositionalArgs("load_text", args, kwargs, 1, &path); err != nil { + return nil, err + } + + resolvedPath, err := validateScriptFilePath(projectRoot, path) + if err != nil { + return nil, fmt.Errorf("load_text: %w", err) + } + + data, err := os.ReadFile(resolvedPath) + if err != nil { + return nil, fmt.Errorf("load_text: cannot read %s: %w", path, err) + } + + text := strings.ReplaceAll(string(data), "\r\n", "\n") + text = strings.TrimRight(text, "\n") + + var lines []string + if text == "" { + lines = []string{} + } else { + lines = strings.Split(text, "\n") + } + + result := make([]starlark.Value, len(lines)) + for i, line := range lines { + result[i] = starlark.String(line) + } + + return starlark.NewList(result), nil + }) +} + // ExecuteMacro executes a named macro with the given arguments and returns output lines func ExecuteMacro(macroName string, args []string, ctx *CompilerContext) ([]string, error) { // Look up the macro @@ -331,9 +449,11 @@ func ExecuteMacro(macroName string, args []string, ctx *CompilerContext) ([]stri // Set execution limit thread.SetMaxExecutionSteps(1000000) - // Build predeclared: math + library globals + parameter bindings + // Build predeclared: math + library globals + file I/O builtins + parameter bindings predeclared := starlark.StringDict{ - "math": math.Module, + "math": math.Module, + "load_binary": makeLoadBinary(ctx.ProjectRoot), + "load_text": makeLoadText(ctx.ProjectRoot), } for k, v := range ctx.ScriptLibraryGlobals { predeclared[k] = v diff --git a/internal/compiler/scriptexec_test.go b/internal/compiler/scriptexec_test.go index 1bb2104..7cc9c20 100644 --- a/internal/compiler/scriptexec_test.go +++ b/internal/compiler/scriptexec_test.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "os" + "path/filepath" "strings" "testing" @@ -437,3 +438,407 @@ func TestPrintScriptErrorFallback(t *testing.T) { t.Errorf("output should contain the error message, got: %q", output) } } + +// --- Tests for validateScriptFilePath --- + +func TestValidateScriptFilePath_Valid(t *testing.T) { + projectRoot := "/home/user/project" + + tests := []struct { + path string + want string + }{ + {"file.bin", filepath.Join(projectRoot, "file.bin")}, + {"subdir/file.bin", filepath.Join(projectRoot, "subdir/file.bin")}, + {"./file.bin", filepath.Join(projectRoot, "file.bin")}, + } + + for _, tt := range tests { + got, err := validateScriptFilePath(projectRoot, tt.path) + if err != nil { + t.Errorf("validateScriptFilePath(%q, %q) unexpected error: %v", projectRoot, tt.path, err) + continue + } + if got != tt.want { + t.Errorf("validateScriptFilePath(%q, %q) = %q, want %q", projectRoot, tt.path, got, tt.want) + } + } +} + +func TestValidateScriptFilePath_RejectsAbsolute(t *testing.T) { + _, err := validateScriptFilePath("/home/user/project", "/etc/passwd") + if err == nil { + t.Error("expected error for absolute path") + } +} + +func TestValidateScriptFilePath_RejectsTraversal(t *testing.T) { + projectRoot := "/home/user/project" + tests := []string{ + "../secret.bin", + "subdir/../../secret.bin", + } + + for _, path := range tests { + _, err := validateScriptFilePath(projectRoot, path) + if err == nil { + t.Errorf("expected error for traversal path: %s", path) + } + } +} + +func TestValidateScriptFilePath_RejectsEmptyProjectRoot(t *testing.T) { + _, err := validateScriptFilePath("", "file.bin") + if err == nil { + t.Error("expected error for empty project root") + } +} + +func TestValidateScriptFilePath_RejectsEmptyPath(t *testing.T) { + _, err := validateScriptFilePath("/home/user/project", "") + if err == nil { + t.Error("expected error for empty path") + } +} + +// --- Tests for load_binary builtin --- + +func TestExecuteScript_LoadBinary(t *testing.T) { + tmpDir := t.TempDir() + binData := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07} + if err := os.WriteFile(filepath.Join(tmpDir, "data.bin"), binData, 0644); err != nil { + t.Fatal(err) + } + + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + ctx.ProjectRoot = tmpDir + + scriptLines := []preproc.Line{ + {Text: "data = load_binary(\"data.bin\")", Filename: "test.c65", LineNo: 1, Kind: preproc.Script}, + {Text: "print(\"len=\" + str(len(data)))", Filename: "test.c65", LineNo: 2, Kind: preproc.Script}, + {Text: "print(\"first=\" + str(data[0]))", Filename: "test.c65", LineNo: 3, Kind: preproc.Script}, + {Text: "print(\"last=\" + str(data[7]))", Filename: "test.c65", LineNo: 4, Kind: preproc.Script}, + } + + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("executeScript failed: %v", err) + } + + if len(output) < 3 { + t.Fatalf("expected at least 3 output lines, got %d: %v", len(output), output) + } + if output[0] != "len=8" { + t.Errorf("output[0] = %q, want \"len=8\"", output[0]) + } + if output[1] != "first=0" { + t.Errorf("output[1] = %q, want \"first=0\"", output[1]) + } + if output[2] != "last=7" { + t.Errorf("output[2] = %q, want \"last=7\"", output[2]) + } +} + +func TestExecuteScript_LoadBinary_Offset(t *testing.T) { + tmpDir := t.TempDir() + binData := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05} + if err := os.WriteFile(filepath.Join(tmpDir, "data.bin"), binData, 0644); err != nil { + t.Fatal(err) + } + + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + ctx.ProjectRoot = tmpDir + + // Load with offset 2: should return [0x02, 0x03, 0x04, 0x05] + scriptLines := []preproc.Line{ + {Text: "data = load_binary(\"data.bin\", 2)", Filename: "test.c65", LineNo: 1, Kind: preproc.Script}, + {Text: "print(\"len=\" + str(len(data)))", Filename: "test.c65", LineNo: 2, Kind: preproc.Script}, + {Text: "print(\"first=\" + str(data[0]))", Filename: "test.c65", LineNo: 3, Kind: preproc.Script}, + } + + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("executeScript failed: %v", err) + } + + if len(output) < 2 { + t.Fatalf("expected at least 2 output lines, got %d: %v", len(output), output) + } + if output[0] != "len=4" { + t.Errorf("output[0] = %q, want \"len=4\"", output[0]) + } + if output[1] != "first=2" { + t.Errorf("output[1] = %q, want \"first=2\"", output[1]) + } +} + +func TestExecuteScript_LoadBinary_OffsetAndLength(t *testing.T) { + tmpDir := t.TempDir() + binData := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07} + if err := os.WriteFile(filepath.Join(tmpDir, "data.bin"), binData, 0644); err != nil { + t.Fatal(err) + } + + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + ctx.ProjectRoot = tmpDir + + // Load with offset 3, length 2: should return [0x03, 0x04] + scriptLines := []preproc.Line{ + {Text: "data = load_binary(\"data.bin\", 3, 2)", Filename: "test.c65", LineNo: 1, Kind: preproc.Script}, + {Text: "print(\"len=\" + str(len(data)))", Filename: "test.c65", LineNo: 2, Kind: preproc.Script}, + {Text: "print(\"first=\" + str(data[0]))", Filename: "test.c65", LineNo: 3, Kind: preproc.Script}, + {Text: "print(\"last=\" + str(data[1]))", Filename: "test.c65", LineNo: 4, Kind: preproc.Script}, + } + + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("executeScript failed: %v", err) + } + + if len(output) < 3 { + t.Fatalf("expected at least 3 output lines, got %d: %v", len(output), output) + } + if output[0] != "len=2" { + t.Errorf("output[0] = %q, want \"len=2\"", output[0]) + } + if output[1] != "first=3" { + t.Errorf("output[1] = %q, want \"first=3\"", output[1]) + } + if output[2] != "last=4" { + t.Errorf("output[2] = %q, want \"last=4\"", output[2]) + } +} + +func TestExecuteScript_LoadBinary_NonexistentFile(t *testing.T) { + tmpDir := t.TempDir() + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + ctx.ProjectRoot = tmpDir + + scriptLines := []preproc.Line{ + {Text: "load_binary(\"nonexistent.bin\")", Filename: "test.c65", LineNo: 1, Kind: preproc.Script}, + } + + _, err := executeScript(scriptLines, ctx, false) + if err == nil { + t.Error("expected error for nonexistent file") + } + if err != nil && !strings.Contains(err.Error(), "Starlark error") { + t.Errorf("error should contain 'Starlark error', got: %v", err) + } +} + +func TestExecuteScript_LoadBinary_TraversalRejected(t *testing.T) { + tmpDir := t.TempDir() + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + ctx.ProjectRoot = tmpDir + + scriptLines := []preproc.Line{ + {Text: "load_binary(\"../outside.bin\")", Filename: "test.c65", LineNo: 1, Kind: preproc.Script}, + } + + _, err := executeScript(scriptLines, ctx, false) + if err == nil { + t.Error("expected error for path traversal") + } +} + +func TestExecuteScript_LoadBinary_AbsoluteRejected(t *testing.T) { + tmpDir := t.TempDir() + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + ctx.ProjectRoot = tmpDir + + scriptLines := []preproc.Line{ + {Text: "load_binary(\"/etc/passwd\")", Filename: "test.c65", LineNo: 1, Kind: preproc.Script}, + } + + _, err := executeScript(scriptLines, ctx, false) + if err == nil { + t.Error("expected error for absolute path") + } +} + +// --- Tests for load_text builtin --- + +func TestExecuteScript_LoadText(t *testing.T) { + tmpDir := t.TempDir() + textContent := "hello\nworld\nfoo" + if err := os.WriteFile(filepath.Join(tmpDir, "data.txt"), []byte(textContent), 0644); err != nil { + t.Fatal(err) + } + + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + ctx.ProjectRoot = tmpDir + + scriptLines := []preproc.Line{ + {Text: "lines = load_text(\"data.txt\")", Filename: "test.c65", LineNo: 1, Kind: preproc.Script}, + {Text: "print(\"len=\" + str(len(lines)))", Filename: "test.c65", LineNo: 2, Kind: preproc.Script}, + {Text: "print(\"line0=\" + lines[0])", Filename: "test.c65", LineNo: 3, Kind: preproc.Script}, + {Text: "print(\"line1=\" + lines[1])", Filename: "test.c65", LineNo: 4, Kind: preproc.Script}, + {Text: "print(\"line2=\" + lines[2])", Filename: "test.c65", LineNo: 5, Kind: preproc.Script}, + } + + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("executeScript failed: %v", err) + } + + if len(output) < 4 { + t.Fatalf("expected at least 4 output lines, got %d: %v", len(output), output) + } + if output[0] != "len=3" { + t.Errorf("output[0] = %q, want \"len=3\"", output[0]) + } + if output[1] != "line0=hello" { + t.Errorf("output[1] = %q, want \"line0=hello\"", output[1]) + } + if output[2] != "line1=world" { + t.Errorf("output[2] = %q, want \"line1=world\"", output[2]) + } + if output[3] != "line2=foo" { + t.Errorf("output[3] = %q, want \"line2=foo\"", output[3]) + } +} + +func TestExecuteScript_LoadText_TrailingNewline(t *testing.T) { + tmpDir := t.TempDir() + textContent := "hello\nworld\n" + if err := os.WriteFile(filepath.Join(tmpDir, "data.txt"), []byte(textContent), 0644); err != nil { + t.Fatal(err) + } + + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + ctx.ProjectRoot = tmpDir + + scriptLines := []preproc.Line{ + {Text: "lines = load_text(\"data.txt\")", Filename: "test.c65", LineNo: 1, Kind: preproc.Script}, + {Text: "print(\"len=\" + str(len(lines)))", Filename: "test.c65", LineNo: 2, Kind: preproc.Script}, + } + + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("executeScript failed: %v", err) + } + + if len(output) < 1 { + t.Fatalf("expected at least 1 output line, got %d: %v", len(output), output) + } + if output[0] != "len=2" { + t.Errorf("output[0] = %q, want \"len=2\"", output[0]) + } +} + +func TestExecuteScript_LoadText_EmptyFile(t *testing.T) { + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, "empty.txt"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + ctx.ProjectRoot = tmpDir + + scriptLines := []preproc.Line{ + {Text: "lines = load_text(\"empty.txt\")", Filename: "test.c65", LineNo: 1, Kind: preproc.Script}, + {Text: "print(\"len=\" + str(len(lines)))", Filename: "test.c65", LineNo: 2, Kind: preproc.Script}, + } + + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("executeScript failed: %v", err) + } + + if len(output) < 1 { + t.Fatalf("expected at least 1 output line, got %d: %v", len(output), output) + } + if output[0] != "len=0" { + t.Errorf("output[0] = %q, want \"len=0\"", output[0]) + } +} + +func TestExecuteScript_LoadText_CRLF(t *testing.T) { + tmpDir := t.TempDir() + textContent := "hello\r\nworld\r\n" + if err := os.WriteFile(filepath.Join(tmpDir, "data.txt"), []byte(textContent), 0644); err != nil { + t.Fatal(err) + } + + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + ctx.ProjectRoot = tmpDir + + scriptLines := []preproc.Line{ + {Text: "lines = load_text(\"data.txt\")", Filename: "test.c65", LineNo: 1, Kind: preproc.Script}, + {Text: "print(\"len=\" + str(len(lines)))", Filename: "test.c65", LineNo: 2, Kind: preproc.Script}, + {Text: "print(\"line0=\" + lines[0])", Filename: "test.c65", LineNo: 3, Kind: preproc.Script}, + {Text: "print(\"line1=\" + lines[1])", Filename: "test.c65", LineNo: 4, Kind: preproc.Script}, + } + + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("executeScript failed: %v", err) + } + + if len(output) < 3 { + t.Fatalf("expected at least 3 output lines, got %d: %v", len(output), output) + } + if output[0] != "len=2" { + t.Errorf("output[0] = %q, want \"len=2\"", output[0]) + } + if output[1] != "line0=hello" { + t.Errorf("output[1] = %q, want \"line0=hello\"", output[1]) + } + if output[2] != "line1=world" { + t.Errorf("output[2] = %q, want \"line1=world\"", output[2]) + } +} + +func TestExecuteScript_LoadText_LibraryFunction(t *testing.T) { + tmpDir := t.TempDir() + textContent := "foo\nbar\nbaz" + if err := os.WriteFile(filepath.Join(tmpDir, "data.txt"), []byte(textContent), 0644); err != nil { + t.Fatal(err) + } + + pragma := preproc.NewPragma() + ctx := NewCompilerContext(pragma) + ctx.ProjectRoot = tmpDir + + // Define a library function that uses load_text + libLines := []preproc.Line{ + {Text: "def get_lines():\n return load_text(\"data.txt\")", Filename: "test.c65", LineNo: 1, Kind: preproc.ScriptLibrary}, + } + _, err := executeScript(libLines, ctx, true) + if err != nil { + t.Fatalf("library definition failed: %v", err) + } + + // Use the library function from a regular script + scriptLines := []preproc.Line{ + {Text: "lines = get_lines()", Filename: "test.c65", LineNo: 5, Kind: preproc.Script}, + {Text: "print(\"len=\" + str(len(lines)))", Filename: "test.c65", LineNo: 6, Kind: preproc.Script}, + {Text: "print(\"line1=\" + lines[1])", Filename: "test.c65", LineNo: 7, Kind: preproc.Script}, + } + + output, err := executeScript(scriptLines, ctx, false) + if err != nil { + t.Fatalf("executeScript failed: %v", err) + } + + if len(output) < 2 { + t.Fatalf("expected at least 2 output lines, got %d: %v", len(output), output) + } + if output[0] != "len=3" { + t.Errorf("output[0] = %q, want \"len=3\"", output[0]) + } + if output[1] != "line1=bar" { + t.Errorf("output[1] = %q, want \"line1=bar\"", output[1]) + } +} diff --git a/language.md b/language.md index 0fca296..7792dff 100644 --- a/language.md +++ b/language.md @@ -507,6 +507,58 @@ SCRIPT ENDSCRIPT ``` +#### File I/O + +Scripts can read binary and text files at compile time using `load_binary()` and `load_text()`. These functions only allow access to files within the project folder (where the main .c65 file resides) and its subdirectories. Absolute paths and path traversal (`..`) are rejected. + +**`load_binary(path, offset=0, length=0)`** + +Reads a binary file and returns a list of integers (0-255). The optional `offset` parameter skips bytes at the start, and `length` limits how many bytes to read (0 = read to end of file). + +```c65 +SCRIPT + sprite = load_binary("assets/hero.spr") + print("hero_sprite:") + for i in range(0, len(sprite), 8): + row = ", ".join(["$%02x" % b for b in sprite[i:i+8]]) + print(" !byte " + row) +ENDSCRIPT +``` + +**`load_text(path)`** + +Reads a text file and returns a list of strings, one per line. Handles both `\n` (Unix) and `\r\n` (Windows) line endings. + +```c65 +SCRIPT + level = load_text("levels/lvl1.txt") + print("level_map:") + for y in range(len(level)): + print(" !text " + repr(level[y])) +ENDSCRIPT +``` + +**Example — resource loading with SCRIPT LIBRARY:** + +```c65 +SCRIPT LIBRARY + # Load resources into library globals + font_data = load_binary("assets/font.chr") + level = load_text("levels/lvl1.txt") + + def emit_font(): + print("font:") + for i in range(0, len(font_data), 8): + row = ", ".join(["$%02x" % b for b in font_data[i:i+8]]) + print(" !byte " + row) + + def emit_level(): + print("level:") + for line in level: + print(" !text " + repr(line)) +ENDSCRIPT +``` + ### SCRIPT LIBRARY Blocks Define reusable Starlark functions that persist across all subsequent SCRIPT blocks: diff --git a/main.go b/main.go index 497bc3a..279279a 100644 --- a/main.go +++ b/main.go @@ -139,6 +139,11 @@ func compileOnly(inFile, outFile string, opt, optDebug, optC64 bool, optExcludes // Create compiler and register commands comp := compiler.NewCompiler(pragma) + absInput, err := filepath.Abs(inFile) + if err != nil { + return fmt.Errorf("failed to resolve input file path: %w", err) + } + comp.Context().ProjectRoot = filepath.Dir(absInput) comp.CmdlineOpt = opt comp.CmdlineDebug = optDebug // Build I/O regions from CLI flags diff --git a/syntax.md b/syntax.md index 4d752e6..8cc9f66 100644 --- a/syntax.md +++ b/syntax.md @@ -397,6 +397,51 @@ ENDSCRIPT - Maximum 1 million execution steps (prevents infinite loops) - Executed at compile time, not runtime +**File I/O Built-in Functions:** + +Scripts can read files from the project folder (the directory containing the main input .c65 file) using the following built-in functions: + +- **`load_binary(path, offset=0, length=0)`** — Loads a binary file and returns a list of integers (0-255). Optional `offset` specifies a starting byte position, and `length` limits the number of bytes read (0 = read all remaining bytes). +- **`load_text(path)`** — Loads a text file and returns a list of strings, one per line. Handles both `\n` and `\r\n` line endings. + +**Security restrictions:** +- Only relative paths are allowed (no absolute paths like `/etc/passwd`) +- Path traversal (`..`) is not permitted +- Only files inside the project folder (or its subdirectories) can be accessed +- File access errors cause a compile error with source location + +**Example — loading sprite data:** + +``` +SCRIPT + sprite = load_binary("assets/hero.spr") # load binary data + print("hero_sprite:") + for i in range(0, len(sprite), 8): + row = ", ".join(["$%02x" % b for b in sprite[i:i+8]]) + print(" !byte " + row) +ENDSCRIPT +``` + +**Example — loading a level map:** + +``` +SCRIPT LIBRARY + # Load a text-based level map at compile time + level = load_text("levels/lvl1.txt") + height = len(level) + width = len(level[0]) if height > 0 else 0 + + def emit_map(): + print("level_map:") + for y in range(height): + print(" !text " + repr(level[y])) + print("level_height:") + print(" !8 %d" % height) + print("level_width:") + print(" !8 %d" % width) +ENDSCRIPT +``` + --- ### SCRIPT LIBRARY...ENDSCRIPT