330 Commits
Author SHA1 Message Date
Stefan ArentzandClaude Opus 4.8 619b4dd92a ROM catalog R6: built-in configs ship as .jsonc; user files may use .json or .jsonc (#344)
The six built-in configs carry comments now (R5), so ship them as
configs/*.jsonc — editors keyed off the extension treat them as JSONC instead
of flagging the comments. git mv keeps history (all renames R100), and the
BUILTINS include_str! paths + the file-reading tests move to .jsonc in
lock-step.

The loader reads config sources by path and never dispatches on the
extension, so a user's --config / --config-overlay file may use .json or
.jsonc freely. Add a commented .jsonc fixture and
jsonc_and_json_user_files_both_load to pin that (a .jsonc with comments and a
plain .json both load).

Verified: fmt/clippy clean; full suite green — including the builtin-config
load/round-trip tests against the renamed files.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:20:59 -04:00
Stefan ArentzandClaude Opus 4.8 c304d53617 ROM catalog R5: JSONC config parsing + name each ROM in a comment (#343)
Make config parsing lenient so config files can carry comments, and annotate
every ROM in the built-in configs with its catalog name.

- config.rs: a hand-rolled strip_json_comments removes // line and /* */ block
  comments (leaving string contents and line numbers intact) before
  serde_json, applied at the two parse points everything funnels through
  (load_builtin, from_str_partial). No new crate — comments are the only
  leniency (not trailing commas).
- rom.rs: rom::describe(key) returns a ROM's catalog description.
- The six built-in configs annotate each builtin:<key> ROM with that
  description, aligned, e.g.
    { "address": "0xd000", "path": "builtin:341-0011" }, // Apple ][+ AppleSoft BASIC, $D000-$D7FF
- README notes that config JSON accepts // and /* */ comments.

Gate: strips_comments_but_not_strings (comments go, string contents stay);
every_config_rom_line_has_its_catalog_name_as_a_comment (the annotations match
the catalog and can't drift); the builtin-config load/round-trip tests pass
against the now-commented files. fmt/clippy clean; full suite green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:12:23 -04:00
Stefan ArentzandClaude Opus 4.8 743f1f2d08 ROM catalog R4b: configs name the socketed character/video ROM by SKU (#342)
Add `machine.character_rom` — the socketed character (][) / video (//e) ROM,
a `builtin:<SKU>` catalog key. Unlike the system ROMs (R4a, which feed the
builders), the character ROM feeds the renderer: the frontend resolves the
config's character_rom and calls Scr::set_character_rom(rom, model), which
re-decodes the model's glyph set from that ROM — Chr::from_rom for the ][,
ChrE::from_rom(_, mousetext) for the //e — instead of the catalog default.

- config.rs: `Machine.character_rom: Option<String>` + validation (resolves).
- chr.rs: Chr::from_rom (new decodes from it); ChrE::from_rom made pub.
- scr.rs: Scr::set_character_rom rebuilds the model's set.
- two.rs: Options.character_rom + the config<->options mappings; the frontend
  (both the SDL and headless/RFB Scr sites) applies it via apply_character_rom.
- The four configs/apple2*.json carry character_rom (341-0036; the //e's
  342-0133-A / 342-0265-A); --print-config shows it.

Gate: set_character_rom_decodes_from_the_given_rom — re-applying a model's
default video ROM is byte-identical, and a swapped ROM re-decodes the glyphs
(the Enhanced-vs-original MouseText slot moves). Golden-BMP screenshots stay
byte-identical; schema regenerated. fmt/clippy clean; full suite green.

This completes the rom-catalog plan (R1-R4).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:59:12 -04:00
Stefan ArentzandClaude Opus 4.8 ace8d75da9 ROM catalog R4a: configs name the socketed motherboard system ROMs by SKU (#341)
Add a `machine.rom` array so each Apple II config lists its socketed
motherboard system-ROM chips by SKU — `{ "address": "0xd000", "path":
"builtin:341-0011" }` for the $D000-$FFFF chips (][ / ][+) or the two 8 KB //e
halves ($C000 CD, $E000 EF). This is the swappable-ROM part the owner asked
for; the language card's $D000-$FFFF is RAM (not a ROM) and is not listed, so
no "language card ROM" appears.

- config.rs: `RomChip { address, path }` + `Machine.rom` + per-chip validation
  (address parses, builtin: SKU resolves); the schema regenerates.
- two.rs: the builders receive the ROM. `new_with_slots_and_rom` takes the
  resolved `(address, bytes)` chips; `new_2plus` gets the assembled
  $D000-$FFFF image, `new_apple2` places each chip, `new_2e` the CD/EF halves.
  `new_with_slots` stays a 4-arg convenience that passes
  `default_rom_chips(model)`, so the ~15 existing callers and `Two::new` are
  unchanged and a bare `ewm two` == its builtin config. `build_machine`
  resolves the config's `machine.rom` (or the default). `IouE::internal_rom`
  became owned (was `&'static`) so the //e CD half can come from a config.
- The four configs/apple2*.json carry the rom array; --print-config shows the
  SKUs.

Gate: builtin_configs_build_the_default_system_rom proves each config's
explicit machine.rom builds a byte-identical $D000-$FFFF to its default
fallback; the golden-BMP screenshots (default path) stay byte-identical;
schema_matches_committed + readme_examples_parse pass. fmt/clippy clean; full
suite green.

R4b (the character/video ROM -> the renderer) is the remaining piece.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:47:39 -04:00
Stefan ArentzandClaude Opus 4.8 a5cf785a2b ROM catalog: record R4 as deferred (needs a design decision) (#340)
R1–R3 landed the SKU-keyed catalog and the builtin:<key> resolver — the core
goal (one source of truth, any ROM reachable by SKU) is delivered. R4 (move
the machine ROM wiring out of the two.rs builders into configs/*.json via
builtin:SKU) is deferred: it is genuinely invasive, not a mechanical
migration.

Two concrete blockers, found during R1–R3:

1. The ][+/​//e system ROMs are language-card / IOU construction data, not
   plain regions — the ][+ hands the ROM to Alc::new(rom), which owns and
   banks $D000-$FFFF. A config machine.memory ROM region there would shadow
   the language card (newest-first region walk) and break its banking, so the
   additive apple1-style approach is unsafe.
2. Two::new(TwoType) — used across the test suite — is not config-driven and
   uses default_slots (Thunderclock + Disk II), which differs from the builtin
   configs (Language + Disk II). The programmatic and config paths are
   different machines and can't be unified without changing test expectations.

Doing R4 right means refactoring the builders to receive the system ROM bytes
(resolved from the config's builtin:SKU) with Two::new passing the catalog
default, plus deciding how a config expresses the language-card-owned system
ROM — a design decision, best done as focused per-machine work with the
golden-BMP screenshots as the tripwire, not a blind migration.

Docs only.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:06:24 -04:00
Stefan ArentzandClaude Opus 4.8 d3fa82493b ROM catalog R3: the builtin: resolver reads the catalog (any SKU mountable) (#339)
Fold the four SKU-less ROMs (WozMon, apple1-basic, Krusader-1.3-6502/65C02)
into ROM_CATALOG and delete config.rs's separate ROM_BUILTINS table.
rom_builtin now resolves against the catalog, so a memory region can name
`"path": "builtin:342-0304-A"` — any catalog key, a SKU or a product slug —
and the existing slug names keep working. The error message lists the catalog
keys.

Refresh the memory-region `path` schema description to advertise the SKU form
and regenerate both JSON schemas (EWM_UPDATE_SCHEMA=1 schema_matches_committed).

Gate: rom_builtins_resolve_and_list (the slugs resolve, builtin:342-0304-A
resolves, an unknown lists the keys); schema_matches_committed;
readme_examples_parse; the apple1/replica1 builtin configs still boot.
Verified: fmt/clippy clean; full suite green including the golden-BMP
screenshots.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:44:03 -04:00
Stefan ArentzandClaude Opus 4.8 2ce1a3a591 ROM catalog R2: one catalog table owns every embedded machine/char/mouse ROM (#338)
Add ewm/src/rom.rs: `ROM_CATALOG`, a table of `RomEntry { key, desc, data }`
that is the single source of every ROM baked into the binary — keyed by SKU
(e.g. 342-0304-A), with the "what is this ROM" story in `desc` instead of
scattered `//` comments. `rom(key)` / `catalog(key)` look up the bytes.

The scattered `include_bytes!` statics move into the catalog:
- two.rs: the 15 machine-ROM statics (ROM_341_*, ROM_IIE_*) are gone; the
  builders and provenance tests fetch `catalog_rom("<SKU>")`.
- chr.rs: CHR_ROM / CHR_ROM_IIE / CHR_ROM_IIE_UNENHANCED are gone;
  generate_bitmap takes the rom slice, and Chr/ChrE fetch by key at
  construction.
- mouse.rs: MOUSE_ROM is gone; Mou holds the `&'static [u8]` from the catalog
  (fetched once in new(), so the per-fetch rom_byte path stays a direct index).

The config.rs `builtin:` ROMs (WozMon, apple1-basic, Krusader) are left in
ROM_BUILTINS for now — R3 folds them into the catalog and points the resolver
at it.

Gate: a catalog test (unique keys, non-empty descriptions, plausible ROM-sized
payloads) plus the existing per-ROM SHA-1 provenance tests (now via catalog
lookups). Verified: fmt/clippy clean; the full suite is green — including the
golden-BMP screenshots (unchanged bytes) and every provenance test.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:39:02 -04:00
Stefan ArentzandClaude Opus 4.8 c317f1e88d ROM catalog R1: canonical SKU-first roms/ layout (#337)
Rename every code-referenced ROM to one canonical convention —
`<SKU> — <description> (<chip>).bin`, SKU first so the folder sorts by part
number and the SKU is greppable — via `git mv` (history follows, all renames
are R100). Flatten the `AppleII/` and `AppleIIe/` subfolders (the SKU prefix
already disambiguates era: 341 = ][ / ][+, 342 = //e). One extension: the
SKU-less `.rom` files (WozMon, apple1-basic, Krusader) become `.bin`.

Collapse the `3410036.bin` ≡ `AppleII/…341-0036` duplicate (same sha1) to a
single `341-0036 — Apple II Character ROM` that both `chr::CHR_ROM` and the ][
machine reuse; the provenance test now pins that one file by SHA-1.

Dispose of the orphans (git history preserves them): the `.rom.bin` Krusader
doubles (wrong size vs the embedded `.rom`), the keyboard decode ROMs, the
Signetics 2513, `a2p.rom`, `applesoft-lite.bin`, and the `341-0868` aggregate.
The Klaus test suites stay (loaded by path from dormann/trace_compare tests).

Every `include_bytes!` (two.rs, chr.rs, config.rs, mouse.rs) and `fs::read`
(one_boot.rs, the provenance test) path is updated in the same commit. The
`builtin:` keys (WozMon, apple1-basic, Krusader-1.3-*) are unchanged — they are
stems, not filenames.

Verified: builds; fmt/clippy clean; the full suite is green — including the
golden-BMP screenshots (the tripwire that no rename silently swapped the bytes
feeding the video path) and every ROM provenance/SHA-1 test.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 17:48:29 -04:00
Stefan ArentzandClaude Opus 4.8 4aa5364c3d Mouse PIA plan P5: docs (notes/MOUSE.md as-built) + cleanup (#336)
Rewrite notes/MOUSE.md as the real-hardware as-built: the 6520 PIA + 6805
controller + banked 342-0270-C ROM, the port-B handshake, the commands, the
X=$Cn/Y=$n0 calling convention and the screen-hole layout (Pos/Read
slot-indexed; ClampMouse's fixed slot-0 quirk), the interrupt model (][+
works, //e backlog), host input, the EWM integration, and the MousePaint
confirmation. Drop the retired synthetic-firmware description and its (now
resolved) "Known limitation".

Update README and notes/IDEAS.md to describe the mouse as the card's real
hardware, and mark plans/20260721-01 (the synthetic card) as superseded by
this plan.

Docs only; no code change.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:52:51 -04:00
Stefan ArentzandClaude Opus 4.8 d85ab6222c Mouse PIA plan P4: interrupts through the real firmware (][+) (#335)
Un-ignore and adapt tests/two_mouse.rs's vbl_interrupts test to the real ROM:
enable VBL interrupts through the real SetMouse, install a handler at the ROM
user IRQ vector $03FE that calls the real ServeMouse, tick frames, and confirm
the interrupt fires exactly once per frame with ServeMouse reporting the VBL
source ($08) in the status hole $077C and de-asserting the line. Adds
Two::mouse_irq_pending() and asserts the line is clear after the last serve
(no spurious re-fire).

As built: on the ][+ the interrupt path works end-to-end. On the //e the IRQ
vectors differently -- $FFFE ($C3FA) enables INTCXROM and jumps into the card
firmware's own interrupt entry ($C400, V-set) rather than the $03FE user
vector, and that firmware interrupt-service routine loops against our card
(it reads a multi-byte handshake stream looking for a terminator our
ServeMouse reply does not match). MousePaint's //e path is polling, which
works (P3), so this does not block the flagship; recorded in the plan backlog.

Verified: fmt/clippy clean; the full suite including the golden-BMP
screenshots is green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:48:27 -04:00
Stefan ArentzandClaude Opus 4.8 ae614ababa Mouse PIA plan P3: host input through the real firmware — the MousePaint flagship (#334)
Add tests/two_mouse_iie.rs: the flagship firmware-level assertion on an
Enhanced //e (MousePaint's environment, the path that hung with the synthetic
card). Host input was already wired in P1 (feed_mouse_* -> set_position /
move_by / set_button); P3 proves it reaches a program through the real ROM:

- The banked $Cn00 ROM identifies the card through the //e IOU's $CX-ROM
  region (the mouse device shadows it under INTCXROM arbitration).
- A fed host pointer (pixel mapped into the clamp window, button down) is
  reported exactly by the real ROM's ReadMouse in the slot-4 screen holes,
  with the button and movement status bits set.

Dev-time confirmation (the MousePaint disk is not redistributable, so this is
not committed): the real MousePaint 680-0239-A now boots to its menu and
RETURN advances the screen from "Press RETURN to learn how to use the mouse"
to "One moment, please." -- the reported hang (the mouse-gated menu ignoring
the keyboard) is resolved.

Verified: fmt/clippy clean; the full suite including the golden-BMP
screenshots is green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:34:11 -04:00
Stefan ArentzandClaude Opus 4.8 99adc253c6 Mouse PIA plan P2: the real ROM firmware drives the card end-to-end (#333)
Un-ignore and fix the two_mouse.rs firmware test so it runs the real
342-0270-C ROM's entry points (via the $Cn12 offset table) end-to-end:
InitMouse -> ClampMouse X/Y -> SetMouse -> PosMouse -> ReadMouse, asserting
the clamped position (700, 200) and mode land in the screen holes.

The P1 controller/handshake needed no changes; making the real 6502 ROM run
required matching two conventions the retired synthetic firmware didn't:

- Register calling convention: the ROM routines require X = $Cn (the ROM-page
  high byte, indexing the screen holes) and Y = slot*16 (the $C0nX DEVSEL
  offset), set by the caller before each JSR. With those, the card's PIA +
  6805 handshake and the ROM's $xx70 mid-routine bank-switching all run
  correctly (verified: $C400/$C470 execute different per-bank code as port B
  flips banks 2->6).

- Screen-hole layout: Pos/Read use the slot-4 holes (X=$047C/$057C,
  Y=$04FC/$05FC); ClampMouse reads the FIXED slot-0 holes ($0478/$0578 min,
  $04F8/$05F8 max) regardless of slot -- the documented clamp quirk (Apple II
  Technical Note Mouse #7, the reason RDMEMMOUSE/GETCLAMP exists).

Adds Two::mouse_position() (introspection). The VBL-interrupt firmware test
stays #[ignore]d for P4 (interrupts through the real handler). Verified:
fmt/clippy clean; the full suite including the golden-BMP screenshots is
green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:24:30 -04:00
Stefan ArentzandClaude Opus 4.8 5e4e98f0a9 Mouse PIA plan P1: the real card substrate (6520 PIA + 6805 + banked ROM) (#332)
Replace the synthetic AppleMouse firmware with the card's real hardware,
ported near-verbatim from the MIT oliverschmidt/mouse-interface reference
(PIA6520.c + MouseInterfaceCard.c) with the 342-0270-C ROM from
freitz85/AppleIIMouse:

- A 6520 PIA at the slot DEVSEL (data/direction select via CRx bit 2).
- The 6805 controller: the full 6502<->6805 port-A/B handshake (advanced to
  a fixpoint after each PIA write, since EWM has no second core) and every
  command body (Set/Read/Serve/Clear/Pos/Init/Clamp/Home/Time/RdMem).
- The 2 KB ROM banked into $Cn00 as eight 256-byte pages, selected live by
  PIA port B bits 1-3 (no $C800 expansion). One device serves both the
  DEVSEL and $Cn00 via map_device; on the //e it shadows the IOU's slot page.
- Host input (move_by / set_position / set_button) and the interrupt model
  (VBL/movement/button gated by mode; ServeMouse clears), wired into Two's
  existing feed_mouse_* / tick_vbl / irq_line path.

The synthetic mouse_rom/Mou protocol and its M2-M4 unit tests are retired
and migrated. Verified: fmt/clippy clean; the mouse unit tests (PIA
registers, port-B banking across all 8 pages, a simulated-6502 handshake
running Init->Clamp->Pos->Read, status/movement/clamp, interrupt gating,
Persist round-trip), ROM provenance (sha1), and the identification bytes
read through the real machine bus all pass; the full suite including the
golden-BMP screenshots is green.

The real-ROM end-to-end firmware run (its entry convention + $xx70
page-switching) is P2: tests/two_mouse.rs's two firmware-execution tests are
#[ignore]d until then.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:59:43 -04:00
Stefan ArentzandClaude Opus 4.8 91f3128f59 Plan (revised) + the 342-0270-C ROM: real AppleMouse hardware (#331)
The reference implementations turn the mouse-hardware plan from blind
reverse-engineering into a port of known-good code. Two sources:
oliverschmidt/mouse-interface (MIT) — PIA6520.c + MouseInterfaceCard.c, which
document the whole card in their headers — and freitz85/AppleIIMouse, whose
342-0270-C ROM + disassembly this commit lands.

Findings that reshape the plan:
- The card is a 6520 PIA + a 6805 microcontroller + a 2KB ROM.
- The ROM banks into the 256-byte $Cn00 slot area as 8 pages, selected by PIA
  port B bits 1-3 — NO $C800 expansion (a real simplification over the prior
  revision's guess).
- The 6502 firmware talks to the 6805 over the PIA: port A = data, port B =
  RDACK/WRREQUEST/RDREADY/WRACK handshake + ROM bank + sync. No timeouts, so a
  wrong handshake hangs it — exactly today's MousePaint symptom.
- The 6805 drives the slot IRQ directly (the PIA's IRQ is unused).

plans/20260721-03 rewritten to this reference-grounded design and five phases
(P1 PIA + banked ROM, P2 the 6805 + handshake, P3 host input + MousePaint
flagship, P4 interrupts, P5 retire the synthetic card + docs). The card works
on both ][+ and //e with its own ROM — no family split. notes/MOUSE.md points
at the reference + ROM.

- roms/Apple Mouse Interface Card ROM - 342-0270-C.bin (2KB, sha1
  3a9d881a8a8d30f55b9719aceebbcf717f829d6f), owner-directed fetch.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:14:23 -04:00
Stefan ArentzandClaude Opus 4.8 7feab74572 Plan: real AppleMouse hardware (6821 PIA) for the //e firmware path (#330)
MousePaint hangs on the //e: it runs the //e's internal mouse firmware
(INTCXROM=1), which drives the card as its real 6821 PIA ($C0n0-$C0n3), not
the synthetic card's $Cn00 entry points. Diagnosed by booting the real disk
headlessly (reaches the menu, RETURN ignored, the device touched twice). The
synthetic firmware (plans/20260721-01) only covered the card's own entry
points — the gap the "no committed MousePaint image" kickoff decision left.

- notes/MOUSE.md: a "Known limitation" section recording the diagnosis and
  pointing at the new plan.
- plans/20260721-03-mouse-pia-hardware.md: a six-phase plan to model the card
  as its real hardware — a 6821 PIA (reusing ewm/src/pia.rs, the Apple 1's)
  with the mouse mechanism (quadrature X/Y, button, IRQ lines) wired to its
  ports, so the //e internal firmware drives it. P1 reverse-engineers the
  wiring (the linchpin); P5 rewrites mouse_rom to drive the PIA for the ][+.
  Three kickoff decisions (][+ path, flagship gate, PR granularity).

Docs only.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:26:19 -04:00
Stefan ArentzandClaude Opus 4.8 a45b920e8f Mouse plan M5: docs + as-built notes/MOUSE.md (#329)
The closer for the AppleMouse II card (plans/20260721-01, now complete). No
code — the as-built record and the config surface's documentation.

- notes/MOUSE.md: the as-built — synthetic firmware and the identification /
  screen-hole map, the private DEVSEL protocol, the interrupt model and the
  reusable maskable-IRQ line (so Mockingboard inherits it), and the
  coordinate decision (absolute/mapped, revised from the plan's
  relative/captured).
- README: the "What's emulated" list gains the AppleMouse II; a runnable
  example (examples/mouse.json, a slot-4 mouse //e) checked by
  readme_examples_parse.
- notes/JSON_CONFIG.md: the slot-card inventory gains "mouse" (one, any slot
  1-7, both families); notes/STATE.md lists the Mou Persist fields;
  notes/IDEAS.md ticks the AppleMouse item and points Mockingboard at the
  shared IRQ line.

Gate: readme_examples_parse + standard gates (382 pass).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:27:48 -04:00
Stefan ArentzandClaude Opus 4.8 4c5c48ec12 Mouse plan M4: interrupt mode on the M1 line (#328)
Phase M4 of plans/20260721-01: the mouse raises the maskable interrupt M1
built. SetMouse's mode bits enable interrupt-on-VBL / movement / button; the
card asserts the Two-local IRQ line on the matching condition, and ServeMouse
reports the source and de-asserts.

- mouse.rs: mode-gated interrupt sources — vbl_tick() raises VBL once per
  frame, movement raises on a clamped position change, button on a press
  edge; irq_asserted() is the OR; ServeMouse (read port 9) reports + clears.
- two.rs: refresh_irq_line() recomputes the cached line from the mouse;
  tick_vbl() pulses VBL + refreshes, called once per frame (60 Hz,
  deterministic) before the burst in both frontends; service_irq re-derives a
  cached-high line before firing, so a handler's ServeMouse de-assert mid-burst
  isn't re-taken. The feed paths refresh too (movement/button interrupts).

Gates: the M1 line test now drives a real mouse (VBL enable -> take -> SEI
holds -> ServeMouse de-asserts). Flagship (two_mouse.rs, the scripted
firmware-level end-to-end): enable VBL interrupts, install a handler through
the ROM user IRQ vector ($03FE) — which works precisely because M1 made a
hardware IRQ push B clear — tick 5 frames, and the handler fires exactly once
per frame with ServeMouse reporting VBL, while a fed movement reaches the
position the firmware reads.

cargo fmt / clippy -D warnings / cargo test (382 pass; golden-BMP + two_timing
intact — no mouse in those configs, so tick_vbl is inert there).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:23:14 -04:00
Stefan ArentzandClaude Opus 4.8 cb33450212 Mouse plan M3: host input drives the mouse (#327)
Phase M3 of plans/20260721-01: the host pointer moves the emulated mouse,
from both frontends. Coordinate model revised to absolute/mapped (recorded
in the plan): the SDL window pointer and the RFB pointer each map their pixel
proportionally into the mouse's clamp window through one `Two::feed_mouse_pixel`
path — no cursor grab, testable headlessly, friendlier for a windowed
emulator. The device keeps `move_by` for a possible future relative mode.

- mouse.rs: `Mou::move_by` (relative integrate), `set_button`, `clamp()`
  getter; `set_host` deduped.
- two.rs: `has_mouse`, `feed_mouse_delta` (relative), `feed_mouse_pixel`
  (absolute pixel -> clamp window). SDL `run` loop feeds the device on
  MouseMotion / MouseButton events (window content area -> clamp); the RFB
  serve path routes a PointerEvent to the mouse when a card is present
  (mask bit0 = button), else the old paddle-0 fallback stands.

Gates: feed_mouse_delta integrates and clamps; feed_mouse_pixel maps a pixel
into the window; an RFB PointerEvent moves the emulated mouse and its button.
cargo fmt / clippy -D warnings / cargo test (380 pass, golden-BMP intact — no
mouse in those configs, so no cursor and no render delta).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:13:47 -04:00
Stefan ArentzandClaude Opus 4.8 bc7cda0d2a Mouse plan M2: the mouse card substrate (polled) (#326)
Phase M2 of plans/20260721-01: a working AppleMouse II card when polled —
config, the device, the firmware, and the machine wiring. No interrupts yet
(Phase M4) and no host input yet (Phase M3); a program that calls the entry
points and reads the screen holes sees a real mouse.

- `ewm/src/mouse.rs`: `mouse_rom(slot)` — synthetic 256-byte firmware
  implementing the eight documented entry points (found via the $Cn12 offset
  table), moving bytes between the caller's screen holes and the card's
  DEVSEL ports; and `Mou`, the `Device` + `Persist` holding position, per-axis
  clamp, button (now + last), the moved flag, mode, and the read stream. The
  DEVSEL protocol is ours (only our firmware touches it) — documented in the
  module header. Identification: Pascal 1.1 ($Cn05=$38/$Cn07=$18/$Cn0B=$01/
  $Cn0C=$20) + AppleMouse ($CnFB=$D6), with $Cn01≠$20 so the Autostart scan
  skips it.
- Config: `SlotCard::Mouse` ("mouse"), any slot 1–7 (slot 0 already rejects
  it), both families, at most one; no files, nothing to resolve. Schemas
  regenerated.
- Machine: `SlotDevice::Mouse` wired into new_2plus / new_apple2 / new_2e
  (DEVSEL device + $Cn00 firmware ROM); build_machine maps the card; `Two`
  holds the handle for M3/M4.

Gates: the golden `mouse_rom(4)` byte test + a moved-slot operand test; the
device driven directly over its ports (Init→Clamp→Pos→Read, clamping); and
`two_mouse.rs` — a ][+ with a slot-4 mouse runs real 6502 through the
firmware entry points and the clamped result lands in the screen holes.
cargo fmt / clippy -D warnings / cargo test (377 pass, golden-BMP intact —
no mouse in those configs).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:04:22 -04:00
Stefan ArentzandClaude Opus 4.8 3e7474ac9f Mouse plan M1: a maskable-interrupt path + corrected cpu.irq() (#325)
The first phase of the AppleMouse II card (plans/20260721-01): the reusable
IRQ machinery the mouse (and later Mockingboard) needs. Dormant in
production — no device asserts the line yet — so no existing test moves.

CPU (ewm-core):
- Split BRK from a hardware IRQ, which shared one BRK-shaped code path. A
  real `cpu.irq()` now pushes the *exact* resume PC with B **clear** and
  vectors $FFFE; `nmi()` matches via $FFFA. BRK keeps its own path
  (`brk_interrupt`): PC+1 with B **set**. A shared `enter_interrupt` helper
  carries the three. Dormann (which exercises BRK) is byte-identical.
- Tests: a hardware IRQ pushes the exact PC / B=0 / masks I / RTI resumes;
  BRK pushes PC+1 / B=1 — the two stay distinct.

Machine (ewm/two.rs):
- `Two` caches the maskable IRQ line as one bool; `service_irq()` polls it
  between `cpu.step()`s in both burst loops (SDL `run`, headless `serve`).
  Level-sensitive and gated by I: high + I==0 takes the IRQ (which sets I,
  so the handler runs to RTI); SEI holds it pending until CLI; a low line is
  never taken. Just a `bool && I` check per step — the line is refreshed on
  state change, never scanned per instruction (the hazard the plan flags).
- Test: the line is level-sensitive and I-gated. `set_irq_line` is a
  test-only hook until Phase M4 wires the mouse device's asserted state.

Kickoff decisions recorded in the plan (owner directed one PR per phase;
relative/captured coords; firmware-default clamp; Two-local IRQ line;
scripted M4 gate).

Gates: cargo fmt / clippy -D warnings / cargo test (370 pass, incl. Dormann,
two_timing, and the golden-BMP screenshots — the burst-loop change is inert).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:46:31 -04:00
Stefan ArentzandClaude Opus 4.8 8232da812c Backlog: //e Ctrl-Reset should clear the MMU soft switches (#324)
Record the reset-fidelity gap found while testing the original //e: EWM's
Ctrl-Reset is only cpu.reset() (CPU registers), but a real //e's RESET line
also clears the memory soft switches (80STORE/RAMRD/RAMWRT/ALTZP/INTCXROM/
SLOTC3ROM) before the ROM handler runs — and the ROM relies on that. Measured:
after Ctrl-Reset from an all-switches-on state, RAMRD/RAMWRT/ALTZP/INTCXROM
persist on both //e, so a program that leaves aux memory active and resets
comes back still in aux memory (never happens on hardware).

New "Emulation accuracy" section in notes/IDEAS.md with the story, the fix
shape (a hardware-reset hook distinct from a program's soft reset through the
vector), what to pin against a reference before implementing, and the //e-only
scope. No code change here — backlog only.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:29:33 -04:00
Stefan ArentzandClaude 6f4d5e7505 Plan: Apple //c profile (original ROM 255, //e reuse) (#319)
A phased plan for an Apple //c (model apple2c), graduating the //c item
from notes/IDEAS.md and APPLE_IIE_ENHANCED.md's future work. Self-
contained design (board model, IOU deltas, serial stub, config surface)
in the style of the recent original-][ and telnet plans; the durable
memory-map/IOU forensics become notes/APPLE_IIC.md as a C4 as-built.

Owner kickoff answers baked in: target the original //c (ROM version
255) — 16K ROM, one built-in 5.25" drive, fixed 128K, no $C028 bank
switch — and land the two serial ports as a boot-satisfying 6551 stub,
not the full modem bridge (both fuller variants are backlog).

The //c is an Enhanced //e in a sealed case: 65C02, MMU/IOU, 128K aux,
80-col, DHGR, and the enhanced char ROM are all reused from the //e. The
//c-specific work is a short list — the //c ROM, two 6551 ACIAs (a device
class EWM lacks), a //c IOU variant that makes IOUDIS/$C07E/$C07F live
(deliberately inert on the //e), a fixed-board config model, and wiring
the built-in mouse + 5.25" drive.

Five phases (C0 ROM assets, C1 machine + IouC boot-to-], C2 6551 stub,
C3 fixed board + mouse/disk, C4 docs). C3 depends on the AppleMouse card
(plan 20260721-01) for its device + shared IRQ line. Gated on the //c ROM
being supplied (roms/ has none today).


Claude-Session: https://claude.ai/code/session_011ZRT6yf4ybQyuSdEKgF9m2

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-21 00:29:02 -04:00
Stefan ArentzandClaude Opus 4.8 645a394256 Fix: Caps Lock must not swallow Return (and the other bare keys) (#323)
With Caps Lock on, Return did nothing — nor did Tab, Delete, Backspace,
the arrows, or Escape. The SDL KeyDown loop gated those bare keys on
`keymod.is_empty()`, but SDL reports Caps Lock (and Num Lock, Mode) as
modifier bits in `keymod`, so the whole branch was skipped whenever a lock
was engaged.

Caps Lock / Num Lock / Mode are lock *states*, not held modifiers. Route
the check through a new `is_unmodified_key(keymod)` that ignores those lock
bits (Ctrl/Shift/Alt/Gui are the real modifiers, handled by the branches
above), and unit-test it — the guard was inline in the event loop with no
coverage.

cargo fmt / clippy -D warnings / cargo test (367 pass, incl.
caps_lock_does_not_swallow_bare_keys).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:17:45 -04:00
Stefan ArentzandClaude Opus 4.8 bd427047f0 Fix: the original //e must pass typed lower case through (#322)
The //e keyboard input path folded letters to upper case for every model
except `TwoType::Apple2EEnhanced`, so the original //e (`Apple2E`, added in
E3) turned typed 'a' into 'A' — everything came out upper case even with
Caps Lock off. Lower case *displayed* fine (the char ROM was correct); the
byte never arrived lower case.

Root cause: two input paths (the SDL `TextInput` handler and the remote/RFB
keysym handler) each open-coded `== TwoType::Apple2EEnhanced` to mean "a //e
with lower case". E3 split the //e into original + Enhanced and taught the
renderer `TwoType::is_iie()`, but these two keyboard sites (and a redundant
build-machine slot-0 guard) were missed.

Extract the shared decision into `typed_key_byte(model, b)` keyed on
`is_iie()`, used by both input paths, and unit-test it — the logic was
previously buried in the event loops with no coverage. Now the original and
Enhanced //e both pass lower case through; the ][ / ][+ still fold to upper.

cargo fmt / clippy -D warnings / cargo test (366 pass, incl. the new
typed_lower_case_reaches_every_iie_but_folds_on_the_original_ii).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:12:08 -04:00
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
Stefan ArentzandClaude Opus 4.8 f246e72fd5 Add the original (unenhanced) //e machine (//e original-plan E3) (#320)
* Rename the Enhanced //e identifiers (E3 prep)

Mechanical prep for the original //e: the Rust variants that name the
Enhanced machine become `TwoType::Apple2EEnhanced` / `Model::TwoEEnhanced`,
freeing the plain `Apple2E` / `TwoE` for the original //e that the next
commit adds. Kickoff decision (owner): the plain name is the *original*
machine, matching `Two` = original ][ and the config tokens (apple2e =
original, apple2enhanced = Enhanced).

Word-boundary-guarded rename across ewm/src + ewm/tests (86 Apple2E, 11
TwoE). No behavior change: the serde tokens are untouched (Model::TwoEEnhanced
keeps rename = "apple2enhanced"), so the schema is byte-unchanged, and the
golden-BMP //e screenshots are identical. cargo test 361 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add the original (unenhanced) //e machine (//e original-plan E3)

`model: apple2e` is now a real machine: a 6502 //e with the unenhanced
342-0134/0135 system ROMs and the 342-0133 video ROM, alongside the
Enhanced //e (65C02, 342-0303/0304, MouseText). Kickoff naming (owner):
`Model::TwoE` / `TwoType::Apple2E` = the original (the plain name), the
Enhanced kept the `*Enhanced` names from the prep commit.

- config: `Model::TwoE` (token `apple2e`) joins the enum; family / token /
  two_type / the aux + slot-0 //e validation all cover both //e models.
- two.rs: `TwoType::Apple2E` + `TwoType::is_iie()`; `new_2e` takes the
  variant and selects (6502, unenhanced ROMs) or (65C02, Enhanced ROMs);
  `IouE::new` takes the CD/EF halves. The `Persist` INFO string and
  `--print-config` mapping gain the `apple2e` arm. Schemas regenerated.
- rendering: `Scr` holds both //e glyph sets and picks by variant; the
  five `== Apple2E` //e-detection sites become `is_iie()` (the original
  and Enhanced //e render identically apart from the character ROM).

The one real subtlety — the original //e is NOT a plain ROM swap. Its
video ROM stores plain upper case at `$40-$5F`, but the original //e
*displays* inverse upper case there (the glyphs Apple later replaced with
MouseText). So `alternate_index` gains a `mousetext` flag: the Enhanced
set reads MouseText straight from ROM, the original inverts the upper-case
glyph — the same steady inverse glyph the primary set flashes.
(`render_txt_row_80` also dropped its redundant `line_offset` arg, derived
from `row`, to stay under clippy's argument limit.)

Gates (all green): the Enhanced //e is byte-identical — the golden-BMP
screenshots don't move; a golden pins the composed original-//e $E000-$FFFF
to the unenhanced EF half (and different from the Enhanced); the bare
original //e cold-boots to `]` on the 6502; and a char test proves the
alternate $40-$5F is inverse upper case, distinct from Enhanced MouseText.
cargo fmt / clippy -D warnings / cargo test (364 pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:43:01 -04:00
Stefan ArentzandClaude Opus 4.8 38d810e5a1 Embed the unenhanced //e ROM set (//e original-plan E2) (#318)
Commit the owner-supplied original (1983, unenhanced) //e ROMs under
roms/AppleIIe/ and embed them, so E3 can build a 6502 //e from them:

- two.rs: ROM_IIE_CD_UNENHANCED (342-0135-B, $C000-$DFFF) and
  ROM_IIE_EF_UNENHANCED (342-0134-A, $E000-$FFFF), the 8K system halves,
  next to the Enhanced 342-0304-A/342-0303-A pair.
- chr.rs: CHR_ROM_IIE_UNENHANCED (342-0133-A), the 4K video ROM, next to
  the Enhanced 342-0265-A — the original //e set has no MouseText ($40-$5F
  is inverse upper case), the one visible difference E3 will render.

All three statics are #[allow(dead_code)] until E3 wires them; each is
pinned by SHA-1 against the committed image so it cannot silently drift:
iie_unenhanced_system_roms_match_the_committed_images (two.rs) and
iie_unenhanced_video_rom_matches_the_committed_image (chr.rs). Nothing is
wired into a machine yet, so the golden-BMP screenshots are untouched.

Verified: cargo fmt --all --check, cargo clippy --all-targets -D warnings,
cargo test (361 pass — the two new provenance tests, golden-BMP screenshots
unchanged).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 23:06:10 -04:00
Stefan ArentzandClaude aa6fdd9dc1 Plan: AppleMouse II card (synthetic firmware + reusable IRQ line) (#317)
A phased plan for Apple II Mouse emulation, driven by the AppleMouse item
in notes/IDEAS.md. Self-contained design (firmware model, interrupt
model, coordinate model) in the style of the recent original-][ and
telnet plans; the as-built note notes/MOUSE.md is produced in the final
phase.

Key design calls captured from a read of the codebase:

- Synthetic $Cn00 firmware (the clk.rs/hdd.rs pattern), not the real
  AppleMouse ROM, because EWM has no per-slot $C800 expansion and the
  eight-entry firmware protocol is the sanctioned interface MousePaint /
  GEOS / Dazzle Draw actually call.
- A real maskable-interrupt path is the prerequisite: cpu.irq() exists
  but nothing asserts a line, and RDVBL is faked. Phase M1 builds a
  cached, level-sensitive machine IRQ line the burst loops poll, and
  corrects cpu.irq() (real-IRQ vs BRK semantics) — reusable by
  Mockingboard later.
- Host input reuses the per-frame joystick-feed cadence; the RFB path is
  nearly free (rfb.rs already parses PointerEvent).

Five phases (M1 IRQ line, M2 card substrate, M3 host input, M4 interrupt
mode + MousePaint gate, M5 docs), each independently landable with its
own gate, plus the standard fmt/clippy/golden-BMP gates.


Claude-Session: https://claude.ai/code/session_011ZRT6yf4ybQyuSdEKgF9m2

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-20 23:01:09 -04:00
Stefan ArentzandClaude Opus 4.8 869ba8175e Rename builtin:apple2e -> apple2enhanced (//e original-plan E1) (#316)
Our `apple2e` was really the Enhanced //e (65C02 + 342-0303/0304 system
ROMs + the 342-0265 MouseText video ROM). Rename the config-level token
to the honest `apple2enhanced`, freeing `apple2e` for the true original
(unenhanced) //e that E2-E4 add. Mechanical, mirroring the
`2plus`->`apple2plus` rename (#308); no ROM or machine changes.

What moved (the config surface only):
- `config::Model` serde token + `Model::token()`; `git mv
  configs/apple2e.json -> apple2enhanced.json` with model updated and
  title made honest ("Apple //e" -> "Enhanced Apple //e", reserving
  "Apple //e" for E4's original). BUILTINS re-sorts in place (apple2enhanced
  sorts before apple2plus, so no manual reorder).
- The `Persist` INFO model string mirrors the token by design
  (apple2/apple2plus/...), so it renames too, keeping save-states
  self-describing as `apple2enhanced`.
- Every `"apple2e"` config-token literal in tests/error strings; usage
  and `--config` help text; README, notes/JSON_CONFIG.md, notes/MAC_APP.md,
  examples/myiie.json. Both JSON schemas regenerated from source
  (EWM_UPDATE_SCHEMA=1), not hand-edited.
- `builtin:list` column widened 12->16 so the longer name still aligns.

Deliberately NOT touched (internal, renamed later by E3): the Rust
identifiers `TwoType::Apple2E` / `Model::TwoE`, the `two_e_*` test files,
and their "apple2e must construct" panic strings — they name the type,
which stays until E3 splits the two machines.

Verified: cargo fmt --all --check, cargo clippy --all-targets -D warnings,
and cargo test (359 pass, incl. the golden-BMP screenshots and
readme_examples_parse). Gate: `builtin:list` shows apple2enhanced; the old
`builtin:apple2e` errors "no built-in config ... (available: apple1,
apple2, apple2enhanced, apple2plus, replica1)".

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 22:58:41 -04:00
Stefan ArentzandClaude Opus 4.8 548040ff7c Plan: SKU-keyed ROM catalog (notes/ROMS.md + plan) (#315)
Capture the ROM-organisation idea as a living design note and a phased
plan. The note inventories today's roms/ folder — five naming styles,
three extensions, an exact duplicate (3410036.bin ≡ AppleII/…341-0036),
size-mismatched .rom/.rom.bin Krusader pairs, and two code paths for
embedding ROMs (two.rs/chr.rs statics vs config::ROM_BUILTINS) — and
proposes SKU-first filenames plus one lookup table SKU → (desc, bytes).

The plan sequences it after the original-//e ROMs land so the reorg
happens once: R1 folder rename → R2 catalog table → R3 builtin:SKU
resolver → R4 optional config migration. Docs only; no code.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 22:54:07 -04:00
Stefan ArentzandClaude Fable 5 2e1cb37136 Plan: original (unenhanced) //e as apple2e + apple2e->apple2enhanced rename (#314)
Our apple2e is really the Enhanced //e (65C02 + 342-0303/0304 ROMs +
the MouseText video ROM), so rename it apple2enhanced and add a true
original //e as apple2e: a 6502 with the unenhanced system + video
ROMs (owner supplied roms/AppleIIe/: 342-0135-B CD, 342-0134-A EF,
342-0133-A video, no MouseText), same //e hardware otherwise.

Four phases: E1 the rename (mechanical, no ROMs, like #308), E2 embed
the unenhanced ROMs, E3 the original //e machine (Model/TwoType
variant, new_2e selects 6502 + unenhanced ROMs, and the renderer picks
the //e char ROM by variant — the one real complication, gated by the
Enhanced golden-BMP screenshots staying byte-identical), E4 the
builtin:apple2e config + docs. Three kickoff decisions. Status: draft.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:19:28 -04:00
Stefan ArentzandClaude Fable 5 2150f7e0a8 Bare ewm two boots builtin:apple2plus (no in-code default machine) (#313)
Owner's decision: the default machine *is* a config, not an in-code
layout. When parse_options collects no sources, it now loads
builtin:apple2plus, so `ewm two` and `ewm two --config builtin:apple2plus`
build the identical machine — code and configs describe the ][+ once,
the way `ewm one` already builds its default from the replica1 builtin.

Consequences: the old in-code default's Thunderclock in slot 1 is
retired (builtin:apple2plus is language@0 + diskii@6, no clock — a
stock ][+); and, combined with the title field, the bare-two window now
reads "EWM - Apple ][+" (the owner's first computer). The golden-BMP
screenshots are unaffected — the Thunderclock never rendered.

Two default-pinning tests updated to the new behavior:
bare_two_is_the_apple2plus_builtin asserts bare == the builtin, and the
print-config round trip checks the title and language card instead of
the retired clock. default_slot_cards stays as Two::new's constructor
default (the lib/test path, distinct from the CLI default). Docs: the
README profiles section and JSON_CONFIG note now say the default is
builtin:apple2plus; the config-sources backlog item is marked done.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:05:47 -04:00
Stefan ArentzandClaude Fable 5 2a36ca634f Window title from the config: EWM, or EWM - <title> (#312)
The window bar was picked by matching options.model, so an apple2
machine fell into the ][+ arm and showed "Apple ][+". Replace it with a
config-driven title: a new top-level `title` field, window reads
"EWM - <title>" when set and plain "EWM" otherwise
(config::window_title, shared by both frontends). The five built-in
configs set it to the machine name: Apple 1, Replica 1, Apple ][,
Apple ][+, Apple //e. The old "EWM v0.1 / …" strings are gone; the
bootloader window aligns to "EWM - Bootloader".

Both `two` and `one` carry the title in Options (from apply_config, and
round-tripped by options_to_config so --print-config shows it). It
comes from the document only: a bare `ewm two`/`ewm one` stays "EWM"
even though one's normalize fills the board from a builtin — so the
title tracks what you asked for, not the machine underneath.

Tests: window_title formatting, and every built-in carries its
machine-name title. Schemas regenerated for the new field.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:51:06 -04:00
Stefan ArentzandClaude Fable 5 c76edb68c3 builtin:apple2 config + PR#6 DOS 3.3 boot gate (A3) (#311)
Phase A3 of plans/20260720-01-original-apple2.md, closing the plan.
configs/apple2.json is the machine the request started from: a 48K
Apple ][ (no slot-0 language card), Integer BASIC, a Disk ][ in slot
6, green monitor — registered in BUILTINS (sorted: apple1, apple2,
apple2e, apple2plus, replica1). The self-containment sweep and the
builtin-name/list assertions extend to it.

Boot gate: builtin:apple2 with the DOS 3.3 System Master in drive 1
resets to the Monitor — asserting it does NOT autostart the disk (a
][+ would) — then C600G runs the slot 6 boot ROM and DOS 3.3 loads
(its cold-start banner is the marker). No `]` check: the original ][
has Integer BASIC, not Applesoft, so DOS drops to `>`.

Docs: the README What's-emulated list gains the Apple ][; the two
profiles section adds builtin:apple2 with its JSON and the C600G/PR#6
boot note; JSON_CONFIG's model inventory lists apple2. Plan complete.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:28:19 -04:00
Stefan ArentzandClaude Fable 5 5512b9c56e The original Apple ][ machine: model apple2 + Two::new_apple2 (A2) (#310)
Phase A2 of plans/20260720-01-original-apple2.md. config::Model gains
Two (serde token "apple2"), apple2 family, two_type() ->
TwoType::Apple2; that arm of new_with_slots is now a real constructor
instead of "unsupported". Two::new_apple2 builds the 48K machine on the
same TwoIo motherboard as the ][+, mapping the Integer ROM set from A1:
Programmer's Aid at $D000-$D7FF, a hole at $D800-$DFFF (unmapped ->
$00), Integer BASIC at $E000-$F7FF, Original Monitor at $F800-$FFFF.

The Monitor is non-autostart, so no emulator code is needed for the
behavioral difference: reset lands at the `*` prompt and does NOT boot
the Disk ][ in slot 6 (a ][+ would). The A1 ROM statics lose their
#[allow(dead_code)]; options_to_config now maps Apple2 -> Model::Two
(so --print-config round-trips), and the state INFO tag is "apple2".

Slot 0 (the memory-expansion socket) is deferred: wiring a Language
Card / Saturn to bank the Integer ROM is future work, so apple2 is 48K
for now. An explicit slot-0 card fails validation with a clear message;
the default ][+ layout's Language Card (inherited by a bare
`--set machine:model=apple2`) is refused at build time, pointing at the
fix. builtin:apple2 (A3) is the clean no-slot-0 path.

Gate: apple2 resets to the Monitor `*` prompt (not an autostart disk
boot), Ctrl-B enters Integer BASIC and PRINT 2+2 answers 4; the slot-0
rejection is pinned at both the validation and build stages. Schemas
regenerated for the new Model token.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:10:40 -04:00
Stefan ArentzandClaude Fable 5 712911ad99 Embed the original Apple ][ ROM set (apple2 A1) (#309)
Phase A1 of plans/20260720-01-original-apple2.md: the owner-supplied
roms/AppleII/ set is embedded as five include_bytes! statics in
two.rs — Programmer's Aid #1 ($D000-$D7FF), Integer BASIC
($E000-$F7FF, three pages), and the Original non-autostart Monitor
($F800-$FFFF). $D800-$DFFF has no socket on this build, so it stays a
hole; the memory map is documented at the statics.

The character ROM (341-0036) is byte-identical to the committed
roms/3410036.bin that chr::CHR_ROM already loads, so it is reused, not
embedded twice. A provenance test pins each of the five images by
SHA-1 (the crate's own ws::sha1) and asserts the AppleII char ROM
equals the committed one, so the reuse cannot silently drift.

The statics carry #[allow(dead_code)] until Two::new_apple2 wires them
in (A2); the provenance test references them now. Standard gates pass.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:59:08 -04:00
Stefan ArentzandClaude Fable 5 d288cbb430 Plan: the original Apple ][ (model: 2) + builtin:minimal-apple2 (#307)
* Plan: the original Apple ][ (model: 2) + builtin:minimal-apple2

A genuine 1978 Apple ][ — Integer BASIC, the non-autostart original
Monitor — distinct from the ][+ we ship. The owner supplied the ROM
set under roms/AppleII/ (Programmer's Aid #1 at $D000, Integer BASIC
$E000-$F7FF, Original Monitor $F800-$FFFF, char ROM); the char ROM is
byte-identical to the committed 3410036.bin, so it is reused, not
duplicated, and the main ROM window is $D000-$FFFF with a hole at
$D800-$DFFF.

Most of the difference from the ][+ is free: no-autostart is inherent
in the ROM (reset lands at the Monitor, PR#6 boots a disk), the char
ROM and renderer are unchanged, and new_2 mirrors new_2plus with a
different $D000-$FFFF image. Three small phases: embed the ROMs
(A1), the `2` model + machine + boot gate (A2), the minimal-apple2
config + docs (A3); A1 may fold into A2. Four kickoff decisions
(language card on 2, naming, phase/PR granularity). Status: draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Plan: original ][ is `apple2` / `builtin:apple2` (post-rename)

Fold in the kickoff naming decisions: the model token is `apple2` and
the builtin `builtin:apple2` (name = token), following the settled
2plus->apple2plus / 2e->apple2e rename (#308) as a landed prerequisite.
Also corrects the stale `--model 2` mention (the flag was retired;
machine.model / --config drives it) and renames new_2 -> new_apple2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:49:46 -04:00
Stefan ArentzandClaude Fable 5 64dc24f7a5 Rename model tokens: 2plus -> apple2plus, 2e -> apple2e (#308)
Spell out the two-family model tokens so every machine reads the same
way — apple1, apple2e, apple2plus, replica1 — ahead of adding the
original Apple ][ as `apple2` (plans/20260720-01). Pre-1.0, so the old
terse tokens go outright: `--set machine:model=2plus` and
`--config builtin:2plus` are now apple2plus / apple2e, and an old name
errors with the available list.

Mechanical and wide: the config::Model serde tokens and Model::token(),
the BUILTINS table (renamed, re-sorted, include_str paths), configs/
files (git mv 2plus.json/2e.json -> apple2plus.json/apple2e.json) and
their model values, the ewm/tests/configs fixtures, every "2plus"/"2e"
string literal in tests and error messages, the examples, both schemas,
and the live docs (README, JSON_CONFIG, IDEAS, MAC_APP). Guarded
word-boundary replacement so e2e, new_2e and hex escaped it. The
builtin:list column widened to fit the 10-char "apple2plus".

Verified: fmt, clippy -D warnings, the full suite (37 binaries incl.
golden-BMP and the README example check) pass; builtin:list and the
old-token error checked against the real binary.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:49:15 -04:00
Stefan ArentzandClaude Fable 5 c9c1a75fb6 Disk images over HTTP, cached with ETag revalidation (#306)
* --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>
2026-07-19 21:48:11 -04:00
Stefan Arentz 75bce7aec8 --tty-telnet: proactive echo negotiation (fix telnet double-echo) (#305) 2026-07-19 19:57:11 -04:00
Stefan ArentzandClaude Fable 5 25f59cc451 scripts/systemd/README.md: the telnet Apple 1 build & setup guide (#304)
The first real deployment surfaced everything a header comment
couldn't hold: SDL's Linux build dependencies (and the XTEST error a
partial set produces), the stale-CMake-cache gotcha after a failed
configure (cargo clean -p sdl3-sys), what sdl-static-headless actually
builds and its --tty-only caveat, the install paths the units expect,
how to watch sessions (systemctl list-units / journalctl), operating
knobs (machine, port, limits, banner), the security posture, and a
troubleshooting section written from the actual failures.

The guide lives next to the units as scripts/systemd/README.md; the
socket header shrinks to a pointer (one source of truth), and the main
README's Experimental bullet and notes/APPLE1.md link to it.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 18:33:23 -04:00
Stefan ArentzandClaude Fable 5 f28879bf7f sdl-static-headless: the telnet server build, plus the install checklist (#303)
Deploying the telnet Apple 1 hit two gaps. First, the default build
links SDL3 dynamically and sdl-static compiles SDL with full video —
which demands X11 dev packages (XTEST et al) that a headless server
neither has nor needs. The new sdl-static-headless feature stacks
sdl3's build-from-source-unix-console on sdl-static: a static SDL
console build with no X11/Wayland at all. Only --tty runs on such a
binary (documented); it builds in a fraction of the time and links no
dynamic SDL.

Second, ewm-one.socket's header said "install both units" without
saying how much more there is: it now carries the full checklist —
the headless build command, binary/banner/unit installs,
daemon-reload, enable, and where the per-instance logs live.

Verified: the headless binary builds, serves a banner + monitor
session over a pipe, and carries no dynamic SDL reference.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:31:30 -04:00
Stefan ArentzandClaude Fable 5 60a161ecee --tty-banner: greet telnet visitors with instructions (T3) (#302)
Phase T3 of plans/20260719-04-apple1-telnet.md (added after T2): a
--tty-banner <path> text file prints to the session before the machine
says anything — where the operator puts the instructions a bare Woz
Monitor prompt doesn't offer. Line endings are CRLF-normalized for raw
terminals; a missing file fails loud at startup (a systemd misconfig
should be visible in the journal, not silently banner-less).

The flag is tty-session tooling, not machine description — it lives
with --tty, outside the config document, like the debug flags do.
scripts/systemd/banner.txt ships as the example (monitor and BASIC
starters, the reset chord, how to leave telnet) and the service unit
mounts it from /usr/local/share/ewm — readable under
ProtectSystem=strict.

Test: the banner precedes the machine's prompt, CRLF-normalized, and
the flag without a value falls into usage. Verified live: piped
session shows banner then prompt; a bad path exits 1 with the error.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:05:51 -04:00
Stefan ArentzandClaude Fable 5 3e177a8bb6 Telnet negotiation, BREAK reset, systemd units (T2) (#301)
Phase T2, closing plans/20260719-04-apple1-telnet.md.

The tty session gains a hand-rolled RFC 854 subset: a state-machine
filter that stays dormant until the first inbound IAC — nc and local
terminals never see a protocol byte — then announces WILL ECHO + WILL
SGA (character-at-a-time, remote echo), refuses every other
negotiation, swallows subnegotiations, drops IAC IAC (a literal 0xFF
is not an Apple 1 key), and maps BRK/IP — telnet's `send brk`, the
serial-terminal attention — to the RESET button: immediate, flushing
typed-ahead, like the real thing.

scripts/systemd/ carries the inetd-style units: ewm-one.socket
(ListenStream=6502, Accept=yes, MaxConnectionsPerSource) and
ewm-one@.service (StandardInput=socket, DynamicUser, ProtectSystem=
strict, MemoryMax, RuntimeMaxSec) — the sandbox is tight because the
binary needs no filesystem; configs and ROMs are embedded. README's
Experimental section gains the entry; notes/APPLE1.md records the
tty/telnet design as built; the plan is complete.

Tests: the filter unit-tested byte for byte (activation volley, the
no-reply-to-DO-ECHO loop guard, WONT/DONT refusals, subnegotiation
swallowing, BRK, IAC IAC); a session-level test where a staged reader
delivers the volley, enters BASIC, then BREAKs back to the monitor —
staged because BREAK is deliberately out-of-band. Verified live:
piping an IAC volley plus a command through `ewm one --tty` yields
prompt, WILLs, echo, and the dump in one stream.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:01:50 -04:00
Stefan ArentzandClaude Fable 5 fd05317108 ewm one --tty: the terminal is the machine's terminal (T1) (#300)
Phase T1 of plans/20260719-04-apple1-telnet.md. The Apple 1 is a
terminal machine — one PIA, bytes in, bytes out — so --tty runs it
headless with stdin/stdout as the keyboard and display: an Apple 1 in
the local terminal, under nc, and (T2) per-connection under systemd
socket activation. No SDL is touched; no networking exists in the
emulator.

The session loop is generic over Read/Write (tests drive it with
in-memory streams), steps the machine in 20ms ticks throttled to
1.023 MHz wall-clock (input arriving early sleeps off the remainder,
so a paste cannot sprint the machine), and feeds keys one at a time
gated on the PIA's one-byte latch — Pia::key_pending exposes IRQA1,
the same problem two's RemoteKeys solved for VNC. On the way in a-z
uppercase (no lower case on the keyboard) and LF/CRLF become CR; on
the way out CR becomes CRLF. Input EOF runs a two-second grace so the
last command finishes printing, then the session ends; a hangup on
write ends it too.

Reset is Meta-R: Ctrl is a real Apple 1 key and bare ESC is the
monitor's cancel-line key, so Meta is the free modifier. A lone ESC is
held ~50ms for the r of ESC-r (what "Alt sends Escape" terminals
transmit); r within the window warm-resets, anything else — or
silence, or EOF — forwards the ESC to the machine.

The old line-based ewm/examples/one.rs retires in favor of the real
mode (kickoff decision 4); the README's headless-consoles line runs
--tty and the example extractor learned trailing shell comments.

Tests: a full session boots to the prompt and dumps BASIC's first
bytes (lower case + LF in, CRLF-once semantics pinned); Meta-R during
BASIC returns to the monitor; a bare ESC and ESC+other reach the
machine as keys. Verified live: `printf 'e000.e003\n' | ewm one --tty`
boots, echoes, dumps, exits 0.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:56:23 -04:00
Stefan ArentzandClaude Fable 5 56aac2a520 Plan: Apple 1 over telnet — ewm one --tty + systemd socket activation (#299)
An experiment with a period-correct premise: the Apple 1 is a
terminal machine (one PIA, bytes in, bytes out), so a TCP stream is a
more faithful display than a framebuffer. systemd owns the socket
(inetd-style Accept=yes); every telnet to port 6502 starts a fresh
headless machine whose keyboard and display are the connection. The
emulator does no networking — StandardInput=socket — so the same
--tty mode is also an Apple 1 in the local terminal and works under
nc.

Design highlights: wall-clock 1.023 MHz pacing; PIA-latch-paced input
(the RemoteKeys problem, again); reset is Meta-R (ESC r with a hold
window — Ctrl is a real Apple 1 key and bare ESC is the monitor's
line-cancel, so Meta is the free modifier) plus telnet BREAK; minimal
hand-rolled IAC negotiation, auto-detected so nc stays byte-clean; a
tight DynamicUser sandbox (the binary needs no filesystem — configs
and ROMs are embedded).

Two phases (T1 the mode, testable over in-memory streams; T2 telnet +
systemd units + docs), five kickoff decisions with recommendations.
Status: draft.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:49:00 -04:00
Stefan ArentzandClaude Fable 5 237955a54a Docs: the one profiles as schematics; as-built notes (R4) (#298)
Phase R4, closing plans/20260719-03-one-machine-components.md.

The README's one-profiles section now shows what the plan was for: the
profile JSON is the machine's schematic — CPU, every RAM bank with
size and address, every ROM image with its address — with the
mountable builtin: ROM names listed, user ROM paths mentioned, the
whole-board semantics and reset-vector rule explained, and the
faithful details told in English (BASIC preloaded in the $E000 RAM
bank as the cassette would have; the Replica 1's $E000-$FFFF being the
Krusader distribution byte for byte).

notes/APPLE1.md gains the as-built section (registry, byte-identity
gates, from_components, whole-board semantics, the profile decisions);
notes/JSON_CONFIG.md's schema inventory adds machine.cpu and the
generalized memory regions with the three-way family rules. Plan
marked complete.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:22:37 -04:00
Stefan ArentzandClaude Fable 5 fe36ddaf25 One built from components; the real profiles; byte-identity gates (R3) (#297)
Phase R3 of plans/20260719-03-one-machine-components.md — the big one.

One::from_components(model, cpu, regions) is the single construction
path: no base RAM, every byte of the board comes from the regions, and
the only fixed hardware is the PIA at $D010-$D013 (model keeps naming
the family and the Apple 1's 7-bit display masking). One::new(model)
now builds from the model's *built-in config* — the board is described
exactly once, in configs/, and bare `ewm one` equals
`--config builtin:replica1` by construction. one's Options carry
config regions directly; normalize() fills cpu/memory from the builtin
when the document leaves them unsaid, so --print-config always shows
the full board.

Semantics flip as planned: one-family memory regions describe the
whole board (absent = the model's builtin), which makes the deferred
reset-vector check judgeable — a board with nothing at $FFFC-$FFFD
fails validation with a message telling you to mount a monitor ROM.
compact_document learned to compact array members, so printed regions
no longer carry the null half of path/size.

The profiles are the notes/APPLE1.md boards: apple1 = 6502, the
chapter-7 map (4KB at $0000 + the $E000 bank preloaded with Integer
BASIC as writable RAM, cassette-faithful minus the cassette), pristine
WozMon at $FF00; replica1 = 65C02, 32KB RAM, ROM BASIC, and the 65C02
Krusader slice — fixing the historical 6502-build-on-65C02 mismatch.

Gates: byte identity — builtin:replica1's $E000-$FFFF SHA-1-matches
the Krusader 65C02 distribution, and the 6502-slice spelling
reproduces the historical krusader.rom machine exactly; the Apple 1
profile boots to the Woz monitor AND into Integer BASIC (E000R to the
">" prompt); a headless board is rejected up front; the component
description round-trips through --print-config.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:15:24 -04:00
Stefan ArentzandClaude Fable 5 a8a91fc125 machine.cpu and RAM-bank regions (R2) (#296)
Phase R2 of plans/20260719-03-one-machine-components.md.

The document can now say what the Apple 1 family is made of:
machine.cpu ("6502" / "65C02"; absent = the model's CPU) and
generalized memory regions taking exactly one of path (an image — file
or builtin:) or size (an empty RAM bank, "4k"/"32k"/bytes, RAM only).
Structurally, a typo'd builtin: image now fails at parse time naming
the available ROMs, instead of at machine build.

Family rules extend: machine.cpu and size banks are Apple 1 family
keys (the ][+ / //e board is a model property), and one-family layouts
are checked — known extents (banks and builtin images) must fit 64K
and must not overlap each other or the PIA at $D010-$D013; file images
have unknown extents, so only identical starts are judged. The
reset-vector check is deferred to R3 (recorded in the plan): in R2,
document regions are still extras on top of the model's default ROMs,
so "nothing covers the vector" is not yet judgeable.

one wires the new keys: Options carries the cpu override
(One::new_with_cpu) and a MemorySource (Image or Bank); build_machine
mounts banks as zeroed RAM; options_to_config round-trips both (banks
print back as "4k"-style sizes). two defensively expects image paths
(family validation guarantees it). Schemas regenerated.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:05:13 -04:00
Stefan ArentzandClaude Fable 5 cdff8386f9 roms/: mountable ROM images, the embedded registry, builtin: paths (R1) (#295)
Phase R1 of plans/20260719-03-one-machine-components.md.

rom/ renames to roms/ wholesale (kickoff decision 4), and the four
mountable one-family images land under the name = stem = token
convention: WozMon.rom (the historical apple1.rom, pristine), the
already-extracted apple1-basic.rom, and the two Krusader 1.3 builds as
4KB $F000-$FFFF slices, each carrying its own modified monitor page.
The historical krusader.rom retires; provenance is pinned by SHA-1
(the crate's own, RFC-vector-tested): BASIC + the 6502 slice
reassemble it byte for byte, BASIC + the 65C02 slice reassemble the
65C02 distribution, and WozMon matches the historical apple1.rom.
One::new mounts the Replica 1 from BASIC + the 6502 slice — memory
byte-identical to the old single 8K mount, proven by the untouched
boot tests.

config.rs gains the embedded ROM_BUILTINS registry and
read_memory_image: a region path of builtin:<name> resolves against
the registry and never touches the filesystem (a literal file named
builtin:x is reachable through a directory component, the same escape
hatch --config has). resolve_paths treats builtin: as a scheme, not a
relative path, and referenced_files skips it — so built-in configs may
carry ROM references and stay self-contained. Both frontends load
memory regions through the shared reader; schemas regenerated for the
path doc.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:56:10 -04:00