Replace legacy shell dispatch with a typed object model#24
Merged
Conversation
…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/.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
class_desc_t/member_tframework, a path resolver that walkscpu.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 thegs_eval/gs_inspect/gs_completeC entry points. Worker-thread affinity guard for the WASM bridge.class_desc_tand*_initlifecycle. CPU registers are read/write attributes;memory.peek.b/w/landmemory.poke.b/w/lfor cell access; debug.breakpoints / debug.logpoints as indexed children with per-entry attributes.gsEval; the oldrunCommandJS bridge andg_cmd_*SAB queue are gone. Frontend object inspector panel reads the same tree.shell_dispatchround-trip — every command parser calls its handler directly. Unknown commands hard-fail. Legacyregister_cmdregistry deleted.gs_classes.{c,h}→root.{c,h};gs_thread.{c,h}→worker_thread.{c,h}(relocated tosrc/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.machine.boot('se30')(boot was crashing because SE/30 initexit(1)s on missing VROM); commandLog test expectations updated for the new gsEval naming;fd probeexit-code convention restored.Test plan
gs-headlessbuild cleancpu.)🤖 Generated with Claude Code