[build-script] Start adding a new build script format

This format is meant to replace the current .json file when describing
.studybox ROM files, as well as handle compiling/assembling new ROMs
from scratch.

In theory, this will handle both ROMs that were decoded from tape as
well as instructions to build a new ROM from various files.  This
will include options to compile scripts, encode images, and handle some
audio timing by allowing individual audio files for each page.
This commit is contained in:
Zorchenhimer 2025-11-29 18:15:51 -05:00
parent b1d8d8335a
commit ac5f29a696
Signed by: Zorchenhimer
GPG Key ID: 70A1AB767AAB9C20
3 changed files with 528 additions and 1 deletions

180
build-script/parser.go Normal file
View File

@ -0,0 +1,180 @@
package build
import (
"fmt"
"bufio"
"os"
"io"
"testing"
"strings"
"unicode"
)
type parseFunc func(key, values string) (Token, error)
var parseFuncs = map[string]parseFunc{
"rom": parseStrValue,
"fullaudio": parseStrValue,
"audiooffsets": parseAudioOffsets,
"page": parseNumValue,
"padding": parseNumValue,
"version": parseNumValue,
"delay": parseDelay,
"script": parseData,
"tiles": parseData,
"pattern": parseData,
}
func ParseFile(filename string) ([]Token, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
return Parse(file)
}
func Parse(r io.Reader) ([]Token, error) {
items := []Token{}
scanner := bufio.NewScanner(r)
prev := ""
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// blanks and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
splitln := strings.SplitN(line, " ", 2)
if len(splitln) != 2 {
return nil, fmt.Errorf("invalid line: %q", line)
}
var itm Token
var err error
// TODO: Some of these will need to be unique in the file
// (rom, version, fullaudio), and probably exclusive/ordered.
// IE, rom, version, & fullaudio must come before the first
// page ##, and must only appear once. if a page has an
// audio line, all pages must have one and there can be no
// fullaudio.
if fn, ok := parseFuncs[splitln[0]]; ok {
itm, err = fn(splitln[0], splitln[1])
} else {
return nil, fmt.Errorf("unknown line: %s\n", splitln[0])
}
if err != nil {
return nil, err
}
if !itm.ValidAfter(prev) {
if prev == "" {
prev = "[empty]"
}
return nil, fmt.Errorf("%s not valid after %s", itm.Type(), prev)
}
prev = itm.Type()
items = append(items, itm)
}
return items, nil
}
var t *testing.T
func pKeyVals(values string) (map[string]string, error) {
m := map[string]string{}
start := 0
runes := []rune(values)
currentKey := ""
quote := false
tlog("[pKeyVals] start")
var i int
for i = 0; i < len(runes); i++ {
tlogf("[pKeyVals] rune: %c\n", runes[i])
if !quote && runes[i] == '"' {
quote = true
tlog("[pKeyVals] start quote")
continue
}
if !quote && unicode.IsSpace(runes[i]) {
tlog("[pKeyVals] !quote && IsSpace()")
if currentKey == "" {
tlog("[pKeyVals] currentKey empty")
start = i+1
} else {
tlog("[pKeyVals] currentKey not empty")
m[currentKey] = string(runes[start:i])
currentKey = ""
start = i+1
}
continue
}
if quote && runes[i] == '"' {
tlog("[pKeyVals] quote && rune == \"")
quote = false
if currentKey == "" {
currentKey = string(runes[start+1:i])
i++
start = i+1
tlogf("[pKeyVals] currentKey empty; set to %s\n", currentKey)
} else {
m[currentKey] = string(runes[start+1:i])
currentKey = ""
tlog("[pKeyVals] currentKey not empty")
}
continue
}
if runes[i] == ':' {
tlog("[pKeyVals] rune == :")
if i == start {
return nil, fmt.Errorf("missing key")
}
currentKey = string(runes[start:i])
start = i+1
continue
}
}
if quote {
return nil, fmt.Errorf("missmatched quote")
}
if currentKey != "" {
//return nil, fmt.Errorf("missing value for %q", currentKey)
tlogf("[pKeyVals] outside loop assign m[%s] = %s\n", currentKey, string(runes[start:i]))
m[currentKey] = string(runes[start:i])
}
return m, nil
}
func tlog(args ...any) {
if t != nil {
t.Log(args...)
}
}
func tlogf(format string, args ...any) {
if t != nil {
t.Logf(format, args...)
}
}

264
build-script/tokens.go Normal file
View File

@ -0,0 +1,264 @@
package build
import (
"strings"
"strconv"
"fmt"
)
type Token interface {
Type() string
String() string
ValidAfter(t string) bool
Text() string
}
type TokenDelay struct {
Value int
Reset bool
}
func (itm *TokenDelay) Type() string { return "delay" }
func (itm *TokenDelay) String() string { return fmt.Sprintf("{TokenDelay Value:%d Reset:%t}", itm.Value, itm.Reset) }
func (itm *TokenDelay) ValidAfter(t string) bool {
switch t {
case "audiooffsets", "pattern", "delay", "page", "script", "tiles":
return true
}
return false
}
func (itm *TokenDelay) Text() string {
return fmt.Sprintf("delay %d reset:%t", itm.Value, itm.Reset)
}
func parseDelay(key, line string) (Token, error) {
vals := strings.Split(line, " ")
itm := &TokenDelay{}
for _, val := range vals {
if strings.Contains(val, ":") {
keyval := strings.SplitN(val, ":", 2)
if len(keyval) != 2 {
return nil, fmt.Errorf("Invalid key/value for delay: %s", val)
}
if keyval[0] != "reset" {
return nil, fmt.Errorf("Invalid key/value for delay: %s", val)
}
switch strings.ToLower(keyval[1]) {
case "true", "yes", "1":
itm.Reset = true
case "false", "no", "0":
itm.Reset = false
default:
return nil, fmt.Errorf("Invalid reset value: %s", keyval[1])
}
} else {
num, err := strconv.ParseUint(val, 0, 32)
if err != nil {
return nil, fmt.Errorf("Invalid delay vaule: %s: %w", val, err)
}
itm.Value = int(num)
}
}
return itm, nil
}
type TokenNumValue struct {
ValType string // delay, padding, page
Value int
}
func (itm *TokenNumValue) Type() string { return itm.ValType }
func (itm *TokenNumValue) String() string { return fmt.Sprintf("{TokenNumValue ValType:%s Value:%d}", itm.ValType, itm.Value) }
func (itm *TokenNumValue) ValidAfter(t string) bool {
switch itm.ValType {
case "page":
if t == "" {
return false
}
return true
case "padding":
switch t {
case "delay", "pattern", "tiles", "script", "page":
return true
default:
return false
}
case "version":
switch t {
case "", "rom", "fullaudio":
return true
default:
return false
}
}
return false
}
func (itm *TokenNumValue) Text() string {
return fmt.Sprintf("%s %d", itm.ValType, itm.Value)
}
func parseNumValue(key, val string) (Token, error) {
v, err := strconv.Atoi(val)
if err != nil {
return nil, fmt.Errorf("Invalid %s value: %q", key, val)
}
return &TokenNumValue{
ValType: key,
Value: int(v),
}, nil
}
type TokenStrValue struct {
ValType string
Value string
}
func (itm *TokenStrValue) Type() string { return itm.ValType }
func (itm *TokenStrValue) String() string { return fmt.Sprintf("{TokenStrVal ValType:%s Value:%q}", itm.ValType, itm.Value) }
func (itm *TokenStrValue) ValidAfter(t string) bool {
switch t {
case "", "rom", "fullaudio", "version":
return true
}
return false
}
func (itm *TokenStrValue) Text() string {
return itm.ValType+" "+itm.Value
}
func parseStrValue(key, value string) (Token, error) {
return &TokenStrValue{
ValType: key,
Value: value,
}, nil
}
type TokenAudioOffsets struct {
LeadIn uint64
Data uint64
}
func (itm *TokenAudioOffsets) Type() string { return "audiooffsets" }
func (itm *TokenAudioOffsets) String() string { return fmt.Sprintf("{ItemAudioOffsets LeadIn:%d Data:%d}", itm.LeadIn, itm.Data) }
func (itm *TokenAudioOffsets) ValidAfter(t string) bool {
switch t {
case "page", "delay", "script", "tiles", "pattern":
return true
}
return false
}
func (itm *TokenAudioOffsets) Text() string {
return fmt.Sprintf("audiooffsets leadin:%d data:%d", itm.LeadIn, itm.Data)
}
func parseAudioOffsets(key, line string) (Token, error) {
vals := strings.Split(line, " ")
itm := &TokenAudioOffsets{}
for _, keyval := range vals {
pair := strings.Split(keyval, ":")
if len(pair) != 2 {
return nil, fmt.Errorf("invalid syntax: %q", keyval)
}
num, err := strconv.ParseUint(pair[1], 0, 64)
if err != nil {
return nil, err
}
switch pair[0] {
case "leadin":
itm.LeadIn = num
case "data":
itm.Data = num
default:
return nil, fmt.Errorf("unknown key: %s", pair[0])
}
}
return itm, nil
}
type TokenData struct {
ValType string
Bank int
Addr int
File string
}
func (itm *TokenData) Type() string { return itm.ValType }
func (itm *TokenData) String() string {
return fmt.Sprintf("{TokenData ValType:%s Bank:0x%02X Addr:0x%02X File:%q}",
itm.ValType,
itm.Bank,
itm.Addr,
itm.File,
)
}
func (itm *TokenData) ValidAfter(t string) bool {
switch t {
case "page", "delay", "script", "tiles", "pattern":
return true
}
return false
}
func (itm *TokenData) Text() string {
return fmt.Sprintf("%s bank:0x%02X addr:0x%02X file:%q",
itm.ValType,
itm.Bank,
itm.Addr,
itm.File,
)
}
func parseData(tokType, vals string) (Token, error) {
args, err := pKeyVals(vals)
if err != nil {
return nil, err
}
itm := &TokenData{ValType: tokType}
for key, value := range args {
switch key {
case "bank":
val, err := strconv.ParseUint(value, 0, 8)
if err != nil {
return nil, fmt.Errorf("%s bank value error: %w", key, err)
}
itm.Bank = int(val)
case "addr":
val, err := strconv.ParseUint(value, 0, 8)
if err != nil {
return nil, fmt.Errorf("%s addr value error: %w", key, err)
}
itm.Addr = int(val)
case "file":
itm.File = value
default:
return nil, fmt.Errorf("%s unknown key: %q", tokType, key)
}
}
return itm, nil
}

View File

@ -4,6 +4,9 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"git.zorchenhimer.com/Zorchenhimer/go-studybox/build-script"
)
func (sb *StudyBox) Export(directory string, includeAudio bool) error {
@ -13,6 +16,18 @@ func (sb *StudyBox) Export(directory string, includeAudio bool) error {
Audio: directory + "/audio" + sb.Audio.ext(),
}
bscript := []build.Token{
&build.TokenStrValue{ValType: "rom", Value: filepath.Base(directory+".studybox")},
&build.TokenNumValue{ValType: "version", Value: 1},
&build.TokenStrValue{
ValType: "fullaudio",
Value: "audio"+sb.Audio.ext(),
},
}
// for delay resets and data file names
var prevTok build.Token
// A "Page" here does not correspond to the entered "Page" number on the
// title screen. These are really segments. The "Page" that is entered on
// the title screen is stored in the header of a segment. Multiple
@ -44,15 +59,31 @@ func (sb *StudyBox) Export(directory string, includeAudio bool) error {
jp.Data = append(jp.Data, jData)
jData = jsonData{}
bscript = append(bscript, &build.TokenNumValue{ValType: "page", Value: int(p.PageNumber)})
bscript = append(bscript, &build.TokenAudioOffsets{
LeadIn: uint64(page.AudioOffsetLeadIn),
Data: uint64(page.AudioOffsetData),
})
prevTok = nil
case *packetDelay:
jData.Type = "delay"
jData.Values = []int{p.Length}
prevTok = &build.TokenDelay{Value: int(p.Length)}
bscript = append(bscript, prevTok)
case *packetWorkRamLoad:
jData.Type = "script"
jData.Values = []int{int(p.bankId), int(p.loadAddressHigh)}
dataStartId = i
prevTok = &build.TokenData{
Bank: int(p.bankId),
Addr: int(p.loadAddressHigh),
}
bscript = append(bscript, prevTok)
case *packetPadding:
jData.Type = "padding"
jData.Values = []int{p.Length}
@ -61,11 +92,23 @@ func (sb *StudyBox) Export(directory string, includeAudio bool) error {
jp.Data = append(jp.Data, jData)
jData = jsonData{}
prevTok = nil
bscript = append(bscript, &build.TokenNumValue{
ValType: "padding",
Value: int(p.Length),
})
case *packetMarkDataStart:
jData.Values = []int{int(p.ArgA), int(p.ArgB)}
jData.Type = p.dataType()
dataStartId = i
prevTok = &build.TokenData{
Bank: int(p.ArgA),
Addr: int(p.ArgB),
}
bscript = append(bscript, prevTok)
case *packetMarkDataEnd:
jData.Reset = p.Reset
@ -79,12 +122,18 @@ func (sb *StudyBox) Export(directory string, includeAudio bool) error {
switch jData.Type {
case "pattern":
jData.File = fmt.Sprintf("%s/segment-%02d_packet-%04d_chrData.chr", directory, pidx, dataStartId)
d := prevTok.(*build.TokenData)
d.ValType = "pattern"
case "nametable":
jData.File = fmt.Sprintf("%s/segment-%02d_packet-%04d_ntData.dat", directory, pidx, dataStartId)
d := prevTok.(*build.TokenData)
d.ValType = "tiles"
case "script":
jData.File = fmt.Sprintf("%s/segment-%02d_packet-%04d_scriptData.dat", directory, pidx, dataStartId)
d := prevTok.(*build.TokenData)
d.ValType = "script"
//script, err := DissassembleScript(scriptData)
//if err != nil {
@ -106,6 +155,16 @@ func (sb *StudyBox) Export(directory string, includeAudio bool) error {
return fmt.Errorf("[WARN] unknown end data type: %s\n", jData.Type)
}
if prevTok != nil {
if jData.Type == "delay" {
d := prevTok.(*build.TokenDelay)
d.Reset = p.Reset
} else {
d := prevTok.(*build.TokenData)
d.File = filepath.Base(jData.File)
}
}
err = os.WriteFile(jData.File, rawData, 0666)
if err != nil {
return fmt.Errorf("Unable to write data to file [%q]: %v", jData.File, err)
@ -145,5 +204,29 @@ func (sb *StudyBox) Export(directory string, includeAudio bool) error {
return err
}
return os.WriteFile(directory+".json", rawJson, 0666)
err = os.WriteFile(directory+".json", rawJson, 0666)
if err != nil {
return err
}
bfile, err := os.Create(directory+".sbb")
if err != nil {
return err
}
defer bfile.Close()
for _, tok := range bscript {
if tok.Type() == "page" {
_, err = fmt.Fprintln(bfile, "")
if err != nil {
return fmt.Errorf("error writing bscript file: %w", err)
}
}
_, err = fmt.Fprintln(bfile, tok.Text())
if err != nil {
return fmt.Errorf("error writing bscript file: %w", err)
}
}
return nil
}