Files
izapple2/configuration.go

555 lines
16 KiB
Go

package izapple2
import (
"embed"
"errors"
"flag"
"fmt"
"maps"
"os"
"path/filepath"
"strings"
"slices"
)
const configSuffix = ".cfg"
const defaultConfiguration = "2enh"
// noModel selects the base configuration without a model on top, the board
// and the roms have to be given as parameters
const noModel = "none"
const baseConfiguration = "_base"
const (
confParent = "parent"
confModel = "model"
confName = "name"
confBoard = "board"
confRom = "rom"
confCharRom = "charrom"
confCpu = "cpu"
confSpeed = "speed"
confRamworks = "ramworks"
confNsc = "nsc"
confTrace = "trace"
confProfile = "profile"
confShowConfig = "showConfig"
confForceCaps = "forceCaps"
confRgb = "rgb"
confRomx = "romx"
confMods = "mods"
confTape = "tape"
confSaveDir = "saveDir"
confScreen = "screen"
confS0 = "s0"
confS1 = "s1"
confS2 = "s2"
confS3 = "s3"
confS4 = "s4"
confS5 = "s5"
confS6 = "s6"
confS7 = "s7"
)
//go:embed configs/*.cfg
var configurationFiles embed.FS
type configurationModels struct {
preconfiguredConfigs map[string]*configuration
}
type configuration struct {
data map[string]string
}
func newConfiguration() *configuration {
c := configuration{}
c.data = make(map[string]string)
return &c
}
func (c *configuration) getHas(key string) (string, bool) {
key = strings.ToLower(key)
value, ok := c.data[key]
return value, ok
}
func (c *configuration) has(key string) bool {
_, ok := c.getHas(key)
return ok
}
func (c *configuration) get(key string) string {
value, ok := c.getHas(key)
if !ok {
// Should not happen
panic(fmt.Errorf("key %s not found", key))
}
return value
}
func (c *configuration) getFlag(key string) bool {
return c.get(key) == "true"
}
func (c *configuration) set(key string, value string) {
key = strings.ToLower(key)
c.data[key] = value
}
func loadConfigurationModelsAndDefault() (*configurationModels, *configuration, error) {
models := &configurationModels{}
dir, err := configurationFiles.ReadDir("configs")
if err != nil {
return nil, nil, err
}
models.preconfiguredConfigs = make(map[string]*configuration)
for _, file := range dir {
if file.Type().IsRegular() && strings.HasSuffix(strings.ToLower(file.Name()), configSuffix) {
content, err := configurationFiles.ReadFile("configs/" + file.Name())
if err != nil {
return nil, nil, err
}
config, err := parseConfiguration(content, file.Name())
if err != nil {
return nil, nil, err
}
name_no_ext := file.Name()[:len(file.Name())-len(configSuffix)]
models.preconfiguredConfigs[name_no_ext] = config
}
}
defaultConfig, err := models.get(defaultConfiguration)
if err != nil {
return nil, nil, err
}
defaultConfig.set(confModel, defaultConfiguration)
return models, defaultConfig, nil
}
func parseConfiguration(content []byte, name string) (*configuration, error) {
lines := strings.Split(string(content), "\n")
config := newConfiguration()
for iLine, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
before, after, ok := strings.Cut(line, ":")
if !ok {
return nil, fmt.Errorf("invalid configuration in %s:%d", name, iLine)
}
key := strings.TrimSpace(before)
value := strings.TrimSpace(after)
config.set(key, value)
}
return config, nil
}
func mergeConfigs(base *configuration, addition *configuration) *configuration {
result := newConfiguration()
for k, v := range base.data {
result.set(k, v)
}
for k, v := range addition.data {
result.set(k, v)
}
return result
}
func (c *configurationModels) get(name string) (*configuration, error) {
name = strings.TrimSpace(name)
if name == noModel {
name = baseConfiguration
}
config, ok := c.preconfiguredConfigs[name]
if !ok {
if filepath.Ext(strings.ToLower(name)) != ".cfg" {
name = name + ".cfg"
}
if _, err := os.Stat(name); errors.Is(err, os.ErrNotExist) {
path, exists := os.LookupEnv("IZAPPLE2_CUSTOM_MODEL")
if exists {
name = filepath.Join(path, name)
}
}
content, err := os.ReadFile(name)
if err != nil {
return nil, fmt.Errorf("configuration %s not found", name)
}
config, err = parseConfiguration(content, name)
if err != nil {
return nil, err
}
}
parentName, hasParent := config.getHas(confParent)
if !hasParent {
return config, nil
}
parent, err := c.get(parentName)
if err != nil {
return nil, err
}
result := mergeConfigs(parent, config)
return result, nil
}
func (c *configurationModels) availableModels() []string {
models := make([]string, 0, len(c.preconfiguredConfigs)-1)
for name := range c.preconfiguredConfigs {
if !strings.HasPrefix(name, "_") {
models = append(models, name)
}
}
slices.Sort(models)
return models
}
func (c *configurationModels) getWithOverrides(model string, overrides *configuration) (*configuration, error) {
configValues, err := c.get(model)
if err != nil {
return nil, err
}
if overrides != nil {
configValues = mergeConfigs(configValues, overrides)
}
return configValues, nil
}
func setupFlags(models *configurationModels, configuration *configuration) error {
paramDescription := map[string]string{
confModel: "set base model, 'none' to give the board, rom and charrom as parameters",
confBoard: "main board, can be '2plus', '2e', 'base64a' or 'basis108'",
confRom: "main rom file",
confCharRom: "rom file for the character generator",
confCpu: "cpu type, can be '6502' or '65c02'",
confSpeed: "cpu speed in Mhz, can be 'ntsc', 'pal', 'full' or a decimal nunmber",
confMods: "comma separated list of mods applied to the board, available mods are 'shift', 'four-colors",
confRamworks: "memory to use with RAMWorks card, max is 16384",
confNsc: "add a DS1216 No-Slot-Clock on the main ROM (use 'main') or a slot ROM",
confTrace: "trace CPU execution with one or more comma separated tracers",
confProfile: "generate profile trace to analyse with pprof",
confShowConfig: "show the calculated configuration and exit",
confForceCaps: "force all letters to be uppercased (no need for caps lock!)",
confRgb: "emulate the RGB modes of the 80col RGB card for DHGR",
confRomx: "emulate a RomX",
confTape: "WAV file with a tape recording for the cassette input",
confScreen: "monitor the screen is shown on, can be 'color', 'colorscanlines', 'green' or 'greenscanlines'",
confSaveDir: "directory to keep what the software writes to the disks, leaving the images unmodified. 'none' to write back to the images",
confS0: "slot 0 configuration.",
confS1: "slot 1 configuration.",
confS2: "slot 2 configuration.",
confS3: "slot 3 configuration.",
confS4: "slot 4 configuration.",
confS5: "slot 5 configuration.",
confS6: "slot 6 configuration.",
confS7: "slot 7 configuration.",
}
boolParams := []string{confProfile, confShowConfig, confForceCaps, confRgb, confRomx}
for name, description := range paramDescription {
defaultValue, ok := configuration.getHas(name)
if !ok {
return fmt.Errorf("default value not found for %s", name)
}
if slices.Contains(boolParams, name) {
flag.Bool(name, defaultValue == "true", description)
} else {
flag.String(name, defaultValue, description)
}
}
flag.Usage = func() {
out := flag.CommandLine.Output()
fmt.Fprintf(out, "Usage: %s [file]\n", flag.CommandLine.Name())
fmt.Fprintf(out, " file\n")
fmt.Fprintf(out, " path to image to use on the boot device\n")
flag.PrintDefaults()
fmt.Fprintf(out, "\nThe available pre-configured models are:\n")
for _, model := range models.availableModels() {
config, _ := models.get(model)
fmt.Fprintf(out, " %s: %s\n", model, config.get(confName))
}
fmt.Fprintf(out, "Custom models may be specified by filename. Use 'IZAPPLE2_CUSTOM_MODEL' to set default location.\n")
fmt.Fprintf(out, "\nThe available cards are:\n")
for _, card := range availableCards() {
builder := getCardFactory()[card]
if !builder.hide {
fmt.Fprintf(out, " %s: %s\n", card, builder.description)
}
}
fmt.Fprintf(out, "\nThe available tracers are:\n")
for _, tracer := range availableTracers() {
builder := getTracerFactory()[tracer]
fmt.Fprintf(out, " %s: %s\n", tracer, builder.description)
}
}
return nil
}
var diskAliases = map[string]string{
"dos33": "<internal>/dos33.dsk",
"prodos": "<internal>/ProDOS_2_4_3.po",
"cpm": "<internal>/cpm_2.20B_56K.po",
"cardcat": "<internal>/Card Cat 1.7.dsk",
}
func applyDiskAliases(filename string) string {
if alias, ok := diskAliases[filename]; ok {
return alias
}
return filename
}
// classifyFile determines if a file is a diskette or block device
// Returns true if the file is a diskette, false if it's a block device
func classifyFile(filename string) bool {
filename = applyDiskAliases(filename)
_, err := LoadDiskette(filename, "")
return err == nil
}
// processPositionalFilenames handles filenames passed as positional arguments
// and configures slots s6, s5, s7 based on the file types. WAV files are
// connected to the cassette input.
func processPositionalFilenames(config *configuration, filenames []string) error {
if len(filenames) == 0 {
return nil
}
diskettes := []string{}
blockDevices := []string{}
tapes := []string{}
for _, filename := range filenames {
filename = applyDiskAliases(filename)
if strings.HasSuffix(strings.ToLower(filename), ".wav") {
tapes = append(tapes, filename)
} else if classifyFile(filename) {
diskettes = append(diskettes, filename)
} else {
blockDevices = append(blockDevices, filename)
}
}
// Configure the cassette tape
if len(tapes) > 1 {
return fmt.Errorf("only one tape can be loaded, %v found", len(tapes))
}
if len(tapes) == 1 {
config.set(confTape, tapes[0])
if len(diskettes) == 0 && len(blockDevices) == 0 {
// Do not boot from disk, go to BASIC to LOAD from the tape
config.set(confS6, noCardName)
}
}
// Configure diskette slots (s6 and s5)
if len(diskettes) == 1 {
config.set(confS6, fmt.Sprintf("diskii,disk1=\"%s\"", diskettes[0]))
} else if len(diskettes) >= 2 {
config.set(confS6, fmt.Sprintf("diskii,disk1=\"%s\",disk2=\"%s\"", diskettes[0], diskettes[1]))
}
if len(diskettes) == 3 {
config.set(confS5, fmt.Sprintf("diskii,disk1=\"%s\"", diskettes[2]))
} else if len(diskettes) >= 4 {
config.set(confS5, fmt.Sprintf("diskii,disk1=\"%s\",disk2=\"%s\"", diskettes[2], diskettes[3]))
}
if len(diskettes) > 4 {
return fmt.Errorf("up to 4 diskettes can be loaded, %v found", len(diskettes))
}
// Configure block device slots (s7 and s5)
if len(blockDevices) > 8 {
return fmt.Errorf("up to 8 block devices can be loaded, %v found", len(blockDevices))
}
if len(blockDevices) > 0 {
config.set(confS7, fmt.Sprintf("smartport,image1=\"%s\"", blockDevices[0]))
if len(blockDevices) > 1 {
var smartportConfig strings.Builder
smartportConfig.WriteString("smartport")
for i, filename := range blockDevices {
if i == 0 {
continue
}
smartportConfig.WriteString(fmt.Sprintf(",image%v=\"%s\"", i+1, filename))
}
config.set(confS5, smartportConfig.String())
}
}
return nil
}
// expandSlotConfiguration expands a partial slot configuration into a full one
// If the configuration is just filenames, it detects the file type and creates
// the appropriate card configuration (diskii for diskettes, smartport for block devices)
func expandSlotConfiguration(configString string) (string, error) {
if configString == "" {
return "", nil
}
// Split by comma to get parts (but respect quotes)
parts := splitConfigurationString(configString, ',')
if len(parts) == 0 {
return configString, nil
}
// Check if first part is a card name or a filename
firstPart := strings.TrimSpace(parts[0])
// Skip expansion for special values
if firstPart == noCardName {
return configString, nil
}
// If it contains '=' it's already a parameter, so it's a full config
if strings.Contains(firstPart, "=") {
return configString, nil
}
// Check if first part is a known card name
_, isCard := getCardFactory()[strings.ToLower(firstPart)]
if isCard {
// Already a full configuration
return configString, nil
}
// It's a partial configuration - just filenames
// Detect the file types and build the appropriate configuration
diskettes := []string{}
blockDevices := []string{}
for _, part := range parts {
filename := strings.TrimSpace(part)
if filename == "" {
continue
}
// Apply disk aliases
filename = applyDiskAliases(filename)
if strings.HasSuffix(strings.ToLower(filename), ".wav") {
return "", fmt.Errorf("a tape recording cannot be assigned to a slot, use -tape")
}
// Try to load as diskette
_, err := LoadDiskette(filename, "")
if err == nil {
diskettes = append(diskettes, part) // Keep original part (may have quotes)
} else {
blockDevices = append(blockDevices, part)
}
}
// Build the configuration based on what we found
if len(diskettes) > 0 && len(blockDevices) == 0 {
// All diskettes - create diskii configuration
var config strings.Builder
config.WriteString("diskii")
for i, disk := range diskettes {
diskNum := i + 1
if diskNum > 2 {
return "", fmt.Errorf("diskii card supports maximum 2 disks, got %d", len(diskettes))
}
config.WriteString(fmt.Sprintf(",disk%d=%s", diskNum, disk))
}
return config.String(), nil
} else if len(blockDevices) > 0 && len(diskettes) == 0 {
// All block devices - create smartport configuration
var config strings.Builder
config.WriteString("smartport")
for i, device := range blockDevices {
imageNum := i + 1
config.WriteString(fmt.Sprintf(",image%d=%s", imageNum, device))
}
return config.String(), nil
} else if len(diskettes) > 0 && len(blockDevices) > 0 {
return "", fmt.Errorf("cannot mix diskettes and block devices in the same slot configuration")
}
// No valid files found
return configString, nil
}
// expandSlotConfigurations expands the partial slot configurations of every
// slot (e.g. "-s4 disk.dsk" -> "-s4 diskii,disk1=disk.dsk")
func expandSlotConfigurations(c *configuration) error {
slotParams := []string{confS0, confS1, confS2, confS3, confS4, confS5, confS6, confS7}
for _, slotParam := range slotParams {
if c.has(slotParam) {
slotConfig := c.get(slotParam)
expandedConfig, err := expandSlotConfiguration(slotConfig)
if err != nil {
return fmt.Errorf("error expanding slot configuration for %s: %w", slotParam, err)
}
if expandedConfig != slotConfig {
c.set(slotParam, expandedConfig)
}
}
}
return nil
}
func getConfigurationFromCommandLine() (*configuration, error) {
models, configuration, err := loadConfigurationModelsAndDefault()
if err != nil {
return nil, err
}
setupFlags(models, configuration)
flag.Parse()
modelFlag := flag.Lookup(confModel)
if modelFlag != nil && strings.TrimSpace(modelFlag.Value.String()) != defaultConfiguration {
// Replace the model
configuration, err = models.get(modelFlag.Value.String())
if err != nil {
return nil, err
}
}
flag.Visit(func(f *flag.Flag) {
configuration.set(f.Name, f.Value.String())
})
err = expandSlotConfigurations(configuration)
if err != nil {
return nil, err
}
// Process positional filenames (e.g., "program disk.dsk")
filenames := flag.Args()
err = processPositionalFilenames(configuration, filenames)
if err != nil {
return nil, err
}
return configuration, nil
}
func (c *configuration) dump() {
fmt.Println("Configuration:")
keys := slices.Sorted(maps.Keys(c.data))
for _, key := range keys {
fmt.Printf(" %s: %s\n", key, c.data[key])
}
}