Files
ewm/notes/JSON_CONFIG.md
Stefan ArentzandClaude Opus 4.8 9a27b68b2b Add builtin:apple2e config + docs (//e original-plan E4) (#321)
The closer for the original //e: a `builtin:apple2e` config so the machine
E3 added is reachable by name, and the docs to match. This completes
plans/20260720-02.

- configs/apple2e.json: model apple2e, aux ext80col, UniDisk 3.5 (Liron) in
  slot 5, Disk ][ in slot 6, RGB — the same layout as apple2enhanced, on the
  6502. Registered in BUILTINS (sorted apple2 < apple2e < apple2enhanced);
  the builtin list / title / model / self-containment tests extend.
- Boot gate: builtin_apple2e_boots_dos33 builds the machine through the
  config pipeline and boots DOS 3.3 to the Applesoft `]` prompt (the //e
  autostarts the slot 6 disk; DOS 3.3 runs on the 6502).
- Docs: README "What's emulated" folds the original and Enhanced //e into
  one entry and gains a builtin:apple2e profile; notes/JSON_CONFIG.md model
  inventory + the aux-validity note cover both //e models; IDEAS.md marks
  "NMOS //e" landed.

Gates: cargo fmt / clippy -D warnings / cargo test (365 pass, incl. the new
boot gate and readme_examples_parse with the new builtin:apple2e example).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:52:01 -04:00

31 KiB
Raw Permalink Blame History

JSON Machine Configuration (--config) — Implementation Plan

A working document for configuring a whole machine from one JSON file:

ewm two --config myemulator.json

In the house style: re-read at the start of every session, update as phases land. Every phase passes the full gates (cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test).

Owner's decisions (recorded)

  • JSON, not TOML — better supported (editors, JSON Schema tooling, every language). This supersedes the TOML wording in MAC_APP.md Phase 3.
  • A JSON Schema captures everything configurable today via the command line and the Command Palette.
  • Slots are configurable: no cards, three Disk ][ controllers, the different aux cards — the machine's physical layout lives in the file.
  • CLI options stay — only --config <path> is added for now.

Status

Phase Description Size Status
A --config + schema + serde types; today's layout expressible M Done
B Real slot flexibility: any slot, multiple Disk ][ controllers, empty slots M/L Done
Sources Built-ins, overlays, --print-config (plans/20260718-02-config-sources.md C1–C5) M Done
One family The same document for ewm one (plans/20260719-02-one-config.md O1–O5) M Done
C "Save current setup" from the palette; .ewmachine integration M Not started — seeded by options_to_config (C4)

Phase A decisions (recorded as built)

  • Precedence is structural, not tracked: parse_options runs two passes — pass 1 loads --config files into Options, pass 2 is the unchanged flag loop, which only assigns a field when its flag is present. CLI-overrides-config falls out without per-flag "was it given" state. Multiple --config flags apply in order; config and CLI --memory regions are additive.
  • schemars is a dev-dependency only (owner's decision): the derive is gated #[cfg_attr(test, derive(schemars::JsonSchema))], keeping schemars out of release builds. Consequence: no #[schemars(...)] attributes on the config structs, ever — doc comments become the schema descriptions. Promote it to a plain dependency if an attribute is genuinely needed.
  • Schema regeneration: the golden-file test keeps schema/ewm-config.schema.json byte-for-byte in sync; regenerate with EWM_UPDATE_SCHEMA=1 cargo test -p ewm schema_matches_committed.
  • serde caveat resolved: deny_unknown_fields on the internally tagged SlotCard enum does reject typo'd keys with current serde (the unknown_slot_card_key_is_rejected canary test pins this).
  • Slot rulings: slot 7 "empty" is accepted (just no HDD attached); "empty" in slots 1 and 6 is rejected with the Phase B message — the machine builders hard-wire the Thunderclock and Disk II today, so "no card there" is real slot flexibility. (Superseded by Phase B: any card in any slot.)
  • Memory addresses are strings, hex ("0xd000") or decimal ("53248"); the CLI --memory flag stays decimal-only.
  • cpu.speed and input.controller exist only in the config (no new CLI flags), wiring to the palette's speed constants and a preferred-name gamepad scan at startup.

Phase B decisions (recorded as built)

  • Two::new_with_slots(model, aux, &BTreeMap<u8, SlotDevice>) is the table-driven constructor; Two::new/new_with_aux delegate with the default table (Thunderclock@1, Disk II@6), so the whole pre-Phase-B test suite runs unchanged. The machine table carries card kinds only — media inserts afterwards (load_disk_at(slot, drive, path), attach_hdd_at(slot, path); the hard drive attaches post-construction because Hdd::new needs its image up front).
  • Devices decode only the DEVSEL low nibble (addr & 0x0f); each is registered over its slot's 16-byte range, so one implementation serves any slot. DSK_ROM is naturally slot-agnostic (the P5 ROM derives its slot from the return address); the clock and hard-disk firmware are generated per slot (clk_rom(slot), hdd_rom(slot)), with golden tests pinning the slot-1/slot-7 images byte-for-byte to the pre-generator statics.
  • Boot controller = highest slot with a Disk II; load_disk, the drag-drop handler, and dsk() target it. No config boot hint — the real Autostart ROM scan (7→1) picks the boot device, exactly as on hardware (proved by the boot-scan tests in ewm/tests/two_slots.rs).
  • Empty slots read 0x00 (owner's decision), the pre-existing unmapped-read behavior on both models — it fails the Autostart boot signature just as safely as the floating/0xFF this plan originally sketched.
  • Multiplicity: at most three Disk ][ controllers (the classic maximum) and one Thunderclock (ProDOS installs a single clock driver); hard drives are unlimited (owner's decision) — Two keeps them in a slot-keyed map like the controllers.
  • Absent machine.slots = the default layout; a present slots object (even {}) is literal — absent keys inside it are empty slots. This keeps {"machine": {"model": "apple2plus"}} equal to bare ewm two while honoring "an absent slot key means empty". (The --drive1/ --drive2/--hdd sugar this section originally described was replaced by --set; see the CLI overrides decisions below.)
  • Drive lights are OR'ed across controllers — the status bar and LED strip keep their two-light layout; at any moment at most one controller spins, so the pair reads as the active controller's drives.

CLI overrides (--set) decisions (recorded as built)

  • --set <key>=<value> overrides one config value by colon-separated key path (--set machine:slots:6:drive1=game.dsk, --set display:monitor=amber). A separate flag (owner's decision — not overloaded onto --config), and the --drive1/--drive2/--hdd sugar flags are removed (owner's decision); the boo launcher and docs speak --set.
  • One config document, sources layered left-to-right: --config files load through the typed path (per-file validation and relative-path resolution intact) and are serialized back to JSON (serde::Serialize on the config types — fronting Phase C); --set mutates the document directly. One typed conversion + validation at the end (config::from_document). Consequence: multiple --config files now deep-merge instead of a later file's slots table replacing wholesale. The remaining convenience flags (--model, --color, …) still override the finished document, per the Phase A precedence design. (Since superseded: the per-setting convenience flags were retired by plans/20260719-01-flag-retirement.md; --serve is the one document-overriding flag left.)
  • Merge rules (config::merge_documents): objects merge recursively; null and empty-array overlays are no-ops (a source that doesn't set a field must not clear it); two objects whose "card" discriminators differ replace wholesale (merging a diskii's drives into an "empty" card would fail validation). apply_set mirrors the card rule: setting a different card resets the object's other fields.
  • Slots materialization: a --set entering machine:slots on a document without one materializes the default table first, so --set machine:slots:6:drive1=x on a bare command line extends the default machine exactly like the removed --drive1 did.
  • Value typing: a --set value that parses as JSON is used as JSON (numbers, booleans, quoted strings, whole objects — --set 'machine:slots:7={"card":"harddrive","image":"tr.hdv"}' is the one-line --hdd replacement); anything else is a plain string. Escape hatch for values that accidentally parse as JSON (a file named 123): quote them (--set 'machine:slots:6:drive1="123"'). --set path values stay as-given (CWD-relative), like the flags they replace; file paths keep resolving against their config's directory.
  • Array paths are rejected (machine:memory:0:path) — memory regions come from --memory.

Config sources — built-ins (C1, recorded as built)

Phase C1 of plans/20260718-02-config-sources.md:

  • --config builtin:<name> loads one of the embedded copies of the configs/ files (include_str!, a static table in config.rs — no build script). Names are the schema's model tokens, matching the file stems 1:1: builtin:apple2plus (configs/apple2plus.json) and builtin:apple2enhanced (configs/apple2enhanced.json; the files were renamed from plus.json / enhanced.json). builtin:list prints the names with descriptions and exits 0, like --help; an unknown name errors listing the available names. A literal file named builtin:x is reachable as ./builtin:x (documented, not engineered around).
  • Built-ins are self-contained: no drive images, memory files, or trace/state paths — there is no directory to resolve relative paths against. load_builtin rejects file references at load time and the builtins_load_and_are_self_contained test pins the property (plus: every builtin carries a description, and the table stays sorted).
  • Top-level description joined the schema — a one-line human description shown by builtin:list, usable by any config file.
  • Bare ewm two boots builtin:apple2plus — the default machine is that built-in config, not an in-code layout (owner's decision; the earlier Thunderclock-in-slot-1 in-code default is retired), so code and configs describe the ][+ once.

Config sources — partial configs (C2, recorded as built)

Phase C2 of plans/20260718-02-config-sources.md, the enabling change for --config-overlay (C3):

  • The serde types are partial-friendly: Config.machine and Machine.model became Option, so any fragment — {}, a slots-only overlay — parses. merge_documents needed no change (absent options serialize to null, already a merge no-op).
  • Validation split in config.rs: validate is structural (per file, fragment-judgeable: unknown keys, enum values, slot rules, multiplicity, sizes/addresses/ports); validate_complete is completeness (final document only: machine.model present, plus the model cross-checks — aux is //e-only, the //e has no slot 0 — which an overlay can't be judged on until the merged document names the model).
  • Loader contract: load/load_source_document (the --config path) still require completeness per file — a partial file fails with machine.model is required (is this an overlay? use --config-overlay) — while load_document runs only the structural pass (with the file named in errors and relative paths resolved) and is the path overlays will load through. from_document runs both passes on the final layered document; its missing-model message is machine.model is required (start from --config, e.g. --config builtin:apple2plus).
  • Two schemas, one generator (plan option 2): the generated schema is now overlay-shaped, so the golden test post-processes the requiredness (machine, machine.model) back into schema/ewm-config.schema.json and commits the relaxed one as schema/ewm-config-overlay.schema.json (own title/description) for editor support of overlay files. One regeneration command updates both.

Config sources — overlays (C3, recorded as built)

Phase C3 of plans/20260718-02-config-sources.md:

  • --config-overlay <source> layers a partial config onto the document; repeatable, and all sources — the --config base, overlays, --set — apply strictly in command-line order through the existing merge. Overlay files load through the structural-only typed path (load_overlay_document → load_document): the overlay file is named in errors and its relative paths resolve against its own directory. The builtin: scheme is shared with --config (a complete config is a valid overlay); overlay-only command lines start from the default machine, like bare --set.
  • One --config max (plan recommendation adopted): a second errors with only one --config allowed; use --config-overlay for additional layers. Mildly breaking — two --config files used to deep-merge — but that read as an accident once partial layers had their own flag.
  • Slots materialization extends to overlays (merge_overlay_document): an overlay carrying machine.slots onto a slotless document materializes the default table first, so --config-overlay hdd7.json means "the default machine plus a hard drive in slot 7". A base's explicit table (from --config) stays literal — materialization fills a missing table, never touches a present one. All four base × overlay combinations are pinned in config.rs tests; --set's materialization is untouched (the boo launcher's drag-drop paths behave identically).
  • Known edge, accepted: overlaying a complete //e config onto the slotless ][+ default (ewm two --config-overlay builtin:apple2enhanced) fails completeness — materialization brings in the ][+ default table, whose slot 0 the //e rejects. Consistent with the rules (an overlay means "default machine plus this"); start from --config builtin:apple2enhanced instead.

Config sources — --print-config (C4, recorded as built)

Phase C4 of plans/20260718-02-config-sources.md:

  • ewm two … --print-config prints the machine the command line describes — sources and convenience flags applied, i.e. the machine a real run would build — as config JSON on stdout and exits 0. Any load/validation error still exits nonzero first, so the flag doubles as a config linter for scripts and CI.
  • options_to_config (two.rs) is the one Options→Config mapping — the inverse of apply_config, kept as a single function so the palette's "save current setup" (Phase C) reuses it. The full mapping was implemented; the plan's fallback (print before convenience flags) wasn't needed. --wozbug, --break and the hidden --screenshot are debug tooling, not machine configuration, and don't appear.
  • The document is explicit where it matters, quiet where it doesn't: model, slot table, display and cpu settings print even at their defaults (stable against future default changes); off-by-default extras (strict, debug, boot delay, remote) appear only when enabled. config::compact_document does the shaping — it drops nulls, empty arrays, and sections that emptied out, but keeps a genuinely bare "slots": {} (which means "no cards", not "default layout"). Keys print in sorted order (documents are sorted maps) — deterministic, semantics-free.
  • The printed document round-trips: fed back via --config it yields identical Options (pinned by e2e tests, including a composed base + overlay + --set + flags command line). Path fields are emitted as the run would use them, so --set paths given relative to the CWD print relative and would re-resolve against the printed file's directory — save next to where you ran, or use absolute paths.

Config sources — the one family (recorded as built)

plans/20260719-02-one-config.md (O1–O5): ewm one speaks the same configuration language.

  • One document type, not a second schema: Model grew apple1 / replica1 tokens with a derived family (Family::Apple2 vs Family::Apple1); a config fully describes which machine it is — the property .ewmachine bundles and a future unified launcher need.
  • Family cross-checks live in the completeness pass, next to the //e-only rules: on one-family documents, machine.slots, machine.aux, display.*, cpu.speed, input, boot, remote, state, and debug.enabled are rejected, not ignored — each error names the offending key and model. Valid everywhere: machine.model, machine.memory, cpu.strict, debug.trace. Relaxing a key for the family (e.g. remote for REMOTE.md Phase 7) is a one-place change.
  • The subcommands enforce the family both ways — a one-family document is a valid config that two refuses to run (and vice versa), each error pointing at the right subcommand.
  • The shared source loop (config::collect_document) carries pass 1 for both subcommands, parameterized by the seed model (apple2plus / replica1 — bare ewm one boots the Replica 1, the C default) and the slots-materialization hook (two-only; a one-family document never grows the ][+ default table).
  • Built-ins builtin:apple1 / builtin:replica1 are minimal (description + machine.model; the ROMs are baked into the machine); builtin:list shows all four names, and the boo bootloader boots the Apple 1 / Replica 1 via --config builtin:….
  • one's flags retired (--model, --memory, --trace, --strict) under the same v0.0.1 ruling as plans/20260719-01-flag-retirement.md; config memory regions are an upgrade (hex addresses, per-file relative-path resolution).

Disk images over HTTP (recorded as built)

A floppy or hard-drive image path may be an http(s):// URL (ewm/src/fetch.rs):

  • Cache: $XDG_CACHE_HOME/ewm (else ~/.cache/ewm) / <sha1 of the URL> / <file name from the URL>, with a meta.json holding the validators. The digest keys the directory because URLs are not filesystem-safe; the file keeps its own name so the cache is browsable and errors read normally. SHA-1 is the crate's own (ws::sha1) — no new dependency for hashing.
  • Freshness: one conditional GET (If-None-Match / If-Modified-Since from the stored validators) rather than HEAD then GET — a 304 costs a single round trip. If the request fails and a copy is cached, the cached copy is used, so a downloaded machine still boots offline.
  • A URL is a source, not a path: resolve_paths leaves it alone (like builtin:), but referenced_files does count it, so a built-in config still may not carry one — builtins must run offline.
  • Downloaded hard drives mount memory-only (writeback = false): ProDOS writes update the in-memory image and never reach the cached file, so revalidating a download cannot destroy them. This needed a real change, not just leaving the file handle unopened — an unopenable file means WRITE PROTECTED in hdd.rs, which is a different (and still supported) state for a local image.
  • Dependency: ureq (blocking + rustls) — owner's decision, the one place the tiny-dependency budget gives way, because TLS is not something to hand-roll. It brings ~25 transitive crates including ring, so the headless server build now compiles native crypto too.

What is configurable today (the schema inventory)

Since plans/20260719-01-flag-retirement.md, the CLI column is --set <key>=<value> (or any config source) for everything — the per-setting convenience flags are gone. Family rules (the completeness pass rejects the rest by name): machine.model, machine.memory, cpu.strict, and debug.trace apply everywhere; machine.cpu and RAM-bank (size) regions are apple1-family only; everything else below is apple2-family only.

Source Setting Values
config + --set machine.model apple2, apple2e, apple2plus, apple2enhanced, apple1, replica1
config + --set title window bar reads EWM - <title>, else plain EWM; the built-ins set the machine name
config + --set machine.cpu 6502, 65C02 (apple1 family; absent = the model's CPU)
config + --set machine.aux 80col, ext80col, ramworksiii (+ size; //e only)
config + --set + palette display.monitor green, amber, white, rgb
config + --set + palette display.scanlines off, light, heavy
config + --set boot.delay seconds
config + --set display.fps display refresh
config / overlay files machine.memory regions: type + address + one of path (a file or builtin:<rom>) or size (an empty RAM bank; apple1 family). On the apple1 family the regions describe the whole board. (--set cannot reach arrays)
config + --set debug.trace, cpu.strict, debug.enabled debugging
config + --set + palette cpu.speed 1.023 MHz (normal), 3.58 MHz, 7.16 MHz — the classic accelerator steps
config + --set + palette input.controller picked by name when several are present
config + --set slots 0–7 card per slot (below); slot 0 is the ][+ language-card socket

Slot 0 decisions (recorded as built)

  • Slot 0 is a "0" key in machine.slots (owner's decision — hardware-faithful, not a separate field). The ][+ language card was hardwired into Two::new_2plus before this; now it is a declared card.
  • Card set restricted: slot 0 takes only "language", "saturn128" or "empty" — it has no $Cn00 firmware space, so firmware-bearing cards can't work there — and those cards fit nowhere else. The //e rejects "0" outright (its language card is soldered onto the motherboard).
  • The literal-table rule covers slot 0 — a deliberate breaking change, accepted by the owner: a ][+ config whose slots table omits "0" is a stock 48K machine ($D000–$FFFF motherboard ROM on the bus, slot 0's DEVSEL range unmapped). configs/apple2plus.json declares the card explicitly. The default table (absent slots, and the --set materialization) gains "0": {"card": "language"}, so bare command lines stay the classic 64K build; --set machine:slots:0:card=empty is the 48K opt-out.
  • Machine plumbing: slot 0 never becomes a SlotDevice — build_machine consumes it as a two::Slot0 (Language / Saturn128 / Empty) passed to Two::new_with_slots. Two::slot0() reports it and WozBug's SLOTS shows the slot 0 line on the ][+ (with the selected bank for the Saturn). DOS 3.3 boots on the 48K machine (it just skips loading Integer BASIC) — regression-tested.
  • "saturn128" — the Saturn Systems 128K RAM Board (ewm/src/saturn.rs, from the Saturn Operations Manual ch. 9): eight 16K banks at $D000–$FFFF, each two 4K banks (A/B) at $D000 plus its own 8K. The $C08x A2=0 column is the exact Language Card protocol (bank 1 is how DOS/Pascal/VisiCalc see a "16K card"); A2=1 selects the 16K bank ($C084–7 → 1–4, $C08C–F → 5–8), with read/write/4K state persisting across switches. Write-enable follows the LC's read-twice rule (the manual's "PEEK or POKE" prose is looser; the LC-compatible semantics are what software relies on and what alc.rs implements). Regression-tested: all eight banks hold independent contents on the bus, and DOS 3.3 loads Integer BASIC into bank 1 with INT/FP switching both ways.
  • Future: an Integer BASIC Firmware card would be a fourth slot 0 card kind; the Saturn 32K/64K siblings would be a size field or variants.

The Liron / UniDisk 3.5 decisions (recorded as built)

  • "liron" (ewm/src/liron.rs): the UniDisk 3.5 Controller as virtual hardware in the hdd.rs style — hand-assembled $Cn00 firmware over magic DEVSEL ports, no IWM emulation. Two drives (drive1/drive2), .2mg only, ProDOS-order, exactly 800 (400K) or 1600 (800K) blocks; the 2IMG locked flag mounts read-only; write-back lands at data_offset + block*512, header preserved. Any slot 1–7, no multiplicity limit; configs/apple2enhanced.json carries one in slot 5.
  • SmartPort identity is real: signature $Cn07=$00, ID type at $CnFB, ProDOS entry via $CnFF with the SmartPort dispatch at entry+3. The dispatch implements STATUS (device count, per-unit status + block count, DIB), READ_BLOCK and WRITE_BLOCK (translated onto the ProDOS driver's pump); everything else returns $21. The Enhanced //e Autostart scan boots it — the boot test proved the scan accepts $Cn07=$00, so an 800K ProDOS .2mg in the highest populated slot boots exactly like a hard drive.
  • Space economies in the 256-byte firmware (all safe for this media): the third SmartPort block byte is ignored (800K = 1600 blocks), $45 is not restored after transfers and $42-$45 are borrowed by the dispatch (ProDOS rebuilds $42-$47 per call), and ProDOS STATUS reports empty drives as 0 blocks rather than an error (reads fail with $2F where it matters). The boot gap below $40 houses the SmartPort block-call setup.

The schema (draft)

Draft 2020-12, committed as schema/ewm-config.schema.json. Sketch of the shape (the committed schema is the authority; field-level descriptions throughout so editors show help):

{
  "machine": {
    "model": "apple2enhanced",
    "aux": { "card": "ramworksiii", "size": "1m" },
    "slots": {
      "1": { "card": "thunderclock" },
      "5": { "card": "diskii", "drive1": "work.woz" },
      "6": { "card": "diskii", "drive1": "dos33.dsk", "drive2": "blank.dsk" },
      "7": { "card": "harddrive", "image": "Total Replay v6.0.1.hdv" }
    },
    "memory": [ { "type": "rom", "address": "0xd000", "path": "custom.bin" } ]
  },
  "display": { "monitor": "green", "scanlines": "off", "fps": 30 },
  "cpu": { "speed": "normal", "strict": false },
  "input": { "controller": "Xbox Wireless Controller" },
  "boot": { "delay": 1.5 },
  "debug": { "trace": "trace.txt", "enabled": false }
}

Schema rules:

  • machine.model required; everything else optional with the current defaults (an empty { "machine": { "model": "apple2plus" } } is today's bare ewm two).
  • slots: object keyed "1"–"7"; each value is a card object discriminated by "card": "diskii" (drive1/drive2 image paths, both optional), "harddrive" (image), "thunderclock", "empty". When the whole slots object is absent the machine gets the default layout (clock in 1, Disk II in 6); when present it is literal — an absent slot key means empty, and "empty" says it explicitly. Up to three "diskii" entries, one "thunderclock", any number of "harddrive" cards (Phase B).
  • machine.aux: { "card": "80col" | "ext80col" | "ramworksiii", "size": "64k".."8m" } — size only valid with ramworksiii; whole aux object only valid with a //e model ("apple2e" / "apple2enhanced") (enforced in code; the schema documents it).
  • cpu.speed: "normal" | "3.58mhz" | "7.16mhz" — exactly the palette's accelerator steps (SPEED_NORMAL/FAST/FASTER).
  • input.controller: a preferred game-controller name; hot-plug still applies when absent or unmatched.
  • additionalProperties: false everywhere — a typo'd key is an error, not a silent ignore.
  • Relative paths resolve relative to the config file's directory — the property that makes .ewmachine bundles (MAC_APP Phase 3) portable.

Implementation

Dependencies (flagged for the owner)

  • serde + serde_json for parsing into typed structs with deny_unknown_fields — the ecosystem standard; this is EWM's first general-purpose third-party dependency beyond sdl3/fontdue/chrono.
  • schemars to derive the JSON Schema from the same serde structs — one source of truth. A unit test regenerates the schema and asserts it matches the committed schema/ewm-config.schema.json byte-for-byte (regenerate with a documented one-liner when the config grows).

Phase A — --config, schema, today's capabilities (M)

  • ewm/src/config.rs: the serde types mirroring the schema + pub fn load(path) -> Result<Config, String> (parse + semantic validation + path resolution against the config's directory).
  • --config <path> in parse_options: the file populates Options first; explicitly given CLI flags override config values (documented precedence — makes quick experiments cheap and keeps .ewmachine + overrides possible later).
  • Phase A accepts only layouts the emulator can already build: slot 6 diskii, slot 7 harddrive, slot 1 thunderclock, any aux card — i.e. today's machine expressed in slot syntax. Any other layout parses fine but errors clearly: "slot 5 diskii: not supported yet (see notes/JSON_CONFIG.md Phase B)".
  • input.controller + cpu.speed wire to the existing palette mechanisms at startup.
  • Tests: schema/struct agreement (the schemars round-trip); example configs parse (a committed ewm/tests/configs/ set); unknown-key and bad-value rejections with useful messages; relative-path resolution; a boot gate — a config naming the DOS 3.3 disk boots exactly like --drive1 does; CLI-overrides-config precedence.
  • Docs: README --config section + an example; MAC_APP.md Phase 3 rewritten TOML→JSON pointing here; IDEAS.md config bullet → planned.

Phase B — real slot flexibility (M/L) — done

The machine builders used to hard-wire the card set; Two::new_* now constructs from the slot table (decisions above):

  • Multiple Disk ][ controllers: Dsk instances at $C080 + slot*16, each with its own P5 boot ROM at $Cn00 — up to three controllers / six drives (the classic maximum). The boot scan order follows the //e/][+ autostart convention (highest slot first).
  • Empty slots: no Thunderclock, no Disk II — the vacated soft-switch and ROM ranges read $00 (not the floating/$FF originally sketched; see the Phase B decisions).
  • Card moves (Disk II in slot 4, clock in slot 2, …) and multiple hard drives fall out of the same table-driven construction.
  • Tests (ewm/tests/two_slots.rs): two controllers with distinct state and disks both readable (bus-level probes at both slots' addresses); empty slot 6 reads $00 and the machine falls through to BASIC; boot-scan order; a clock moved to slot 2 (through a full ProDOS boot); hard drives in two slots; the whole existing suite green with the default table, untouched.

Phase C — round-tripping (M)

  • Palette: "Save machine configuration…" writes the current state (monitor, scanlines, speed, mounted disks, aux card) as a valid config file — the seed of the .ewmachine document (MAC_APP Phase 3).
  • serde::Serialize on the same structs; a round-trip test (save → load → identical machine).

Risks & open questions

  • .po in slots: the floppy/hard-drive ambiguity does not exist here — the slot's card type says which is meant. (The drag-drop size heuristic in media.rs stays for pathless opens.)
  • Boot order with multiple controllers (resolved in Phase B): the Autostart scan (slot 7 → 1) picks the boot device — highest populated slot wins, as on hardware. No boot hint in the config.
  • Palette state vs config: monitor/scanlines/speed change at runtime; the config sets the initial state only (Phase C's save captures the current one).
  • one (Apple 1 / Replica 1): out of scope for now — the schema's machine.model enum leaves room ("apple1", "replica1") if wanted later.