Added file loading (bin, text files) into SCRIPTS

This commit is contained in:
Mattias Hansson 2026-06-12 14:48:33 +02:00
parent 3748e0bd3e
commit b2e9e3dc17
10 changed files with 694 additions and 4 deletions

View file

@ -0,0 +1,5 @@
#!/bin/sh
# Define filename as variable
PROGNAME="load_binary_demo"
# Compile and assemble directly
c65gm ${PROGNAME}.c65

View file

@ -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 <c64start.c65>
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

View file

@ -0,0 +1,2 @@

 !"#$%&'(

View file

@ -0,0 +1 @@
x64 -autostartprgmode 1 load_binary_demo.prg

View file

@ -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

View file

@ -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,
"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,
"load_binary": makeLoadBinary(ctx.ProjectRoot),
"load_text": makeLoadText(ctx.ProjectRoot),
}
for k, v := range ctx.ScriptLibraryGlobals {
predeclared[k] = v

View file

@ -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])
}
}

View file

@ -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:

View file

@ -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

View file

@ -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