Skip to content

Board refactor - #11665

Draft
Thomas Ball (thomasjball) wants to merge 40 commits into
masterfrom
boardRefactor
Draft

Thomas Ball (thomasjball) wants to merge 40 commits into
masterfrom
boardRefactor

Conversation

@thomasjball

@thomasjball Thomas Ball (thomasjball) commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

The PR here decouples the simulator runtime responsible for running generated JavaScript from the browser DOM. We test first in the context of pxt-microbit. Once done, we will make necessary mods to pxt-arcade. With related PRs:

This is a breaking change that requires a bump in major version.

See pxtsimcore/README.md for details, reproduced here

The DOM-free simulator core

The simulator is two TypeScript projects that share the pxsim namespace. pxtsimcore/ holds
everything that runs without a browser: the VM, board state, sound synthesis, the host seams and
the editor-side frame policy. pxtsim/ holds the code that needs the DOM: the embed, SVG views,
Web Audio and iframes. Consumers keep loading one composed built/pxtsim.js.

Layout

  • pxtsimcore/tsconfig.json compiles pxtsimcore/**/*.ts with lib: ["es2017", "ES2018.Promise"],
    types: [] and no dom, to built/pxtsimcore.js and built/pxtsimcore.d.ts. That the
    project compiles without dom is the check that core is DOM-free.
    • runtime.ts: the VM, Runtime, BaseBoard, CoreBoard and RuntimeBoardView.
    • messages.ts (the simulator message interfaces), eventbus.ts (EventBus and the board
      location types), simtypes.ts (SimulatorState, SimulatorRunOptions and friends).
    • host.ts and virtualhost.ts: the host seams and the virtual host.
    • allocator.ts, debugcore.ts, debugProtocol.ts, langsupport.ts, libgeneric.ts,
      localization.ts, logger.ts, utils.ts.
    • simcoordinator.ts and simframe.ts: the editor-side frame policy.
    • sound/: synthesis and sequencing (soundSynthesizer*.ts, soundEmojiSynthesizer.ts,
      soundexpression.ts, song.ts, sequencer.ts, musicalProgressions.ts), the
      play-instructions format (instructions.ts) and the audio seam (audiohost.ts).
    • state/: AnimationQueue and the generic ButtonPairState, CompassState,
      FileSystemState and LightSensorState.
    • visuals/boardgeometry.ts: coordinates, wire colors and breadboard geometry, which
      allocator.ts needs.
  • pxtsim/tsconfig.json compiles pxtsim/**/*.ts with the dom libs, against
    built/pxtsimcore.d.ts, to built/pxtsimdom.js and built/pxtsimdom.d.ts: embed.ts,
    domhost.ts, domutils.ts (the pxsim.U element helpers), debugger.ts, instructions.ts,
    accessibility.ts, simlib.ts, svg.ts, simdriver.ts, simframedom.ts, sound/ (the Web
    Audio implementation, audiohostdom.ts, the metronome worker and the worklet) and visuals/
    (BoardHost, the breadboard, generic boards and parts, wiring).
  • Membership is location: a file belongs to the project whose directory holds it, and neither
    config lists sources.
  • The gulp pxtsim task builds core, then the DOM project, then composes built/pxtsim.js as
    pxtsimcore.js followed by pxtsimdom.js, and built/pxtsim.d.ts the same way with the DOM
    declarations' reference to pxtsimcore.d.ts removed. Its pxtsimcore step also copies
    pxtsimcore/globals.d.ts to built/pxtsimcore-globals.d.ts.
  • All of these outputs ship in the pxt-core package (built/*.js, built/**/*.d.ts). A target's
    core config compiles against pxtsimcore.d.ts followed by pxtsimcore-globals.d.ts.
  • gulp lint lints both directories; pxtsimcore/.eslintrc.js points the parser at the core
    config.

The boundary

  • Core may name its own declarations, the globals in pxtsimcore/globals.d.ts and the
    localtypings/ files it references (pxtparts.d.ts, pxtarget.d.ts, pxtmusic.d.ts,
    vscode-debug-protocol.d.ts). It never names anything in pxtsim/; DOM code reaches core
    through built/pxtsimcore.d.ts.
  • globals.d.ts declares what browsers, workers and Node share: the timer functions, console,
    performance, btoa/atob, TextEncoder/TextDecoder, URL/URLSearchParams, the
    ImageData fields core reads, and requestAnimationFrame, cancelAnimationFrame,
    setImmediate and MessageChannel typed as possibly undefined, so core checks before calling.
  • Core reads time, timers and randomness through pxsim.host, and audio through
    pxsim.audiohost. The serial message's time: field stays on Date.now as wall-clock
    telemetry.
  • Load order: each project concatenates its files into one script, so a file that reads another
    file's members while the script loads pins that file ahead of it with /// <reference path>.
    In core the one load-time read is libgeneric.ts, whose RefCollection extends RefObject
    from langsupport.ts. In pxtsim/ the Web Audio sources pin sound/audioSource.ts, their
    base class, and simdriver.ts pins simframedom.ts.

Host seams

  • pxsim.host.Environment is what the runtime knows about its page: postToParent,
    locationHref, isPxtElectron, isIpcRenderer and frameId. The defaults are inert;
    pxtsim/domhost.ts installs the browser environment at load time through setEnvironment.
  • pxsim.host.Clock (now, perfNowUs), Timers (setTimeout, clearTimeout, setInterval,
    clearInterval, requestFrame, cancelFrame) and RandomSource (next, seed) are read
    through host.clock(), host.timers() and host.rng(). Every timer and frame call returns a
    TimerHandle, an opaque value passed back to the matching cancel call.
  • The defaults are Date.now, a performance-based microsecond clock, the global timers looked up
    at call time, requestAnimationFrame or a FRAME_MS (16 ms) timeout, and Math.random with a
    seed that does nothing. setEnvironment, setClock, setTimers and setRng override some
    members and fill the rest from the defaults.
  • pxsim.audiohost.AudioHost is the audio seam: tones, sample and PCM playback, MIDI, audio
    elements for recorded audio, muting, stopAll, spatial players and createMetronome.
    audiohost.audio() returns the current host and setAudioHost overrides members over the
    silent defaults. pxtsim/sound/audiohostdom.ts registers the Web Audio host, which forwards to
    AudioContextManager.
  • The silent host, used whenever no other host is registered:
    • playInstructionsAsync resolves once the buffer's duration (music.instructionsDurationMs)
      has passed on the host clock, or early when isCancelled returns true, polled on frame-sized
      ticks;
    • createMetronome returns an audiohost.TimerMetronome, which ticks on host.timers();
    • stopAll calls its onStopAll listeners in registration order;
    • createAudioElement returns an inert element whose play resolves at once;
    • isMuted is true, isAudioElementActive is false, sample and PCM playback resolve at once,
      and everything else does nothing.
  • Sound events: audiohost.SoundEvent is "playinstructions" | "muteallchannels".
    audiohost.setSoundEventHandler sets the single observer and audiohost.notifySoundEvent
    calls it. Both hosts notify: playing instructions with the buffer, stopAll with none.
  • Queued play: audiohost.queuePlay(when, play) calls play after when ms on host.timers()
    unless a stopAll ran in between, and audiohost.queuePlayInstructions queues a
    playInstructionsAsync that way. AudioContextManager.queuePlayInstructions delegates to it.

The virtual host

  • pxsim.host.createVirtualHost(options) returns a deterministic host: a clock that moves only
    when driven, timers and FRAME_MS frames on one queue ordered by virtual time, and a seeded
    random source. use() makes it the host the runtime reads.
  • advance(ms) moves virtual time forward, firing everything due on the way in time order and
    yielding to the event loop around each callback. runUntil(done) fires queued callbacks one at
    a time until done() is true, and keeps yielding while the queue is empty so real timers can
    make it true. stop() ends both for good. now() reads elapsed virtual milliseconds; the clock
    itself reads from a nonzero epoch, since the runtime treats a performance time of 0 as unset.
  • yieldToEventLoop may be injected; the default is setImmediate, else a MessageChannel
    message, else setTimeout(fn, 0).
  • The seed: the random source is pxsim.host.createSeededRandom(seed), a mulberry32 generator,
    started from options.seed or pxsim.host.DEFAULT_SEED. One seed always yields one sequence;
    a program that calls seedAddRandom reseeds it.
  • The yield budget: the runtime's maybeYield yields after 20 ms of host time, or after
    YIELD_BUDGET (100000) calls on one unchanged clock reading. On the virtual clock a busy loop
    therefore still yields, so a headless run times out instead of hanging Node.

Board state and view

  • runtime.board is the board state (BaseBoard, usually a CoreBoard subclass) and
    runtime.boardView is its optional view (RuntimeBoardView). The board() and boardView()
    accessors are defined by each target: board() for shims and state code, boardView() for
    view code only. A board state constructs and runs with no view.
  • BaseBoard holds the id, the EventBus, the run options, stored state, serial buffering and
    message listeners. CoreBoard adds builtinParts, the part states keyed by simulation
    behavior, and stops sound when killed.
  • RuntimeBoardView members and their callers:
    • builtinVisuals (part view constructors by simulation behavior) and builtinPartVisuals
      (part image constructors by built-in part name): read by the parts instructions page in
      pxtsim/instructions.ts.
    • initAsync(msg): called by the embed for each run once the board's initAsync has
      resolved, while that runtime is still current and alive; the program starts when it resolves.
    • updateView(): called by Runtime.updateDisplay, which a target calls to redraw and which
      the scheduler calls when updates queued with queueDisplayUpdate are flushed.
    • addUpdateSubscriber(fn): registers fn to run on every updateView; view code such as
      the board SVG subscribes here.
    • screenshotAsync(width?): called for each screenshot request, for each frame while
      recording, and for thumbnail frames after a user interaction.
    • kill(): called by the embed when it stops a run, after the runtime and the board.
  • With no view the runtime skips every view call; screenshots, recording and thumbnails do
    nothing.
  • A target implements: a board state class; pxsim.initCurrentRuntime, which sets
    runtime.board and runtime.boardView; a view class implementing RuntimeBoardView; and, to
    run headless, pxsim.initHeadlessRuntime, which sets the board with no view.
  • BoardHost wiring: the view's initAsync builds a visuals.BoardHost from
    visuals.mkBoardView(...) and visuals.BoardHostOpts, passing the board state as state and
    its own builtinVisuals. BoardHost.addPart builds each part that declares a
    simulationBehavior from the matching builtinVisuals entry and initializes it with the
    matching builtinParts state.

Simulator frames

SimulatorDriver runs in the editor, not in the simulator frame. Its policy is DOM-free and lives
in core; only the iframe handling needs the DOM.

  • pxtsimcore/simframe.ts defines SimFrame, an opaque handle to one frame (id,
    messageChannel, runId, runtimeId, permanent, loading, origin, aspectRatio,
    attached, matchesSource, focus), and SimFrameHost, which the environment holding the
    frames implements: list, create, remove, hide, reveal, post to, style, size and freeze frames.
  • pxtsimcore/simcoordinator.ts defines SimEnvironment (origin, URL, parent window, dependent
    editor liveness, configured simulator URL, message listener) and SimulatorCoordinator, which
    owns the policy:
    • the SimulatorState machine and run, stop, restart, suspend, mute and preload;
    • run-message construction: frame ids, theme rotation and run ids;
    • spin-up decisions for broadcast messages: a secondary board frame (at most two board frames,
      none in single-simulator mode), simulator extension and legacy message simulator frames, and
      restarting a frame whose run is stale;
    • routing: which frames receive a message, the source frame and jacdac traffic to secondary
      boards skipped, messages deferred until a frame has loaded, and at most one recorder;
    • the debugger protocol and trace interval, and the allowed-origin check on incoming messages.
  • pxtsim/simframedom.ts has DomSimFrameHost, the iframe-backed SimFrameHost; a frame's state
    lives in the iframe's dataset, and loaning a frame is DOM-only. pxtsim/simdriver.ts has
    SimulatorDriver extends SimulatorCoordinator, which builds a DomSimFrameHost and a DOM
    SimEnvironment and adds loanSimulator, unloanSimulator, isLoanedSimulator and the
    element callbacks in SimulatorDriverOptions; the rest of its public API is the
    coordinator's.
  • The coordinator reads its frame cleanup timer and frame-id randomness through pxsim.host.
    tests/simdriver-test drives SimulatorDriver through a fake DOM.

Target core bundles

  • A target ships a DOM-free simulator bundle by adding sim/tsconfig.core.json with outFile
    ../built/simcore.js. Its files are the target's sim/core/ folder (and any library core
    files it lists), compiled against pxtsimcore.d.ts and pxtsimcore-globals.d.ts with no
    dom lib.
  • sim/tsconfig.json compiles only the view files, against pxtsim.d.ts and the core bundle's
    .d.ts, and lists ../built/simcore.js in a top-level prepend. prepend is a pxt key, not a
    TypeScript one, and only tsconfig.json may carry it.
  • pxt buildtarget (and pxt rebundle) compile the sim folder: tsconfig.core.json first, to
    built/simcore.js, then tsconfig.json, to built/sim.js. After a build, each prepend
    entry is concatenated ahead of the output, and when the config emits declarations the prepends'
    .d.ts files are concatenated ahead of the output's .d.ts. So sim.js is simcore.js
    followed by the view code. An outFile other than the required path is a build error.
  • The npm-package form: a sim folder at node_modules/pxt-<target>-sim may use
    ./built/simcore.js and ./built/sim.js, and the outputs are copied into the target's
    built/.
  • pxt-common-packages keeps each library's DOM-free files in libs/<lib>/sim/core/ and its view
    files in libs/<lib>/sim/. Its sim/tsconfig.core.json compiles ../libs/*/sim/core/*.ts to
    built/common-simcore.js, and its sim/tsconfig.json compiles ../libs/*/sim/*.ts and
    prepends built/common-simcore.js, to built/common-sim.js. A target's pxt buildtarget
    builds both when common-sim.js is missing, pxt serve builds both on every build, and both
    copy both pairs into the target's built/.
  • A consumer loads pxtsim.js and the target's sim.js (the browser simulator), or
    pxtsimcore.js and simcore.js (headless, DOM-free); never both pairs, since pxtsim.js
    already contains core.

Running programs headless

pxt simtest [file|dir ...] compiles each test program on top of a base project and runs it in
a Node vm context with no browser globals (cli/simtest.ts, runHeadlessAsync).

  • Flags: --base <dir> is the project each test compiles on top of (default: the current
    directory); it must have exactly one .ts file, which the test replaces. --sim <file> is the
    target bundle (default built/sim.js, or built/simcore.js with --core). --core runs on
    pxtsimcore.js and built/simcore.js instead of pxtsim.js and built/sim.js, and stops
    with a message when the target has no core bundle. --timeout <ms> bounds each run in real time
    (default 5000). --settle <ms> is the
    virtual time a program runs after its top-level code finishes (default 300).
  • A directory argument runs every .ts file in it, in name order.
  • Each run installs a virtual host and the silent audio host before constructing the Runtime,
    sets pxsim.initCurrentRuntime to pxsim.initHeadlessRuntime, steps the host until the top-level
    code finishes, advances it by --settle, and then ends whatever is still scheduled. A run
    never waits for the program to go idle, so a forever program passes. The timeout watchdog
    stays on real time.
  • Expectations: every line of the form // @serial: text is one expected serial line, in order.
  • A test passes when it compiles, raises no runtime error, finishes before the timeout and, when
    it has @serial lines, writes exactly those lines. simtest runs every test, prints ok or
    FAIL with the reason for each and a count, and exits 1 when any failed.
  • gulp testsimcore (tests/simcore-test) loads built/pxtsimcore.js alone into such a
    context, runs programs through runHeadlessAsync on the TypeScript fixture
    tests/simcore-test/fixtures/toybundle.ts, and drives a virtual host directly.

Open items

  • pxt-common-packages' browser-events input state (libs/browser-events/sim/: keyboard and
    mouse) is view code; it has no DOM-free state in sim/core/.
  • pxt-common-packages' mqtt, wifi and text-to-speech simulators (libs/mqtt/sim/net.ts,
    libs/wifi---esp32/sim/wifisockets.ts, libs/text-to-speech/sim/tts.ts) are view code only.
  • pxt-microbit keeps its own accelerometer, edge connector, thermometer and music simulators in
    sim/core/ instead of using pxt-common-packages' versions.
  • debugProtocol.ts times out pending requests on the global setTimeout, not on
    host.timers().
  • The coordinator has no tests through a fake SimFrameHost; its tests go through
    SimulatorDriver and a fake DOM.
  • pxt-arcade's simulator has not been brought onto this split. pxt-common-packages calls two
    board methods that only an arcade board defines, through an any cast: updateStats (screen
    stats, view-only) and handleKeyEvent (multiplayer key input, state). A board view that tilts
    the accelerometer constructs visuals.AccelerometerTiltInput with the board's
    AccelerometerState and calls attach with the board element; pxt-arcade's simulator is a
    caller.

@thomasjball
Thomas Ball (thomasjball) marked this pull request as draft October 21, 2026 14:09
…io functions, and implement Web Audio integration
…llel execution, and enhance tsconfig for core output
…ardView for visual management, and update related components for improved state handling
…rdState, decouple view dependencies, and streamline state-to-view updates
…ig for core and sim, and enhance state management
…tures, implement audio element management, and separate DOM-dependent audio logic
…o core, enhance audio handling, and streamline state management
…ndary board frames and simulator extensions, enhance message handling and frame management
- Added `simcoordinator.ts` to manage the simulator's lifecycle, including message handling, state management, and frame creation.
- Introduced `simframe.ts` to define the `SimFrame` interface and related types for frame management.
- Created `simframedom.ts` for the DOM implementation of `SimFrameHost`, handling iframe creation, messaging, and appearance management.
- Established a communication protocol between the simulator and its frames, allowing for message passing and state updates.
- Enhanced the simulator's ability to handle multiple frames and extensions, improving overall functionality and user experience.
… related functionality for running tests in a DOM-free environment

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved headless-runner correctness, declaration completeness, and view-startup lifecycle issues prevent approval.

5 open issues
1 file not reviewed
  • kiosk/package-lock.json: Generated file
What changed in this PR

Separates simulator state and execution from browser rendering to support headless testing, alongside the related Micro:bit and common-packages refactors.

Changes:

  • Adds a DOM-free simulator core while retaining combined browser bundles and declarations.
  • Separates board views, audio hosts, and iframe handling from runtime and coordination logic.
  • Adds the pxt simtest command, headless tests, broadcast-routing coverage, and design plans.
File Description
tests/​simdriver-test/​simdriverrunner.ts Adds broadcast-routing and frame-lifecycle coverage.
tests/​simcore-test/​tsconfig.json Configures headless simulator tests.
tests/​simcore-test/​simcorerunner.ts Tests core execution and the headless runner.
pxtsimcore/​tsconfig.json Defines the DOM-free core build.
pxtsimcore/​globals.d.ts Declares minimal host-neutral globals.
pxtsim/​visuals/​breadboard.ts Moves shared layout helpers into core.
pxtsim/​visuals/​boardhost.ts Receives visual factories separately from state.
pxtsim/​visuals/​boardgeometry.ts Extracts DOM-free geometry and layout helpers.
pxtsim/​tsconfig.json Builds browser sources against core declarations.
pxtsim/​state/​animationqueue.ts Extracts animation scheduling into core.
pxtsim/​sound/​soundexpression.ts Routes sound playback through the audio host.
pxtsim/​sound/​sequencer.ts Uses host-provided playback and metronome interfaces.
pxtsim/​sound/​audiohostdom.ts Adapts browser audio to the host interface.
pxtsim/​sound/​audiohost.ts Defines audio contracts and silent defaults.
pxtsim/​sound/​audioContextManager.ts Preserves public aliases for moved audio types.
pxtsim/​simtypes.ts Extracts shared simulator types and enums.
pxtsim/​simlib.ts Removes state and geometry code moved into core.
pxtsim/​simframedom.ts Implements browser-backed simulator frames.
pxtsim/​simframe.ts Defines host-neutral frame contracts.
pxtsim/​simdriver.ts Wraps coordination with browser-specific adapters.
pxtsim/​simcoordinator.ts Extracts routing, lifecycle, and debugger coordination.
pxtsim/​runtime.ts Separates board views and delegates host operations.
pxtsim/​messages.ts Extracts shared simulator message definitions.
pxtsim/​instructions.ts Uses view-owned visual registries.
pxtsim/​host.ts Defines injectable environment services.
pxtsim/​eventbus.ts Extracts event handling and board-location types.
pxtsim/​embed.ts Adds separate board-view initialization and cleanup.
pxtsim/​domutils.ts Isolates DOM utility functions.
pxtsim/​domhost.ts Registers browser environment services.
pxtsim/​debugger.ts Removes helpers moved into core.
pxtsim/​debugcore.ts Extracts runtime debugging and heap inspection.
pxtsim/​allocator.ts Decouples allocation results from DOM types.
pxtsim/​.eslintrc.js Includes both simulator compilation projects.
kiosk/​package-lock.json Adjusts dependency metadata, unrelated to the refactor.
gulpfile.js Builds combined artifacts and wires headless tests.
docs/​pxtsimcore-refactor-plan.md Documents architecture, migration, and testing plans.
docs/​microbit-sim-input-api-plan.md Proposes typed simulator input and observation APIs.
cli/​simtest.ts Adds headless execution and serial-result checking.
cli/​cli.ts Registers and implements the simtest command.

Review effort: Balanced

Comment thread cli/simtest.ts Outdated
Comment thread cli/simtest.ts Outdated
Comment thread cli/simtest.ts Outdated
Comment thread cli/simtest.ts Outdated
Comment thread pxtsim/embed.ts Outdated
Refactor addSerial function to handle serial buffer.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: thomasjball <7671657+thomasjball@users.noreply.github.com>
…hrough pxt simtest (#11688)

* Add deterministic clock, timers, and rng

* update plan docs

* Run headless simulator runs on the virtual host, with a bounded virtual settle and a yield budget for busy loops

* Move the deterministic virtual host into the DOM-free simulator core

* update doc files

* restore lost instructions

* Tick the silent audio host's metronome on the host timers

* Update song.ts to use shared constants

---------

Co-authored-by: Eric Anderson <humanapp@users.noreply.github.com>
…les (#11690)

* Move the DOM-free simulator core sources into pxtsimcore/

* Build the target core simulator bundle in buildtarget and deploy pxtsimcore.js

---------

Co-authored-by: Eric Anderson <humanapp@users.noreply.github.com>
…t 2) (#11691)

* Ship the core globals declaration and build the common core simulator bundle

The pxtsimcore gulp task copies pxtsimcore/globals.d.ts to built/pxtsimcore-globals.d.ts, which gains btoa and atob, so target and library core configs reference it instead of hand copies; buildSimAsync's logic moves into buildSimFolderAsync, which buildCommonSimAsync shares so the common sim folder's tsconfig.core.json builds after its tsconfig.json with the same outFile check, copyCommonSim copies common-simcore.js and common-simcore.d.ts when present, and pxt serve watches bundled directories two levels deep so edits under a library's sim/core/ rebuild; host.Timers returns and accepts an opaque host.TimerHandle in place of any, and the core's timer holders are typed with it; the refactor plan and simulator instructions describe the core/ folder rule and the common-packages layout.

* Document the accelerometer tilt input in the simcore plan

The target layout now says a board view drives the accelerometer through visuals.AccelerometerTiltInput constructed with the board's AccelerometerState, and the open questions list asks which targets still call accelerometerState.attachEvents and must construct a tilt input instead.

* Point the simulator docs at pxt-microbit's sim/core layout

* Compose target simulator bundles on their core bundle

buildSimFolderAsync now builds a sim folder's tsconfig.core.json before its tsconfig.json, and buildFolderAsync's prepend also concatenates each prepended x.js's sibling x.d.ts ahead of the emitted declarations when the config sets declaration, copying the composed .d.ts to built/ for a node_modules pxt-*-sim folder, so a full sim config that compiles only view files against the core .d.ts and prepends the core bundle yields sim.js = simcore.js + view and common-sim.js = common-simcore.js + view; the refactor plan's target layout and the simulator instructions state that composition rule and that a view file references only the core declarations, never a core source file.

* Carry sound events, queued play and stop-all listeners on the core audio host

pxsim.audiohost gains SoundEvent ("playinstructions" | "muteallchannels", the multiplayer wire strings), setSoundEventHandler and notifySoundEvent, which replace the AudioContextManager.soundEventCallback slot and its three uses, and queuePlayInstructions(when, instructions), which plays through audio().playInstructionsAsync after when ms on host.timers() unless a stopAll ran in between, counted by an onStopAll listener registered once per audio host, with AudioContextManager.queuePlayInstructions kept as a one-line delegate; the silent host now keeps its onStopAll listeners and runs them on stopAll, and notifies "playinstructions" and "muteallchannels" as the Web Audio host does, covered by three new virtual-host cases in the simcore tests and recorded in the refactor plan's seams progress.

* Notify the playinstructions sound event once per play on the Web Audio host

PlayInstructionsSource.playInstructionsAsync no longer notifies the sound event, leaving AudioContextManager.playInstructionsAsync as the one notifier on that path, as the silent host notifies in its own playInstructionsAsync, so a multiplayer server sends one Audio message per sound instead of two.

* Build the simulator before the target libraries

buildTargetAsync and rebundleAsync now run buildSimAsync before buildTargetCoreAsync, so simulatorCoverage reads the built/sim.d.ts of the same build instead of a previous one and reports missing simulator implementations on a clean build too; simshimAsync stays ahead of the libraries build, whose sims.d.ts files it generates.

* Watch library simulator core folders without scanning build output

pxt serve watches each bundled library's sim/core folder by listing it alongside the sim folder's subdirectories, at the existing depth of one, so edits under libs/<lib>/sim/core/ rebuild while the per-library built/ and _locales/ outputs a build rewrites stay outside the scan.

* Quiet the target build's shim scan and simulator coverage output

plainTscCompileDir takes a reportProgramDiagnostics flag and the simulator shim scan passes false, since it reads only the //% annotations and the bundled compiler's parse diagnostics on the sim sources are reported by the sim's own build; simulatorCoverage prints one line with the count of target functions that have no simulator implementation and lists them at debug level.

---------

Co-authored-by: Eric Anderson <humanapp@users.noreply.github.com>
@thomasjball
Thomas Ball (thomasjball) marked this pull request as draft October 25, 2026 18:38
* Document the board view wiring the runtime expects from targets

The runtime's board view interface now documents each member with what calls it and when, the BoardHost's builtinVisuals option documents what it supplies, and BoardHost.updateState documents that the owning view calls it from its own updateView.

* Rename the runtime view interface to RuntimeBoardView

pxsim.BoardView collided with pxsim.visuals.BoardView, which targets use alongside it, so the runtime's interface is now pxsim.RuntimeBoardView and Runtime.boardView is typed with it; targets that extend or implement the old name switch to the new one.

* Guard the runtime's no-view paths and complete the silent audio host

Recording and thumbnail capture now do nothing when the runtime has no board view instead of posting undefined frames, the recorder timer fields are typed as host timer handles, the parts instructions page reads built-in part visuals only from a view that exists, and the silent audio host's createAudioElement returns an inert handle whose play resolves, with a simcore test running it in a vm context.

* Route the core's remaining time and random reads through the host seams

The board id fallback and the coordinator's frame ids now draw from host.rng() and the coordinator's frame cleanup timer runs on host.timers(), so no core file reads Math.random or the global timers directly; the editor's default host keeps today's behavior.

* Tidy the host, audio and message declarations

TimerHandle and Timers each carry their own doc, FRAME_MS is exported once from host.ts, the high-resolution clock lookup uses mozNow and falls back to Date.now instead of throwing, the spatial audio queue shares the core queuePlay helper and its stopAll generation so instrStopId is gone, Metronome.initAsync is typed as a promise, setAudioHost no longer shadows the host namespace, the multiplayer audio message reuses audiohost.SoundEvent, and the globals header says what the file declares.

* Assert virtual time in the simcore tests

The simcore tests assert the virtual host's time and that runs did not time out instead of bounding real elapsed time, the board cleanup check has its own case, and the toy bundle builds its instruction buffer from the shared step size and duration offset constants.

* Tidy simtest, lint configuration and moved simulator frame code

runHeadlessAsync clears its sandbox timers in one finally and documents its context globals, its dropped teardown errors and its rejections, its messages and timer sets are typed, the serial expectation doc matches the parser, simtest names itself in the single-test-file error, pxtsimcore lints through its own eslintrc instead of quoted parser options, the pxtsim eslintrc and the kiosk lockfile match master again, the frame host drops a duplicate icon hide and clears the dim opacity, the driver keeps one typed reference to its frame host, and the moved debugger code documents that evalGetter's getter must have no side effects.

* Move the simulator plans out of the published docs

* Play sound on the silent audio host in headless runs

runHeadlessAsync installs the silent audio host after the virtual host, so a program that plays sound runs the same on the full bundle as with --core instead of reaching the Web Audio host in the vm; the core README and the simulator instructions say so.

* State the core logging rule in the simulator instructions

Core code reports through pxsim.log, pxsim.warn and pxsim.error, the pxsim.Logger a host may replace, never through console directly.

---------

Co-authored-by: Eric Anderson <humanapp@users.noreply.github.com>
* Design documents for the headless test harness above pxt simtest

Adds three documents beside the headless core. pxtsimcore/headless-harness-overview.md is a short tour for a first read. pxtsimcore/headless-harness.md is the complete design: contributors as one shape serving the board, modules from a simulator extension's DOM-free core, program contributors registering over the program-message channel, and media; structured declarations composed into one named surface with fixed order and collision rules; scenario files with frame-addressed inputs, windowed level and arrival expectations, a watch list and a versions pin; a canonical JSON Lines trace that is the golden and the replay oracle; three shells over one driver, each offering a run and a surface operation; worlds of several simulators stepped in lockstep; media and topology with the radio ether's model surface, hot plugging and a project staging file reachable through its own request family; recording and replay at the host seams; conformance checks; worked examples throughout; and a Status section recording which parts exist. pxtsimcore/headless-harness-plan.md sequences the rest into nine phases with testable landing criteria and the working agreements for the agents that build it. The README and simulator.instructions.md point at the three files. Documentation only.

* Name the command-line shells a project author runs tests with

pxt simtest runs in a target checkout over the bundles the checkout builds and in a project folder over the bundles the installed target package ships; mkc test is the same command for a project author with no checkout, fetching the two headless bundles from the target's release beside the compiler it already fetches, compiling the project and running every scenario it holds; both are front ends over the Node caller, and a target release ships pxtsimcore.js and its simcore.js beside pxtworker.js. The Status section records that simtest runs only in a checkout today, that no release ships the bundles and that the MakeCode CLI has no test command. The plan adds pxt-mkc to its repositories and lands the two commands and the release packaging in phase 4, whose landing test requires byte-identical traces from every shell. The overview names both commands.

* Remove harness line from instruction file

* Let extensions ship media, with contracts, declared quantities and driven kinds

A simulator extension's core script defines a medium with defineMedium(kind, factory) as it defines a module, and a staging's media entry names the extension as its module; the platform keeps the media a target's cores speak to, the radio ether, the jacdac bus, the display shield and a spatial world of positions alone, and a richer world with obstacles, surfaces, sources or echo is an extension's medium whose layers are its own structure. Media propagate in the order the staging lists them and feed one another only through declared kinds, a world applying per-link strength through the ether's strength kind and sensor levels through each board's kinds, read on the next frame; propagate(apply) is the seam, delivering to a board under a quantity its attachment map names or to a later medium under one of its kinds. A board's attachment carries what a medium reads and nothing else, a radio profile the target declares for the ether and a quantity-to-kind map for a sensing world. The seam between a target's core and a medium is a named contract, radio, jacdac or shield, implemented once by the platform and implementable by an extension's medium, the radio contract fixing the link, strength and drop names every implementation declares. A medium declares the quantities it applies and the params its staging accepts, maps and params are validated against them, and a board kind a staged map targets is marked driven by that medium in the surface and refused when a scenario scripts it. A lamps world is the worked example, core script and staging. Core scripts are plain JavaScript in the simulator context; the harness library and program contributors compile under the static subset. Plan phases 5, 6 and 7 carry the work with landing tests; the overview names extension media.

* Add the STS form of a scenario

Any file the project compiles may declare scenarios through `harness.scenario(name, fields, declare)`, a test file or a program file, the handle taking inputs, expectations and a watch list as values; the library sends the declaration over the harness channel, the driver takes every declared scenario at the first frame boundary and runs each as if it were the file beside the declaring file, a declaration after that boundary is refused.

* Name the headless seam the program-message channel rides

Phase 3's driver side runs over the runtime's existing hooks, Runtime.messagePosted collecting what the program sends and board.receiveMessage delivering what the driver posts, both DOM-free and the same seam the live simulator puts a window behind, so the program-message channel needs no abstraction to work headless.

* Save and restore a world's state at a frame boundary

Contributors gain optional saveState and restoreState, plain JSON in a shape of their own carrying their own format version, and a save is one JSON value serialized canonically: the driver's form (frame index, pending inputs, the position of every random stream it handed out, the surface digest), every contributor's state in composed order, each board's runtime saved form with the compiled program it ran, and the versions record. A restore runs the embedded program on the restoring runtime, so a save made under an older target resumes under a newer one; every saved form restores every version it has ever written, release versions are recorded and never refused on, and a restore is refused only by a layer that cannot read a format version or by a surface digest that does not match the staging; a contributor without saveState makes its world unsaveable, refused by name. A save is the driver's operation, never the program's: every shell offers save as a third operation beside run and surface, a scenario asks for saves at frames through a saves field and starts from one through a from field that names a save or carries it whole, the static TypeScript handle declares save frames with t.save, and a tab asks through the coordinator. Serialization and storage are separate layers throughout: the driver produces and consumes serialized state and opens no file; the command-line shells keep saves, traces and bulk values beside the scenario and resolve from paths, and the worker shell passes a save as a message, so browser-stored saves load back in. The ether's random stream is handed to it by the driver, as any medium's is. Two saves at one frame are byte-identical and a restored run continues to the same trace, which conformance checks together with a corpus of saves from each released format.

* Reconcile the harness documents after the media, scenario and save additions

A fresh read of the overview, the spec and the plan to fix inconsistencies and stale facts.

* Add anchored windows and cap the scenario's expectation vocabulary

An expectation's window may be given relative to an anchor instead of to frame 0: an input at the frame it is due, or an event at its first arrival of that kind, with that value when one is given and from a floor frame onward when the anchor carries from. The window begins at the anchor's frame and runs through within frames after it inclusive, within 0 being the point form, or to the run's end when unbounded; each form keeps its meaning over the resolved window, the anchoring event does not count toward an arrival of its own kind and value, a window the run cuts short is unsatisfied as an absolute one is, an anchor that never occurs leaves every form unsatisfied, negated included, which is the opposite of a logic's vacuous truth and is chosen, and an input anchor naming no scripted input or a negative within is refused before the run. Anchors are one input or one event, never another expectation and never nested; they resolve to absolute frames from the trace, which the report shows beside the verdict, so the evaluator, the trace and the golden are those of absolute windows, and absolute windows remain for recordings and tests of timing. The vocabulary ends there: absolute windows, anchored windows and the four forms over them, with no comparison, composition or conditional; a richer check is TypeScript over the trace through the trace reader, which pxt ships as a library and pxt simtest runs beside a scenario, leaving the run and the trace unchanged. The counter scenario is shown a second time with anchored windows; the static TypeScript handle takes a window as from and to or as an anchor object; plan phase 1 exports the reader and tests the anchor rules, and phase 2 runs checks beside scenarios.

---------

Co-authored-by: Eric Anderson <humanapp@users.noreply.github.com>
Co-authored-by: Eric Anderson <humanapp@users.noreply.github.com>
* Pass btoa and atob into the headless sandboxes

cli/simtest.ts and tests/simcore-test/simcorerunner.ts provide btoa and atob in the vm context beside console, performance, the text codecs, URL and the timer functions, matching the shared globals that pxtsimcore/globals.d.ts declares, so a simulator library that encodes buffers through them, such as settings, runs headless.

* Run pxt simtest on the DOM-free bundles when the target ships them

pxt simtest runs pxtsimcore.js with built/simcore.js when the target has built simcore.js and pxtsim.js with built/sim.js otherwise, --core requires the DOM-free pair and stops when the target has not built one, --sim names the target bundle explicitly, and the pxtsimcore README states that a target shipping simcore.js is never loaded headless through sim.js, whose view code may use the DOM at load.

* Record the Arcade simulator on the DOM-free split

The pxtsimcore README's open items no longer list the browser-events input state or pxt-arcade's simulator, the tilt-input wiring sentence stands in "Board state and view" as a mechanism of its own, the harness Status names pxt-arcade's initHeadlessArcadeBoard and simcore.js beside pxt-microbit's, and phase 9 of the harness plan carries the Arcade screen channel with no split condition.

* Describe how a target ports to the DOM-free split

* State the live topology a project without staging.json has

The live simulator's coordinator links every extension frame to every other when the project has no staging.json, so an extension that stages nothing receives every channel it subscribes to, and staging narrows the topology only where a project or a run declares one.

* Let a board declare the frame period its scenarios run at

A board contributor may declare framePeriod, the period in milliseconds its engine steps on, which is the nominal frame period of a scenario that states none and FRAME_MS when the board declares none; a world of several boards takes the scenario's period, else the smallest period any of its boards declares, every board's engine stepping on its own timers inside it.

---------

Co-authored-by: Eric Anderson <humanapp@users.noreply.github.com>
The uploaded markdown (snapshot-and-restore-proposal.md) outlines the architectural blueprint for a deterministic runtime snapshot and restore mechanism within the PXT MakeCode simulator.
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.

5 participants