Files
darceye 9cd5863d36 MAX7219 display + logic analyzer with Saleae-style floating window (0.9.0 + 0.10.0)
0.9.0 - MAX7219 Display (max7219.js v1.0.0, engine in max7219-lib.js):
- 8-digit LED driver on a firmware bit-banged DIN/CLK/LOAD bus (hardware
  SPI stays invisible, M0). Level-driven shift engine samples DIN on the
  CLK rising edge, latches MSB-first 16-bit words on the LOAD rising edge;
  misaligned frames are dropped and counted in `dropped`, never garbled.
  Datasheet register model: Code B decode (independent DP), intensity PWM,
  scan limit, shutdown/display test, power-on defaults. 8x8 matrix and
  7-segment Views auto-selected from the decode-mode register; daisy
  chains share the three pads (sharedBus) and latch their chain position.
  Lease sizing measured against the tightest real firmware (gun 0.1.0,
  delay-free pulses ~20 instructions apart): fine at the scheduler floor
  20, arm 60. Smoke S12 boots the gun 0.1.0 release (machine-local, skips
  when absent) with zero drops; examples/esp32s3-gun.json wires it.

0.10.0 - Logic analyzer (logic-analyzer.js v1.0.0, logic in la-lib.js):
- Multi-channel GPIO waveform capture: up to 8 pads, channel+edge or
  immediate trigger, 1-1000 ms guest-time window, auto re-arm, Z shown
  when the pad is released; rides the M4 scheduler (arm 1000, fine lease
  per resolution preset 20/100/300, renewed per edge); 20 000-edge buffer
  freezes with an overflow flag. sharedBus makes it a pure tap on other
  bus devices' pads (smoke S13 arms it on the P4 soft-I2C bus: SCL 1950 /
  SDA 352 alternating edges in a 40 ms window, frozen afterwards).
- Floating analyzer window: draggable, resizable, non-modal popup
  (position fixed, no backdrop - the page underneath keeps every pointer
  event; geometry clamped + persisted). One drawWaveform painter feeds
  the popup, the details mini-preview and the row icon (AGENT 3.8);
  channels picked from a GPIO grid, occupied pins greyed.
- Saleae-style plot interactions: wheel zoom anchored at the cursor (1 ns
  floor), left/middle-drag pan, double-click/F fit; hover measurement
  shades the pulse and reads level + width + period + frequency + PWM
  duty (rise-to-rise cycle); A/B cursors with delta-t and 1/delta-t;
  lane selection + </> buttons and arrow keys jump view-centre onto the
  previous/next edge; lane gutter shows the right-edge level; a fresh
  capture re-fits the view and drops cursors (capId).
- Device API v1 additions (backwards compatible): ctx.pinLevel(pin)
  snapshot read of the driven level; ViewHost.pinInfo() for multi-pin
  pickers; View.destroy() teardown on row removal / list rebuild so Views
  owning DOM outside their row (floating windows) clean it up.
- Tests: la-lib units (parsePins, levelAt, axis ticks, fit span, pixel
  transform, zoom/pan view model, pulseAround, pwmOf, fmtHz/levelChar),
  the capture state machine via fake ctx (immediate/triggered starts,
  direction filtering incl. Z-as-falling, window end, stop/clear/auto,
  buffer overflow, onEmuReady reset, capId), sigbus.pinLevel against a
  fake emulator; smoke S13 + S1 grows to 12 devices;
  examples/esp32c3-probe.json wires the analyzer on the I2C pair.
- Docs (both languages): CHANGELOG 0.9.0 + 0.10.0 + device version table,
  ROADMAP shipped sections, DEVICE-API instrument section + new ctx/host/
  View members, ARCHITECTURE GUI checklist + floating-window note,
  README capability table + tree, AGENT rule 3.11 + GUI-smoke bug-table
  rows; version synced to 0.10.0 in all three places.
2026-11-02 00:05:48 +08:00

11 KiB

English | 简体中文

esp-emu-peris system architecture

1. Layered overview

┌───────────────────────────────── page ──────────────────────────────────────┐
│ esp-emu-peris.html   stock style block kept verbatim (easy upgrade diff)    │
│                      + <link peris/peris.css>                               │
│ three panes at once: UART0 console | peripherals | debug (no tabs since 0.3)│
│ build/ui/*  (TypeScript compiler output)                                    │
│   app.ts          orchestrator: machine state, Run/Pause/firmware/network   │
│                   controls, message routing                                 │
│   worker-client   the only Worker entry (all other modules go through it)   │
│   terminal / chart / debug-panels     UART console / perf chart / read-only │
│                                       debug panels                          │
│   peris-panel + config-editor         row-based peripherals list            │
│                                       (icon/name/GPIO/V·G badge            │
│                                       + expand/remove/drag-sort) and the   │
│                                       details config editor                │
└───────────────▲───────────────────────────────────────────────▲────────────┘
        postMessage (protocol in core/protocol.ts)                │ DOM
┌───────────────┴───────────────────────────────────────────────▼────────────┐
│ page-side device Views (View classes of peris/devices/*.js, instantiated    │
│ by peris-panel)                                                             │
├─────────────────────────────────────────────────────────────────────────────┤
│                            ← peris_state / peris_command →                  │
├─────────────────────────────────────────────────────────────────────────────┤
│ build/core/worker.ts  upstream worker replica + 6 peris: hooks (the only    │
│                       file allowed to touch upstream logic)                 │
│ build/core/runtime.ts device host: config validation/instantiation/commands │
│                       /onBatch MMIO tick + throttled state pushes           │
│ build/core/sigbus.ts  pin signal bus: per-batch gpio_state() diff dispatch  │
│ build/core/registry.ts plugin registry (manifest-driven + meta validation)  │
│ build/core/{version,types,protocol,chips,mmio}.ts  version/contracts/       │
│                       messages/chip tables/register tables                  │
│ worker-side devices (Device classes of peris/devices/*.js; shared helpers   │
│                       such as ledc-lib.js sit outside the manifest)         │
└───────────────────────────────────────────────────┬─────────────────────────┘
                                                    │ WasmEmulator (closed-source
                                                    │ wasm, touched only here)
                                            esp_emu_bg.wasm v0.51.0

2. Key decisions and why

decision rationale
emulator lives only in the worker the page never touches the wasm; all interaction goes through postMessage, so the threading model cannot be broken
framework in TypeScript, device plugins in plain JS type-checking covers the bug-prone areas (the big refactor fixed a dropped-state defect this way); plugins stay drop-in with no build dependency
tsc per-file compilation, no bundling output stays a standard ESM directory, deployment model unchanged (www add-only); same shape as upstream for easy upgrade diffs
peris styles split into peris.css, stock styles stay inline in the HTML upstream sections of the HTML can be diffed directly on upgrade
data lives in TS constants (chips/mmio), not JSON carried natively by tsc, type-checked, no runtime copy issues
registry validates plugin metadata at load time plugin authors get no compile-time checks; a missing pins()/typeId fails immediately at load (a real bug class)

3. Data flow and state ownership

  • Firmware → device: after each run_batch() the worker calls runtime.onBatchEnd() → sigbus.sample() reads gpio_state() (four 32-bit words) and, using the union change mask (out|oe), dispatches one onPinState per changed pin. With no changes the cost is 1 wasm call + 4 XORs. Register-level devices (LEDC/RMT) additionally receive an onBatch() tick in the same call and poll MMIO through ctx.readU32/ctx.mmio — their signals never surface as pin changes (GPIO matrix, M0 Q2a/Q3a).
  • Device → page: markDirty() → flushStates throttled to ~30 fps; a trailing timer is attached inside the throttle window so the last dirty flag is always delivered (otherwise a final state resting inside the window would be dropped).
  • Page → device: View sendCmd() → peris_command → runtime.command() → onCommand. Views may own page-side DOM beyond their row (the logic-analyzer floating window is position: fixed on document.body, non-modal by design — no backdrop, so the rest of the page keeps every pointer event); the panel calls the optional View.destroy() on row removal and list rebuilds so such DOM cannot outlive its device.
  • Input pin levels: the only writers are ctx.setPinInput (devices) and the Debug grid's gpio_input. The page-side gpioInputLevels[] is only the Debug grid's own mirror, not authoritative state; the authority lives in the devices (re-driven on onEmuReady).
  • Config: the page's PerisPanel.config is the single source of truth; every change is sent in full via peris_config (the worker destroys and rebuilds instances) and written to localStorage. Pin conflicts are validated by the worker and reported back through peris_ready.errors, shown as a yellow bar on the page.

4. Directory responsibilities

src/core/        worker-side framework (TS sources)   → compiled to build/core/
src/ui/          page-side modules (TS sources)       → compiled to build/ui/
build/           tsc output (not in git; HTML/worker/tests reference it)
peris/devices/   device plugins (JS sources, not compiled; manifest.json
                 registers them; each carries its own version; shared
                 helper modules like ledc-lib.js live here but outside the
                 manifest)
peris/vendor/    vendored xterm
peris/peris.css  peris-specific styles
tests/unit/      unit tests (node:test, zero dependencies)
tests/m1_smoke.mjs  integration smoke (real wasm + firmware + runtime + devices)
tests/m0_probe.mjs  emulator capability probe (run first on upstream upgrades)
tests/firmware/  IDF test firmware

5. Build & tests

npm install                 # one-time (typescript from the npmmirror registry)
npm run build               # tsc → peris/core + peris/ui (mandatory after src changes)
npm run check               # type-check only
npm test                    # unit + integration smoke
npm run serve               # python serve.py (merges the service repo and www/pkg)
npm run sync                # publish into the official www (build first)

Test layering: unit (pure logic: bit math / level semantics / validation / row-badge derivation / LEDC register decode against fake MMIO, plus the M4 protocol layer — scheduler leases, the I2C slave engine under a synthetic open-drain master, register models, NEC decode — fast, pinpoints failures) → integration (real wasm + firmware, full chain; S7/S8 drive by the ###P2 markers, S9 by ###P3, S10/S11 — soft-I2C slaves + both NEC capture paths — by ###P4; every window is marker-bounded because armed batches fast-forward whole sleep phases) → GUI (manual checklist: page load, three panes visible at once, row rendering after adding a device, inline rename / GPIO change / V·G flip, row-icon interactions (LED colour / press / switch), expand details & config editing, drag sorting, firmware load, LED toggling while running, PWM/RGB LED auto-binding to the firmware's LEDC channels with smooth breathing during the P2 phase, the I2C transaction log filling during P4 with the EEPROM hex dump editable from the View, IR codes appearing on both ir-nec rows, logic-analyzer: add one, pick channels from the GPIO grid in the floating window (occupied pins greyed), arm + waveform rendering while the P4 soft-I2C bus runs, window drag/resize with the page underneath still interactive, Saleae-style plot checks (wheel zoom at the cursor, drag pan, hover measurement shows pulse/period/frequency/duty, two clicks drop A/B cursors with Δt + 1/Δt, double-click re-fits), window closes when the row is removed, config import/export round-trip; browser automation to be introduced with Playwright once the network allows — PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright).

6. Upgrade procedure (summary; details in UPGRADE.md)

  1. After a new wasm package lands, run npm run test:m0 first and compare against FEASIBILITY.md;
  2. diff the upstream worker.js ↔ src/core/worker.ts (all changes carry peris: markers);
  3. diff upstream app.js/index.html ↔ src/ui/*.ts and the stock style section of the HTML;
  4. npm run build && npm test, manual GUI pass.

7. Extension points

  • New peripheral: drop a module into peris/devices/ + one manifest line (see docs/DEVICE-API.md); worker and page load it automatically with immediate metadata validation.
  • New chip: add the address table in src/core/mmio/<chip>.ts and register it in mmio.ts; add the pin count in chips.ts.
  • New page panel: add a module in ui/ and register it in app.ts's message routing — the worker protocol stays untouched.