* --tty-telnet: proactive echo negotiation (fix telnet double-echo) The telnet echo suppression was reactive — the server only announced WILL ECHO / WILL SGA after the client sent an IAC first. But a classic telnet client sits in line-mode-with-local-echo and waits for the server to negotiate, so the announcement never went out, the client kept echoing locally, and every line appeared twice (its echo plus the machine's own authentic echo). A telnet server that wants remote echo has to offer it proactively on connect. The new --tty-telnet flag (implies --tty) does exactly that: it writes WILL ECHO + WILL SGA before the banner and marks the filter active so the reactive path doesn't double-announce. Bare --tty stays byte-clean for local terminals and nc (kickoff option B); the reactive fallback still helps a plain --tty client that does negotiate first. The systemd @.service switches its ExecStart to --tty-telnet. The growing session-behavior params (throttle, banner, telnet) fold into a TtyConfig struct. Tests: proactive announce leads the stream (before the banner) and doesn't double-announce when the client then sends its own IAC; the flag implies --tty and parses. Verified live: `ewm one --tty-telnet | xxd` shows FF FB 01 FF FB 03 then the prompt. Docs: scripts/systemd/README.md and notes/APPLE1.md explain proactive --tty-telnet vs byte-clean --tty, with troubleshooting for the double-echo and the nc negotiation bytes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Disk images over HTTP, downloaded once into a cache A floppy, hard-drive or 3.5" image path may now be an http(s):// URL: ewm two --set 'machine:slots:6:drive1=https://example.com/Frogger.dsk' ewm/src/fetch.rs downloads it into $XDG_CACHE_HOME/ewm (else ~/.cache/ewm) / <sha1 of the URL> / <file name from the URL>, beside a meta.json holding the ETag and Last-Modified. 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. The hash is the crate's own SHA-1 (ws::sha1) — no dependency for that. Freshness is one *conditional* GET (If-None-Match / If-Modified-Since) 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 machine assembled from URLs still boots offline; a cold cache with no server is a loud error instead. A URL is a source, not a path: resolve_paths leaves it alone (like builtin:), while referenced_files still counts it, so a built-in config may not carry one — builtins must run offline. Downloaded hard drives mount memory-only. This needed a real change, not just leaving the file handle unopened: in hdd.rs an unopenable file means WRITE PROTECTED, which is a different (and still supported) state for a local image. Hdd now carries an explicit `writeback` flag — false for downloads, so ProDOS writes update the in-memory image and never reach the cached file, and revalidating a download cannot destroy them. Floppies already behaved this way. 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. Tests: the cache round trip against a real local HTTP server (cold download, conditional revalidation with a 304, offline fallback, and a cold cache with nothing listening failing loudly); URL naming and digest stability including degenerate URLs; memory-only vs write-back block writes at the register level; URLs surviving path resolution; and the wiring gate — a machine builds with DOS 3.3 served over HTTP into drive 1, which only passes if the mount path really fetched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
30 KiB
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.mdPhase 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_optionsruns two passes — pass 1 loads--configfiles intoOptions, 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--configflags apply in order; config and CLI--memoryregions 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.jsonbyte-for-byte in sync; regenerate withEWM_UPDATE_SCHEMA=1 cargo test -p ewm schema_matches_committed. - serde caveat resolved:
deny_unknown_fieldson the internally taggedSlotCardenum does reject typo'd keys with current serde (theunknown_slot_card_key_is_rejectedcanary 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--memoryflag stays decimal-only. cpu.speedandinput.controllerexist 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_auxdelegate 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 becauseHdd::newneeds 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_ROMis 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, anddsk()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 inewm/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/0xFFthis 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) —
Twokeeps them in a slot-keyed map like the controllers. - Absent
machine.slots= the default layout; a presentslotsobject (even{}) is literal — absent keys inside it are empty slots. This keeps{"machine": {"model": "2plus"}}equal to bareewm twowhile honoring "an absent slot key means empty". (The--drive1/--drive2/--hddsugar 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/--hddsugar flags are removed (owner's decision); the boo launcher and docs speak--set.- One config document, sources layered left-to-right:
--configfiles load through the typed path (per-file validation and relative-path resolution intact) and are serialized back to JSON (serde::Serializeon the config types — fronting Phase C);--setmutates the document directly. One typed conversion + validation at the end (config::from_document). Consequence: multiple--configfiles 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 byplans/20260719-01-flag-retirement.md;--serveis the one document-overriding flag left.) - Merge rules (
config::merge_documents): objects merge recursively;nulland 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_setmirrors the card rule: setting a differentcardresets the object's other fields. - Slots materialization: a
--setenteringmachine:slotson a document without one materializes the default table first, so--set machine:slots:6:drive1=xon a bare command line extends the default machine exactly like the removed--drive1did. - Value typing: a
--setvalue 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--hddreplacement); anything else is a plain string. Escape hatch for values that accidentally parse as JSON (a file named123): quote them (--set 'machine:slots:6:drive1="123"').--setpath 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 theconfigs/files (include_str!, a static table inconfig.rs— no build script). Names are the schema's model tokens, matching the file stems 1:1:builtin:2plus(configs/2plus.json) andbuiltin:2e(configs/2e.json; the files were renamed fromplus.json/enhanced.json).builtin:listprints the names with descriptions and exits 0, like--help; an unknown name errors listing the available names. A literal file namedbuiltin:xis 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_builtinrejects file references at load time and thebuiltins_load_and_are_self_containedtest pins the property (plus: every builtin carries adescription, and the table stays sorted). - Top-level
descriptionjoined the schema — a one-line human description shown bybuiltin:list, usable by any config file. - Bare
ewm twois unchanged — the in-code default machine (Thunderclock in slot 1) still differs frombuiltin:2plusdeliberately; unifying them is backlog in the plan.
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.machineandMachine.modelbecameOption, so any fragment —{}, a slots-only overlay — parses.merge_documentsneeded no change (absent options serialize tonull, already a merge no-op). - Validation split in
config.rs:validateis structural (per file, fragment-judgeable: unknown keys, enum values, slot rules, multiplicity, sizes/addresses/ports);validate_completeis completeness (final document only:machine.modelpresent, 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--configpath) still require completeness per file — a partial file fails withmachine.model is required (is this an overlay? use --config-overlay)— whileload_documentruns only the structural pass (with the file named in errors and relative paths resolved) and is the path overlays will load through.from_documentruns both passes on the final layered document; its missing-model message ismachine.model is required (start from --config, e.g. --config builtin:2plus). - 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 intoschema/ewm-config.schema.jsonand commits the relaxed one asschema/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--configbase, 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. Thebuiltin: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
--configmax (plan recommendation adopted): a second errors withonly one --config allowed; use --config-overlay for additional layers. Mildly breaking — two--configfiles 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 carryingmachine.slotsonto a slotless document materializes the default table first, so--config-overlay hdd7.jsonmeans "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 inconfig.rstests;--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:2e) 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:2einstead.
Config sources — --print-config (C4, recorded as built)
Phase C4 of plans/20260718-02-config-sources.md:
ewm two … --print-configprints 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 ofapply_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,--breakand the hidden--screenshotare 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_documentdoes the shaping — it dropsnulls, 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
--configit yields identicalOptions(pinned by e2e tests, including a composed base + overlay +--set+ flags command line). Path fields are emitted as the run would use them, so--setpaths 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:
Modelgrewapple1/replica1tokens with a derived family (Family::Apple2vsFamily::Apple1); a config fully describes which machine it is — the property.ewmachinebundles 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, anddebug.enabledare 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.remotefor 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
tworefuses 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 (2plus/replica1— bareewm oneboots 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:replica1are minimal (description+machine.model; the ROMs are baked into the machine);builtin:listshows 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 asplans/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 ameta.jsonholding 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-Sincefrom the stored validators) rather than HEAD then GET — a304costs 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_pathsleaves it alone (likebuiltin:), butreferenced_filesdoes 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 inhdd.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 includingring, 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 |
2plus, 2e, apple1, replica1 |
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 inmachine.slots(owner's decision — hardware-faithful, not a separate field). The ][+ language card was hardwired intoTwo::new_2plusbefore this; now it is a declared card. - Card set restricted: slot 0 takes only
"language","saturn128"or"empty"— it has no$Cn00firmware 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
slotstable omits"0"is a stock 48K machine ($D000–$FFFF motherboard ROM on the bus, slot 0's DEVSEL range unmapped).configs/2plus.jsondeclares the card explicitly. The default table (absentslots, and the--setmaterialization) gains"0": {"card": "language"}, so bare command lines stay the classic 64K build;--set machine:slots:0:card=emptyis the 48K opt-out. - Machine plumbing: slot 0 never becomes a
SlotDevice—build_machineconsumes it as atwo::Slot0(Language / Saturn128 / Empty) passed toTwo::new_with_slots.Two::slot0()reports it and WozBug'sSLOTSshows 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 whatalc.rsimplements). 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 thehdd.rsstyle — hand-assembled$Cn00firmware 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 atdata_offset + block*512, header preserved. Any slot 1–7, no multiplicity limit;configs/2e.jsoncarries one in slot 5.- SmartPort identity is real: signature
$Cn07=$00, ID type at$CnFB, ProDOS entry via$CnFFwith 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),
$45is not restored after transfers and$42-$45are borrowed by the dispatch (ProDOS rebuilds$42-$47per call), and ProDOS STATUS reports empty drives as 0 blocks rather than an error (reads fail with$2Fwhere it matters). The boot gap below$40houses 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": "2e",
"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.modelrequired; everything else optional with the current defaults (an empty{ "machine": { "model": "2plus" } }is today's bareewm two).slots: object keyed"1"–"7"; each value is a card object discriminated by"card":"diskii"(drive1/drive2image paths, both optional),"harddrive"(image),"thunderclock","empty". When the wholeslotsobject 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" }—sizeonly valid withramworksiii; wholeauxobject only valid with"model": "2e"(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: falseeverywhere — 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
.ewmachinebundles (MAC_APP Phase 3) portable.
Implementation
Dependencies (flagged for the owner)
serde+serde_jsonfor parsing into typed structs withdeny_unknown_fields— the ecosystem standard; this is EWM's first general-purpose third-party dependency beyond sdl3/fontdue/chrono.schemarsto derive the JSON Schema from the same serde structs — one source of truth. A unit test regenerates the schema and asserts it matches the committedschema/ewm-config.schema.jsonbyte-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>inparse_options: the file populatesOptionsfirst; 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 7harddrive, slot 1thunderclock, 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.speedwire 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--drive1does; CLI-overrides-config precedence. - Docs: README
--configsection + an example;MAC_APP.mdPhase 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:
Dskinstances 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/$FForiginally 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$00and 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
.ewmachinedocument (MAC_APP Phase 3). serde::Serializeon the same structs; a round-trip test (save → load → identical machine).
Risks & open questions
.poinslots: the floppy/hard-drive ambiguity does not exist here — the slot's card type says which is meant. (The drag-drop size heuristic inmedia.rsstays 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
boothint 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'smachine.modelenum leaves room ("apple1","replica1") if wanted later.