From ac5f29a6968d3870b9432349d37d99a852f0b1d9 Mon Sep 17 00:00:00 2001 From: Zorchenhimer Date: Sat, 29 Nov 2025 18:15:51 -0500 Subject: [PATCH] [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. --- build-script/parser.go | 180 ++++++++++++++++++++++++++++ build-script/tokens.go | 264 +++++++++++++++++++++++++++++++++++++++++ rom/export.go | 85 ++++++++++++- 3 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 build-script/parser.go create mode 100644 build-script/tokens.go diff --git a/build-script/parser.go b/build-script/parser.go new file mode 100644 index 0000000..feb8c68 --- /dev/null +++ b/build-script/parser.go @@ -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...) + } +} diff --git a/build-script/tokens.go b/build-script/tokens.go new file mode 100644 index 0000000..7897d7e --- /dev/null +++ b/build-script/tokens.go @@ -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 +} diff --git a/rom/export.go b/rom/export.go index 7716eeb..b4a717f 100644 --- a/rom/export.go +++ b/rom/export.go @@ -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 }