Compare commits

...

6 commits
v1.5 ... main

Author SHA1 Message Date
3d94dde5ea Fixed up POKE and POKEW ,-syntax 2026-07-05 23:47:10 +02:00
cf01db5e28 Fixed reported Starlark error line numbers to reflect the source files. 2026-07-03 17:07:41 +02:00
a1c9e16abe Fixed up so trying to read (from script) symlinks outside project folder result in error 2026-06-12 16:06:06 +02:00
9f24408a0b Added pragma to control number of steps in scripts and macros. Now that we support file loading some scripts might need more steps to process images or whatnot. 2026-06-12 15:12:34 +02:00
b2e9e3dc17 Added file loading (bin, text files) into SCRIPTS 2026-06-12 14:48:33 +02:00
3748e0bd3e v1.5
Built with: go version go1.25.1 linux/amd64
Build date: 2026-06-11T00:16:51Z

SHA256 checksums:
f4fdc16cebdc1db0398ffd4599136fc767e431d319b44afdebd81845aeb4b8a8  c65gm_v1.5_darwin_amd64.tar.gz
eeb0f2afd1201bea068889ab14dc07fb9c01fa2dc452883e71efa71354cc3c77  c65gm_v1.5_darwin_arm64.tar.gz
fe1f597ca4a06b0470f4553b964c87cf10e4388cb831f7a4e52aa65fbc734e49  c65gm_v1.5_linux_amd64.tar.gz
971b630f0cec3eb01b3bdae3883d25d467ac1760a7632525a89566e23ce43c24  c65gm_v1.5_linux_arm64.tar.gz
d6314b4dab8ca297a0e57eabda581fba707f74c951c6c5f14ab8ea47d14cac2f  c65gm_v1.5_windows_amd64.zip
2026-06-11 02:17:17 +02:00
32 changed files with 2402 additions and 87 deletions

View file

@ -1 +1 @@
5 6

View file

@ -1,5 +1,5 @@
FROM ghcr.io/anomalyco/opencode:1.14.48 #FROM ghcr.io/anomalyco/opencode:1.14.48
#FROM ghcr.io/anomalyco/opencode:latest FROM ghcr.io/anomalyco/opencode:latest
RUN apk add --no-cache go gcc musl-dev RUN apk add --no-cache go gcc musl-dev

View file

@ -8,12 +8,12 @@ FUNC sethires
BYTE b BYTE b
b = PEEK $d011 b = PEEK $d011
b = b | 32 //enable bitmap mode b = b | 32 //enable bitmap mode
POKE $d011 , b POKE $d011, b
b = PEEK $d018 b = PEEK $d018
b = b & %11110000 b = b & %11110000
b = b | 8 //enable bitmap mode b = b | 8 //enable bitmap mode
POKE $d018 , b POKE $d018, b
FEND FEND
@ -22,7 +22,7 @@ FEND
FUNC fillmem({WORD start_addr @ $fa} {WORD end_addr @ $fc} {BYTE value}) FUNC fillmem({WORD start_addr @ $fa} {WORD end_addr @ $fc} {BYTE value})
WHILE start_addr <= end_addr WHILE start_addr <= end_addr
POKE start_addr , value POKE start_addr, value
start_addr++ start_addr++
WEND WEND

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

@ -40,7 +40,7 @@ FUNC wait_key
WEND WEND
// Reset key buffer // Reset key buffer
POKE $c6 WITH 0 POKE $c6, 0
FEND FEND

View file

@ -45,7 +45,7 @@ FUNC wait_key
ENDIF ENDIF
WEND WEND
POKE $c6 WITH 0 POKE $c6, 0
FEND FEND
//----------------------------------------------------------- //-----------------------------------------------------------

View file

@ -36,7 +36,7 @@ FUNC wait_key
ENDIF ENDIF
WEND WEND
POKE $c6 WITH 0 POKE $c6, 0
FEND FEND
//----------------------------------------------------------- //-----------------------------------------------------------

View file

@ -1,5 +1,5 @@
#!/bin/sh #!/bin/sh
# Define filename as variable # Define filename as variable
PROGNAME="multicolorbm" PROGNAME="multicolorbm"
# Compile and assemble directly # Compile and assemble directly, keep intermediate .asm file, enable optimizations
c65gm ${PROGNAME}.c65 c65gm build -i ${PROGNAME}.c65 --keep-asm --opt

View file

@ -8,16 +8,16 @@ FUNC setmulti
BYTE b BYTE b
b = PEEK $d011 b = PEEK $d011
b = b | 32 b = b | 32
POKE $d011 , b POKE $d011, b
b = PEEK $d016 b = PEEK $d016
b = b | 16 b = b | 16
POKE $d016 , b POKE $d016, b
b = PEEK $d018 b = PEEK $d018
b = b & %11110000 b = b & %11110000
b = b | 8 b = b | 8
POKE $d018 , b POKE $d018, b
FEND FEND
@ -25,7 +25,7 @@ FEND
FUNC fillmem({WORD start_addr @ $fa} {WORD end_addr @ $fc} {BYTE value}) FUNC fillmem({WORD start_addr @ $fa} {WORD end_addr @ $fc} {BYTE value})
WHILE start_addr <= end_addr WHILE start_addr <= end_addr
POKE start_addr , value POKE start_addr, value
start_addr++ start_addr++
WEND WEND
@ -41,7 +41,7 @@ FUNC main
fillmem(screen, screen+999, $12) fillmem(screen, screen+999, $12)
fillmem(colorram, colorram+999, $03) fillmem(colorram, colorram+999, $03)
POKE $d021 , 0 POKE $d021, 0
WHILE 1 WHILE 1
fillmem($2000, $3fff, %00011011) fillmem($2000, $3fff, %00011011)

5
examples/multicolorbm_v2/cm.sh Executable file
View file

@ -0,0 +1,5 @@
#!/bin/sh
# Define filename as variable
PROGNAME="multicolorbm_v2"
# Compile and assemble directly, keep intermediate .asm file, enable optimizations
c65gm build -i ${PROGNAME}.c65 --keep-asm --opt

View file

@ -0,0 +1,131 @@
//-----------------------------------------------------------
// multicolorbm_v2 - Multi-color bitmap demo using SCRIPT MACRO
//
// Uses compile-time code generation via SCRIPT MACRO to produce
// optimized fill routines instead of a generic runtime loop.
// The fill_fast macro analyzes the memory range at compile time
// and selects the most cycle-efficient 6502 fill strategy.
//-----------------------------------------------------------
#INCLUDE <c64start.c65>
#INCLUDE <c64defs.c65>
GOTO start
//-----------------------------------------------------------
// SCRIPT LIBRARY: optimized fill code generation
//
// At compile time, this analyzes the start/end/value and emits
// specialized assembly. Small fills (<256 bytes) use a simple
// X-indexed loop. Whole-page fills use page-based unrolling.
// Large partial-page fills choose the cycle-cheapest strategy.
//-----------------------------------------------------------
SCRIPT LIBRARY
def to_hex(v):
digits = "0123456789abcdef"
return digits[(v >> 12) & 15] + digits[(v >> 8) & 15] + digits[(v >> 4) & 15] + digits[v & 15]
def emit_fill_fast(start, end, value):
total = end - start + 1
print("")
print("; fill_fast $" + to_hex(start) + "..$" + to_hex(end) + " = " + str(total) + " bytes")
print(" lda #" + str(value))
if total < 256:
print(" ldx #" + str(total))
print("-")
print(" sta $" + to_hex(start) + "-1,x")
print(" dex")
print(" bne -")
elif total == 256:
print(" ldx #0")
print("-")
print(" sta $" + to_hex(start) + ",x")
print(" inx")
print(" bne -")
else:
full_pages = int(total / 256)
remain = total - full_pages * 256
cycles_a = (5 * full_pages + 4) * 256 + 9 * remain
cycles_b = (5 * (full_pages + 1) + 4) * 256 - 1
if cycles_a <= cycles_b:
pages = [start + i * 256 for i in range(full_pages)]
print(" ldx #0")
print("-")
for a in pages:
print(" sta $" + to_hex(a) + ",x")
print(" inx")
print(" bne -")
if remain > 0:
print(" ldx #" + str(remain))
print("--")
print(" sta $" + to_hex(start + full_pages * 256) + "-1,x")
print(" dex")
print(" bne --")
else:
pages = [start + i * 256 for i in range(full_pages)]
pages.append(end - 255)
print(" ldx #0")
print("-")
for a in pages:
print(" sta $" + to_hex(a) + ",x")
print(" inx")
print(" bne -")
ENDSCRIPT
//-----------------------------------------------------------
// SCRIPT MACRO: inline fill_fast
//
// Replacements for runtime fillmem(). Generates optimized
// assembly inline at each call site.
//-----------------------------------------------------------
SCRIPT MACRO fill_fast(start, end, value)
emit_fill_fast(start, end, value)
ENDSCRIPT
//-----------------------------------------------------------
// VIC-II multi-color bitmap setup (unchanged from v1)
//-----------------------------------------------------------
FUNC setmulti
BYTE b
b = PEEK $d011
b = b | 32
POKE $d011, b
b = PEEK $d016
b = b | 16
POKE $d016, b
b = PEEK $d018
b = b & %11110000
b = b | 8
POKE $d018, b
FEND
//-----------------------------------------------------------
// Main program
//
// Uses @fill_fast macro instead of runtime fillmem() loops.
// Each invocation generates optimal fill code for its range.
//-----------------------------------------------------------
FUNC main
setmulti()
@fill_fast($0400, $0400+999, $12)
@fill_fast(colorram, colorram+999, $03)
POKE $d021, 0
WHILE 1
@fill_fast($2000, $3fff, %00011011)
@fill_fast($2000, $3fff, %01101100)
@fill_fast($2000, $3fff, %10110001)
@fill_fast($2000, $3fff, %11000110)
WEND
FEND
LABEL start
main()

View file

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

View file

@ -43,7 +43,7 @@ FUNC wait_key
WEND WEND
// Reset key buffer // Reset key buffer
POKE $c6 WITH 0 POKE $c6, 0
FEND FEND

View file

@ -11,8 +11,9 @@ import (
// MacroCommand handles macro invocations // MacroCommand handles macro invocations
// Syntax: @macroname(arg1, arg2, ...) // Syntax: @macroname(arg1, arg2, ...)
type MacroCommand struct { type MacroCommand struct {
macroName string macroName string
args []string args []string
pragmaSetIndex int
} }
func (c *MacroCommand) WillHandle(line preproc.Line) bool { func (c *MacroCommand) WillHandle(line preproc.Line) bool {
@ -30,11 +31,12 @@ func (c *MacroCommand) Interpret(line preproc.Line, _ *compiler.CompilerContext)
c.macroName = name c.macroName = name
c.args = args c.args = args
c.pragmaSetIndex = line.PragmaSetIndex
return nil return nil
} }
func (c *MacroCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) { func (c *MacroCommand) Generate(ctx *compiler.CompilerContext) ([]string, error) {
macroOutput, err := compiler.ExecuteMacro(c.macroName, c.args, ctx) macroOutput, err := compiler.ExecuteMacro(c.macroName, c.args, ctx, c.pragmaSetIndex)
if err != nil { if err != nil {
return nil, fmt.Errorf("macro %s: %w", c.macroName, err) return nil, fmt.Errorf("macro %s: %w", c.macroName, err)
} }

View file

@ -38,7 +38,7 @@ type PokeCommand struct {
} }
func (c *PokeCommand) WillHandle(line preproc.Line) bool { func (c *PokeCommand) WillHandle(line preproc.Line) bool {
params, err := utils.ParseParams(line.Text) params, err := utils.ParseParams(utils.NormalizeCommas(line.Text))
if err != nil || len(params) != 4 { if err != nil || len(params) != 4 {
return false return false
} }
@ -62,7 +62,7 @@ func (c *PokeCommand) Interpret(line preproc.Line, ctx *compiler.CompilerContext
// Store pragma set for Generate phase // Store pragma set for Generate phase
c.pragmaSet = ctx.Pragma.GetPragmaSetByIndex(line.PragmaSetIndex) c.pragmaSet = ctx.Pragma.GetPragmaSetByIndex(line.PragmaSetIndex)
params, err := utils.ParseParams(line.Text) params, err := utils.ParseParams(utils.NormalizeCommas(line.Text))
if err != nil { if err != nil {
return err return err
} }

View file

@ -0,0 +1,987 @@
package commands
import (
"strings"
"testing"
"c65gm/internal/compiler"
"c65gm/internal/preproc"
)
// setupPokeVars adds all needed symbols to a symbol table for POKE/POKEW tests.
func setupPokeVars(st *compiler.SymbolTable) {
st.AddConst("vic2", "", compiler.KindWord, 0xd000, preproc.Line{Filename: "test.c65", LineNo: 1})
st.AddVar("addrvar", "", compiler.KindByte, 0, preproc.Line{Filename: "test.c65", LineNo: 2})
st.AddVar("valvar", "", compiler.KindByte, 0, preproc.Line{Filename: "test.c65", LineNo: 3})
st.AddVar("wvalvar", "", compiler.KindWord, 0, preproc.Line{Filename: "test.c65", LineNo: 4})
st.AddVar("waddrvar", "", compiler.KindWord, 0, preproc.Line{Filename: "test.c65", LineNo: 5})
st.AddAbsolute("zpptr", "", compiler.KindWord, 0x80, preproc.Line{Filename: "test.c65", LineNo: 6})
st.AddVar("offsvar", "", compiler.KindByte, 0, preproc.Line{Filename: "test.c65", LineNo: 7})
st.AddAbsolute("byaddr", "", compiler.KindByte, 0x40, preproc.Line{Filename: "test.c65", LineNo: 8})
}
func newCtx() *compiler.CompilerContext {
pragma := preproc.NewPragma()
ctx := compiler.NewCompilerContext(pragma)
setupPokeVars(ctx.SymbolTable)
return ctx
}
func newLine(text string, pragma *preproc.Pragma) preproc.Line {
return preproc.Line{
Text: text,
Kind: preproc.Source,
PragmaSetIndex: pragma.GetCurrentPragmaSetIndex(),
}
}
// =============================================================================
// POKE comma-spacing tests (constant address + literal value — Case 4)
// =============================================================================
func TestPokeCommaSpacing(t *testing.T) {
expectedAsm := []string{
"\tlda #5",
"\tsta 53280",
}
tests := []struct {
name string
line string
}{
{"space before and after comma", "POKE $d020 , 5"},
{"no space before or after comma", "POKE $d020,5"},
{"no space before comma, space after", "POKE $d020, 5"},
{"space before comma, no space after", "POKE $d020 ,5"},
{"no space comma, expression address", "POKE $d020+0, 5"},
{"WITH keyword", "POKE $d020 WITH 5"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newCtx()
cmd := &PokeCommand{}
line := newLine(tt.line, ctx.Pragma)
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
asm, err := cmd.Generate(ctx)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if !equalAsm(asm, expectedAsm) {
t.Errorf("Generate() mismatch\ngot:\n%s\nwant:\n%s",
strings.Join(asm, "\n"),
strings.Join(expectedAsm, "\n"))
}
})
}
}
// =============================================================================
// POKEW comma-spacing tests (constant address + literal value — Case 4)
// =============================================================================
func TestPokeWCommaSpacing(t *testing.T) {
expectedAsm := []string{
"\tlda #$65",
"\tsta 53280",
"\tlda #$00",
"\tsta 53281",
}
tests := []struct {
name string
line string
}{
{"space before and after comma", "POKEW $d020 , 101"},
{"no space before or after comma", "POKEW $d020,101"},
{"no space before comma, space after", "POKEW $d020, 101"},
{"space before comma, no space after", "POKEW $d020 ,101"},
{"WITH keyword", "POKEW $d020 WITH 101"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newCtx()
cmd := &PokeWCommand{}
line := newLine(tt.line, ctx.Pragma)
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
asm, err := cmd.Generate(ctx)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if !equalAsm(asm, expectedAsm) {
t.Errorf("Generate() mismatch\ngot:\n%s\nwant:\n%s",
strings.Join(asm, "\n"),
strings.Join(expectedAsm, "\n"))
}
})
}
}
// =============================================================================
// POKE Case 4 — Direct addressing (expression/constant address)
// =============================================================================
func TestPokeDirectAddr(t *testing.T) {
tests := []struct {
name string
line string
wantAsm []string
}{
{
name: "expression constant + expression value, no-space comma",
line: "POKE vic2+15,5+2",
wantAsm: []string{
"\tlda #7",
"\tsta 53263",
},
},
{
name: "variable value, no-space comma",
line: "POKE $d020,valvar",
wantAsm: []string{
"\tlda valvar",
"\tsta 53280",
},
},
{
name: "variable value, space before comma",
line: "POKE $d020 ,valvar",
wantAsm: []string{
"\tlda valvar",
"\tsta 53280",
},
},
{
name: "variable value, space after comma",
line: "POKE $d020, valvar",
wantAsm: []string{
"\tlda valvar",
"\tsta 53280",
},
},
{
name: "expression address + var value, no-space comma",
line: "POKE vic2+15,valvar",
wantAsm: []string{
"\tlda valvar",
"\tsta 53263",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newCtx()
cmd := &PokeCommand{}
line := newLine(tt.line, ctx.Pragma)
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
asm, err := cmd.Generate(ctx)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if !equalAsm(asm, tt.wantAsm) {
t.Errorf("Generate() mismatch\ngot:\n%s\nwant:\n%s",
strings.Join(asm, "\n"),
strings.Join(tt.wantAsm, "\n"))
}
})
}
}
// =============================================================================
// POKE Case 1 — ZP pointer (indexed indirect addressing)
// =============================================================================
func TestPokeZPPointer(t *testing.T) {
tests := []struct {
name string
line string
wantAsm []string
}{
{
name: "no offset, literal value, no-space comma",
line: "POKE zpptr,10",
wantAsm: []string{
"\tldy #0",
"\tlda #10",
"\tsta (zpptr),y",
},
},
{
name: "no offset, var value, no-space comma",
line: "POKE zpptr,valvar",
wantAsm: []string{
"\tldy #0",
"\tlda valvar",
"\tsta (zpptr),y",
},
},
{
name: "literal offset, literal value, no-space comma",
line: "POKE zpptr[5],10",
wantAsm: []string{
"\tldy #5",
"\tlda #10",
"\tsta (zpptr),y",
},
},
{
name: "literal offset, var value, no-space comma",
line: "POKE zpptr[5],valvar",
wantAsm: []string{
"\tldy #5",
"\tlda valvar",
"\tsta (zpptr),y",
},
},
{
name: "var offset, literal value, no-space comma",
line: "POKE zpptr[offsvar],10",
wantAsm: []string{
"\tldy offsvar",
"\tlda #10",
"\tsta (zpptr),y",
},
},
{
name: "var offset, var value, no-space comma",
line: "POKE zpptr[offsvar],valvar",
wantAsm: []string{
"\tldy offsvar",
"\tlda valvar",
"\tsta (zpptr),y",
},
},
{
name: "literal offset, var value, spaced comma",
line: "POKE zpptr[5] , valvar",
wantAsm: []string{
"\tldy #5",
"\tlda valvar",
"\tsta (zpptr),y",
},
},
{
name: "word variable value (uses low byte), no-space comma",
line: "POKE zpptr[5],wvalvar",
wantAsm: []string{
"\tldy #5",
"\tlda wvalvar",
"\tsta (zpptr),y",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newCtx()
cmd := &PokeCommand{}
line := newLine(tt.line, ctx.Pragma)
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
asm, err := cmd.Generate(ctx)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if !equalAsm(asm, tt.wantAsm) {
t.Errorf("Generate() mismatch\ngot:\n%s\nwant:\n%s",
strings.Join(asm, "\n"),
strings.Join(tt.wantAsm, "\n"))
}
})
}
}
// =============================================================================
// POKE Case 2 — Byte variable address (self-modifying code)
// =============================================================================
func TestPokeSMByteAddr(t *testing.T) {
tests := []struct {
name string
line string
wantAsm []string
}{
{
name: "literal value, no-space comma",
line: "POKE addrvar,10",
wantAsm: []string{
"\tlda addrvar",
"\tsta _L1+1",
"\tlda #10",
"_L1",
"\tsta $ff",
},
},
{
name: "var value, no-space comma",
line: "POKE addrvar,valvar",
wantAsm: []string{
"\tlda addrvar",
"\tsta _L1+1",
"\tlda valvar",
"_L1",
"\tsta $ff",
},
},
{
name: "word var value (uses low byte), no-space comma",
line: "POKE addrvar,wvalvar",
wantAsm: []string{
"\tlda addrvar",
"\tsta _L1+1",
"\tlda wvalvar",
"_L1",
"\tsta $ff",
},
},
{
name: "literal value, spaced comma",
line: "POKE addrvar , 10",
wantAsm: []string{
"\tlda addrvar",
"\tsta _L1+1",
"\tlda #10",
"_L1",
"\tsta $ff",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newCtx()
cmd := &PokeCommand{}
line := newLine(tt.line, ctx.Pragma)
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
asm, err := cmd.Generate(ctx)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if !equalAsm(asm, tt.wantAsm) {
t.Errorf("Generate() mismatch\ngot:\n%s\nwant:\n%s",
strings.Join(asm, "\n"),
strings.Join(tt.wantAsm, "\n"))
}
})
}
}
// =============================================================================
// POKE Case 3 — Word variable address (self-modifying code)
// =============================================================================
func TestPokeSMWordAddr(t *testing.T) {
tests := []struct {
name string
line string
wantAsm []string
}{
{
name: "literal value, no-space comma",
line: "POKE waddrvar,10",
wantAsm: []string{
"\tlda waddrvar",
"\tsta _L1+1",
"\tlda waddrvar+1",
"\tsta _L1+2",
"\tlda #10",
"_L1",
"\tsta $ffff",
},
},
{
name: "var value, no-space comma",
line: "POKE waddrvar,valvar",
wantAsm: []string{
"\tlda waddrvar",
"\tsta _L1+1",
"\tlda waddrvar+1",
"\tsta _L1+2",
"\tlda valvar",
"_L1",
"\tsta $ffff",
},
},
{
name: "word var value (uses low byte), no-space comma",
line: "POKE waddrvar,wvalvar",
wantAsm: []string{
"\tlda waddrvar",
"\tsta _L1+1",
"\tlda waddrvar+1",
"\tsta _L1+2",
"\tlda wvalvar",
"_L1",
"\tsta $ffff",
},
},
{
name: "literal value, spaced comma",
line: "POKE waddrvar , 10",
wantAsm: []string{
"\tlda waddrvar",
"\tsta _L1+1",
"\tlda waddrvar+1",
"\tsta _L1+2",
"\tlda #10",
"_L1",
"\tsta $ffff",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newCtx()
cmd := &PokeCommand{}
line := newLine(tt.line, ctx.Pragma)
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
asm, err := cmd.Generate(ctx)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if !equalAsm(asm, tt.wantAsm) {
t.Errorf("Generate() mismatch\ngot:\n%s\nwant:\n%s",
strings.Join(asm, "\n"),
strings.Join(tt.wantAsm, "\n"))
}
})
}
}
// =============================================================================
// POKEW Case 4 — Direct addressing
// =============================================================================
func TestPokeWDirectAddr(t *testing.T) {
tests := []struct {
name string
line string
wantAsm []string
}{
{
name: "literal value, no-space comma",
line: "POKEW $d020,101",
wantAsm: []string{
"\tlda #$65",
"\tsta 53280",
"\tlda #$00",
"\tsta 53281",
},
},
{
name: "literal value >255, no-space comma",
line: "POKEW $d020,$1234",
wantAsm: []string{
"\tlda #$34",
"\tsta 53280",
"\tlda #$12",
"\tsta 53281",
},
},
{
name: "word var value, no-space comma",
line: "POKEW $d020,wvalvar",
wantAsm: []string{
"\tlda wvalvar",
"\tsta 53280",
"\tlda wvalvar+1",
"\tsta 53281",
},
},
{
name: "word var value, spaced comma",
line: "POKEW $d020 , wvalvar",
wantAsm: []string{
"\tlda wvalvar",
"\tsta 53280",
"\tlda wvalvar+1",
"\tsta 53281",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newCtx()
cmd := &PokeWCommand{}
line := newLine(tt.line, ctx.Pragma)
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
asm, err := cmd.Generate(ctx)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if !equalAsm(asm, tt.wantAsm) {
t.Errorf("Generate() mismatch\ngot:\n%s\nwant:\n%s",
strings.Join(asm, "\n"),
strings.Join(tt.wantAsm, "\n"))
}
})
}
}
// =============================================================================
// POKEW Case 1 — ZP pointer (indexed indirect, two stores)
// =============================================================================
func TestPokeWZPPointer(t *testing.T) {
tests := []struct {
name string
line string
wantAsm []string
}{
{
name: "no offset, literal value, no-space comma",
line: "POKEW zpptr,101",
wantAsm: []string{
"\tldy #0",
"\tlda #$65",
"\tsta (zpptr),y",
"\tiny",
"\tlda #$00",
"\tsta (zpptr),y",
},
},
{
name: "no offset, word var value, no-space comma",
line: "POKEW zpptr,wvalvar",
wantAsm: []string{
"\tldy #0",
"\tlda wvalvar",
"\tsta (zpptr),y",
"\tiny",
"\tlda wvalvar+1",
"\tsta (zpptr),y",
},
},
{
name: "literal offset, literal value, no-space comma",
line: "POKEW zpptr[5],101",
wantAsm: []string{
"\tldy #5",
"\tlda #$65",
"\tsta (zpptr),y",
"\tiny",
"\tlda #$00",
"\tsta (zpptr),y",
},
},
{
name: "literal offset, word var value, no-space comma",
line: "POKEW zpptr[5],wvalvar",
wantAsm: []string{
"\tldy #5",
"\tlda wvalvar",
"\tsta (zpptr),y",
"\tiny",
"\tlda wvalvar+1",
"\tsta (zpptr),y",
},
},
{
name: "literal offset, literal value, spaced comma",
line: "POKEW zpptr[5] , 101",
wantAsm: []string{
"\tldy #5",
"\tlda #$65",
"\tsta (zpptr),y",
"\tiny",
"\tlda #$00",
"\tsta (zpptr),y",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newCtx()
cmd := &PokeWCommand{}
line := newLine(tt.line, ctx.Pragma)
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
asm, err := cmd.Generate(ctx)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if !equalAsm(asm, tt.wantAsm) {
t.Errorf("Generate() mismatch\ngot:\n%s\nwant:\n%s",
strings.Join(asm, "\n"),
strings.Join(tt.wantAsm, "\n"))
}
})
}
}
// =============================================================================
// POKEW Case 2 — Byte variable address (zero-page indexed)
// =============================================================================
func TestPokeWByteAddr(t *testing.T) {
tests := []struct {
name string
line string
wantAsm []string
}{
{
name: "literal value, no-space comma",
line: "POKEW byaddr,101",
wantAsm: []string{
"\tldx byaddr",
"\tlda #$65",
"\tsta $00,x",
"\tinx",
"\tlda #$00",
"\tsta $00,x",
},
},
{
name: "word var value, no-space comma",
line: "POKEW byaddr,wvalvar",
wantAsm: []string{
"\tldx byaddr",
"\tlda wvalvar",
"\tsta $00,x",
"\tinx",
"\tlda wvalvar+1",
"\tsta $00,x",
},
},
{
name: "literal value, spaced comma",
line: "POKEW byaddr , 101",
wantAsm: []string{
"\tldx byaddr",
"\tlda #$65",
"\tsta $00,x",
"\tinx",
"\tlda #$00",
"\tsta $00,x",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newCtx()
cmd := &PokeWCommand{}
line := newLine(tt.line, ctx.Pragma)
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
asm, err := cmd.Generate(ctx)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if !equalAsm(asm, tt.wantAsm) {
t.Errorf("Generate() mismatch\ngot:\n%s\nwant:\n%s",
strings.Join(asm, "\n"),
strings.Join(tt.wantAsm, "\n"))
}
})
}
}
// =============================================================================
// POKEW Case 3 — Word variable address (self-modifying code)
// =============================================================================
func TestPokeWSMWordAddr(t *testing.T) {
tests := []struct {
name string
line string
wantAsm []string
}{
{
name: "literal value, no-space comma",
line: "POKEW waddrvar,101",
wantAsm: []string{
"\tlda waddrvar",
"\tsta _L1+1",
"\tsta _L2+1",
"\tlda waddrvar+1",
"\tsta _L1+2",
"\tsta _L2+2",
"\tlda #$65",
"_L1",
"\tsta $ffff",
"\tinc _L2+1",
"\tbne _L2",
"\tinc _L2+2",
"_L2",
"\tlda #$00",
"\tsta $ffff",
},
},
{
name: "word var value, no-space comma",
line: "POKEW waddrvar,wvalvar",
wantAsm: []string{
"\tlda waddrvar",
"\tsta _L1+1",
"\tsta _L2+1",
"\tlda waddrvar+1",
"\tsta _L1+2",
"\tsta _L2+2",
"\tlda wvalvar",
"_L1",
"\tsta $ffff",
"\tinc _L2+1",
"\tbne _L2",
"\tinc _L2+2",
"_L2",
"\tlda wvalvar+1",
"\tsta $ffff",
},
},
{
name: "literal value, spaced comma",
line: "POKEW waddrvar , 101",
wantAsm: []string{
"\tlda waddrvar",
"\tsta _L1+1",
"\tsta _L2+1",
"\tlda waddrvar+1",
"\tsta _L1+2",
"\tsta _L2+2",
"\tlda #$65",
"_L1",
"\tsta $ffff",
"\tinc _L2+1",
"\tbne _L2",
"\tinc _L2+2",
"_L2",
"\tlda #$00",
"\tsta $ffff",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newCtx()
cmd := &PokeWCommand{}
line := newLine(tt.line, ctx.Pragma)
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
asm, err := cmd.Generate(ctx)
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if !equalAsm(asm, tt.wantAsm) {
t.Errorf("Generate() mismatch\ngot:\n%s\nwant:\n%s",
strings.Join(asm, "\n"),
strings.Join(tt.wantAsm, "\n"))
}
})
}
}
// =============================================================================
// Error cases
// =============================================================================
func TestPokeErrors(t *testing.T) {
tests := []struct {
name string
line string
wantErr string
}{
{
name: "offset on non-ZP word pointer",
line: "POKE waddrvar[5],10",
wantErr: "POKE: offset",
},
{
name: "value out of byte range",
line: "POKE $d020,256",
wantErr: "out of byte range",
},
{
name: "POKEW with byte variable as value",
line: "POKEW $d020,valvar",
wantErr: "cannot use byte variable",
},
{
name: "POKEW self-referential (zp pointer == value)",
line: "POKEW zpptr,zpptr",
wantErr: "writing pointer",
},
{
name: "invalid separator",
line: "POKE $d020 WRONG 5",
wantErr: "must be 'WITH' or ','",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := newCtx()
if strings.HasPrefix(tt.line, "POKEW") {
cmd := &PokeWCommand{}
line := newLine(tt.line, ctx.Pragma)
err := cmd.Interpret(line, ctx)
if err == nil {
t.Fatal("Interpret() expected error but got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("Interpret() error = %q, want containing %q", err.Error(), tt.wantErr)
}
return
}
cmd := &PokeCommand{}
line := newLine(tt.line, ctx.Pragma)
err := cmd.Interpret(line, ctx)
if err == nil {
t.Fatal("Interpret() expected error but got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("Interpret() error = %q, want containing %q", err.Error(), tt.wantErr)
}
})
}
}
func TestPokePragmaImmutable(t *testing.T) {
t.Run("SM byte addr with USE_IMMUTABLE_CODE", func(t *testing.T) {
pragma := preproc.NewPragma()
pragma.AddPragma("_P_USE_IMMUTABLE_CODE", "1")
ctx := compiler.NewCompilerContext(pragma)
ctx.SymbolTable.AddVar("addrvar", "", compiler.KindByte, 0, preproc.Line{Filename: "test.c65", LineNo: 1})
cmd := &PokeCommand{}
line := preproc.Line{
Text: "POKE addrvar,10",
Kind: preproc.Source,
PragmaSetIndex: pragma.GetCurrentPragmaSetIndex(),
}
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
_, err := cmd.Generate(ctx)
if err == nil {
t.Fatal("Generate() expected error with USE_IMMUTABLE_CODE")
}
if !strings.Contains(err.Error(), "USE_IMMUTABLE_CODE") {
t.Errorf("Generate() error = %q, want containing USE_IMMUTABLE_CODE", err.Error())
}
})
t.Run("SM word addr with USE_IMMUTABLE_CODE", func(t *testing.T) {
pragma := preproc.NewPragma()
pragma.AddPragma("_P_USE_IMMUTABLE_CODE", "1")
ctx := compiler.NewCompilerContext(pragma)
ctx.SymbolTable.AddVar("waddrvar", "", compiler.KindWord, 0, preproc.Line{Filename: "test.c65", LineNo: 1})
cmd := &PokeCommand{}
line := preproc.Line{
Text: "POKE waddrvar,10",
Kind: preproc.Source,
PragmaSetIndex: pragma.GetCurrentPragmaSetIndex(),
}
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
_, err := cmd.Generate(ctx)
if err == nil {
t.Fatal("Generate() expected error with USE_IMMUTABLE_CODE")
}
if !strings.Contains(err.Error(), "USE_IMMUTABLE_CODE") {
t.Errorf("Generate() error = %q, want containing USE_IMMUTABLE_CODE", err.Error())
}
})
t.Run("ZP pointer is allowed with USE_IMMUTABLE_CODE", func(t *testing.T) {
pragma := preproc.NewPragma()
pragma.AddPragma("_P_USE_IMMUTABLE_CODE", "1")
ctx := compiler.NewCompilerContext(pragma)
ctx.SymbolTable.AddAbsolute("zpptr", "", compiler.KindWord, 0x80, preproc.Line{Filename: "test.c65", LineNo: 1})
cmd := &PokeCommand{}
line := preproc.Line{
Text: "POKE zpptr,10",
Kind: preproc.Source,
PragmaSetIndex: pragma.GetCurrentPragmaSetIndex(),
}
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
asm, err := cmd.Generate(ctx)
if err != nil {
t.Fatalf("Generate() error = %v (ZP pointers should be allowed)", err)
}
expected := []string{
"\tldy #0",
"\tlda #10",
"\tsta (zpptr),y",
}
if !equalAsm(asm, expected) {
t.Errorf("Generate() mismatch\ngot:\n%s\nwant:\n%s",
strings.Join(asm, "\n"),
strings.Join(expected, "\n"))
}
})
t.Run("direct addr is allowed with USE_IMMUTABLE_CODE", func(t *testing.T) {
pragma := preproc.NewPragma()
pragma.AddPragma("_P_USE_IMMUTABLE_CODE", "1")
ctx := compiler.NewCompilerContext(pragma)
cmd := &PokeCommand{}
line := preproc.Line{
Text: "POKE $d020,5",
Kind: preproc.Source,
PragmaSetIndex: pragma.GetCurrentPragmaSetIndex(),
}
if err := cmd.Interpret(line, ctx); err != nil {
t.Fatalf("Interpret() error = %v", err)
}
asm, err := cmd.Generate(ctx)
if err != nil {
t.Fatalf("Generate() error = %v (direct addr should be allowed)", err)
}
expected := []string{
"\tlda #5",
"\tsta 53280",
}
if !equalAsm(asm, expected) {
t.Errorf("Generate() mismatch\ngot:\n%s\nwant:\n%s",
strings.Join(asm, "\n"),
strings.Join(expected, "\n"))
}
})
}

View file

@ -37,7 +37,7 @@ type PokeWCommand struct {
} }
func (c *PokeWCommand) WillHandle(line preproc.Line) bool { func (c *PokeWCommand) WillHandle(line preproc.Line) bool {
params, err := utils.ParseParams(line.Text) params, err := utils.ParseParams(utils.NormalizeCommas(line.Text))
if err != nil || len(params) != 4 { if err != nil || len(params) != 4 {
return false return false
} }
@ -61,7 +61,7 @@ func (c *PokeWCommand) Interpret(line preproc.Line, ctx *compiler.CompilerContex
// Store pragma set for Generate phase // Store pragma set for Generate phase
c.pragmaSet = ctx.Pragma.GetPragmaSetByIndex(line.PragmaSetIndex) c.pragmaSet = ctx.Pragma.GetPragmaSetByIndex(line.PragmaSetIndex)
params, err := utils.ParseParams(line.Text) params, err := utils.ParseParams(utils.NormalizeCommas(line.Text))
if err != nil { if err != nil {
return err return err
} }

View file

@ -169,7 +169,7 @@ func (c *Compiler) Compile(lines []preproc.Line) ([]string, error) {
return nil, fmt.Errorf("compilation failed") return nil, fmt.Errorf("compilation failed")
} }
macroOutput, err := ExecuteMacro(macroName, args, c.ctx) macroOutput, err := ExecuteMacro(macroName, args, c.ctx, line.PragmaSetIndex)
if err != nil { if err != nil {
c.printErrorWithContext(lines, i, fmt.Errorf("macro %s: %w", macroName, err)) c.printErrorWithContext(lines, i, fmt.Errorf("macro %s: %w", macroName, err))
return nil, fmt.Errorf("compilation failed") return nil, fmt.Errorf("compilation failed")

View file

@ -352,7 +352,7 @@ func TestExecuteMacro_Basic(t *testing.T) {
} }
// Execute macro // Execute macro
output, err := ExecuteMacro("test_macro", []string{"3"}, ctx) output, err := ExecuteMacro("test_macro", []string{"3"}, ctx, 0)
if err != nil { if err != nil {
t.Fatalf("ExecuteMacro failed: %v", err) t.Fatalf("ExecuteMacro failed: %v", err)
} }
@ -392,7 +392,7 @@ func TestExecuteMacro_WithLibraryFunction(t *testing.T) {
} }
// Execute macro // Execute macro
output, err := ExecuteMacro("nop_macro", []string{}, ctx) output, err := ExecuteMacro("nop_macro", []string{}, ctx, 0)
if err != nil { if err != nil {
t.Fatalf("ExecuteMacro failed: %v", err) t.Fatalf("ExecuteMacro failed: %v", err)
} }
@ -416,7 +416,7 @@ func TestExecuteMacro_StringParameter(t *testing.T) {
} }
// Execute with identifier (should be passed as string) // Execute with identifier (should be passed as string)
output, err := ExecuteMacro("jump_to", []string{"my_label"}, ctx) output, err := ExecuteMacro("jump_to", []string{"my_label"}, ctx, 0)
if err != nil { if err != nil {
t.Fatalf("ExecuteMacro failed: %v", err) t.Fatalf("ExecuteMacro failed: %v", err)
} }
@ -451,7 +451,7 @@ func TestExecuteMacro_LocalVariableExpansion(t *testing.T) {
} }
// Execute macro with "myvar" as argument - should expand |myvar| to testfunc_myvar // Execute macro with "myvar" as argument - should expand |myvar| to testfunc_myvar
output, err := ExecuteMacro("load_var", []string{"myvar"}, ctx) output, err := ExecuteMacro("load_var", []string{"myvar"}, ctx, 0)
if err != nil { if err != nil {
t.Fatalf("ExecuteMacro failed: %v", err) t.Fatalf("ExecuteMacro failed: %v", err)
} }
@ -500,7 +500,7 @@ func TestExecuteMacro_LocalVariableExpansion_MultipleVars(t *testing.T) {
} }
// Execute macro with actual variable names as arguments // Execute macro with actual variable names as arguments
output, err := ExecuteMacro("table_lookup", []string{"scroll_color_table", "color_index", "row_color"}, ctx) output, err := ExecuteMacro("table_lookup", []string{"scroll_color_table", "color_index", "row_color"}, ctx, 0)
if err != nil { if err != nil {
t.Fatalf("ExecuteMacro failed: %v", err) t.Fatalf("ExecuteMacro failed: %v", err)
} }
@ -1232,3 +1232,83 @@ func testExecuteScript(scriptLines []string, ctx *CompilerContext, isLibrary boo
} }
return executeScript(lines, ctx, isLibrary) return executeScript(lines, ctx, isLibrary)
} }
func TestCompile_MultipleScriptBlocks_Success(t *testing.T) {
// Two consecutive SCRIPT blocks should execute independently
pragma := preproc.NewPragma()
comp := NewCompiler(pragma)
lines := []preproc.Line{
// Block 1: print("hello")
{Text: "print('hello')", Filename: "test.c65", LineNo: 2, Kind: preproc.Script},
// ENDSCRIPT boundary (empty Source)
{Text: "", Filename: "test.c65", LineNo: 3, Kind: preproc.Source},
// Block 2: print("world")
{Text: "print('world')", Filename: "test.c65", LineNo: 5, Kind: preproc.Script},
// ENDSCRIPT boundary (empty Source)
{Text: "", Filename: "test.c65", LineNo: 6, Kind: preproc.Source},
}
output, err := comp.Compile(lines)
if err != nil {
t.Fatalf("Compile failed: %v", err)
}
// Output should contain both "hello" and "world" in that order
foundHello := false
foundWorld := false
helloBeforeWorld := false
for _, line := range output {
if strings.Contains(line, "hello") && !foundHello {
foundHello = true
}
if foundHello && strings.Contains(line, "world") && !foundWorld {
foundWorld = true
helloBeforeWorld = true
}
}
if !foundHello {
t.Error("expected 'hello' from block 1 in output")
}
if !foundWorld {
t.Error("expected 'world' from block 2 in output")
}
if !helloBeforeWorld {
t.Error("expected 'hello' before 'world' in output")
}
}
func TestCompile_MultipleScriptBlocks_ErrorInBlock2(t *testing.T) {
// Error in second SCRIPT block should not be confused with block 1
pragma := preproc.NewPragma()
comp := NewCompiler(pragma)
lines := []preproc.Line{
// Block 1: no error
{Text: "x = 1", Filename: "test.c65", LineNo: 2, Kind: preproc.Script},
{Text: "print(x)", Filename: "test.c65", LineNo: 3, Kind: preproc.Script},
// ENDSCRIPT boundary
{Text: "", Filename: "test.c65", LineNo: 4, Kind: preproc.Source},
// Block 2: error at line 8 (division by zero)
{Text: "y = 2", Filename: "test.c65", LineNo: 6, Kind: preproc.Script},
{Text: "z = y + 1", Filename: "test.c65", LineNo: 7, Kind: preproc.Script},
{Text: "1 / 0", Filename: "test.c65", LineNo: 8, Kind: preproc.Script},
// ENDSCRIPT boundary
{Text: "", Filename: "test.c65", LineNo: 9, Kind: preproc.Source},
}
_, err := comp.Compile(lines)
if err == nil {
t.Fatal("expected error from block 2, got none")
}
errMsg := err.Error()
if !strings.Contains(errMsg, "Starlark error") {
t.Errorf("expected Starlark error, got: %s", errMsg)
}
// Verify error references the correct source line in block 2 (line 8 = division by zero)
if !strings.Contains(errMsg, ":8:") {
t.Errorf("error should reference source line 8 in block 2, got: %s", errMsg)
}
}

View file

@ -43,6 +43,11 @@ type CompilerContext struct {
// ScriptMacros holds named macro definitions from SCRIPT MACRO blocks // ScriptMacros holds named macro definitions from SCRIPT MACRO blocks
ScriptMacros map[string]*ScriptMacro 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 // NewCompilerContext creates a new compiler context with initialized resources

View file

@ -5,6 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"regexp"
"strconv"
"strings" "strings"
"c65gm/internal/preproc" "c65gm/internal/preproc"
@ -36,6 +39,10 @@ func mapStarlarkLine(starlarkLine int, numScriptLines int, isLibrary bool) int {
return idx return idx
} }
// starlarkErrorMsgLineMatch matches Starlark error messages of the form
// "filename:LINE:COL: message" produced by resolve errors.
var starlarkErrorMsgLineMatch = regexp.MustCompile(`^(.+):(\d+):(\d+): (.*)$`)
// starlarkPosition extracts the source position from a Starlark error. // starlarkPosition extracts the source position from a Starlark error.
// Returns the 1-based line number (0 if unknown). // Returns the 1-based line number (0 if unknown).
func starlarkPosition(err error) int { func starlarkPosition(err error) int {
@ -46,16 +53,43 @@ func starlarkPosition(err error) int {
return int(top.Pos.Line) return int(top.Pos.Line)
} }
} }
// For non-EvalError (resolve errors), extract line from the message
return parseStarlarkLineFromMsg(err.Error())
}
// parseStarlarkLineFromMsg attempts to extract the Starlark line number from
// a non-EvalError error message string. Starlark resolve errors follow the
// format "filename:LINE:COL: message".
func parseStarlarkLineFromMsg(msg string) int {
matches := starlarkErrorMsgLineMatch.FindStringSubmatch(msg)
if len(matches) >= 3 {
line, err := strconv.Atoi(matches[2])
if err == nil && line > 0 {
return line
}
}
return 0 return 0
} }
// starlarkErrorMsg extracts just the message from a Starlark error. // starlarkErrorMsg extracts just the message from a Starlark error.
// For EvalError, it returns the Msg field directly.
// For non-EvalError (resolve errors), it strips the "filename:LINE:COL: " prefix.
func starlarkErrorMsg(err error) string { func starlarkErrorMsg(err error) string {
var evalErr *starlark.EvalError var evalErr *starlark.EvalError
if errors.As(err, &evalErr) { if errors.As(err, &evalErr) {
return evalErr.Msg return evalErr.Msg
} }
return err.Error() return stripStarlarkPositionFromMsg(err.Error())
}
// stripStarlarkPositionFromMsg removes the "filename:LINE:COL: " prefix from
// a Starlark error message if present.
func stripStarlarkPositionFromMsg(msg string) string {
matches := starlarkErrorMsgLineMatch.FindStringSubmatch(msg)
if len(matches) >= 5 {
return matches[4]
}
return msg
} }
// printScriptErrorContext prints a Starlark error with script-block-bounded source context, // printScriptErrorContext prints a Starlark error with script-block-bounded source context,
@ -210,12 +244,14 @@ func executeScript(scriptLines []preproc.Line, ctx *CompilerContext, isLibrary b
}, },
} }
// Set execution limit (prevent infinite loops) // Set execution limit from pragma or default (prevent infinite loops)
thread.SetMaxExecutionSteps(1000000) // 1M steps thread.SetMaxExecutionSteps(readScriptMaxSteps(ctx, scriptLines[0].PragmaSetIndex))
// Build predeclared: math module + library globals // Build predeclared: math module + library globals + file I/O builtins
predeclared := starlark.StringDict{ 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 { for k, v := range ctx.ScriptLibraryGlobals {
predeclared[k] = v predeclared[k] = v
@ -226,17 +262,19 @@ func executeScript(scriptLines []preproc.Line, ctx *CompilerContext, isLibrary b
if err != nil { if err != nil {
// Map Starlark error position back to source // Map Starlark error position back to source
starLine := starlarkPosition(err) starLine := starlarkPosition(err)
idx := -1
if starLine > 0 { if starLine > 0 {
idx := mapStarlarkLine(starLine, len(scriptLines), isLibrary) idx = mapStarlarkLine(starLine, len(scriptLines), isLibrary)
if idx >= 0 { }
msg := starlarkErrorMsg(err) if idx >= 0 {
blockType := "SCRIPT" msg := starlarkErrorMsg(err)
if isLibrary { blockType := "SCRIPT"
blockType = "SCRIPT LIBRARY" if isLibrary {
} blockType = "SCRIPT LIBRARY"
printScriptErrorContext(err, msg, scriptLines, idx, blockType)
return nil, fmt.Errorf("Starlark error: %s", msg)
} }
printScriptErrorContext(err, msg, scriptLines, idx, blockType)
srcLine := scriptLines[idx]
return nil, fmt.Errorf("Starlark error: %s:%d: %s", srcLine.Filename, srcLine.LineNo, msg)
} }
// Fallback: print whatever info we can extract // Fallback: print whatever info we can extract
printScriptErrorFallback(err) printScriptErrorFallback(err)
@ -280,8 +318,149 @@ func expandVariables(text string, ctx *CompilerContext) string {
return result 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")
}
// Resolve symlinks to prevent symlink-based escape
realPath, err := filepath.EvalSymlinks(resolved)
if err == nil {
if !strings.HasPrefix(realPath, projectRootWithSep) && realPath != projectRoot {
return "", fmt.Errorf("file access denied: symlink target resolves outside project folder")
}
return realPath, nil
}
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
})
}
// readScriptMaxSteps reads the _P_SCRIPT_MAX_STEPS pragma from the given pragma set.
// Returns the configured value (must be > 0), or 1000000 as default.
func readScriptMaxSteps(ctx *CompilerContext, pragmaSetIndex int) uint64 {
const defaultSteps uint64 = 1000000
ps := ctx.Pragma.GetPragmaSetByIndex(pragmaSetIndex)
v := ps.GetPragma("_P_SCRIPT_MAX_STEPS")
if v == "" {
return defaultSteps
}
n, err := strconv.ParseUint(v, 10, 64)
if err != nil || n == 0 {
return defaultSteps
}
return n
}
// ExecuteMacro executes a named macro with the given arguments and returns output lines // ExecuteMacro executes a named macro with the given arguments and returns output lines
func ExecuteMacro(macroName string, args []string, ctx *CompilerContext) ([]string, error) { // pragmaSetIndex is the index of the pragma set at the macro invocation call site.
func ExecuteMacro(macroName string, args []string, ctx *CompilerContext, pragmaSetIndex int) ([]string, error) {
// Look up the macro // Look up the macro
macro, ok := ctx.ScriptMacros[macroName] macro, ok := ctx.ScriptMacros[macroName]
if !ok { if !ok {
@ -328,12 +507,14 @@ func ExecuteMacro(macroName string, args []string, ctx *CompilerContext) ([]stri
}, },
} }
// Set execution limit // Set execution limit from pragma at call site or default
thread.SetMaxExecutionSteps(1000000) thread.SetMaxExecutionSteps(readScriptMaxSteps(ctx, pragmaSetIndex))
// Build predeclared: math + library globals + parameter bindings // Build predeclared: math + library globals + file I/O builtins + parameter bindings
predeclared := starlark.StringDict{ 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 { for k, v := range ctx.ScriptLibraryGlobals {
predeclared[k] = v predeclared[k] = v

View file

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@ -354,7 +355,7 @@ func TestExecuteMacro_ErrorDefinitionSite(t *testing.T) {
r, w, _ := os.Pipe() r, w, _ := os.Pipe()
os.Stderr = w os.Stderr = w
_, err := ExecuteMacro("div_macro", []string{"10", "0"}, ctx) _, err := ExecuteMacro("div_macro", []string{"10", "0"}, ctx, 0)
w.Close() w.Close()
os.Stderr = old os.Stderr = old
@ -386,7 +387,7 @@ func TestExecuteMacro_ErrorFallbackNoSource(t *testing.T) {
Body: []string{"1 // 0"}, Body: []string{"1 // 0"},
} }
_, err := ExecuteMacro("m", []string{}, ctx) _, err := ExecuteMacro("m", []string{}, ctx, 0)
if err == nil { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
@ -437,3 +438,660 @@ func TestPrintScriptErrorFallback(t *testing.T) {
t.Errorf("output should contain the error message, got: %q", output) 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")
}
}
func TestValidateScriptFilePath_RejectsSymlinkEscape(t *testing.T) {
projectRoot := t.TempDir()
// Symlink pointing outside the project root
outsideTarget := filepath.Join(projectRoot, "..", "outside.txt")
if err := os.WriteFile(outsideTarget, []byte("secret"), 0644); err != nil {
t.Fatal(err)
}
symlinkPath := filepath.Join(projectRoot, "evil_link")
if err := os.Symlink(outsideTarget, symlinkPath); err != nil {
t.Fatal(err)
}
_, err := validateScriptFilePath(projectRoot, "evil_link")
if err == nil {
t.Error("expected error for symlink escaping project root, got nil")
}
}
func TestValidateScriptFilePath_AllowsSymlinkWithinProject(t *testing.T) {
projectRoot := t.TempDir()
// Create a file inside the project
targetPath := filepath.Join(projectRoot, "real_file.txt")
if err := os.WriteFile(targetPath, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
// Create a symlink pointing inside the project
symlinkPath := filepath.Join(projectRoot, "link_to_real.txt")
if err := os.Symlink("real_file.txt", symlinkPath); err != nil {
t.Fatal(err)
}
got, err := validateScriptFilePath(projectRoot, "link_to_real.txt")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != targetPath {
t.Errorf("got %q, want %q", got, targetPath)
}
}
// --- 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])
}
}
// --- Tests for _P_SCRIPT_MAX_STEPS pragma ---
func TestReadScriptMaxSteps_Default(t *testing.T) {
pragma := preproc.NewPragma()
ctx := NewCompilerContext(pragma)
// No pragma set — index 0 has empty set
got := readScriptMaxSteps(ctx, 0)
if got != 1000000 {
t.Errorf("readScriptMaxSteps() with no pragma = %d, want 1000000", got)
}
}
func TestReadScriptMaxSteps_CustomValue(t *testing.T) {
pragma := preproc.NewPragma()
pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "5000000")
ctx := NewCompilerContext(pragma)
got := readScriptMaxSteps(ctx, pragma.GetCurrentPragmaSetIndex())
if got != 5000000 {
t.Errorf("readScriptMaxSteps() = %d, want 5000000", got)
}
}
func TestReadScriptMaxSteps_Zero(t *testing.T) {
pragma := preproc.NewPragma()
pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "0")
ctx := NewCompilerContext(pragma)
got := readScriptMaxSteps(ctx, pragma.GetCurrentPragmaSetIndex())
if got != 1000000 {
t.Errorf("readScriptMaxSteps(0) = %d, want 1000000 (default)", got)
}
}
func TestReadScriptMaxSteps_Invalid(t *testing.T) {
pragma := preproc.NewPragma()
pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "not-a-number")
ctx := NewCompilerContext(pragma)
got := readScriptMaxSteps(ctx, pragma.GetCurrentPragmaSetIndex())
if got != 1000000 {
t.Errorf("readScriptMaxSteps(invalid) = %d, want 1000000 (default)", got)
}
}
func TestExecuteScript_MaxStepsPragma_LowLimit(t *testing.T) {
pragma := preproc.NewPragma()
pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "5") // very low limit
ctx := NewCompilerContext(pragma)
// A simple loop will exceed 5 steps
scriptLines := []preproc.Line{
{Text: " for i in range(10):", Filename: "test.c65", LineNo: 1, Kind: preproc.Script, PragmaSetIndex: pragma.GetCurrentPragmaSetIndex()},
{Text: " x = i", Filename: "test.c65", LineNo: 2, Kind: preproc.Script, PragmaSetIndex: pragma.GetCurrentPragmaSetIndex()},
}
_, err := executeScript(scriptLines, ctx, false)
if err == nil {
t.Fatal("expected step-limit error, got nil")
}
if !strings.Contains(err.Error(), "Starlark error") {
t.Errorf("error should contain 'Starlark error', got: %v", err)
}
}
func TestExecuteScript_MaxStepsPragma_HighLimit(t *testing.T) {
pragma := preproc.NewPragma()
pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "500000") // generous limit
ctx := NewCompilerContext(pragma)
scriptLines := []preproc.Line{
{Text: " result = 0", Filename: "test.c65", LineNo: 1, Kind: preproc.Script, PragmaSetIndex: pragma.GetCurrentPragmaSetIndex()},
{Text: " for i in range(100):", Filename: "test.c65", LineNo: 2, Kind: preproc.Script, PragmaSetIndex: pragma.GetCurrentPragmaSetIndex()},
{Text: " result = result + i", Filename: "test.c65", LineNo: 3, Kind: preproc.Script, PragmaSetIndex: pragma.GetCurrentPragmaSetIndex()},
{Text: " print(str(result))", Filename: "test.c65", LineNo: 4, Kind: preproc.Script, PragmaSetIndex: pragma.GetCurrentPragmaSetIndex()},
}
output, err := executeScript(scriptLines, ctx, false)
if err != nil {
t.Fatalf("executeScript failed with generous limit: %v", err)
}
if len(output) < 1 || output[0] != "4950" {
t.Errorf("expected output '4950', got %v", output)
}
}
func TestExecuteMacro_MaxStepsPragma_LowLimit(t *testing.T) {
pragma := preproc.NewPragma()
pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "5") // very low limit
ctx := NewCompilerContext(pragma)
// Register a simple macro
ctx.ScriptMacros["loop_macro"] = &ScriptMacro{
Name: "loop_macro",
Params: []string{},
Body: []string{"for i in range(10):", " x = i"},
SourceFile: "test.c65",
StartLine: 1,
}
_, err := ExecuteMacro("loop_macro", []string{}, ctx, pragma.GetCurrentPragmaSetIndex())
if err == nil {
t.Fatal("expected step-limit error, got nil")
}
if !strings.Contains(err.Error(), "Starlark error") {
t.Errorf("error should contain 'Starlark error', got: %v", err)
}
}
// Test that two SCRIPT blocks in the same compilation get different step limits
// when a #PRAGMA _P_SCRIPT_MAX_STEPS change appears between them.
func TestExecuteScript_MaxStepsPragma_ChangesBetweenBlocks(t *testing.T) {
pragma := preproc.NewPragma()
ctx := NewCompilerContext(pragma)
// First block: default limit (index 0, no pragmas) — should succeed
firstLines := []preproc.Line{
{Text: " result = 0", Filename: "test.c65", LineNo: 1, Kind: preproc.Script, PragmaSetIndex: 0},
{Text: " for i in range(10):", Filename: "test.c65", LineNo: 2, Kind: preproc.Script, PragmaSetIndex: 0},
{Text: " result = result + i", Filename: "test.c65", LineNo: 3, Kind: preproc.Script, PragmaSetIndex: 0},
{Text: " print(str(result))", Filename: "test.c65", LineNo: 4, Kind: preproc.Script, PragmaSetIndex: 0},
}
output, err := executeScript(firstLines, ctx, false)
if err != nil {
t.Fatalf("first script (default limit) should succeed: %v", err)
}
if len(output) < 1 || output[0] != "45" {
t.Errorf("first script output = %v, want ['45']", output)
}
// Simulate #PRAGMA _P_SCRIPT_MAX_STEPS 5 between the blocks
pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "5")
lowLimitIdx := pragma.GetCurrentPragmaSetIndex()
// Second block: low limit (index from pragma) — should fail
secondLines := []preproc.Line{
{Text: " for i in range(10):", Filename: "test.c65", LineNo: 10, Kind: preproc.Script, PragmaSetIndex: lowLimitIdx},
{Text: " x = i", Filename: "test.c65", LineNo: 11, Kind: preproc.Script, PragmaSetIndex: lowLimitIdx},
}
_, err = executeScript(secondLines, ctx, false)
if err == nil {
t.Fatal("second script (limit=5) should fail with step-limit error, got nil")
}
if !strings.Contains(err.Error(), "Starlark error") {
t.Errorf("second script error should contain 'Starlark error', got: %v", err)
}
}
// Test that two macro invocations in the same compilation get different step limits
// when a #PRAGMA _P_SCRIPT_MAX_STEPS change appears between them.
func TestExecuteMacro_MaxStepsPragma_ChangesBetweenBlocks(t *testing.T) {
pragma := preproc.NewPragma()
ctx := NewCompilerContext(pragma)
ctx.ScriptMacros["loop_macro"] = &ScriptMacro{
Name: "loop_macro",
Params: []string{},
Body: []string{"result = 0", "for i in range(10):", " result = result + i", "print(str(result))"},
SourceFile: "test.c65",
StartLine: 1,
}
// First invocation: default limit (first pragma set) — should succeed
output, err := ExecuteMacro("loop_macro", []string{}, ctx, pragma.GetCurrentPragmaSetIndex())
if err != nil {
t.Fatalf("first macro (default limit) should succeed: %v", err)
}
if len(output) < 1 || output[0] != "45" {
t.Errorf("first macro output = %v, want ['45']", output)
}
// Simulate #PRAGMA _P_SCRIPT_MAX_STEPS 5 between the invocations
pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "5")
lowLimitIdx := pragma.GetCurrentPragmaSetIndex()
// Second invocation: low limit — should fail
_, err = ExecuteMacro("loop_macro", []string{}, ctx, lowLimitIdx)
if err == nil {
t.Fatal("second macro (limit=5) should fail with step-limit error, got nil")
}
if !strings.Contains(err.Error(), "Starlark error") {
t.Errorf("second macro error should contain 'Starlark error', got: %v", err)
}
}
func TestExecuteMacro_MaxStepsPragma_HighLimit(t *testing.T) {
pragma := preproc.NewPragma()
pragma.AddPragma("_P_SCRIPT_MAX_STEPS", "500000") // generous limit
ctx := NewCompilerContext(pragma)
ctx.ScriptMacros["sum_macro"] = &ScriptMacro{
Name: "sum_macro",
Params: []string{},
Body: []string{"result = 0", "for i in range(100):", " result = result + i", "print(str(result))"},
SourceFile: "test.c65",
StartLine: 1,
}
output, err := ExecuteMacro("sum_macro", []string{}, ctx, pragma.GetCurrentPragmaSetIndex())
if err != nil {
t.Fatalf("ExecuteMacro failed with generous limit: %v", err)
}
if len(output) < 1 || output[0] != "4950" {
t.Errorf("expected output '4950', got %v", output)
}
}

View file

@ -170,7 +170,21 @@ func (p *preproc) run(root string) ([]Line, error) {
p.inScript = false p.inScript = false
p.inScriptLibrary = false p.inScriptLibrary = false
p.inScriptMacro = false p.inScriptMacro = false
continue // don't emit ENDSCRIPT marker // Emit an empty Source line as a block boundary marker so the
// compiler can distinguish consecutive SCRIPT blocks via kind
// transitions. The compiler skips empty Source lines, so this
// serves purely as a transition trigger.
if includeSource {
out = append(out, Line{
RawText: raw,
Text: "",
Filename: currFrame.path,
LineNo: currFrame.line,
Kind: Source,
PragmaSetIndex: p.pragma.GetCurrentPragmaSetIndex(),
})
}
continue
} }
// Determine the kind based on which mode we're in // Determine the kind based on which mode we're in
kind := Script kind := Script

View file

@ -151,9 +151,10 @@ func TestPreProcess_ScriptBlock(t *testing.T) {
t.Fatalf("PreProcess failed: %v", err) t.Fatalf("PreProcess failed: %v", err)
} }
// SCRIPT and ENDSCRIPT markers are stripped // SCRIPT and ENDSCRIPT markers are stripped, but ENDSCRIPT emits
if len(lines) != 3 { // an empty Source boundary marker
t.Fatalf("expected 3 lines, got %d", len(lines)) if len(lines) != 4 {
t.Fatalf("expected 4 lines, got %d", len(lines))
} }
// Script content should NOT be processed // Script content should NOT be processed
@ -171,12 +172,20 @@ func TestPreProcess_ScriptBlock(t *testing.T) {
t.Errorf("expected Kind=Script, got %v", lines[1].Kind) t.Errorf("expected Kind=Script, got %v", lines[1].Kind)
} }
// After ENDSCRIPT, defines work again // Line 2 is the ENDSCRIPT boundary marker (empty Source line)
if lines[2].Text != "LDA #100" {
t.Errorf("expected 'LDA #100', got %q", lines[2].Text)
}
if lines[2].Kind != Source { if lines[2].Kind != Source {
t.Errorf("expected Kind=Source, got %v", lines[2].Kind) t.Errorf("line 2: expected Kind=Source (ENDSCRIPT boundary), got %v", lines[2].Kind)
}
if lines[2].Text != "" {
t.Errorf("line 2: expected empty text, got %q", lines[2].Text)
}
// After ENDSCRIPT, defines work again
if lines[3].Text != "LDA #100" {
t.Errorf("expected 'LDA #100', got %q", lines[3].Text)
}
if lines[3].Kind != Source {
t.Errorf("expected Kind=Source, got %v", lines[3].Kind)
} }
} }
@ -262,8 +271,9 @@ func TestPreProcess_CommentInScriptBlock(t *testing.T) {
t.Fatalf("PreProcess failed: %v", err) t.Fatalf("PreProcess failed: %v", err)
} }
if len(lines) != 2 { // 2 script lines + 1 ENDSCRIPT boundary marker + 0 trailing source = 3 lines
t.Fatalf("expected 2 lines, got %d", len(lines)) if len(lines) != 3 {
t.Fatalf("expected 3 lines, got %d", len(lines))
} }
// Comments should be preserved in Script blocks // Comments should be preserved in Script blocks
@ -273,6 +283,11 @@ func TestPreProcess_CommentInScriptBlock(t *testing.T) {
if lines[1].Text != " y = 2 // another one" { if lines[1].Text != " y = 2 // another one" {
t.Errorf("expected comment preserved, got %q", lines[1].Text) t.Errorf("expected comment preserved, got %q", lines[1].Text)
} }
// Line 2 is ENDSCRIPT boundary marker
if lines[2].Kind != Source || lines[2].Text != "" {
t.Errorf("line 2: expected empty Source (ENDSCRIPT boundary), got Kind=%v Text=%q", lines[2].Kind, lines[2].Text)
}
} }
func TestPreProcess_RawTextPreservation(t *testing.T) { func TestPreProcess_RawTextPreservation(t *testing.T) {
@ -883,6 +898,7 @@ func TestPreProcess_MixedBlocksAndComments(t *testing.T) {
{"LDA #10", Source}, {"LDA #10", Source},
{" lda #X // asm comment", Assembler}, {" lda #X // asm comment", Assembler},
{" y = X // script comment", Script}, {" y = X // script comment", Script},
{"", Source}, // ENDSCRIPT boundary marker
{"STA $D020", Source}, {"STA $D020", Source},
} }
@ -938,12 +954,17 @@ func TestPreProcess_EmptyScriptBlock(t *testing.T) {
t.Fatalf("PreProcess failed: %v", err) t.Fatalf("PreProcess failed: %v", err)
} }
if len(lines) != 1 { // Empty script emits an ENDSCRIPT boundary marker + NOP
t.Fatalf("expected 1 line, got %d", len(lines)) if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d", len(lines))
} }
if lines[0].Text != "NOP" { if lines[0].Kind != Source || lines[0].Text != "" {
t.Errorf("expected 'NOP', got %q", lines[0].Text) t.Errorf("line 0: expected empty Source (ENDSCRIPT boundary), got Kind=%v Text=%q", lines[0].Kind, lines[0].Text)
}
if lines[1].Text != "NOP" {
t.Errorf("expected 'NOP', got %q", lines[1].Text)
} }
} }
@ -963,9 +984,9 @@ func TestPreProcess_ScriptLibraryBlock(t *testing.T) {
t.Fatalf("PreProcess failed: %v", err) t.Fatalf("PreProcess failed: %v", err)
} }
// Should have 2 script lines + 1 source line // Should have 2 script lines + 1 ENDSCRIPT boundary + 1 source line = 4 lines
if len(lines) != 3 { if len(lines) != 4 {
t.Fatalf("expected 3 lines, got %d", len(lines)) t.Fatalf("expected 4 lines, got %d", len(lines))
} }
// Script library lines should have ScriptLibrary kind // Script library lines should have ScriptLibrary kind
@ -980,12 +1001,20 @@ func TestPreProcess_ScriptLibraryBlock(t *testing.T) {
t.Errorf("expected Kind=ScriptLibrary, got %v", lines[1].Kind) t.Errorf("expected Kind=ScriptLibrary, got %v", lines[1].Kind)
} }
// Source line after ENDSCRIPT // Line 2 is the ENDSCRIPT boundary marker
if lines[2].Kind != Source { if lines[2].Kind != Source {
t.Errorf("expected Kind=Source, got %v", lines[2].Kind) t.Errorf("line 2: expected Kind=Source (ENDSCRIPT boundary), got %v", lines[2].Kind)
} }
if lines[2].Text != "NOP" { if lines[2].Text != "" {
t.Errorf("expected 'NOP', got %q", lines[2].Text) t.Errorf("line 2: expected empty text, got %q", lines[2].Text)
}
// Source line after ENDSCRIPT
if lines[3].Kind != Source {
t.Errorf("expected Kind=Source, got %v", lines[3].Kind)
}
if lines[3].Text != "NOP" {
t.Errorf("expected 'NOP', got %q", lines[3].Text)
} }
} }
@ -1006,18 +1035,29 @@ func TestPreProcess_ScriptVsScriptLibrary(t *testing.T) {
t.Fatalf("PreProcess failed: %v", err) t.Fatalf("PreProcess failed: %v", err)
} }
if len(lines) != 2 { // Two blocks → 1 lib line + 1 lib ENDSCRIPT boundary + 1 script line + 1 script ENDSCRIPT boundary = 4 lines
t.Fatalf("expected 2 lines, got %d", len(lines)) if len(lines) != 4 {
t.Fatalf("expected 4 lines, got %d", len(lines))
} }
// First line is from SCRIPT LIBRARY // Line 0: SCRIPT LIBRARY content
if lines[0].Kind != ScriptLibrary { if lines[0].Kind != ScriptLibrary {
t.Errorf("line 0: expected ScriptLibrary, got %v", lines[0].Kind) t.Errorf("line 0: expected ScriptLibrary, got %v", lines[0].Kind)
} }
// Second line is from regular SCRIPT // Line 1: ENDSCRIPT boundary for library
if lines[1].Kind != Script { if lines[1].Kind != Source || lines[1].Text != "" {
t.Errorf("line 1: expected Script, got %v", lines[1].Kind) t.Errorf("line 1: expected empty Source (ENDSCRIPT boundary), got Kind=%v Text=%q", lines[1].Kind, lines[1].Text)
}
// Line 2: regular SCRIPT content
if lines[2].Kind != Script {
t.Errorf("line 2: expected Script, got %v", lines[2].Kind)
}
// Line 3: ENDSCRIPT boundary for script
if lines[3].Kind != Source || lines[3].Text != "" {
t.Errorf("line 3: expected empty Source (ENDSCRIPT boundary), got Kind=%v Text=%q", lines[3].Kind, lines[3].Text)
} }
} }
@ -1036,9 +1076,9 @@ func TestPreProcess_ScriptMacroBlock(t *testing.T) {
t.Fatalf("PreProcess failed: %v", err) t.Fatalf("PreProcess failed: %v", err)
} }
// Should have header + body line + source line = 3 lines // Should have header + body line + ENDSCRIPT boundary + source line = 4 lines
if len(lines) != 3 { if len(lines) != 4 {
t.Fatalf("expected 3 lines, got %d", len(lines)) t.Fatalf("expected 4 lines, got %d", len(lines))
} }
// First line is the header (also ScriptMacroDef kind) // First line is the header (also ScriptMacroDef kind)
@ -1054,12 +1094,20 @@ func TestPreProcess_ScriptMacroBlock(t *testing.T) {
t.Errorf("line 1: expected ScriptMacroDef, got %v", lines[1].Kind) t.Errorf("line 1: expected ScriptMacroDef, got %v", lines[1].Kind)
} }
// Third line is source // Third line is ENDSCRIPT boundary marker
if lines[2].Kind != Source { if lines[2].Kind != Source {
t.Errorf("line 2: expected Source, got %v", lines[2].Kind) t.Errorf("line 2: expected Source (ENDSCRIPT boundary), got %v", lines[2].Kind)
} }
if lines[2].Text != "NOP" { if lines[2].Text != "" {
t.Errorf("line 2: expected 'NOP', got %q", lines[2].Text) t.Errorf("line 2: expected empty text, got %q", lines[2].Text)
}
// Fourth line is source
if lines[3].Kind != Source {
t.Errorf("line 3: expected Source, got %v", lines[3].Kind)
}
if lines[3].Text != "NOP" {
t.Errorf("line 3: expected 'NOP', got %q", lines[3].Text)
} }
} }

View file

@ -102,6 +102,34 @@ func ToUpper(s string) string {
return strings.ToUpper(s) return strings.ToUpper(s)
} }
// NormalizeCommas inserts spaces around commas outside quoted strings,
// then collapses multiple spaces. This ensures commas are treated as
// separate tokens when passed to ParseParams.
func NormalizeCommas(s string) string {
var result strings.Builder
inString := false
for i := 0; i < len(s); i++ {
ch := s[i]
if ch == '"' {
inString = !inString
result.WriteByte(ch)
continue
}
if !inString && ch == ',' {
result.WriteByte(' ')
result.WriteByte(',')
result.WriteByte(' ')
} else {
result.WriteByte(ch)
}
}
return NormalizeSpaces(result.String())
}
// ValidateIdentifier checks if s is a valid identifier (starts with letter/underscore, continues with alphanumeric/underscore) // ValidateIdentifier checks if s is a valid identifier (starts with letter/underscore, continues with alphanumeric/underscore)
func ValidateIdentifier(s string) bool { func ValidateIdentifier(s string) bool {
if len(s) == 0 { if len(s) == 0 {

View file

@ -507,6 +507,58 @@ SCRIPT
ENDSCRIPT 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 ### SCRIPT LIBRARY Blocks
Define reusable Starlark functions that persist across all subsequent SCRIPT blocks: Define reusable Starlark functions that persist across all subsequent SCRIPT blocks:

View file

@ -139,6 +139,15 @@ func compileOnly(inFile, outFile string, opt, optDebug, optC64 bool, optExcludes
// Create compiler and register commands // Create compiler and register commands
comp := compiler.NewCompiler(pragma) comp := compiler.NewCompiler(pragma)
absInput, err := filepath.Abs(inFile)
if err != nil {
return fmt.Errorf("failed to resolve input file path: %w", err)
}
projectRoot := filepath.Dir(absInput)
if canonicalRoot, err := filepath.EvalSymlinks(projectRoot); err == nil {
projectRoot = canonicalRoot
}
comp.Context().ProjectRoot = projectRoot
comp.CmdlineOpt = opt comp.CmdlineOpt = opt
comp.CmdlineDebug = optDebug comp.CmdlineDebug = optDebug
// Build I/O regions from CLI flags // Build I/O regions from CLI flags

View file

@ -12,6 +12,15 @@
"build": { "build": {
"model": "deepseek/deepseek-v4-flash", "model": "deepseek/deepseek-v4-flash",
"description": "Implementation and coding using DeepSeek V4 Flash" "description": "Implementation and coding using DeepSeek V4 Flash"
},
"build-pro": {
"model": "deepseek/deepseek-v4-pro",
"mode": "primary",
"options": {
"thinking": { "type": "enabled" },
"reasoningEffort": "high"
},
"description": "Implementation and coding using DeepSeek V4 Pro"
} }
} }
} }

View file

@ -394,9 +394,56 @@ ENDSCRIPT
- Output from `print()` goes directly to assembler - Output from `print()` goes directly to assembler
- Can reference compiler variables using `|varname|` syntax - Can reference compiler variables using `|varname|` syntax
- Math module available: `import math` - Math module available: `import math`
- Maximum 1 million execution steps (prevents infinite loops) - Maximum 1 million execution steps per block (prevents infinite loops). Each `SCRIPT`, `SCRIPT LIBRARY`, and `@macro()` invocation gets its own step counter. The limit is not cumulative across the compilation.
- If a block exceeds the limit, compilation fails with: `Starlark computation cancelled: too many steps`
- Use `#PRAGMA _P_SCRIPT_MAX_STEPS <n>` to change the limit (e.g., `#PRAGMA _P_SCRIPT_MAX_STEPS 5000000` for more steps, or `#PRAGMA _P_SCRIPT_MAX_STEPS 100` for a tight limit during debugging). The pragma is sticky — it applies to all subsequent blocks until changed.
- Executed at compile time, not runtime - 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 ### SCRIPT LIBRARY...ENDSCRIPT