Skip to content

Replace legacy shell dispatch with a typed object model#24

Merged
pappadf merged 122 commits intomainfrom
feature/object-model
May 5, 2026
Merged

Replace legacy shell dispatch with a typed object model#24
pappadf merged 122 commits intomainfrom
feature/object-model

Conversation

@pappadf
Copy link
Copy Markdown
Owner

@pappadf pappadf commented May 5, 2026

Summary

Long-running branch (121 commits) that introduces the typed object model and retires the old shell-dispatch pipeline. JS now talks to the emulator via gsEval('cpu.pc') / gsEval('floppy.drives[0].insert', ['/tmp/fd0', false]) etc., with the same path form usable as a shell language.

What's in:

  • Object-model substrate. A class_desc_t / member_t framework, a path resolver that walks cpu.fpu.fp0 / debug.breakpoints[3].hit_count, JSON-shaped value encoding, ${expr} interpolation, alias table ($pc / $d0 / $fpcr), tab-completion engine over the tree, and the gs_eval / gs_inspect / gs_complete C entry points. Worker-thread affinity guard for the WASM bridge.
  • Typed surfaces for every subsystem (cpu / fpu / memory / mmu / scc / rtc / via / scsi / floppy / sound / appletalk / debug / mouse / keyboard / screen / vfs / find / storage / archive / rom / vrom / machine / checkpoint / scheduler). Each owns its own class_desc_t and *_init lifecycle. CPU registers are read/write attributes; memory.peek.b/w/l and memory.poke.b/w/l for cell access; debug.breakpoints / debug.logpoints as indexed children with per-entry attributes.
  • JS migration. Every web-side caller routes through gsEval; the old runCommand JS bridge and g_cmd_* SAB queue are gone. Frontend object inspector panel reads the same tree.
  • Shell dispatch retired. No more shell_dispatch round-trip — every command parser calls its handler directly. Unknown commands hard-fail. Legacy register_cmd registry deleted.
  • File / symbol cleanup. gs_classes.{c,h}root.{c,h}; gs_thread.{c,h}worker_thread.{c,h} (relocated to src/core/); gs_api.{c,h}api.{c,h}. Stripped milestone tags (M5/M6/M7/M8/…), dead stubs, unused includes, and section headers across the codebase.
  • Two SE/30-specific bug fixes uncovered along the way: the e2e helper now stages VROM before machine.boot('se30') (boot was crashing because SE/30 init exit(1)s on missing VROM); commandLog test expectations updated for the new gsEval naming; fd probe exit-code convention restored.

Test plan

  • CI green: unit tests, integration tests, full Playwright e2e suite
  • Headless gs-headless build clean
  • Web bundle (WASM) builds clean and boots Plus + SE/30 via the URL-media auto-boot path
  • State spec test 9 (SE/30 save / load round-trip) and test 10 (background checkpoint with browser reload) pass
  • Tab completion still works at line-start and mid-path (cpu.)
  • Smoke check the object inspector panel in the browser (M11 panel)

🤖 Generated with Claude Code

pappadf added 30 commits May 1, 2026 21:25
…sers

Lands the foundational types for the object-model rewrite under
src/core/object/, dormant: no instances yet, no shell wiring, no
existing behavior changed.

- value.{c,h}: value_t tagged union (V_NONE/BOOL/INT/UINT/FLOAT/STRING/
  BYTES/ENUM/LIST/OBJECT/ERROR), constructors that copy, value_free
  safe on every kind, value_copy, truthiness, VALUE_AUTO cleanup.
- object.{c,h}: opaque object_t, member_t/class_desc_t/node_t,
  attach/detach, object_resolve over dotted paths and [idx] segments,
  node_get/node_set/node_call, reserved-word + class validation.
  Substrate only — no concrete classes registered.
- parse.{c,h}: unified literal parser — ints (10/16/2/8/$/0d, `_`
  separators, u/i suffix), floats (incl. hex-float), bool spellings,
  "..." with escapes, NUMBER:N bytes, enum tags.
- expr.{c,h}: recursive-descent expression parser/evaluator covering
  the full operator table (proposal §2.3), type-promotion ladder,
  short-circuit && / ||, ternary, ${...} interpolation, alias hooks.
  Path/method-call resolution failures propagate as V_ERROR rather
  than as syntax errors so short-circuit and `!error` work cleanly.
- New unit suites under tests/unit/suites/object_{value,parse,expr}.
- Wired into Makefile, Makefile.headless, and tests/unit/common.mk.
Stand up the object-model root and expose `gs_eval` as an additional
entry point alongside the legacy shell. The legacy shell remains
primary; this milestone is purely additive.

- object_root() / object_root_reset() — single-root accessor with
  lazy creation.
- gs_api.{c,h}: gs_eval / gs_inspect / gs_complete entry points. M2
  is read-only (args_json reserved for method dispatch in M4); the
  result is JSON-encoded into the caller's buffer with a tiny
  emitter that handles every value_kind_t. gs_complete returns "[]"
  — completion stays on the legacy engine through M9.
- gs_classes.{c,h}: stub classes (cpu, memory, scheduler, machine,
  shell, storage) wrapping live config_t state read-only. CPU
  exposes pc/sr/d0..d7/a0..a7 as hex u32 attributes; memory has
  ram_size/rom_size; machine has model_id/model_name/cpu_clock_hz.
  Real members land per-peripheral in M3–M7.
- system_create() calls gs_classes_install(cfg) after machine init;
  system_destroy() calls gs_classes_uninstall() before teardown.
- cmd_eval.c: new `eval <path>` shell command — printer, not a
  predicate (returns 0 even when the JSON itself encodes an error).

Resolver fix surfaced by the new tests: an integer segment
immediately after an indexed-child member now sets that member's
index instead of looking for *another* indexed child, so both
`bucket.devices[0]` and `bucket.devices.0` resolve correctly.

- New unit suite tests/unit/suites/object_resolve covering named
  children, sparse-stable indexed children, the next() iterator
  skipping holes, and reserved-word + duplicate-member rejection at
  registration.
- New integration test tests/integration/object-eval — Plus image,
  exercises eval against cpu/memory/machine paths plus an explicit
  unresolved-path case to confirm the error-JSON shape.
…acked

Land the two-tier alias table that the proposal makes the canonical
directory for `$name` substitution, auto-populate the `mac` root
child from mac_globals_data.c, and rewrite resolve_symbol() to
consult the new table with the legacy chain as fallthrough — no
behavior change visible to users.

- alias.{c,h}: built-in tier (registered by classes at init,
  immutable) + user tier (shell.alias.add/remove). Reserved-word
  check at registration; collisions return descriptive errors.
  alias_clear_user() called by checkpoint restore (proposal §4.4.5
  — sessions don't preserve user aliases).
- gs_classes.c extends the M2 stubs:
  * CPU adds ccr, sp, ssp, usp, msp, vbr.
  * cpu.fpu child class with fp0..fp7 (V_BYTES, raw 80-bit), fpcr,
    fpsr, fpiar — attached only when the CPU has an FPU.
  * `mac` class auto-built at install time from mac_globals_data.c
    — 471 attributes, V_UINT for sizes 1/2/4 (verbatim parity with
    legacy resolver), V_BYTES for everything else (proposal §5.5
    strict improvement: no more u32 truncation of buffer-shaped
    entries). Two historical-duplicate names (TimeSCSIDB) deduped
    on first-found, matching legacy behavior.
  * shell.alias child with add/remove/list methods (callable today
    via node_call; surfaced in `eval` once method dispatch lands
    in M4).
  * On install: register built-in aliases for every CPU register,
    every FPU register (when present), and every mac global —
    ~500 aliases total.
- cmd_symbol.c: resolve_symbol() rewritten to consult the alias
  table first and dispatch to the legacy CPU/FPU/mac readers via
  path prefix. Falls back to the legacy chain for SR flag bits,
  MMU registers, and case-insensitive matches that aren't aliased
  per §4.4.1.

Substrate change so a single dispatcher can serve many members:
attr_get_fn / attr_set_fn / method_fn now receive the member_t* of
the call site, and the attr struct gains `user_data`. The 471-entry
mac class uses one shared getter that finds its row via
m->attr.user_data.

- New unit suite tests/unit/suites/object_alias (12 tests):
  registration, idempotence, user replace, built-in immutability,
  collision rules, reserved-word + invalid-identifier rejection,
  closed-namespace lookup, clear_user, iteration order.
- Resolver toy classes updated to the new attr_get_fn signature
  (older form happened to compile thanks to x86_64 calling
  convention forgiveness — now correct by construction).
- Integration test object-eval extended with mac.* and cpu.ccr /
  cpu.sp checks.
…, math

Wire the M1 expression parser into the legacy shell and land the new
predicate `assert` form. Legacy commands keep working — expressions
are an additive feature.

- shell.c::tokenize() recognizes $(...) as a single token with proper
  paren-depth tracking, including "..." string literals inside the
  expression so a `)` inside an expression-internal string does not
  close the expression early. Quote state outside the expression
  still splits tokens normally.
- shell.c::expand_args() — between tokenize and dispatch, every
  token starting with $( is fed to expr_eval (with object_root() and
  alias_lookup bound) and replaced with the formatted result string.
  Errors abort the line cleanly.
- shell.c: top-level operator detection (proposal §4.1.3). A token
  that is exactly `+ - * / % & | ^ ~ ! < > == != <= >= && || << >>`
  triggers the corrective syntax error so silent acceptance of e.g.
  `cpu.d0 = $cpu.pc + 4` (which would drop "+ 4") becomes impossible.
- debug.c: assert rewritten with raw_argc dispatch — 2 args is the
  new predicate form (`assert $(cond)`), 3 args is predicate plus
  message (`assert $(cond) "msg"`), 4 args keeps the legacy
  `<sym> <op> <value>` form alive. Truthiness follows proposal §2.5
  on the post-$(...) formatted string.
- gs_classes.c: math root object with close(a,b,eps), abs, min, max
  (proposal §6 minimum). Methods preserve int-likeness when both
  inputs are ints and fall back to double otherwise.

Tests:
- New unit suite tests/unit/suites/object_method (7 tests): node_call
  arity check, call form inside $(...), method-result in arithmetic,
  predicate methods, error propagation through arithmetic, and the
  proposal §3.3 rule that bare-method reads (no parens) error.
- New integration test tests/integration/object-expr — Plus boot
  exercises $(literal arithmetic), $(memory.rom_size + 1),
  $($pc + 4), `break set $($pc + 0x10)`, predicate assert (truthy /
  falsy / with-message), assert $(a && b), and every math method.
Land the unified ${...} string interpolator and migrate logpoint
messages to it. The bespoke `$pc` / `$value` / `$mem.l.<src>` /
`$str.<src>` mini-language is gone — user messages now use the same
expression grammar everywhere (proposal §4.2.1, §5.3).

- expr.c: expr_interpolate_string parses an optional `:fmt` trailer
  at the rightmost top-level colon and dispatches to a new
  format_value_with_spec covering the full proposal table:
  `:d` / `:x` / `:X` / `:08x` / `:5d` width-padded forms / `:s`
  string / `:%-3d` printf escape hatch. Top-level colon detection
  respects nested parens, brackets, and string literals so
  ${"a:b"} doesn't split.
- gs_classes.c/h:
  * memory.peek child class with b/w/l methods — sized reads.
  * memory.read_cstring(addr, max?) — quoted, escape-encoded reader.
  * lp synthetic class (lp.value, lp.addr, lp.size, lp.pc,
    lp.instruction_pc) backed by a per-call context flipped on/off
    via gs_lp_context_begin/end. Outside that window every getter
    returns V_ERROR.
- debug.c:
  * resolve_lp_addr deleted.
  * format_logpoint_message collapsed from a 100-line bespoke parser
    to gs_lp_context_begin → expr_interpolate_string → end.
  * Logpoint key=val parser tightened: only category= / level= /
    value= are recognised as named params; messages can now contain
    `pc=${cpu.pc}` patterns without tripping the parser.
- shell_var.c: shell_var_expand leaves unresolved ${...} intact for
  the object-model interpolator instead of silently dropping them
  (pre-M5 it ate every M5 message before the formatter saw it).
- object.h: ARG_OPTIONAL / ARG_REST renamed to OBJ_ARG_OPTIONAL /
  OBJ_ARG_REST to avoid namespace collision with the legacy shell's
  cmd_types.h enum (different values; the collision was latent and
  surfaced when M5 added object.h to debug.c).
- SKILL.md: examples migrated to ${...} form with the format-spec
  catalogue.

Tests:
- 10 new format-spec tests in tests/unit/suites/object_expr
  (decimal, hex lower/upper, zero-padded, width-padded, string,
  %-printf escape, multi-chunk, top-level-colon detection).
- New tests/integration/object-logpoint — Plus boot exercises
  ${cpu.pc} addr=${lp.addr:08x} val=${lp.value:08x} size=${lp.size}
  on a write logpoint at $016A; the VBL handler bumps it twice and
  both lines render correctly.
… children

M6 lands the `debugger` root object with `breakpoints`, `logpoints`,
and `watches` collections. Breakpoints and logpoints become real
indexed-child objects whose ids are sparse and stable (proposal §2.1):
removing entry #0 leaves #1 at id 1, and the next add gets max-id-ever+1.
Each entry exposes addr / hit_count / condition (etc.) attrs and a
remove() method; legacy `break` and `logpoint` commands continue to
work and share the same store.

The `watches` collection is a proposal-faithful placeholder — the
legacy `watch` shell command is a synonym for `run-until` (one-shot,
no persistent storage), so the collection always enumerates empty
until a real watchpoint feature lands.

object.h gains the per-entry invalidation hook (proposal §9):
register / unregister / fire weak-reference callbacks so hot-path
consumers holding a pre-resolved node_t can be told when their
target was removed. object_delete fires automatically.

Tests: new unit suite object_invalidate (7 tests covering register/
fire/unregister mechanics, registration-order firing, sparse-index
remove + re-add, held-node invalidation through object_resolve), new
integration test object-debugger exercising debugger.breakpoints.add
and conditional breakpoints via $(...). Full unit/integration/e2e
debug smoke green.
Lands the first peripheral object class per the M7 sub-milestone plan
(proposal §5.4). Adds:

- `scc.loopback` (writable bool) wrapping scc_set/get_external_loopback
- `scc.pclk_hz` / `scc.rtxc_hz` (read-only BRG source frequencies)
- `scc.reset()` method
- `scc.a` / `scc.b` channel children with `index`, `dcd`, `tx_empty`,
  `rx_pending` attributes — single getter dispatched by the channel
  index encoded in `m->attr.user_data` (same pattern as the mac
  globals adapter)

The legacy `scc loopback on|off` shell command remains unchanged and
shares the same backing state — flipping one and reading via the
other is the integration test's primary signal.

Tests: new tests/integration/object-scc exercises both paths and the
channel children. Full unit + integration + e2e terminal smoke green.
Lands the RTC peripheral object class per the M7 plan. Adds:

- `rtc.time` (writable uint) — Mac-epoch seconds (1904-based). Setter
  delegates to rtc_set_seconds, same backing as the legacy `set-time`
  command; both views read/write the same state.
- `rtc.read_only` (read-only bool) — write-protect bit.
- `rtc.pram` (read-only V_BYTES) — full 256-byte PRAM snapshot.
- `rtc.pram_read(addr)` / `rtc.pram_write(addr, value)` methods —
  per-byte access. `pram_write` honors the write-protect bit the
  same way the chip-level command stream does (returns V_ERROR when
  the bit is set).

The legacy `set-time` command remains unchanged; tests/integration/
object-rtc verifies both paths read/write the same state and exercises
the PRAM read/write methods end-to-end.
Lands the two VIA peripherals per the M7 plan. Plus has only via1;
SE/30 / IIcx add via2. Both share the `via` class shape, differing
only in the user_data tag that picks cfg->via1 vs cfg->via2 inside
each accessor (same dispatch trick used by the SCC channel children
and the mac globals adapter).

Each VIA exposes:
- ifr / ier / acr / pcr / sr (read-only bytes, hex)
- freq_factor (read-only — CPU/VIA divisor; 10 for Plus, 20 for SE/30)
- port_a / port_b children with output / input / direction (R/O bytes)

via.h gains via_get_{ifr,ier,acr,pcr,sr}, via_port_{output,input,
direction}, via_timer_{counter,latch}, via_get_freq_factor — all
read-only views, all NULL-safe.
Lands the SCSI peripheral object class per the M7 plan (proposal §5.4).
Adds:

- `scsi.loopback` (R/W bool) — wraps scsi_get/set_loopback
- `scsi.bus` named child with `phase` (V_ENUM: bus_free / arbitration /
  selection / reselection / command / data_in / data_out / status /
  message_in / message_out), `target`, `initiator`
- `scsi.devices` indexed children — sparse stable indices = SCSI ID
  (0..7). Empty IDs are holes; count() / next() skip them. Each entry
  exposes id, type (none/hd/cdrom enum), vendor, product, revision,
  block_size, read_only, medium_present.
- `eject()` and `insert(path)` methods stubbed with deferral errors;
  they need image-loading plumbing that lands with M7e / M8.

Eight per-slot device objects are pre-allocated at install time and
freed at uninstall; the indexed-child get() returns a slot's object
only when the underlying device is populated, so empty slots show up
as "empty" via node_get rather than NULL-deref.

scsi.h gains read-only views (bus phase/target/initiator, per-slot
type/vendor/product/etc.) so the object class doesn't pull
scsi_internal.h.
Lands the floppy peripheral object class per the M7 plan (proposal §5.4).
Adds:

- `floppy.type` (V_ENUM: iwm | swim) — IWM-only on Plus, SWIM dual-mode
  on SE/30 / IIcx
- `floppy.sel` (R/O bool) — VIA-driven head-select signal
- `floppy.drives` indexed children — exactly 2 slots (internal/external);
  index is the slot number, count is always 2 when a controller is
  attached
- Each drive: index, present, track, side, motor_on, disk (path),
  eject() method, insert(path) (deferred to M8 image-loading rollout)

floppy.h gains read-only views (`floppy_get_type`, `floppy_get_sel`,
`floppy_drive_track/side/motor_on/disk_path`) and a public
`floppy_drive_eject` that mirrors the in-controller IWM eject path:
flush modified tracks first while the image is still valid, drop the
cached GCR buffers, then null the controller's slot. Importantly does
NOT call image_close — the image_t is owned by cfg->images and freed
at system teardown; double-freeing it segfaults on shutdown.
Final M7 peripheral per the plan (proposal §5.4: "sound.enabled,
sound.sample_rate, sound.volume, method sound.mute(bool)"). Adds:

- `sound.enabled` (R/W bool) — gate setter wraps sound_enable
- `sound.volume` (R/W uint, 0..7) — bounded by the existing assert
- `sound.sample_rate` (R/O uint) — 22255 Hz on Plus PWM
- `sound.mute(bool)` method — convenience wrapper inverting enabled

The sound module previously lived only in `plus_state_t`, unreachable
from the object tree. cfg->sound is added to system_config.h and
plus.c mirrors ps->sound onto it (clearing the cfg slot in cleanup).
SE/30 / IIcx use ASC and leave cfg->sound NULL — the object is only
attached when the field is set.

sound.h gains read-only accessors (sound_get_enabled / volume /
sample_rate) and sound_mute as a thin wrapper over sound_enable so
callers don't have to remember the inverted semantics.
First slice of the M8 top-level rollout per the plan (proposal §5.8 /
§5.9). M8 is sized for 3-4 PRs; this commit lands the network and
input subtrees. Storage (`storage.images`, `storage.import`) and the
root introspection methods (`objects`, `attributes`, `methods`) land
in follow-up M8 sub-commits.

Adds:
- `network.appletalk.shares` — indexed children, sparse stable indices
  matching the underlying share table slots. Empty slots are holes
  (count() / next() skip them). Methods `add(name, path)` /
  `remove(name)` on the collection; per-entry method `remove()`.
- `network.appletalk.printer` — `enabled` (R/O bool), `name` (R/O str)
  attributes plus `enable(name?)` / `disable()` methods.
- `input.mouse` — `move(x, y)`, `click(down?)`, `trace(enabled)`
  methods. Wraps debug_mac_set_mouse / system_mouse_update /
  debug_mac_set_trace_mouse so the legacy `set-mouse` /
  `mouse-button` / `trace-mouse` commands continue to work alongside
  with shared backing state.

Public API surface:
- appletalk.h: atalk_share_max / in_use / name / path / vol_id +
  atalk_printer_is_enabled / object_name (read-only views)
- debug_mac.h: debug_mac_set_mouse, debug_mac_set_trace_mouse —
  thin entry points around the file-private helpers used by the
  legacy `set-mouse` / `trace-mouse` commands
The M8 slice 1 integration test (commit 6bd6466) hardcoded
/workspaces/granny-smith/{tests,scripts} as the host paths fed to
network.appletalk.shares.add. Those paths only exist in the dev
container; CI runs at /__w/granny-smith/granny-smith and
atalk_share_add stat()s the directory before accepting it. The
share-add silently failed in CI, leaving slot 0 empty, which then
made every subsequent shares[0].* path resolve to "did not resolve"
and tripped the integration harness.

Switching to /tmp keeps the test portable: /tmp exists in both the
dev container and the GitHub Actions runner, and atalk_share_add
only requires the path to be a real directory — it doesn't read or
write anything from the share at this stage.
Second slice of the M8 top-level rollout per the plan (proposal §5.7).
Replaces the M2 `storage` stub (namespace-only, n_members=0) with a
real class exposing the cfg->images[] entries as indexed children.

Adds:
- `storage.images` indexed collection — get/count/next walk cfg->images[]
  with NULL-slot skipping. Slot index matches cfg->images[i] so
  enumerations are stable across the lifetime of an image attachment.
- `storage.images.N.*` per-entry attributes: index, filename, path,
  raw_size, writable, type (V_ENUM: other / fd_ss / fd_ds / fd_hd /
  hd / cdrom — matches enum image_type ordering).
- `storage.import(host_path, dst_path)` method stubbed with a
  deferral error. The proposal's image-persist plumbing
  (image_persist_volatile + checkpoint integration) lands in a
  follow-up M8 sub-commit.

Per-slot entry objects are pre-allocated at gs_classes_install
(MAX_IMAGES = 10) and freed at uninstall, mirroring the scsi.devices
and floppy.drives patterns from M7d / M7e.

The remaining M8 deliverables — storage.import, top-level root
methods (cp, peeler, hd_*, rom_*, vrom_*, objects, attributes,
methods, help, time, print, quit, source, let, list_partitions,
unmount, partmap, probe) — land in further sub-commits. Root
methods need the `emu` root class to gain a member table, which is
a substrate touch beyond this slice.
Third slice of the M8 top-level rollout per the plan (proposal §5.10).
Lands the introspection-and-utility subset of the root methods that
have no dependency on legacy command internals.

Substrate touch: object.h gains `object_root_set_class()` so
gs_classes_install can swap the namespace-only emu_root_class for
one carrying members. The default class lives in object.c so M1/M2
callers (and tests) don't depend on registration order; uninstall
reverts to that default.

Root methods:
- `objects(path?)` — list child object names at the given path,
  combining static M_CHILD members with runtime-attached children.
- `attributes(path?)` — list M_ATTR member names of the resolved
  object's class.
- `methods(path?)` — list M_METHOD member names.
- `help(path?)` — return the doc string of the resolved member, or
  the class name if the path resolves to an object.
- `print(value)` — format a value as a string (kind-aware: numerics
  honor VAL_HEX, V_OBJECT becomes "<object:class>", etc.).
- `time()` — wall-clock seconds since the Unix epoch.

Deferred to a follow-up substrate-and-shell sub-commit: cp / peeler /
hd_create / hd_download / rom_probe / rom_validate / vrom_probe /
vrom_validate / partmap / probe / list_partitions / unmount / let /
quit / source. Most of those wrap legacy commands with shell-state
dependencies (quit / source) or need argument-routing the legacy
shell hands them; landing them cleanly is its own slice.
…methods

Closes out the M8 top-level rollout per proposal §5.7 / §5.10. Lands:

storage.import(host_path, dst_path?) — real implementation:
- 1-arg form: image_persist_volatile (content-hashed
  /opfs/images/<hash>.img — handles drag-drop and idempotent reimport)
- 2-arg form: routes through legacy `cp` for explicit destinations,
  keeping VFS handling and quoting in one place

13 root-method wrappers around legacy shell commands, dispatched via
shell_dispatch:
- cp(src, dst, [flags]) — flat alias for storage.import (UNIX
  muscle memory)
- peeler(path, [out_dir]), hd_create(path, size)
- rom_probe / rom_validate / vrom_probe / vrom_validate (path)
- partmap / probe / list_partitions / unmount (path) — flatten the
  legacy `image foo` subcommand form
- quit() — same as the legacy `quit` command

Wrappers always return V_NONE on dispatch success because legacy
commands' int returns conflate "succeeded" with "command-specific
counters and bool results" (rom_validate uses 1=valid, cp uses byte
counts on some paths, …). Errors flow through the existing stderr
path the shell already prints — same UX as typing the command.

Deferrals (each documented in code with a comment):
- hd_download: needs platform fetch plumbing
- let / source: shell-state plumbing the M9–M10 cutover rewrites

This completes M8. Next milestone is M9 — tab-completion engine
rewrite over the new tree.

Tests: object-storage now exercises storage.import("/path", "/dst"),
object-root-methods now invokes rom_probe / rom_validate / quit
through the new tree. Full unit + integration + e2e basic-ui
green; drag-drop passes per-test (the 2 concurrency flakes are
the same ones seen in slice 2's first run, not a regression).
Replaces the legacy command-registry completer with one that walks the
object tree at the cursor's path position (proposal §4.6). Lands:

src/core/shell/cmd_complete.c — full rewrite:
- Depth-tracking state machine mirrors §4.1.2: `paren` for $(...) and
  expression-internal (), `brace` for ${...}, `bracket` for top-level
  [...] subscripts, plus a quote tracker. Cursor inside any of those
  contexts returns an empty completion set.
- Word-boundary scan finds the partial token under the cursor and the
  index of the first word — the dispatch input.
- Word 0 (line-start, no dot/bracket): union of the root class's
  members, the root's statically-attached children, and the legacy
  command registry. Legacy shims still complete until the M10 cutover
  deletes the registry.
- Word 0 with a dotted/bracketed partial: split at the rightmost
  depth-zero `.`, resolve the head against object_root(), and emit
  `head.<member>` candidates filtered by the tail prefix. Indexed-child
  position emits live indices via the child member's next() callback.
- Words ≥1: resolve the first word as a tree path; if it lands on an
  M_METHOD, dispatch by `arg_decl[i]` (V_BOOL → on/off/true/false,
  V_ENUM → enum_values, V_OBJECT → tree, V_STRING → filesystem when
  the arg name suggests a path). Otherwise fall back to the legacy
  arg_spec dispatch (subcommand-aware).
- Per-call string pool backs dynamically composed candidates
  (`drives.0`, `head.<tail>`); static class-member and registry
  strings are passed through directly.

app/web/js/emulator.js: expose tabComplete on window so E2E tests
can drive the completer directly without simulating Tab keystrokes.
First step of the M10 cutover (proposal §7.2). Lands the new bridge
without retiring the old one — both surfaces coexist until M10e.

src/core/object/gs_api.c — gs_eval becomes real:
- Decodes args_json as a JSON array of primitives (number / string /
  bool / null) — minimal parser sufficient for the ~340 callers in
  app/web/js that M10b will migrate. JSON objects and nested arrays
  are not declared arg shapes; reject them.
- Method paths dispatch to node_call. Attribute paths with one arg
  dispatch to node_set; with no args, node_get. The "object node with
  args" combination is an error.
- node_set takes ownership of its argument; pass value_copy so the
  outer free_args() can still walk argv.

src/platform/wasm/em_main.c, Makefile: export em_gs_eval /
em_gs_inspect / get_gs_eval_buffer. The result buffer is distinct
from g_cmd_json_buffer so an interleaved attribute read can't
clobber a pending runCommand result.

app/web/js/emulator.js: new gsEval(path, args?) and gsInspect(path)
helpers using ccall through the worker thread. Window-exposed for
E2E. runCommand/runCommandJSON paths untouched.
First per-area migration of the M10 cutover (proposal §7.2). This
converts app/web/js/media.js and app/web/js/upload-pipeline.js — the
JS surface that handles archive extraction, ROM/floppy probing, and
upload persistence — from `runCommand("foo bar baz")` to typed
`gsEval("foo", [...])`.

Side-effect root methods now return V_BOOL so callers can branch
without re-deriving the legacy command's int / cmd_bool convention:
- cp / peeler / hd_create — V_BOOL (rc == 0)
- rom_probe / vrom_probe — V_BOOL (rc == 0; cmd_int convention)
- rom_validate / vrom_validate — V_BOOL (rc == 1; cmd_bool convention)
- partmap / probe / list_partitions / unmount — V_BOOL (rc == 0)
- New: peeler_probe (rc == 0), fd_probe (rc == 0),
  find_media (rc == 0)

Migrated callers:
- media.js: probeRom, probeFloppy, probePeelerArchive,
  extractPeelerToDir, findMediaInDirectory
- upload-pipeline.js: file-copy → cp via gsEval

Substrate fix: the top-level method table is now installed by
shell_init() via the new gs_classes_install_root() helper. Previously
gs_classes_install ran from system_create, so gsEval('rom_probe', …)
returned "path did not resolve" until a machine booted. That broke
the drag-drop pipeline (drop ROM → probe → load) for `?noui` paths
that haven't created a config_t yet.

Peeler wrapper now invokes `peeler -o "<dir>" "<archive>"` (legacy
command takes the output dir via -o, not a positional arg) — caught
when the migrated drag-drop archive test failed and fixed in this
commit.

Stable state: media-area callers ride the new bridge; the rest of
app/web/js/** still uses runCommand. Next sub-PR migrates another
area (terminal / config-dialog / drag-drop / checkpoint / boot-time
helpers) per the §7.2 sequence.
Second per-area migration of the M10 cutover (proposal §7.2). This
converts app/web/js/checkpoint.js and main.js's per-machine setup
call from `runCommand("checkpoint --…")` to typed `gsEval(...)`.

Six new root methods, all V_BOOL (rc == 0 success):
- checkpoint_probe()             — true if a valid checkpoint exists
- checkpoint_clear()              — remove all checkpoint files
- checkpoint_load(path?)          — load a checkpoint (auto when omitted)
- checkpoint_save(path, mode?)    — save the current machine state
- register_machine(id, created)   — set the active machine identity
- running()                        — true if scheduler is active

`running()` reads scheduler_is_running(system_scheduler()) directly
instead of going through the legacy `status` shim, so the cmd_int
"1 = running" convention disappears at the JS boundary.

Migrated callers:
- checkpoint.js: maybeOfferBackgroundCheckpoint — checkpoint_probe /
  _clear / _load and the post-resume `running` query.
- main.js: per-machine `checkpoint --machine` boot call.

Comparison style: the migrated probes test `=== true` rather than
JS truthiness so an error result (`{error: …}` from a method that
hasn't installed yet) doesn't get treated as "checkpoint exists" and
trip the resume dialog.
64bd9f2 migrated main.js's per-machine setup and
checkpoint.js's maybeOfferBackgroundCheckpoint from runCommand to
gsEval. Both fire during the boot window between Module-ready and
the worker's main loop becoming active, where ccall-based gsEval
requests aren't yet served — they return {error: "did not resolve"}
silently. The downstream effect: register_machine never registers
the per-machine checkpoint directory, so save_quick_checkpoint
short-circuits and `checkpoint --probe` polls time out (state tests
3, 4, 5; drag-drop checkpoint flow). Local runs hit this less often
than CI because the local worker happens to be slower / less
contended.

Revert just those two callers back to runCommand. The runCommand
path naturally waits via the cmd_pending flag the main loop polls,
so it's the right pattern for boot-time setup until M10c lands the
e2e helper migration and we can rework gsEval's pre-main-loop
behavior.

Root methods (checkpoint_probe / _clear / _load / _save /
register_machine / running) stay registered — they remain useful
for post-boot callers (drop.js, the inspector panel in M11). The
specific call sites that fired pre-main-loop are the only ones
walked back.

No C-side changes; root-method shims and exports unchanged.
Proposed commit message:

object: M10b — migrate drop area to gsEval

Third per-area migration of the M10 cutover (proposal §7.2). This
converts app/web/js/drop.js — the drag-drop pipeline — from
`runCommand("foo bar baz")` to typed `gsEval(...)` and lands the
root-method shims drop.js needs:

- rom_checksum(path) → V_STRING with the 8-char hex checksum (empty on
  invalid). Reads the file with fopen/fread + rom_identify_data so the
  JS side no longer has to runCommandJSON the legacy `rom checksum`
  command and parse stdout.
- rom_load(path)        → V_BOOL
- fd_insert(path, slot, writable) → V_BOOL
- run([cycles])         → V_BOOL

Migrated callers in drop.js:
- ROM drop: rom_checksum + rom_load + cp + run (replaces
  rom checksum / rom load / file-copy / run runCommands).
- Floppy drop: fd_insert.
- Checkpoint drop import: checkpoint_load + running (replaces
  checkpoint --load / status).

drop.js's calls fire on user drop events, so the worker is reliably
in its main loop by the time ccall lands — the boot-window ordering
problem that c6a1aff walked back for main.js / checkpoint.js does
not apply here.

tests/e2e/helpers/terminal.ts: the test shim wraps `runCommand` and
records every call into `__commandLog`; specs assert on legacy shell-
form strings (`commandLog.some(cmd => cmd.includes('rom load'))`).
Mirror the same wrapper for `gsEval` and synthesise the legacy
spelling (`rom_load` → `rom load`) into the same log so existing
assertions keep working without teaching every spec the new API.
This is the right place for the compat shim — once M10c migrates the
e2e helper itself the synthesis lives alongside the gsEval runCommand
helper, and M10e drops both cleanly.
Closes out the M10b per-area sweep started by the media / checkpoint
/ drop sub-PRs. Migrates the remaining four files in app/web/js/ to
typed gsEval calls and lands the root-method shims they need:

- vrom_load / hd_attach / hd_validate / cdrom_validate / cdrom_attach
- fd_validate (V_STRING density tag) — opens the image directly via
  image_open_readonly so the JS side no longer parses stdout
- setup_machine / schedule / download
- rom_probe(path?) now accepts an optional path; the no-arg form
  reports whether a ROM is currently loaded (replaces the stdout-
  parsing dance media-types.js used)

Migrated callers:
- url-media.js (12 calls): rom_probe / run / peeler_probe / peeler /
  find_media / cp / rom_load / fd_insert / hd_attach.
- config-dialog.js (12 calls): rom_probe / rom_checksum / cp /
  hd_create / vrom_load / setup_machine / rom_load / fd_insert /
  hd_attach / cdrom_attach / run.
- ui.js (5 calls): fd_insert / run / checkpoint_save / download /
  schedule.
- media-types.js (5 runCommandJSON calls): rom_checksum / vrom_probe /
  fd_validate / hd_validate / cdrom_validate — all returning typed
  values, no more stdout parsing.
- fs.js (1 call): romExistsAsync uses rom_probe with no args.

Stays on runCommand (each commented):
- main.js #79 (register_machine) and checkpoint.js boot flow — these
  fire pre-main-loop, where ccall-based gsEval requests aren't yet
  served (see c6a1aff). Will move once M10c reworks gsEval timing.
- main.js #36 — terminal user-input passthrough; M10e folds this away.
- ui.js settings-modal checkpoint query/toggle — needs a future
  `auto_checkpoint` attribute for the stdout-formatted state read.
- url-media.js `ls ${ROMS_DIR}` — awaits a future `storage.list`.
- config-dialog.js `hd models --json` — awaits a future `hd_models()`
  returning V_LIST.
Reimplements tests/e2e/helpers/run-command.ts to translate well-known
shell-form lines into typed gsEval calls (proposal §7.2). The
`runCommand(page, line)` signature is preserved so spec files don't
need to change — the translator parses the line and dispatches via
`window.gsEval(method, args)` where a typed root method exists, then
maps the typed return back to the legacy int convention each spec
already expects.

Translation table (covers the ~30 distinct command shapes used across
the spec corpus):
- rom probe / checksum / load / validate
- vrom probe / validate / load
- fd probe / validate / insert
- hd validate / attach / create
- cdrom validate / attach
- image partmap / probe / list / unmount
- peeler --probe / -o
- find-media, file-copy, cp
- checkpoint --probe / clear / --load / --save / --machine
- run, status, schedule, download, setup --model --ram

Return-convention mapping mirrors each legacy command's shape:
- cmd_int (0=success): bool true → 0, false → 1
- cmd_bool (1=true): bool true → 1, false → 0
- V_STRING (rom_checksum / fd_validate density): non-empty → 1/0
  matching the old cmd_bool callers

Anything without a typed wrapper falls through to `window.runCommand`
(eval / br / s / x / log / set / print / mouse-button / scc / sync /
exists / size / atalk-share-add / background-checkpoint / …). Those
remaining lines are removed by M10e together with the legacy shell.

waitForPrompt and waitForCompleteCheckpoint switch to direct gsEval
polls (`running()` and `checkpoint_probe()`) — both are typed-bool
queries; the polling logic is unchanged.

waitForSync stays on the legacy bridge for now since `sync` /
`sync status` don't have typed wrappers yet.
Adds the proposal §4.1 dispatch order to shell_dispatch:

   bare `path`           → node_get + print
   `path = value`         → node_set with parse_literal_full(value)
   `path arg arg arg`     → node_call (arg list parsed via the same
                            literal parser, with bare-word fallback to
                            V_STRING for legacy-command-style tokens)

Slots in between the legacy `find_cmd` lookup and the unknown-command
suggestion. Triggers only when argv[0] contains `.`/`[` or resolves
to a method directly on the root — top-level legacy commands (run,
step, info, …) keep winning so scripts that haven't migrated stay on
the registry path.

A small format_value_print() shell-side formatter handles bare-path
output: hex for VAL_HEX attributes, decimal otherwise; `<class:name>`
for object references; bare strings unquoted (the JSON-printer eval
form is preserved alongside, see below).

tests/integration/object-eval/test.script is rewritten to exercise
all three new-grammar forms (bare reads, setter via sound.enabled,
unknown-path "did you mean", and a couple of legacy `eval` lines so
both dispatch surfaces stay covered).

Other integration scripts already use forms that work in both
grammars (`run N`, `eval <path>`, `echo $(method(args))` …), so they
need no changes — they will pass through the legacy registry path
exactly as before until M10e drops it.
The plan's full M10e (delete cmd_parse / cmd_reg / register_command /
register_cmd / cmd_symbol / runCommand JS) requires every legacy
command still used by integration scripts to have a typed root-method
wrapper first — info, disasm, br, s, x, log, set, print, find,
prompt, mouse-button, key, screenshot, assert, help, plus the WASM-
only ones. That cut is genuinely large and warrants its own pass.
This commit lands the part that does cleanly close out today:

- hd_models() — new root method returning the drive-model catalog
  as a JSON-encoded V_STRING (array of {label, vendor, product,
  size}). Builds the array directly from drive_catalog_get; no more
  stdout-parsing detour.
- config-dialog.js: the disk-creation flow now reads models via
  gsEval('hd_models'); the runCommandJSON code path is gone.
- runCommandJSON JS surface deleted entirely (no more callers in
  app/web/js after the migration). cmdJsonBufPtr drops with it.
- emulator.js's `window.runCommand` is kept but the surrounding
  comments now scope its use precisely to terminal user input and
  the two pre-main-loop boot calls (main.js / checkpoint.js,
  c6a1aff). Everything else flows through gsEval.

Stable state: gsEval is the typed bridge for all structured calls;
runCommand persists only as the shell-line bridge for free-form
terminal text and a tiny boot-window slice. The remaining legacy-
shell deletion work (cmd_parse / cmd_reg / register_command call
sites + per-command typed wrappers) lands in subsequent passes —
naming them out so reviewers know they are not forgotten:
- M10e-2: typed wrappers for the debug-shell family (info, disasm,
  s, x, br, lp, watch, find, log, set, print, help)
- M10e-3: typed wrappers for test/sim helpers (mouse-button, key,
  screenshot, assert, prompt, set-mouse, set-time)
- M10e-4: delete cmd_parse.c, cmd_reg, register_command,
  register_cmd, cmd_fn_simple, subcmd_spec, cmd_symbol.c — only
  possible after every command-registration call site is gone.
CI on 9e67ea3 failed every state-suite test:
- waitForPrompt timed out (`scheduler to become idle after 120000ms`)
- waitForCompleteCheckpoint timed out
- screen checksums returned 0x0 — the ROM hadn't loaded

Root cause: bootWithUploadedMedia fires legacy runCommand calls
(`rom load /tmp/rom`, `fd insert …`, `hd attach …`) without awaiting
them — they queue via cmdInFlight on the JS side. Pre-M10c the
following `runCommand(page, 'run 18000000')` went through the same
executeShellCommand queue, so it serialized correctly. After M10c the
helper translates `run NNNN` into a gsEval call, which uses
Module.ccall directly and bypasses the cmdInFlight queue. In CI the
ccall raced ahead of the queued `rom load`, started the scheduler
before the ROM was mapped, and the screen stayed black. Locally the
worker happened to be fast enough to drain the legacy queue before
the ccall fired, masking the bug.

Fix: make gsEval / gsInspect `async` and await cmdInFlight (same
queue executeShellCommand uses). Re-establishes the JS-side
serialization invariant the legacy bridge already had so a gsEval
call placed after un-awaited runCommand sends still observes them.

tests/e2e/specs/terminal/gs-eval.spec.ts — three page.evaluate
callbacks changed to `await` gsEval now that it returns a Promise.
A read-only browser of the gs_eval object tree (proposal §7), driven
by gsEval / gs_inspect. Lives in a collapsible panel below the
terminal so it doesn't compete with the existing canvas / terminal
layout. Idle while the emulator is running; repaints once on every
"stopped" run-state edge per §7.1 — no subscribe/poll mechanism.

Layout:
- Left column: root children listed via gsEval('objects', ['']).
- Right column: heading + attribute table + methods chips +
  drillable child chips for the selected path. Attribute values come
  from gsEval(`<path>.<attr>`) — formatted with the same rules the
  shell's format_value_print() uses.

Idle invariant: onRunStateChange tracks the running flag; the panel
only refreshes on the running→idle edge while expanded. While the
emulator is running, value reads would race the worker, so the
panel intentionally stays static. First paint is on user-initiated
expand; subsequent paints are stop-driven.

Methods are shown as chips for now — clicking is inert until M12
wires per-method arg dialogs (the proposal calls them "buttons");
keeping invocation out scopes M11 to the read path.

E2E: tests/e2e/specs/terminal/inspector.spec.ts (3 cases, behind a
booted Plus_v3 ROM) covers tree listing, cpu attribute rendering,
and the post-write refresh edge. The panel exposes a small
`window.__gsInspector` test hook (expand/refresh/select/getDetailText
/getTreeNames) so specs assert on the rendered content without
chasing layout pixels.
- src/core/object/gs_classes.c: new `dump_tree()` root method that
  walks the live object tree and returns one JSON document
  (path/class/members/children) covering every reachable node. The
  schema lives on the C side so future class-table changes flow
  through to the docs without a separate generator update.

- scripts/dump_object_model.py: runs gs-headless with a Plus_v3 ROM,
  invokes `dump_tree()` via the shell, parses the JSON, renders
  markdown grouped by member kind. Re-run after any class-table or
  root-method change.

- docs/object-model-reference.md: checked-in output of the generator
  (~43 KB). Sections per object, member tables for attributes
  (type, rw), methods (signature, return), and child objects.

- AGENTS.md: new "Object model (gs_eval)" section — top-level paths,
  the four shell surface forms (bare path / setter / shell-form call
  / expression call), the gsEval JS boundary, and the regeneration
  command.

The remaining M12 deliverables (ARCHITECTURE.md / shell.md /
headless-debug SKILL.md updates, moving completed proposal notes
out of local/gs-docs/notes/) are doc rewrites that read end-to-end
better as their own pass — flagging them here so they don't slip:
  - ARCHITECTURE.md: object model substrate section.
  - shell.md: new shell-form grammar reference.
  - .agents/skills/headless-debug/SKILL.md: example syntax refresh.
  - local/gs-docs/notes/proposal-*.md → completed-proposals/.
pappadf and others added 28 commits May 5, 2026 00:04
Batches 2/5/6 retired the legacy method names (rom_load, fd_insert,
hd_attach, cdrom_*, run, running, schedule, register_machine,
setup_machine, hd_models, cp, find_media, ...) but only the C-side
tests, the integration scripts, and the runCommand translator were
updated. The web frontend (app/web/js/*) still issued the old names
via direct gsEval calls; they returned `{error: "path 'rom_load' did
not resolve"}` and url-media.js's await never threw, so the boot
sequence wedged forever — that's the root cause of the canceled
1+ hour CI run.

Migrations:
  rom_load            -> rom.identify + machine.boot + rom.load
  rom_probe (path)    -> rom.identify  (non-empty list = valid)
  rom_probe (no arg)  -> rom.loaded
  rom_checksum        -> rom.checksum_of
  vrom_load           -> vrom.load
  vrom_probe          -> vrom.identify
  fd_insert           -> floppy.drives[N].insert
  fd_validate         -> floppy.identify
  fd_probe            -> floppy.identify (truthy = valid)
  hd_validate         -> scsi.identify_hd
  cdrom_validate      -> scsi.identify_cdrom
  hd_attach           -> scsi.attach_hd
  cdrom_attach        -> scsi.attach_cdrom (id 3 now explicit)
  hd_models           -> scsi.hd_models  (V_LIST attribute)
  hd_create           -> storage.hd_create
  find_media          -> storage.find_media
  cp                  -> storage.cp
  run                 -> scheduler.run
  running             -> scheduler.running
  schedule            -> scheduler.mode = ...   (writable attribute)
  register_machine    -> machine.register
  setup_machine       -> machine.boot

The url-media.js + drop.js + config-dialog.js boot paths now do the
explicit `rom.identify -> machine.boot -> rom.load` dance the C-side
new model requires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more debug-flavoured root methods leave gs_classes.c:

  - method_root_examine -> memory.dump(addr, [count])
    Memory is the natural owner of byte regions; the legacy `x`
    command's polymorphic addr (integer-or-alias-or-expression) is
    preserved by routing through shell_examine_argv as before.

  - method_root_disasm -> debug.disasm([count])
    Lives under debug.* (not cpu.*) following the proposal's own
    precedent that debugger affordances belong with the rest of the
    debugger surface — same place as debug.breakpoints,
    debug.logpoints, debug.mac.*. The encoding knowledge stays in
    cpu_disasm.c (debugger_disasm); only the exposed name moves.

The test scripts and the e2e `x ADDR` translator follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the WASM CI build, which treats implicit function declarations as
errors (clang/emcc default). Headless gcc was lenient and compiled
through the same warning. The new floppy.drives[N].insert method (added
in commit 231b24b) calls tokenize() and shell_fd_argv(); both are
declared in shell.h, which floppy.c was missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's CPU unit-test link broke after commit 095fa85 added
method_mem_dump to memory.c, which calls tokenize() + shell_examine_argv()
to route through the legacy examine handler. Unit tests link memory.o
but no shell.o, so those references were unresolved. Headless / WASM
were unaffected (they link the real shell).

Stubs return 0/0 — the unit tests never exercise method_mem_dump, the
stubs just satisfy the linker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CI e2e tests are failing with 'rom.identify: no compatible machines
for /tmp/url_rom' but the same ROM file works under the headless
integration tests. Adding the actual gsEval return value (and a parallel
rom.checksum_of probe) to the console log so the next CI run can tell
us whether gsEval returned an error object, an empty array, null, or
something else entirely. To be reverted once the root cause is
diagnosed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trying to diagnose why rom.identify('/tmp/url_rom') returns either an
error or empty list in the WASM e2e tests but works fine in headless.
Switches read_rom_file to quiet=false and adds explicit fprintf(stderr)
markers around the file read + checksum identification. Will revert
once the failure mode is understood.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
rom and vrom are *process-singleton* namespace objects whose file-level
methods (rom.identify, rom.checksum_of, vrom.identify, vrom.load) are
called *before* a machine is booted — that's the whole point of the
new explicit-machine-creation flow. Putting their init calls inside
system_create was wrong: the WASM platform's main() doesn't run
system_create at startup (it only fires when --model is supplied or
when the legacy `rom load` triggers system_ensure_machine), so URL-
media boot calls would land on an unresolved 'rom.identify' path.

In practice this is exactly what was breaking the e2e suite:

  [url-media] loading ROM from: /tmp/url_rom
  [url-media] rom.identify: no compatible machines for /tmp/url_rom

shell_init runs unconditionally on both platforms, so the rom/vrom
classes now register there alongside gs_classes_install_root. The
init functions remain idempotent so the previous double-call from
system_create wouldn't have hurt; pulling it out is just hygiene.

Also reverts the diagnostic prints added in commits f43e428 and
a6b4afa now that the root cause is identified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same root cause as rom/vrom: the machine namespace was getting attached
in gs_classes_install (called from system_create), but the WASM platform
doesn't run system_create at startup. Result: when url-media.js called
gsEval('machine.boot', [...]), the path didn't resolve and the boot
silently failed; the subsequent rom.load + floppy.drives[0].insert
calls then failed too because no machine had been created.

Fixed by:
  - Promoting machine to a process-singleton (machine_init / machine_delete
    in machines/machine.c, called from shell_init alongside rom/vrom).
  - Switching the machine.* attribute getters from object_data(self) to
    global_emulator so the live machine state is reflected regardless of
    when the singleton object was attached and how many cfg lifetimes
    have come and gone.
  - Removing the attach_stub(machine_class) call from gs_classes_install
    (the singleton already covers it).

Also fixes the e2e bootWithMedia hang: the in-page __commandLog
normaliser only converted underscores to spaces ('rom_load' → 'rom
load'), so the new typed paths ('rom.load', 'floppy.drives[0].insert')
didn't match the legacy `cmd.includes('rom load')` test in
tests/e2e/helpers/boot.ts. Extended the regex to /[_.]/g.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous round of fixes (rom/vrom/machine singletons in shell_init)
got the URL-media boot path past machine.boot, but the test still
failed at the next layer: storage.find_media (used by url-media to
scan extracted .sit/.hqx archives for floppy images) was also gated on
gs_classes_install, which only runs from system_create. Same root
cause, one namespace deeper.

Calling gs_classes_install(NULL) from shell_init makes all the
cfg-scoped namespaces (storage, shell, mouse, keyboard, screen, vfs,
find) resolve at startup. The pre-boot surfaces that don't read
object_data — storage.cp, storage.find_media, storage.list_dir,
vfs.* — work immediately. Cfg-dependent surfaces (storage.images[N],
mouse.*, keyboard.*, etc.) return empty / error until system_create
re-installs with the real cfg, which is the existing cfg-change
uninstall+reinstall path.

Verified locally:
  npx playwright test -g "boot system and run MacTest"        -> ok (1.1m)
  npx playwright test -g "Mounting shared volume over AFP"    -> ok (54s)

Both were failing on this branch before the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The checkpoint surface (probe / clear / load / save / snapshot / auto)
moves out of gs_classes.c and into a process-singleton class registered
at shell_init time, alongside rom / vrom / machine.

  checkpoint_probe       -> checkpoint.probe()
  checkpoint_clear       -> checkpoint.clear()
  checkpoint_load(p?)    -> checkpoint.load([path])
  checkpoint_save(p, m?) -> checkpoint.save(path, [mode])
  background_checkpoint  -> checkpoint.snapshot(name)
  auto_checkpoint (attr) -> checkpoint.auto (RW bool)

Verified locally:
  make test-checkpoint test-checkpoint2 test-machine-lifecycle  -> all pass
  npx playwright test -g 'boot system and run MacTest'          -> ok (1.4m)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
floppy.identify returns the density string ('400K' / '800K' / '1.4MB')
for a recognised image, or '' otherwise — not a bool. The Batch 6 sed
that mapped legacy gsEval('fd_probe') to gsEval('floppy.identify')
preserved the '=== true' check from the old fd_probe (which DID return
bool), so probeFloppy always returned false.

Effect: drop.js's classifyMediaFile() never recognised dropped disk
images as floppies, the auto-mount path was skipped, and the e2e
'drag/drop floppy disk image auto-mounts' test wedged for 90s waiting
for a screen match that would never happen.

Verified locally: the test now passes in 40s with the expected
'Disk image mounted successfully' / 'system booted from dropped disk
image' progression in the console log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The library is called 'peeler' but to a user that name means nothing.
The user-facing concept is 'Mac archive' — .sit / .cpt / .hqx / .bin
/ .sea — so the typed surface should reflect that:

  peeler                -> archive (process-singleton namespace)
  peeler_probe(path)    -> archive.identify(path) — returns format short
                           name ('sit'/'cpt'/'hqx'/'bin'/'sea') or empty
                           (mirrors the floppy.identify pattern; non-
                           empty == 'is an archive')
  peeler(path, [out])   -> archive.extract(path, [out_dir])

File moves:
  - Old: src/core/shell/peeler_shell.{c,h} (171-line shim under shell/)
  - New: src/core/storage/archive.{c,h} (process-singleton + class)

Storage/ is the right home: archives feed the same pipeline that
storage.find_media drives — drop .sit, extract, find disk image inside,
mount via floppy.drives[0]. The peeler library include is also now
contained to storage/.

JS migration:
  - media.js: probePeelerArchive -> probeMacArchive (also fixes the
    same '=== true' vs string-return bug we saw with probeFloppy);
    extractPeelerToDir -> extractMacArchiveToDir;
    isPeelerArchive -> isMacArchive.
  - url-media.js: same '=== true' bug fixed; isPeelerArch -> isMacArch;
    comment cleanup.
  - upload-pipeline.js: identifier renames + comment cleanup.

E2E translator: legacy 'peeler --probe X' shell-form still routes
through runCommand for the existing peeler.spec.ts tests, but converts
archive.identify's string return to the cmd_int convention via a new
'string_to_cmd_int' return-conversion (non-empty -> 0, empty -> 1).

Verified locally:
  npx playwright test -g 'drag/drop floppy disk image auto-mounts' -> ok (32s)
  npx playwright test -g 'Peeler'                                  -> 2/2 ok (15s)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SE/30 init aborts with exit(1) if no Video ROM file is reachable from
its search paths, so machine.boot('se30') would crash the WASM module
(observable as a hang in page.evaluate that ends in 'page closed' at
the test timeout). bootSE30WithUploadedMedia previously injected the
VROM and called vrom.load AFTER bootWithUploadedMedia returned — too
late: the bytes weren't available when SE/30 peripheral init ran.

Promote bootWithUploadedMedia to accept an optional vromRel; when set,
inject /tmp/vrom in the upload batch and issue vrom.load BEFORE
machine.boot so vrom_pending_path is honored during peripheral setup.
state.spec.ts's SE/30 helper now just passes vromRel through.

Verified locally: state tests 9 and 10 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six leftover failures from the object-model migration, all on the test
side (assertions that hard-coded pre-migration shapes):

- floppy.spec / config-dialog (unified) / drag-drop (non-media): `fd probe`
  needs the legacy probe convention (0=success, 1=failure), not
  string_nonempty (which inverts it). Split: `fd probe` →
  string_to_cmd_int, `fd validate` keeps string_nonempty.

- basic-ui (command logging) / config-dialog (start button): commandLog
  records the gsEval method (`scheduler run`), not the original `run`
  shell-form. Update the assertions to match what's actually logged.

- tab-complete (line-start prefix): the example used `cp` as both root
  child (cpu) and root method, but `cp` is no longer a root method
  (moved to storage.cp). Drop the `cp` expectation; `cpu` alone still
  exercises prefix narrowing.

Verified locally: all 6 named tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Batch 10 of the migration. The two generic root methods existed because
the cpu/memory namespaces had read-only attribute surfaces and no poke
analogue, so the legacy `print` / `set` shell verbs had to dispatch
through string-keyed wrappers around shell_print_argv / cmd_set.

This commit fills the typed surface and deletes the wrappers:

- cpu: every register attribute (D0-D7, A0-A7, PC, SR, CCR, SSP, USP,
  MSP, VBR, SP) is now read/write — setters route to the existing
  cpu_set_* helpers. CCR bits surface as cpu.c / cpu.v / cpu.z /
  cpu.n / cpu.x (1-bit RW). New cpu.instr_count attribute exposes
  cpu_instr_count() so `print instr` no longer needs the legacy
  symbol-resolver path.

- memory: new memory.poke.b/w/l mirror peek's shape; the lifecycle
  attaches/detaches the new child alongside peek.

- gs_classes.c: print_value / set_value methods deleted.

- tests/e2e/helpers/run-command.ts: `print` and `set` translate the
  legacy target (d5 / pc / instr / 0x1000.b / `\$z`) to the right typed
  path (cpu.<reg> attribute read-or-write, cpu.instr_count, or
  memory.peek.*/poke.*). pass_through now decodes V_UINT-with-VAL_HEX
  values that gs_api JSON-encodes as "0x…" strings, so register reads
  arrive as numbers without losing the exit-code contract that tests
  rely on.

Verified locally: full debug.spec (7 tests) green; state test 9, fd
probe, command-logging, tab-complete also green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The object-model rollout left behind a layer of M2/M5/M6/M7/M8/M9/M10/
M10b "milestone" annotations and "phase 5b/c/d" markers in comments.
Those refer to the rollout plan, not anything a future reader can act
on; they aged out the day each phase landed.

This sweep removes those tags and the few dead surfaces still riding
along with them:

- gs_classes.c: drop the unused storage_class_desc empty stub (only
  shell_class_desc is still attached); rewrite the file header to
  describe what the file actually contains today; trim includes
  that the print_value/set_value removal left dangling
  (addr_format/debug_mac/drive_catalog/image_apm/image_vfs).

- gs_classes.h: rewrite the file header (no longer "M2 wires these
  in"); rename the M6 debug-entry-factories section header.

- debug.h / debug.c: drop the now-unused shell_print_argv declaration
  (callers retired with print_value); strip M5/M6 references from
  comments; rephrase the M6 section header.

- expr.c, gs_api.c, object.c, cmd_complete.c, cmd_symbol.c, cmd_io.h,
  memory.c, storage_class.c, archive.c, appletalk*, debug_mac.c:
  same shape — strip milestone tags and "phase 5x" notes; let
  comments describe the current behaviour.

No functional change: every section that previously read "(M6) does X"
still does X. Verified locally: full debug.spec (7 tests) green;
headless and WASM builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The root \`print(value)\` method was a pure value→string formatter — no
I/O, returned V_STRING. The shell verb \`print\` translates to typed
attribute reads (cpu.*, memory.peek.*) and never invoked it; \${expr}
interpolation already formats values everywhere a string is needed;
\`echo(...)\` covers the side-effect-write-to-stdout case. No caller
in src/, app/, or tests/ resolved to method_root_print.

Removes the function, its arg_decl, and the emu_root_members entry.
Updates the file-header comment and the inspector/tab-complete notes
that still listed \`print\` among the introspection methods.

Verified locally: tab-complete (8 tests) and \`get\`/\`set\` debug
specs green; both build targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
register_cpu_aliases / register_fpu_aliases listed CPU and FPU register
names ($pc, $d0..$d7, $fpcr, $fp0..$fp7, etc.) — content that belongs
next to the cpu_class and fpu_class definitions, not in the
gs_classes.c install orchestrator.

Move both helpers into cpu.c and call them from cpu_init: CPU set
always, FPU set only when the model has an FPU. alias_register_builtin
is idempotent (re-registering with the same target returns 0), so
repeated cpu_init / cpu_delete cycles via machine.boot are safe.

Drops the call site, helpers, and now-empty "Built-in alias
registration" section from gs_classes.c, and updates its file header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
\`step([n])\` is a debugging affordance — it sits next to debug.disasm
in user mental model, not next to introspection helpers and the file-
download verb at the root. The implementation is just \`scheduler_run
_instructions\` + \`scheduler_stop\`, but exposing it via \`debug.step\`
is the natural namespace.

Move the body and arg_decl into debug.c next to debug_method_disasm,
register it on debug_class. Drop the root-level \`step\` entry from
gs_classes.c (its containing "Debugging-area root methods" section
becomes empty and goes too). Update run-command.ts to translate the
shell-form \`s\` / \`step\` to debug.step.

Verified locally: full debug.spec (7 tests) green; both build targets
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These five classes are stateless facades — none of their methods read
object_data(self); they all call into platform-level globals
(debug_mac_*, the active scheduler/cpu/memory). They were the last
batch of cfg-tracked stubs in gs_classes_install only because each one
lacked its own *_init / *_delete pair.

Add a process-singleton register/unregister pair to the owning module
(mouse.c, adb.c for keyboard_class, debug.c for screen_class,
vfs_class.c, cmd_find.c), call register from shell_init alongside the
other singletons (rom/vrom/machine/checkpoint/archive), and drop the
attach_stub lines + unused extern decls from gs_classes.c.

The stubs were already idempotent under repeated install — no behaviour
change, just a relocation so each class owns its lifecycle.

Verified locally: full debug.spec (7 tests) and state test 1 (which
exercises screen.checksum via matchScreenFast) green; both build
targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the recent moves (cpu/fpu register aliases → cpu.c; mouse / kbd /
screen / vfs / find class registration → owning modules; print_value /
set_value retired), gs_classes.c no longer touches most of the headers
it imports — they were carried in for symbols that have all moved out.

Trim to the actual working set:
  stdio.h, stdlib.h, time.h, alias.h, debug.h, object.h, system.h,
  system_config.h, value.h.

Drop the now-unused extern decls for scheduler_class and
storage_image_class while we're here.

No functional change. Both build targets clean; debug.spec (7 tests)
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "gs_classes" name dates back to when this file held a dozen-plus
class definitions (peeler/rom/vrom/machine/storage/mouse/keyboard/
screen/vfs/find/debug stubs). After the migration moved every class
to its owning module, what's left is the \`emu\` root class plus the
small install/uninstall harness that attaches the few remaining
cfg-scoped stubs (storage, shell, shell.alias) underneath it. Inside
src/core/object/, the \`gs_\` prefix adds no information either —
neighbouring files (alias.c / expr.c / object.c / parse.c / value.c)
already drop it.

Rename:
  - gs_classes.c → root.c
  - gs_classes.h → root.h
  - gs_classes_install        → root_install
  - gs_classes_install_root   → root_install_class
  - gs_classes_uninstall      → root_uninstall
  - gs_classes_uninstall_if   → root_uninstall_if

Updated callers in system.c, shell.c, debug.c, storage_class.c,
object.c, alias.h, em_main.c plus a unit-test and e2e comment.

The two debug-entry factory declarations (gs_classes_make_breakpoint
_object / _make_logpoint_object) keep their old names for now — they
belong in debug.h, not a "root" header, and that move is its own
cleanup.

No functional change. Both targets build clean; debug.spec (7 tests)
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The thread-affinity guard isn't object-model machinery — three of its
five call sites are in shell/ (shell.c, cmd_complete.c) and the file
itself describes a JS-to-C bridge invariant tied to the WASM
PROXY_TO_PTHREAD design. Living in src/core/object/ misled readers
about its scope.

Move and rename:

  - src/core/object/gs_thread.{c,h} -> src/core/worker_thread.{c,h}
  - gs_thread_record_worker -> worker_thread_record
  - gs_thread_assert_worker -> worker_thread_assert
  - header guard GS_THREAD_H -> WORKER_THREAD_H

Updated callers (shell.c, cmd_complete.c, gs_api.c) and the Makefile
comment. Source discovery still works automatically because the
existing wildcard picks up CORE_DIR/*.c.

No functional change. Both targets build clean; debug.spec (7 tests)
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same scope-tightening as the root.{c,h} rename: inside
src/core/object/, the \`gs_\` prefix on the file name adds nothing —
the directory already names the namespace, and the neighbouring files
(alias / expr / object / parse / value / root) already drop it.

Renames just the files and the header guard:
  - gs_api.c -> api.c
  - gs_api.h -> api.h
  - GS_OBJECT_GS_API_H -> GS_OBJECT_API_H

The public symbols gs_eval / gs_inspect / gs_complete keep their
\`gs_\` prefix — they cross the JS/C boundary (em_main.c uses them
in the SAB-queue protocol, JS calls the bridge as \`gsEval\`), and
renaming them is a much larger surface change. That's a separate
question if we want it.

Updates the one external caller (em_main.c) to the new include path,
plus the file-header comment. Source discovery picks up the new name
automatically via the existing \`*.c\` wildcard in the Makefile.

No functional change. Both targets build clean; debug.spec (7 tests)
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass over root.{c,h} after the migration left a layer of stale or
redundant prose. No behaviour change.

- Drop the dangling \`//\` line at the end of the file header (root.c).
- Remove the standalone "instance_data on these stubs is config_t*"
  paragraph — the install/uninstall block below already covers the
  lifecycle invariant.
- Delete the unused \`class_walker_t\` typedef and its description; it
  has no callers (left over from the pre-migration introspection
  shape).
- Move the docstring for \`string_list_push\` to sit next to the
  function instead of above the typedef it isn't documenting; describe
  the typedef itself in one sentence.
- Tighten the "Top-level wrappers" section header — only quit / assert
  / echo / download remain; the listing of image / hd / rom / vrom /
  cp shell forms and the deferred let / source / hd_download
  paragraph are out of date.
- Trim the assert docstring (drop the proposal §2.5 reference and the
  shell-runner exit-code implementation detail).
- Tighten the install/uninstall block: keep the two-pattern lifecycle
  invariant and why the cfg-pointer guard exists; drop the verbose bug
  retelling that referred to gsEval error shapes.
- Remove the duplicative \`root_uninstall_if\` comment that just
  repeated what the block above already says.
- Tighten the four function docs in root.h.

Both targets build clean; debug.spec (7 tests) green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The latest CI failed at the unit-test link step because cpu_init now
calls alias_register_builtin (the \`\$reg\` aliases moved out of
gs_classes/root.c), and attr_cpu_instr_count references
cpu_instr_count. The cpu unit-test harness compiles cpu.c in isolation
and was missing both symbols.

cpu_instr_count is already stubbed via stub_system.c (existing
scheduler-related stub block), so that one resolves without changes.

For alias_register_builtin, link the real alias.c into the cpu
harness's EMU_SRCS — alias.c only depends on object.h+value.h which
the harness already provides, and a stub would conflict with the
object_alias suite that links the real file.

Verified locally: \`make clean && make && make run\` passes 14/14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cpu.c now exposes attr_cpu_instr_count → cpu_instr_count, which lives
in scheduler.c. The cpu unit-test harness doesn't compile scheduler.c
(out of scope), so the linker complains.

Add a no-op stub next to the other scheduler-related stubs in
stub_system.c — unit tests don't drive the scheduler so 0 is fine.

Companion to the previous commit; together they unblock the unit-test
step in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #24 hit two integration-test failures:

1. object-expr: \`\${\$pc + 4}\` resolved to "no such alias '\$pc'".
   Root cause: \`root_install(cfg)\` runs *after* \`cpu_init\`. When the
   cfg differs from the previous install (e.g. shell_init's
   \`root_install(NULL)\` followed by system_create's
   \`root_install(plus_cfg)\`), the install path triggers
   root_uninstall() to tear down stale stubs — and root_uninstall() was
   calling \`alias_reset()\` which wiped *both* tiers, including the
   built-in \`\$pc\` / \`\$d0\` / … aliases that cpu_init had just
   registered. Built-in aliases are owned by their subsystem _init
   hooks now and re-register on every machine boot; only user-added
   aliases should be cleaned. Switch to \`alias_clear_user()\`.

2. object-root-methods: the test exercises three \`\${print(...)}\`
   calls, but the root \`print()\` method was deleted (commit 7015665)
   when we observed it had no callers — except this test. The method
   was a value→string formatter that exactly duplicated what
   \`\${expr}\` interpolation already does (the same test uses
   interpolation everywhere else). Drop the three lines and the
   header-comment mention of \`print(v)\`; refresh the test description
   to remove the M8-slice tag.

Verified locally: \`make integration-test\` runs all 34 integration
tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@pappadf pappadf merged commit 44146b1 into main May 5, 2026
2 checks passed
@pappadf pappadf deleted the feature/object-model branch May 5, 2026 23:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant