The menubar now matches the desktop app's native menu set: File, Edit,
View, Language, Help.
View collects what the user asked to reach from a menu: Compile (Ctrl+B),
Run, Stop, Reset up top; the panel toggles — File Explorer, Output
Console, Serial Monitor, Oscilloscope/Logic Analyzer — in the middle
(store-backed ones render a live check, like a real desktop menu); and
the canvas view actions (center, zoom) move here from Edit, which goes
back to being undo/redo only, as menus have always worked.
Language lists the nine locales with the current one checked, switching
through the same switchLocale path the header globe uses — so language is
reachable from the menubar regardless of the account state, on top of
living in the signed-in account menu.
Run/Stop/Compile/Reset and the console toggle register through the
editorCommands seam from EditorToolbar (their handlers close over its
state); the explorer toggle from EditorPage; serial and scope call their
stores directly, same as undo/redo.
The floating corner box sat ON TOP of the file tree — create enough files
and the last rows scrolled underneath it. The account + language block is
now the explorer panel's footer (VS Code style): the tree scrolls above
it, always. When the explorer is collapsed the block falls back to the
small fixed corner box, so account and language stay reachable.
The standalone language globe becomes a logged-out affordance: once the
pro auth slot renders its signed-in marker (data-auth-user), a :has()
rule hides the globe — signed-in users change language inside the
account menu, where the pro overlay now hosts it.
The language menu and the account menu were written for the top bar and
open downward; from the bottom-left corner box that means straight off
the bottom of the viewport. The language menu flips via a scoped CSS
rule; the account menu (pro) now anchors bottom-up whenever its trigger
sits in the lower half of the screen.
The header's backdrop-filter makes it a containing block for fixed
descendants, so the corner box's bottom:8px resolved against the 45px
header — measured at y=-4, sitting on the logo. Portaled to <body>, where
fixed means the viewport. The auth slot keeps working: pro's injector
finds it by attribute wherever it lives.
IDE-style: a small fixed box in the explorer's quiet bottom corner holds
the language switcher and the account button (pro keeps injecting it into
the same data-velxio-slot, just relocated). The autosave dot rides next
to the menus, and the header's Share button is gone — File > Share/Embed
already covers it.
The point is width: emptying the header's right side gives the toolbar
the last ~150px it needed, so the single-row layout now holds down to
~1400px WITH the chat docked — which is exactly the reported 1440x900
case. Tight-fallback thresholds drop accordingly (1400/1060/1040).
Desktop-editor variant only; marketing pages and mobile keep their
header untouched.
At 1440x900 with the chat docked, the merged header hit an ugly in-between:
the wrapped canvas controls floated as an orphan group inside a tall
transparent header, next to a dead gap before the language/user controls.
Reported by the user as "se ve raro" — wider screens looked fine.
Below the width where one row genuinely fits (thresholds account for the
docked chat's 380px / collapsed 36px reservation), the strip now falls
back to being its own full-width second bar with its old background and
border — the exact familiar pre-merge layout. Wide screens keep the
single 44px row; tight screens get the classic two bars; the broken-
looking limbo between them no longer exists.
The toolbar's overflow button held four leftovers (Share/Embed, Sync to
GitHub, Upload firmware, Record simulation). They move into the File
menu, which is where a session-frequency action belongs, and the strip
loses one more button. The PRO pill travels with them — same style the
overflow used (now .emb-pro) — and the two File items that were always
premium (BOM, schematic image) finally show it too: users should know an
item is premium BEFORE clicking, not via a surprise upgrade prompt.
The pro actions keep firing the same window events the overflow items
fired (share/github-sync/record prompts), so the overlay's listeners are
untouched and OSS builds keep their silent no-op. Dead overflow CSS
removed with the button.
With the marketing nav gone from the editor header, its middle was ~700px
of dead space at 1280 while the toolbar occupied a whole second 38px bar.
The unified toolbar strip now fills that middle through a new AppHeader
editorToolbar slot: one 44px row where there used to be 44+38 — a full
row returned to the code and the canvas.
The strip keeps its own class names, so everything that keyed on them
keeps working untouched: the container queries, the docked-chat
padding-right, and the internal flex-wrap. When it truly cannot fit, the
strip wraps and the header grows (height: auto on the modifier class)
instead of clipping or overlapping; brand and the right-side controls
stay pinned to the first line. Inside the header the strip drops its own
background and border so it reads as one bar, not a box within a bar.
Mobile keeps the previous layout (no strip; the mobile tab bar remains).
The open-source build now ships exactly its product surface: /editor, the
examples gallery (/examples, /examples/:id) and the per-example editor
(/example/:id). Landing, about, pricing, docs, the 14 keyword-targeted
simulator landings and the v2/v2.5/v3 showcases move to the private
overlay, registered through the same registerProRoutes seam that already
carries login/admin/classroom.
Root behaves per build: an overlay that registers an index route claims
'/' (velxio.dev keeps its landing); otherwise '/' redirects to /editor.
The redirect waits for the overlay import to settle — same contract as
markProExamplesSettled — so a velxio.dev visitor is never bounced into
the editor because the landing was 300ms away from registering.
Prerender and sitemap follow the split: entry-server pulls the marketing
page map from '@pro/pages/marketing' behind a VITE_PRO_BUILD-gated dynamic
import (the proven main.tsx pattern, with an OSS stub for tsc), and
generate-sitemap lists only served routes in OSS builds (2 URLs) while
pro builds stay byte-identical — verified against a pre-migration
baseline: sitemap (37 URLs) and prerendered /, /about, /docs,
/arduino-simulator, /esp32-simulator, /v3 all identical modulo hashed
asset names. OSS prerender drops exactly the 32 marketing pages (348→316).
The OSS header slims down to match: Editor, Examples, GitHub, Discord —
the marketing links only render in pro builds, where their routes exist.
The editor Help menu links those pages absolutely (velxio.dev) in OSS,
exactly like the desktop app's Help menu.
Mismo contenido y orden que el Help del menu nativo de escritorio
(pro/desktop menu.rs): Documentation, Examples, Pricing | Velxio Home,
Blog, About Velxio | Discord Community, GitHub Repository. Los enlaces que
el header del editor dejo de mostrar como nav de marketing recuperan aqui
un sitio ordenado, y todo abre en pestana nueva para que el editor (y el
trabajo sin guardar) se quede donde esta — el mismo criterio que la app
nativa, que abre el navegador del sistema.
Reportado en /example/pi5-opencv-vision: al recargar aparecia un instante
el 404 con el header completo (menus incluidos) y despues cargaba el
editor. La carrera: los ejemplos pro se registran cuando aterriza el
import dinamico del overlay, y la pagina resolvia la galeria ANTES,
concluia "no existe", pintaba el 404 y al llegar el registro re-renderizaba
al editor.
"No esta en la galeria" y "no esta TODAVIA" son respuestas distintas, asi
que el registro gana una senal de asentado: main.tsx la activa cuando el
import del overlay resuelve (o falla — finally), e inmediatamente en la
build OSS, donde no viene overlay y un 404 debe ser instantaneo. La pagina
se queda en "Loading example..." hasta que el registro asienta y solo
entonces un id ausente es de verdad un 404.
markProExamplesSettled es idempotente y solo notifica una vez, con test —
el sintoma de romper eso seria una tormenta de re-renders por hot-reload
del overlay.
Con la ventana estrecha y el chat acoplado, a la fila unificada le faltan
~60px y flexbox se los quitaba al toggle Code/Both/Circuit: de 101px a 32,
con dos de sus tres botones recortados a esquirlas inalcanzables bajo el
overflow hidden. Medido en staging a 1024px.
El toggle deja de encogerse (flex-shrink 0) y la fila hace wrap: cuando de
verdad no cabe, los controles del canvas bajan a una segunda linea corta.
Dos filas breves ganan a botones invisibles.
La queja: demasiados botones en una sola fila, y en pantallas pequenas se
solapan — medido, no impresion: a 1024px Code/Both/Circuit pisaban a
Compile/Run/Stop por 9-20px y Add pisaba el chat por 29px, con el idioma y
el usuario cortados fuera del viewport.
Tres piezas:
1. En el editor, el nav de marketing (Home/Docs/Pricing/...) sobra: ya
estas dentro, y cuesta exactamente el ancho que le falta a la toolbar.
AppHeader gana la variante editorMenu — mismo mecanismo que ya usa la
build de escritorio (VITE_DESKTOP) — que oculta el nav y pinta un menu
File/Edit junto al logo. El logo sigue llevando a inicio; idioma,
autoguardado, Share y usuario se quedan.
2. File/Edit al estilo escritorio: lo que se usa cada minuto sigue siendo
boton; lo que se usa unas veces por sesion va al menu. File: nuevo
workspace, nuevo fichero, abrir, guardar, importar, exportar, BOM,
imagen del esquema. Edit: undo/redo (con su estado real del historial),
centrar vista, zoom. Las acciones viven en cierres de cuatro
componentes distintos, asi que hay un registro id->handler
(lib/editorCommands, mismo patron de seam que registerProExamples o
registerBoardBuiltins): cada dueno registra al montar y el menu invoca
por id; un item sin dueno montado se pinta deshabilitado, que ademas es
la verdad. El registro sobrevive al remontaje StrictMode (el cleanup
viejo no borra al sucesor) y tiene test de eso.
3. La toolbar adelgaza: fuera Undo/Redo (Ctrl+Z/Y siguen), fuera los
botones inline de Import/Export y sus gemelos responsivos del More
(~70px recuperados); el More queda solo con los extras pro (firmware,
GitHub, share, grabacion). El CSS responsivo de los botones retirados
se va con ellos.
Suite en verde (2300) y build del overlay verificado desde este arbol.
Tres cambios que van juntos porque atacan la misma queja: "anado un
elemento y a veces ni veo donde se anadio".
1. Donde cae. Ya se anclaba a la esquina visible, pero la cascada que evita
que se apilen iba indexada por components.length: seguia avanzando
aunque movieras o borraras piezas, asi que las caidas se alejaban cada
vez mas de donde estabas mirando. Ahora toma el primer hueco LIBRE desde
la esquina, bajando en diagonal; si apartas la ultima, la siguiente
recupera su sitio. Extraido a utils/dropSlot con 8 tests, incluido el
caso de "la apartaron" y el tope para no salirse de la vista.
2. Que se vea. El recien anadido queda seleccionado, y la seleccion pasa de
un borde discontinuo quieto a un caminito de hormigas. El movimiento es
lo que capta el ojo en un canvas lleno; un borde fijo se pierde. Va en
un pseudo-elemento por fuera del cuerpo, sin robar clicks ni tapar el
dibujo, y se queda quieto si el sistema pide menos animacion.
3. Clicks. El izquierdo SELECCIONA y ya esta; antes abria el panel de
propiedades, o sea que no podias ni senalar una pieza sin comerte un
popup que luego habia que cerrar. Propiedades y pines pasan al click
derecho, que es donde va lo deliberado. En tactil se mantiene tocar ->
panel, que ahi no hay boton derecho.
Velxio es internacional y su galeria estaba mezclada. Tres focos:
- examples-robot-desktop: el codigo que el ejemplo ENTREGA al usuario
llevaba 33 comentarios en castellano ("Tiempo del ultimo movimiento
detectado", "Cola de estados") y 7 cadenas que el sketch imprime por
serie ("INICIO DE LA LECTURA DE SENSORES", "Movimiento DETECTADO").
Traducido todo y reescrito en ASCII, como el resto de la galeria.
- examples.ts: siete comentarios mios en castellano, de cuando anadi las
resistencias en serie a los LEDs. El resto del fichero estaba en ingles;
los deje incoherentes.
Sin cambios de comportamiento: solo texto. Los tests de galeria siguen en
verde (167).
Seam nuevo registerBoardBuiltins/getBoardBuiltins: una placa que el arbol
OSS ya dibuja (la familia Pi) puede recibir perifericos del overlay sin
registrar un ProBoardDef, que es lo que secuestraba el arte de la placa y
dejaba los cables en la esquina. El overlay lo usa para llevar los frames
del guest (cv2.imshow) al panel cableado en el canvas.
Arregla ademas el guard de connect() del bridge: comparaba readyState con
WebSocket.OPEN leido del global, asi que cuando esas constantes no estaban
`undefined === undefined` era cierto con socket a null y connect() volvia
sin abrir nada — el bridge se quedaba muerto. Ahora comprueba que el socket
exista y usa el valor numerico. Recupera los 12 tests de
multi-board-integration que esto habia roto.
El test del UART Pi->Uno pasa a comprobar el contrato vigente: por el cable
va onUartTx (el UART del header), no la consola del guest; y se anade el
caso que fija que la charla de arranque NO se filtra al vecino.
El fan-out del Interconnect envuelve el onSerialData de la INSTANCIA y
la marca con un flag. Compilar, resetear o cambiar de motor crea una
instancia nueva: sin flag y sin envoltorio, asi que la placa dejaba de
oirse por el cable a mitad de sesion. Sintoma real: la Pi en modo Linux
encendia el LED del Arduino (Pi -> Uno), pero la respuesta del Arduino
no volvia nunca (Uno -> Pi), porque el Uno habia recreado su simulador
al compilar despues de que se construyeran las rutas.
Se vuelve a enganchar en cada simulatorMap.set (siete puntos: AVR,
RP2040, RISC-V, ESP32, STM32 y los shims).
Una linea por start con el motor elegido, si estaba fijado y el motivo.
Sin ella, una placa que no arranca NADA (motor equivocado, bridge
ausente) es indistinguible de una que arranco bien: no hay error, no hay
proceso y la barra dice lo mismo. El toolbar ya deja su traza
[handleRun]; esta cubre el camino del boton de modo Linux.
startBoard llamaba `getBoardBridge(id)?.connect()`: si la placa no tenia
bridge, el encadenamiento opcional se lo tragaba y el usuario se quedaba
sin guest, sin error y con la barra mostrando Stop. Ahora avisa por
consola y baja el flag de running, que es lo unico honesto que se puede
hacer ahi.
Test nuevo del camino que usa el boton de modo Linux: fijar el modo,
parar y arrancar tiene que abrir el WebSocket del guest, dejar
engineMode en linux y running en true; y un socket en CLOSING no puede
impedir la reconexion.
connect() se rendia si el socket no estaba CLOSED, y uno en CLOSING pasa
esa prueba: pulsar "Linux terminal" justo despues de una ejecucion
cerraba el socket y el arranque siguiente no hacia nada -- ni guest, ni
error, y la barra seguia mostrando Stop. Ahora solo se rinde con OPEN o
CONNECTING y descarta el que se esta cerrando.
startBoard marca ademas running en la rama Pi: el boton de Linux
reinicia la placa por su cuenta, sin pasar por la barra, asi que el flag
se quedaba con lo que hubiera dejado la ejecucion anterior.
Las rutas serie se construyen al cargar la pagina, pero el bridge de una
placa QEMU-Linux nace al pulsar Run: ensureSerialHook encontraba bridge
nulo, hacia no-op y nadie volvia a intentarlo — los bytes que el guest
transmitia por el header salian del backend (uart_tx) y morian en un
onUartTx sin instalar. reensureSerialHooks(boardId) repite el enganche
(idempotente por el flag) y el store lo llama al crear el bridge.
Auditoria de "la Pi tiene todo lo de la placa real" con cuatro huecos
encontrados y cerrados:
1) GPIO de entrada en modo Linux: GPIO_IN respondia VAL 0 fijo (stub de
la fase 2), asi que GPIO.input() leia 0 eternamente aunque el canvas
empujara el nivel. El backend guarda ahora el ultimo nivel por pin
(set_pin_state lo escribe) y GPIO_IN contesta de ahi. Los flancos
(SET) siguen llegando al guest como antes.
2) UART del header hacia otra placa: el shim del rootfs ya hablaba
`UART <port> TX <hex>` / RX_REQ, pero sin modelo de esclavo el
backend tragaba los bytes. Ahora TX sin esclavo se emite al canvas
(uart_tx) y RX_REQ sin esclavo drena la cola que llena pi_uart_rx —
el mismo protocolo de siempre, sin ops nuevas.
3) El escaner de esclavos I2C/SPI/UART estaba doblemente muerto:
clasificaba por numero fisico de pin ('3','5','19'...) cuando el
elemento expone GPIOxx, y su unico llamador era RaspberryPiWorkspace,
que el terminal unificado reemplazo. Acepta ambos nombres y corre en
onBooted del store.
4) boardPinToNumber solo mapeaba los pines de la 3/4/5; la Zero, 1B+ y
2B (mismo header de 40 pines, mismo elemento) se quedaban sin mapa.
La placa QEMU-Linux tiene DOS flujos serie y hasta ahora el cableado
usaba el equivocado: el enrutado entregaba los bytes del vecino a la
consola (el shell) y sacaba al cable la cháchara del arranque. El
header, que es lo que el usuario cablea, no existia.
Ahora el canal de protocolo lleva dos ops nuevas:
UARTTX <b64> el guest transmitio por el header -> al canvas
UARTRX el guest pregunta que le llego -> UART_RXQ <b64>
El backend guarda una cola por instancia (acotada a 64 KB, que un script
que no lee nunca no la haga crecer) y el websocket acepta `pi_uart_rx`
con los bytes que el vecino manda. En el frontend el bridge gana
onUartTx / sendUartBytes y el Interconnect engancha ESE flujo en vez de
la consola para las placas Pi.
Con esto el mismo script -- import serial, escribir, dormir, leer --
funciona en los dos motores.
El constructor de netlist lee properties.value y cae a 1 kohm si no esta;
once ejemplos declaraban `resistance: '220'`, asi que sus LEDs lucian a
un quinto de lo previsto (brillo 0,16 en vez de 0,72 medido en el
pi-to-arduino-led-control). Se normaliza el nombre de la propiedad.
Dos piezas que faltaban para que pi-to-arduino-led-control fuera algo
mas que un guion imprimiendo lo que "habria enviado".
1) classifyPin no reconocia los pads del header por su nombre. Se llaman
GPIO14 / GPIO15 en el dibujo de la placa y en todos los cables de los
ejemplos, pero solo se aceptaban numeros fisicos: parseInt('GPIO14')
daba NaN, el pin no clasificaba como nada y el Interconnect nunca
construia la ruta. Ahora se acepta el prefijo GPIO/BCM y la numeracion
fisica sigue funcionando.
2) Seam de serie para placas que no tienen ni simulador ni bridge: el
motor de navegador corre el Python de la Pi en la propia pestana.
registerSerialSink(placa, fn) recibe los bytes que le llegan y
feedBoardSerialOut(placa, ch) anuncia los que envia, que es lo que el
enrutado por cables ya sabia repartir.
Dos fallos que salieron probando pi-to-arduino-led-control y
pi5-pir-motion-alarm.
1) BoardOnCanvas mira getProBoard() ANTES del switch OSS para decidir si
dibuja un elemento del overlay. El overlay registraba un def minimo
para las seis Pi solo para llevar una linea de setup del guest, y eso
basto para cambiarles el render: en vez de la ilustracion
Raspberry_Pi_3_illustration.svg salia la caja esquematica, con otras
coordenadas de pines, y los cables quedaban colgando en la esquina.
Ahora hay un registro aparte, registerGuestSetup(kind, linea), que
lleva la cadena y nada mas; getGuestSetup() la resuelve dando
prioridad al def del overlay si existe.
2) Las partes de entrada (PIR, botones, sensores) avisan con
simulator.setPinState(pin, nivel). En una placa QEMU-Linux no hay
simulador de MCU -- el CPU es el guest -- asi que la llamada acababa
en la instancia AVR heredada y se perdia: pulsar el sensor no hacia
nada. traceDetailed devuelve ahora tambien la placa a la que llega el
pin, y si es de la familia Pi la parte recibe un simulador que empuja
el nivel al bridge (gpio_in para el guest, el valor pin<N> que leen
los shims del motor de navegador) y al PinManager de esa placa.
Guarda de regresion para lo que se acaba de arreglar. Recorre el grafo de
cableado (no solo el vecino inmediato del LED, porque la resistencia
protege igual desde el catodo o desde el otro lado de un transistor o un
rele) y falla si algun LED llega a un pin sin limitar la corriente.
Es el peor tipo de ejemplo roto: el sketch imprime "LED ON", el runtime
quema el LED y no aparece ningun error, solo un LED oscuro.
El modelo electrico es honesto: un LED colgado directo de un pin a 3,3 V
o 5 V pide una corriente absurda, el simulador lo quema y se queda
oscuro aunque el sketch imprima "LED ON". Ya se habia arreglado en los
ejemplos de Raspberry Pi con un solo LED, pero quedaban 24 sin proteger
-- sobre todo los de Pico (i2c-scanner, spi-loopback, adc-read,
multi-protocol, serial-echo, eeprom, rtc) y todos los RGB, que necesitan
una resistencia POR CANAL.
Se anaden 36 resistencias de 220 ohm en serie, cada una entre el pin y
el anodo, colocadas al lado de su LED. Los ejemplos que ya llevaban la
resistencia en el catodo se dejan como estan: protege igual.
Con esto la galeria pasa de 24 LEDs que se queman al pulsar Play a 0.
Every Pi example hung its LED straight off a GPIO pin, so the electrical
model solved 2.4e29 A and burnt it out: the script printed LED ON while
the canvas stayed dark. Written before the SPICE engine landed, and
nobody noticed because reaching that point took a 90 s boot. 220R in
series, like the ESP32 examples already do.
Without this we cannot tell whether the in-browser path is actually
displacing guest boots — the whole point of the dual engine is a number
(% of runs that never touched the backend), so the event has to say.
A profile's extra_drive is the same file for everyone; an overlay may
need a disk built for THIS session (what the project declared). New
seam set_pi_extra_drive_resolver(fn) receives the client id, the board
and the start_pi payload and returns raw images, mounted read-only after
the profile's own. The WS route forwards msg_data and the bridge gained
startPayload so a client can declare it. Generic: no package manager,
no OS knowledge in the OSS tree.
Generic seam only — no board, OS or runtime specifics in the OSS tree:
an overlay may register an engine that decides, per board, whether a run
needs the Linux guest or can happen in the browser. The decision is data
(engine + reason + where) so the UI can explain a 90 s boot instead of
just taking it, and BoardInstance carries engineMode / enginePinned so
the user's choice is predictable and the terminal panel knows whether an
interactive shell exists. Nothing registers in OSS: the QEMU path is
untouched.
The netlist collector only understood GPIO<n>/GP<n>/bare digits, so a
wire on a QEMU-Linux board's P24 Gravity pad never got its V-source
stamped and the LED stayed dark while the guest toggled the pin.
Keeping Play enabled while the guest ran broke the convention every
other board follows (owner report). Run now disables as usual; RESET on
a booted guest re-uploads the edited files and re-runs the script
without the ~45 s reboot, matching Reset's restart-the-program meaning.
The generic arm64 image prints another product's banner/motd/login line
during boot, before guestSetup can re-brand the guest. Boards with
quietBoot show a neutral '[Velxio] Booting <label> (Linux guest)...'
progress line (dots every 4 s) while boot detection and the prompt-gated
upload still run underneath; the shell is revealed (already re-branded)
right before the auto-run command, so the user's first visible output is
their own program.
With the bare boardId as client_id, two tabs (or two users) on the same
example shared one QEMU instance: serial output went to whichever socket
connected last, keystrokes interleaved and one tab's stop killed the
other's guest. Suffix the tab session id so each tab gets its own
instance (stopped on its own disconnect, like the ESP32 workers).
QEMU-Linux boards used three competing file surfaces (workspace group
with a meaningless sketch.ino/libraries.json, the VFS panel with its
Upload button, and the Pi workspace's own editor). Now they behave like
every other board:
- the editor file group defaults to script.py for ANY Pi-family kind
(kind-based check via isPiBoardKind, not the old raspberry-pi- string)
- the libraries.json manifest row is hidden for Pi boards
- EditorPage always renders Monaco; the RaspberryPiWorkspace swap is
gone
- the bottom serial panel renders the interactive xterm (PiTerminal,
now seeded with session history) for running Pi boards
- example vfsFiles load into the editor group (single source of truth);
the run path uploads the group into the guest home
- Run on a booted guest re-runs without the 45 s reboot (Ctrl-C +
re-upload + run); starting a Pi board pops the terminal open
- piSyncAndRunScript/piRerunScript exported from the store; auto-run
now applies to the whole family (guestHome/autoRun overridable)
- ProBoardDef.autoRun: after boot (+guestSetup) the VFS uploads itself
and the command runs, so a single Run click boots, uploads and starts
the user's script (same UX as compiled boards)
- ProBoardDef.guestHome: VFS home dir override ('/root' for guests that
log in as root); those boards drop the historic hello.sh sample
- upload sequence extracted to utils/piUpload (shared by the VFS panel
button and autoRun)
- serial monitor strips DEL/C0 control echoes (backspace showed tofu)
El caso ownsPointer retornaba sin stopPropagation y el mousedown llegaba al
fondo del canvas, cuyo convenio arrastre-izquierdo-panea movia el mundo
entero bajo el dedo a mitad de swipe (solo el tap funcionaba). El modelo
tactil escucha POINTER events — stream aparte — asi que cortar el mousedown
(y el touchstart movil) no le quita nada. Los knobs wokwi conservan el
pass-through de siempre.
The boards array is replaced on every serial batch, so the attach effect
detached/re-attached built-in peripherals at up to 60 Hz while output
flowed; events landing inside the 500 ms re-attach window were lost.
One-shot streams (a guest display list) never recover — the repainting
SPI LCD decoders masked this for ESP32 boards.
isBoardSeated lee pinInfo/boardSocket del DOM; en el primer render de un
ejemplo que ABRE con la placa ya posada ni la placa ni el zocalo estan
montados, el memo devolvia 'no posada' y no recalculaba nunca (z=0 pese a
asiento exacto medido). Re-chequeo al siguiente frame y a los 400ms.
La whitelist de tags que poseen el puntero durante la simulacion gana una
salida generica regla-6a: el elemento declara `get ownsPointer() { return
true }` y el wrapper no inicia el drag mientras corre. El cristal del Round
Display pintaba el punto verde Y arrastraba el shield por el canvas a la vez.
El bump global a zIndex 3 (efecto iman del Round Display) puso TODAS las
placas por encima de TODOS los componentes: una resistencia al lado de un
Arduino quedaba oculta tras la placa en cualquier ejemplo normal (se veian
los cables, no el cuerpo). Ahora isBoardSeated (misma matematica del snap,
umbral 0.5px) decide: posada -> z3 encima de su zocalo, como el XIAO fisico
apilado en el shield; libre -> z0 debajo de los componentes, como siempre.
El iman coloca la placa exactamente en el asiento, asi que al capturarla
salta al frente y al arrancarla vuelve abajo.
- profile key extra_drive: optional read-only second virtio-blk so an
overlay can ship guest-side shim libraries (/dev/vdb)
- SENS <name> protocol op: canvas-fed named values (built-in sensors /
buttons) served from PiInstance.sensor_state, pushed by the frontend
via the new pi_sensor_state WS message
- DISP <b64> protocol op: guest display commands forwarded to the
frontend as 'display' events (built-in screens)
- RaspberryPi3Bridge: onDisplay / onGpioPwm callbacks + setSensorState
- SimulatorCanvas hands piFamily boards their Pi bridge in
attachBuiltins (was ESP32-only)
Overlay QEMU-Linux boards are not Raspberry Pis: ProBoardDef.guestSetup
lets a board send one shell line at the boot prompt (hostname/PS1/clear)
to de-brand the generic image, sent before piBooted flips so uploads
cannot interleave; the workspace start button and power-on title carry
the board's own label for non raspberry-pi kinds; the compile console
line uses the board label instead of 'Raspberry Pi 3B'.
The QEMU branch of handleRun required compiledProgram, but Pi-family
boards never have one (handleCompile early-returns 'no compilation
needed' without producing firmware), so the toolbar Run always surfaced
'Compilation produced no firmware' instead of powering the board on.
Start them directly, same as the workspace Start button.
registerPiFamilyKind + ProBoardDef.piFamily route an overlay board through
the existing Raspberry Pi bridge path (WebSocket qemu, VFS panel, boot
terminal); register_pi_board_profile lets the overlay add the matching
PI_CONFIGS entry (cpu/image_set/...) at registration time.
setAdcVoltage/setAdcWaveform solo conocian el mapa del ESP32 clasico
(GPIO32..39): en un S3 devolvian false para TODOS los pines y el part no
podia alimentar el ADC. Ahora el shim consulta bridge.adcChannelForGpio si
existe (los puentes js exponen el mapa de su chip) y cae al mapa clasico
solo si no.
El adaptador SPI del Esp32BridgeShim descartaba el MISO de los parts ('el worker
lo lleva por _spi_response'), cierto solo en la era QEMU: cualquier part SPI que
RESPONDE (una SD contestando CMD0) hablaba con nadie en modo js — medido como
SD.begin()=0 con sd_diskio reintentando CMD0 para siempre en el Round Display.
Ahora reenvia a bridge.setSpiResponse, que todos los puentes tienen: el de QEMU
lo manda al worker, y los motores js fijan el byte que su SpiForwarder devuelve
para ESTA transferencia (toda la cadena onByte corre sincrona dentro del
transfer del motor). El reposo lo restaura el decodificador compartido, que ya
completa cada byte ajeno con 0xff.
El boton conservaba animation:'none' inline fuera del estado requesting, y el
estilo inline gana a la clase: .velxio-ants quedaba con animationName none —
medido con getComputedStyle en staging. Inline solo mientras solicita permiso;
en el resto, undefined para que la clase anime.
El modulo es retrato con la botonera de pines en el borde INFERIOR y el sketch
dibuja en setRotation(1): segun el mapeo MADCTL del propio modelo (writePixel:
el borde superior del contenido rot-1 cae sobre el borde IZQUIERDO del canvas),
verlo derecho exige girar el modulo un cuarto horario — igual que en el banco
fisico. El mismo giro lleva los pines al borde izquierdo, de cara a la placa,
asi que los cables van rectos entre ambos en vez de por encima del cristal.
Cuatro tiras de gradiente repetido, una por borde, deslizando un periodo de
guion por ciclo — fondo y no border porque un borde CSS no puede animar su
dash-offset. currentColor hereda el color de estado (gris en reposo, rojo en
error). Se apaga en streaming/solicitando, y con prefers-reduced-motion.
Ojo del gato encerrado: el estilo inline usaba el shorthand background, que
pisa el background-image de la clase — pasa a backgroundColor.
Un sketch de ESP32-CAM existe para capturar, asi que esperar el click en el
toggle se lee como 'la camara esta rota': el guest arranca, esp_camera_init
completa contra el OV2640 modelado, y cam_hal agota el tiempo eternamente
esperando fotogramas que nadie envia — sin pedir siquiera el permiso.
Ahora la webcam se solicita al iniciar la ejecucion; el prompt del navegador ES
el consentimiento, y el toggle queda como apagador manual. Solo una vez por
ejecucion: parar el stream a mano no lo re-dispara.
Estaban a zIndex 0 con los componentes a 1: cualquier solape las escondia. El
caso que lo decide es el apilado — una XIAO posada en el zocalo del Round
Display debe ser lo que se ve, como la placa recien soltada queda arriba del
monton. Los montajes lado a lado no solapan, asi que nada mas cambia.
Como el iman de la breadboard pero para PLACAS: un componente puede declarar en
su elemento (estilo regla 6a, igual que pinInfo) que lleva un zocalo:
get boardSocket(): { anchorPin, accepts }
y una placa cuyo boardKind case con accepts, arrastrada cerca, se posa de golpe
con su pad anchorPin sobre el homonimo del zocalo. Un solo ancla basta porque
ambas rejillas comparten paso — esa es la gracia de un zocalo. Arrastrarla mas
alla de la tolerancia la suelta, sin estado que recordar.
Enganchado en los dos caminos de arrastre de placas (raton y tactil). El
overlay privado declara el zocalo sin que este repo sepa que existe.
Tres cosas que salieron al tirar del hilo de los pull-ups.
1) ESP32_ADC_PIN_MAP era la tabla del ESP32 CLASICO aplicada a toda la familia, y
fallaba de dos maneras:
- S3: sus pines ADC son GPIO1..20, ninguno esta en esa tabla, asi que la
busqueda devolvia undefined y el listener del potenciometro no llegaba a
engancharse. El mando no hacia nada.
- C3/C6: GPIO0 SI esta en la tabla clasica, como ADC2_CH1 -> canal 9. Pero en
esos chips GPIO0 es ADC1_CH0 y sus motores toman un indice de canal de ADC1
(0..4). El valor se empujaba al canal 9, fuera de rango, descartado en
silencio mientras el firmware leia el 0. Una respuesta equivocada en vez de
ninguna, que es peor.
adcPinMapFor(boardKind) devuelve la tabla del chip.
2) El ejemplo de las gafas OLED en MicroPython declaraba sus botones con Pin.IN a
secas. Van entre 3V3 y el pin, asi que sin pull-down el pin queda FLOTANDO con
el boton abierto. El propio ejemplo ya lo avisaba en un comentario ("Add
Pin.PULL_DOWN if the pin floats") — ahora lo hace. Es un fallo de hardware de
verdad, no un artefacto del emulador.
3) gallery-run-gate.audit.test.ts: EditorToolbar bloquea el Run cuando el
verificador de circuito saca errores, asi que un circuito invalido no es
cosmetico, es la diferencia entre un ejemplo que arranca y uno que parece
muerto. Y esos defectos se esconden hasta que el circuito resuelve DE PUNTA A
PUNTA: c3-button llevaba un LED de 506 mA que nadie veia porque sus cables
apuntaban a pines inexistentes.
El test reproduce la puerta del Run tal cual (mismo snapshot de peor caso,
mismo buildInputFromStore, mismo verifyCircuit, ngspice de verdad) sobre los
227 ejemplos. Encontro tres mas sin resistencia en serie —nano-button-led,
mega-led-chase y mega-serial-control, 17 LEDs en total— y ahora quedan 0.
A partir de aqui, un ejemplo nuevo con un LED colgado del GPIO salta en CI.
Los dos colgaban el LED directo del GPIO. Con los cables del pulsador arreglados
el circuito por fin resuelve entero, y el verificador saca lo que llevaba ahi
desde siempre: 506,9 mA por un LED de 20 mA. Como un error de circuito bloquea la
ejecucion (EditorToolbar checkOrBlock devuelve false y saca el modal de
"ejecutar de todos modos"), el ejemplo se quedaba sin arrancar.
O sea que no era un fallo nuevo, era uno viejo que hasta ahora nadie podia ver
porque el circuito no llegaba a resolverse. 220 ohm en serie, igual que el resto
de ejemplos con LED (esp32-blink-led y esp32-doom ya lo hacian bien).
El canvas reenviaba la pulsacion de un pulsador cableado como
sendPinEvent(gpio, true), o sea "pulsado = ALTO". Para el idiom canonico de
Arduino — pin -> pulsador -> GND con INPUT_PULLUP, activo a nivel BAJO, que usan
13 de los 18 ejemplos con entrada de usuario — eso esta al reves: pulsar conducia
el pin a su nivel de REPOSO y soltarlo al ACTIVO. Ademas escribia directamente en
el latch de entrada del emulador por detras de connectDigitalInputsToMcu,
desincronizando la cache lastLevel de la que ese fichero se declara unico
escritor. La victima visible era esp32-doom: cuatro botones que hacian lo
contrario de lo que pulsabas.
No lo sustituyo por la polaridad opuesta, que seria la misma adivinanza al reves:
cuando el simulador resuelve entradas por el circuito (spiceDrivenInputs), el
nivel sale del propio cableado. El INPUT_PULLUP del guest se reporta ahora como
gpio_pull, estampa una resistencia de 45k al riel de 3V3, y cerrar el pulsador
cortocircuita el nodo contra la pata que tenga al otro lado. Sale bien tanto para
un boton a GND como para uno a 3V3, sin que nadie asuma nada. El atajo se queda
solo para simuladores que se salgan del modelo electrico.
Aparte, dos arreglos de cableado:
- boardPinToNumber devuelve -1 (no null) para pines de alimentacion y masa, y
la guarda comprobaba `=== null`. Asi que la pata de GND de cada boton colaba
y registraba un SEGUNDO par de listeners apuntando al pin -1.
- c3-button y pico-button-led cableaban sus pulsadores a los pines '1a' y '1b',
que NO EXISTEN: el wokwi-pushbutton expone 1.l, 2.l, 1.r y 2.r. Esos dos
cables llevaban colgando desde siempre, asi que esos botones no han
funcionado nunca. Pasan a 1.l / 2.l, como el resto de ejemplos.
boardPinToNumber no tenia rama para 'cardputer-adv', asi que caia al return
null del final: cada cable a su cabecera EXT o al Grove Port A se conectaba a
nada. La pieza quedaba en el lienzo, el sketch leia un pin muerto y nadie
avisaba de nada.
La rama compartida de esp32 tampoco habria servido: recorta en 39 y este board
saca G40 en la cabecera. El S3 tiene 48 GPIOs.
Se anclaba a la esquina inferior derecha del lienzo y tapaba una parte del area
util del circuito, que es justo donde se trabaja. Quien pana y hace zoom no lo
necesitaba para orientarse, asi que se retira entero en vez de esconderlo tras
un interruptor mas.
Se van el componente y su CSS (no los usaba nadie mas) y la linea del listado de
novedades que lo anunciaba, que ya no seria cierta.
Las dos ramas habian divergido: master llevaba el modo lenguaje ESP-IDF puro
(#139) y v3.2 la ruta de compilacion IDF v5.5 para toda la familia ESP32 mas
los arreglos de venv/toolchain. Ambas tocaban espidf_compiler.py.
Los dos lados son ejes ORTOGONALES y se conservan enteros:
- use_idf5 / arduino_mode (v3.2): que arbol IDF usa el build (5.5 vs 4.4) y
si cabe Arduino-como-componente.
- pure_idf (master): el modo LENGUAJE que elige el usuario; sus ficheros son
las fuentes del componente main con su propio app_main().
Resolucion:
- _build_env acepta los tres. Un build IDF puro fuerza arduino_mode a falso:
la plantilla CMake mete el componente arduino-esp32 en cuanto existe
ARDUINO_ESP32_PATH, asi que dejarlo puesto compilaba el core de Arduino en
un build que no tiene sketch. Lo cazaron los tests de master.
- VELXIO_PURE_SKETCH solo con pure_idf, nunca con arduino_mode a falso a
secas: un target sin core arduino-esp32 sigue entregando un SKETCH al
traductor legacy y no debe tomar la rama del glob puro.
- La identidad del build-dir suma los dos tokens (|idf:N|ard:N y |lang:pure):
ningun par de esas combinaciones puede compartir un build/ configurado.
- La cadena de escritura de fuentes queda pure_idf -> arduino_mode -> legacy.
- sdkconfig: render de v3.2 (con target/use_idf5) mas el filtrado de simbolos
CONFIG_ARDUINO* de master cuando el build es puro.
test/backend/unit/test_espidf_compiler.py: los 7 tests que ya estaban rotos en
v3.2 (AttributeError: idf5_path, fixture sin actualizar desde que se anadio la
seleccion de IDF) vuelven a pasar.
Verificado: backend 293 pasan / 0 fallan (v3.2 traia 7 rotos); frontend 2268
pasan / 0 fallan en los dos shards.
connectMcuEdgesToService resubscribes its per-pin listeners whenever
pinNetMap changes. The subscription swept pin NUMBERS 0..63 and
reverse-mapped them to names ('GPIO2' on ESP32, 'GPIO17' on Pi) to match
against pinNetMap's keys — but those keys are the WIRE pin names ('2',
'4', 'A0'), so after the first solve the match failed for every pin and
the resubscription attached nothing. Any mid-run pinNetMap update then
silently killed the MCU-edge → SPICE path and the canvas froze at the
last solved state while the firmware kept toggling.
Masked until now because nothing perturbed pinNetMap mid-run on the
blink examples; pure ESP-IDF mode (#139) unmasked it — gpio_reset_pin()
leaves the internal pull-up enabled, the worker reports gpio_pull, the
handler requests an electrical resolve, pinNetMap gets a new identity,
and the ESP-IDF blink example's LED froze ON.
Fix: subscribe FROM the pinNetMap names, mapped to PinManager pins with
the same pinNameToArduinoPin the netlist collector uses (STM32 via
stm32PinNameToLinear), and hand schedulePin the netlist name so
handleMcuEdge's v_<board>_<pin> lookup hits the fast alterSource path
instead of a full rebuild per edge. The 0..63 sweep remains as the
pre-first-solve fallback. Also fixed pinNameToArduinoPin's dead 'GPIO'
branch ('GP' tested first turned 'GPIO32' into parseInt('IO32') = NaN).
Adds a third entry to the board language selector next to Arduino C++
and MicroPython: ESP-IDF. In this mode the user writes a plain ESP-IDF
project — app_main() entry point, FreeRTOS + driver APIs — and the
backend compiles it through the same ESP-IDF toolchain it already uses
for ESP32 Arduino sketches, just without the arduino-esp32 component.
Backend:
- CompileRequest.language ('espidf') threaded through the sync + async
compile paths and folded into the dedup job key (language='arduino'
and omitted hash identically so old clients keep dedupping).
- espidf_compiler: pure_idf flag. User files are written into main/
as-is (no Arduino.h wrap, no velxio_compat.h, Arduino library
resolution skipped), ARDUINO_ESP32_PATH is dropped from the build env
and VELXIO_PURE_SKETCH raised so the template CMake compiles the
user's own sources via a glob branch. Pure builds get their own
persistent build-dir variant through the eff_hash fold.
- QEMU WiFi compat for IDF-style code: esp_wifi.h/esp_wifi_init
detection sets has_wifi, and literal #define SSID/PASS plus
wifi_config_t designated initializers are normalized to the QEMU AP.
- CONFIG_ARDUINO_* lines are stripped from sdkconfig.defaults in pure
mode (the symbols don't exist without the arduino component).
Frontend:
- LanguageMode gains 'espidf'; BOARD_SUPPORTS_ESPIDF covers the ESP32
family (Xtensa, S3, C3). Toolbar shows the option only for those.
- Switching modes seeds a main.c blink skeleton (app_main + gpio
driver), mirroring the MicroPython main.py flow.
- compileCode sends language='espidf'; run/stop paths are unchanged
(the QEMU worker consumes the same merged flash image).
- New gallery example: esp32-idf-blink (LED + resistor on GPIO 2).
Tests: unit coverage for the build-env switch, IDF wifi normalization,
job-key variance, file-group seeding and the new example; verified
end-to-end in a container from the prod image (pure build produces a
bootable flash image; Arduino-mode build unchanged, same variant hash).
componentToSpice/isSpiceMapped now consult a pro-registered mapper table after
the static MAPPERS, so a private build can give its closed components a SPICE
model (e.g. the DFRobot Gravity analog sensors emit a voltage source at AOUT).
Empty in a pure OSS build. Mapper type exported.
Round Display, Grove Gesture and ReSpeaker Lite now advertise themselves in
the OSS picker like the M5Stack matrices; they auto-hide in any build whose
overlay merges the real components (registry.getById).
A wire to a pro board's Dx pad (XIAO 'D2', etc.) did nothing because
isBoardComponent only knew the OSS board-id list, so the electrical/sensor
chain skipped the board endpoint. It now also returns true for any
proBoardRegistry kind, so boardPinToNumber (which already consults the pro
def's pinToNumber) resolves the Dx name. Fixes the numeric-alias-only
workaround in the XIAO examples.
registerSensorControls() lets a private build add SensorControlDef entries for
sensors it ships outside the OSS tree (e.g. the DFRobot Gravity analog family)
so they get the live slider panel; every SENSOR_CONTROLS lookup now goes
through getSensorControl(id) which falls back to the registered map. Dead code
in a pure OSS build, same contract as proBoardRegistry / registerComponentDoc.
xiao-esp32s3-sense / xiao-esp32c6 / xiao-rp2040 advertise themselves in the
OSS picker like the other hosted-only boards; the ads auto-hide in any build
whose overlay registers the real kinds (same BOARD_KIND_LABELS contract).
filteredComponents' new registryVersion dep evaluated in its useMemo deps
array while the const was still in the temporal dead zone (declared further
down the component) — 'Cannot access N before initialization', white screen
on every page. Hooks moved up next to the registry declaration.
The @pro overlay import is dynamic, so board/component registration can land
AFTER the picker mounted and memoized its lists. allBoards had frozen deps —
if the picker rendered first, overlay boards (M5Stack Core, Cardputer,
Pimoroni, C6) vanished for the whole session while their ONLINE ads were
already hidden; the component grid + ONLINE component ads flipped between ad
cards and real entries depending on who won the reload race (different SVG,
ONLINE badge appearing and disappearing, wrong hover thumbnail).
proBoardRegistry and ComponentRegistry.mergeComponents now bump a version and
notify subscribers; the picker's allBoards / filteredComponents /
visibleComponentAds memos key off those via useSyncExternalStore — same
contract as proRoutes and registerProExamples.
- The card preview creates the live element but only forwarded
defaultValues.value, so variants sharing a tag rendered identically (both
M5Stack Chain matrices showed the dark RGB housing — the mono flag never
reached the element). Forward every defaultValues entry, matching what
DynamicComponent assigns at placement.
- The hover datasheet panel sat at z-index 2000 while the picker overlay was
raised to 9000 (above the AI chat), hiding the popover behind the very
modal that summons it. Raise it to 9100.
Replaces the static hero-editor screenshot with the animated
estacion-meteorologica-esp32.gif (whole circuit executing). contain +
matching background so the full circuit stays visible instead of
cover-cropping it.
Boards with built-in hardware (LCD on the element's own canvas, speaker,
on-board buttons/keyboard) need run-time wiring between the DOM element and
the board's simulator shim / ESP32 bridge. One generic SimulatorCanvas effect
now hands those handles to the overlay's attachBuiltins shortly after run
start and runs its cleanup on stop — no board names in the OSS tree.
registerProExamples() now notifies useSyncExternalStore subscribers (same
contract as proRoutes): a direct URL to an overlay-registered example resolved
before the dynamic @pro import landed and stuck on the 404 branch. The three
example pages subscribe to the version counter.
registerProBoards() lets a hosted overlay ship boards outside the OSS tree as
data-only definitions: registration patches the exported BoardKind maps
(labels / FQBN / MicroPython) so every existing read site keeps working, and
the sites a map can't cover consult the registry — canvas render (custom
element or overlay render fn), BOARD_SIZE, pin-name mapping, picker list +
descriptions + tag, ESP32 family routing, in-browser simulator construction
and firmware load (structural ProBoardSimulator contract, duck-typed PIO
attach/detach), built-in bridge sensors, and a CS-gated built-in microSD
(sdCsPin -> sd_card.cs_pin worker config). Esp32Bridge additionally gains the
esp32-c6 machine type + TX pin (public chip knowledge — the C6 compile path
already ships) and a generic sendKey() for built-in matrix keyboards.
registerProExamples() appends gallery examples at runtime; the board ONLINE
ads recompute at render so registration hides them. OSS behavior without an
overlay is unchanged — the registry is dead code, same as the other seams.
Components that only exist in the hosted editor now advertise themselves in
the picker exactly like the online-only boards do: ONLINE_ONLY_COMPONENT_ADS
(same module as the board ads) renders an ONLINE-badged card that links to
velxio.com, auto-hidden in any build whose ComponentRegistry has the real
component (the hosted overlay merges it in — no per-build switches). First
entries: the M5Stack Chain RGB/Mono 8x8 matrices. componentDocs gains
registerComponentDoc(id, raw) so an overlay can supply the hover datasheet
for components it injects at runtime.
Gallery templates and agent-loaded projects can store element tag names
("wokwi-lcd2004") where the registry keys by the bare id ("lcd2004").
getById now falls back to the stripped id, so such a component renders
instead of sitting invisible in the store — a real agent session shipped
an LCD counter whose LCD existed in the store, was wired and validated,
and simply never appeared on the canvas.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of "el agente construye el reloj, dice que funciona, pero el
display queda en blanco hasta recargar la página" — diagnosed by driving
the live agent end-to-end and instrumenting the element:
The 7-segment part simulator caches its state (segments, digitValues,
digitEnabled) in a WeakMap keyed by the DOM element, sizing it from
element.digits at FIRST access. The agent builds incrementally: it adds
the display with the default digits=1 and only then sets digits=4 — so
the cached state was born in single-digit mode. Every later attachEvents
(compile bumps hexEpoch → re-attach with the finished wiring) kept
consulting the stale state: it subscribed COM.1/COM.2 (which don't exist
on a 4-digit part) instead of DIG1..DIG4, and because those resolvers DID
attach, the all-digits-on fallback never kicked in either. Result: no
digit ever enabled, no flush ever ran, values stayed a frozen 8-zero
array. A page reload "fixed" it because the fresh element mounted with
digits already 4.
get7SegState now compares the cached digit count against the element's
current value and rebuilds the state when they differ, so any re-attach
after a digits change subscribes the right pins.
Test: attach with digits=1 (COM subscribed), set digits=4, re-attach →
DIG1..4 subscribed, and a segment+digit pulse actually lights values[0]
in the 32-slot array.
Verified live: the exact agent prompt that produced a permanently blank
display now shows the multiplexed digits + blinking colon in-session,
no reload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stop-first guard in the Run button only fires when board.running is
true, but the Run button is DISABLED while a board runs — so by the time the
user can actually click Run, the board has already disconnected
(running=false) and the guard is a no-op. The failure lives one level down:
Esp32Bridge.connect() early-returned whenever a socket lingered in ANY
non-CLOSED state (CONNECTING/OPEN/CLOSING). The agent's run_simulation
leaves such a socket; when its backend QEMU session ends but the frontend
socket is still zombie, the user's Run → startBoard → connect() did nothing.
A page reload "fixed" it only by constructing a fresh bridge.
connect() now tears down any lingering socket (detaching handlers + close)
and opens a new one to the same session key — exactly what the reload does,
which is why the reload always worked. The backend already handles a new WS
replacing an existing session, so no reload is needed.
Test: connect() on an OPEN socket closes the old one and boots a fresh
start_esp32 (esp32-dht22-flow).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two issues from a real ESP32 7-segment clock the agent built.
Run after the agent didn't work until a page reload
---------------------------------------------------
The agent's run_simulation leaves the ESP32 board RUNNING (live QEMU
WebSocket). Esp32Bridge.connect() is a no-op while the socket is non-CLOSED,
so the user's subsequent Run click called startBoard() → connect() → did
NOTHING. And if the backend QEMU session had since died while the frontend
socket lingered (CONNECTING/OPEN/CLOSING), the user saw a dead sim that only
a reload cleared — exactly the "di Run y no funcionó; recargué y sí" report.
The Arduino/C++ QEMU path now stops a running board first (closing the WS),
waits for it to settle, then boots fresh — the MicroPython path already did
this for the same reason.
Wires painted over the 7-segment digits
----------------------------------------
The agent bridges each segment strip to its resistor from a breadboard hole
that is physically UNDER the seated display; on the flat canvas those wires
(wire layer z 35) painted over the digits (component z 1) — "casi ni se ven
los dígitos". A large-bodied display seated on a breadboard now renders
ABOVE the wire layer, so its face occludes the wires crossing it exactly as
the real part's body would (the wire passes behind it to reach the hole).
Scoped to display bodies (7segment, matrix, oled, lcd, ili9341, led-ring…)
and only when actually seated; thin parts and free-floating displays are
untouched. The pin overlay + seated-pin markers share the display's stacking
group, so they rise with it and wiring still works.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the reported breadboard wiring UX ("requerimos un vocabulario"):
Vocabulary implemented (breadboardOccupancy.ts, pure + unit-tested):
- 1 hole = 1 wire. A hole already holding a visible wire can't start a
new one — clicking it SELECTS that wire. This is the core fix: wires
running hole-to-hole across the board were impossible to select
because the pin overlays swallowed every click and silently started a
new wire (so the top horizontal rail wire was un-deletable).
- Same 5-hole strip / rail = one net. When a new wire end lands in an
occupied hole (a seated leg or another wire), it shifts to the
NEAREST FREE hole of the same group — electrically identical, the
real-world "bridge to the next hole in the row". Never crosses strips.
Two selection bugs behind the symptom:
- Click on a wire lying over the breadboard BODY now selects the wire
instead of opening the breadboard's 830-hole property dialog (that
list popping over everything was the "se sobrepone la lista de todos
los puntos" report). Guarded so the bubbled canvas click doesn't
re-toggle the fresh selection.
- Click on a hole occupied by a wire selects the wire (handlePinClick),
so wires anchored in holes are reachable at all.
Jumper colors (like a real kit — a board of identical green wires is
unreadable, "se ven todos verdes"):
- Power-rail holes mandate red (tp./bp. = +) / black (tn./bn. = −).
- Other breadboard holes get a random jumper-palette color on manual
draw; red and black are reserved for rails.
- jumperColorForId gives agent/deterministic callers a stable per-wire
color across reloads.
Tests: breadboard-occupancy.test.ts (12) — findWireAtHole (skips seating
wires, topmost wins), resolveFreeHole (same-strip shift, no cross-strip,
rail shift, passthrough), color policy (rails, palette determinism,
red/black reserved).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generic platform work ported from the internal line:
- Esp32BridgeFactory seam + rebuildEsp32Bridge + sync-I2C seam: a
substitute simulation bridge (e.g. the hosted editor's in-browser JS
emulators) can be installed without touching OSS code
- Component datasheets: hover popover (ComponentInfoPanel) + markdown
docs for common parts
- Per-chip S3/C3 basics examples for the gallery
- .gitignore: never allow pro emulator mask ROMs into the OSS repo
New: online-only board showcase. Boards implemented by the hosted editor
(ESP32-C6, M5Stack Core, Cardputer ADV, Pimoroni RP2350 family) appear
in the picker as advertisement cards with an ONLINE badge linking to
velxio.com, where they are free to use. Ads auto-hide in any build that
registers the real BoardKind.
Running a multiplexed 4-digit 7-segment clock on ESP32/QEMU froze the
browser for minutes after Run — evaluate probes waited 40-90 s, and before
the first fixes the sim WebSocket eventually died (code 1006) with the page
never recovering. CPU-profiled on staging; four compounding per-GPIO-edge
costs, in profile order:
updateComponentState minted a new components array per edge
------------------------------------------------------------
The store setter rebuilt `components` (and one properties object) on EVERY
edge even when the state didn't change. The breadboard is direct-wired to
13 board pins, so segment toggles produced thousands of store sets per
second; every subscriber re-rendered each time, and the canvas subscription
effect (deps: [components, ...]) re-subscribed all pin listeners in a loop.
Now a no-op guard returns prevState unchanged, and breadboards are treated
as self-managed (they have no visual on/off state to echo).
CompilationConsole re-rendered every log line per editor render
----------------------------------------------------------------
The post-compile console holds hundreds of lines; each render called
Date.toLocaleTimeString per line (~0.2 ms each — it builds a fresh Intl
formatter every call). Profile: 162 s of self time in LogLine over a 337 s
window, in ~150 ms tasks. LogLine is now memoized (entries are immutable),
timestamps go through one shared Intl.DateTimeFormat, and the console
itself is React.memo'd against parent re-renders.
Per-edge full SPICE re-solves
------------------------------
PinManager requested a FULL netlist rebuild+solve on every 'mcu' edge.
Now only the edge that newly classifies a pin as MCU-output triggers the
rebuild (that's what emits the pin's V-source); steady-state updates flow
through connectMcuEdgesToService's per-pin coalesced alterSource path.
The start.ts resolve hook is trailing-throttled (33 ms) for the other
per-edge callers (RP2040, custom chips), the service's pending-edge queue
drains on a 33 ms gap timer instead of replaying back-to-back, and new
edges arriving inside the gap queue instead of soloing a solve.
STM32 / Pi reverse pin-name mappings added to connectMcuEdgesToService so
those boards keep fine-grained updates now that the full-tick storm is
gone (PA0/PC13-style and GPIO-style names never matched before).
wokwi-7segment re-rendered per segment write
---------------------------------------------
element.values now flushes at most every 8 ms per display (trailing write
guaranteed), instead of re-rendering the 32-shape SVG per edge.
Also: CLN (colon) pin support for 7-segment clock faces — wired CLN now
drives colon/colonValue in both the attachEvents path and the QEMU
onPinStateChange path; it was silently ignored, so clock colons never lit.
Verified on staging with the failing project: main-thread probes drop from
40-90 s waits (324 long tasks, 52.6 s blocked in 150 s) to 5-11 ms
(2 long tasks, 179 ms), display shows 12:00 with the colon blinking at
1 Hz from the first seconds after Run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The registry's Pi Zero/1/2/3/4/5 component entries rendered the live
velxio-raspberry-pi-* element clipped to a sliver and carried no PRO
marker. Reuse the board illustrations keyed by tagName (Zero/1/2
intentionally share the Pi 3 art) and show the shared gold PRO pill on
any component whose id is a pro board kind (Pi Linux family + STM32).
The Pi 4/5 cards instantiated their live custom element at natural size
with a CSS scale; the transform keeps the unscaled layout box, so the
100px thumbnail clipped the board to a narrow vertical sliver. Use the
existing board illustration PNGs with objectFit contain, same as Pi 3.
The PRO badge on gated boards grows to a readable pill with a drop
shadow.
Three router bugs found by replaying a real agent session (reloj_3333) where
wires ran straight across a seated 4-digit display. Each fix is covered by a
regression test built from the failing geometry.
Endpoint inside an obstacle no longer drops the whole obstacle
--------------------------------------------------------------
Breadboard strips under a seated display start INSIDE its inflated bbox, so
the "rects containing an endpoint are dropped" rule deleted the display as
an obstacle for every wire leaving those strips — 15 wires crossed it end to
end. The rect is now carved instead: an escape corridor (ROUTE_MARGIN wide)
from the endpoint to the chosen edge, with the rest of the body still
blocking. Side blocks overlap the endpoint's row by 1px, or the strict
segment-hit test leaves the row as a free seam straight across the body.
Overlapping rects escape in ONE shared direction
------------------------------------------------
Seated resistors overlap heavily (19px pitch, ~66px inflated boxes). When
each containing rect picked its own nearest edge, the corridors pointed
different ways and walled each other off — A* found no exit, fell back to
the direct elbow, and the wire crossed the display anyway. The escape
direction is now chosen once against the UNION of containing rects and
every carve uses it, so the corridors chain into a continuous exit.
Null route materialises the CHECKED elbow
------------------------------------------
routeAroundObstacles returns null when the PREVIEW elbow (longer-axis-first)
is clear — but the re-route pass stored empty waypoints, which the renderer
expands as the horizontal-first corner: a DIFFERENT elbow the router never
validated. Three wires shipped crossing a display whose checked route was
clean. The pass now materialises previewElbow explicitly, exactly like
finishWireCreation always did.
Verified E2E: the same agent prompt that produced 15 crossings now builds
the ESP32 clock with ZERO wire segments crossing the display body.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the existing component-avoiding A* (wireAutoRoute.ts) into the full
auto-router the canvas was missing. Three pieces:
Wire avoidance with soft costs
------------------------------
Component bodies stay hard-blocked, but wires get graded costs: running
parallel on top of another wire (within an 8px corridor) is charged per px,
a perpendicular crossing costs a small fixed amount, and bends keep their
existing penalty. Crossings must stay possible — hard-blocking wires makes
dense boards unroutable and everything would degrade to the default elbow.
The compressed grid gains "corridor" coordinates 8px to each side of every
wire segment, so the router actually has a lane to run BESIDE a wire; that
is also what lays multi-wire runs out as a tidy side-by-side bus, since
each new wire routes seeing the previous ones. Wires sharing an endpoint
with the route are exempt (wires meeting on a pin must touch there), and
only wires within 120px of the route's bbox participate, keeping the grid
under the coordinate cap on dense canvases.
autoRouted: the system owns the shape until the user takes it
-------------------------------------------------------------
New Wire flag, set by pin-to-pin creation and by agent add_wire. Every
shape-editing gesture (segment drag, waypoint drag, waypoint insert — five
call sites) clears it: from that moment the wire is hand-authored and is
NEVER re-shaped, exactly where the user put it. Wires from older projects
have no flag and are treated as hand-authored.
recalculateAllWirePositions re-routes flagged wires after endpoints move
(component drag end, agent batches, mount settle — never per drag frame).
This is also what routes agent wires at all: they are created before their
elements mount and before pin coords are final, so creation-time routing
is impossible; the settle-timer recalc routes them once geometry is real.
Live routed preview
-------------------
updateWireInProgress routes start->cursor (throttled to 40ms) and the
preview renders that path, so the wire dodges components and wires AS THE
MOUSE MOVES instead of snapping into shape on the final click. Hand-guided
previews (user-placed waypoints) keep the classic path untouched.
Verified in the live app: an agent-built breadboard circuit shows 0 wire
overlap px and 0 body crossings across all wires, and a hand-started wire
aimed collinear with an existing run previews 21px beside it, overlap 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An ESP32 clock built by the agent stayed dark while QEMU was verifiably
emitting hundreds of GPIO edges per second (437/pin measured on the live
websocket). Reload did not help — this was not the seating race. Two
independent tracing bugs, reproduced from the real project circuit (fixture
included) and each sufficient to kill the display:
Boards added at runtime were invisible
--------------------------------------
isBoardComponent matches static id prefixes ('arduino-uno', ...), which only
covers the default board. Every board added at runtime gets a minted UUID id
— the agent's add_board always does — so traceDetailed treated the board
endpoint as an unknown component and resolved null, and SimulatorCanvas's
direct-wire subscription path skipped it entirely. Every Uno project happened
to work because they reuse the default board whose instance id IS the literal
'arduino-uno'. Both sites now consult the live boards list first, keeping
isBoardComponent as the legacy-id fallback.
Strip walking missed wires stacked on one hole
----------------------------------------------
The breadboard group walk continued the trace from every OTHER wired hole of
the strip, excluding the arrival hole by name. But two wires may legitimately
share one hole — the agent bridges strips straight into the seat hole (8 of
this circuit's 9 bridges land exactly on a resistor's own hole), which is
electrically identical to using a free hole of the strip. The name exclusion
made those junctions dead ends. Exclusion is now by incoming WIRE id, so
same-hole connections resolve; the depth bound already prevents ping-ponging
between two wires of one net.
With both fixes the exact saved circuit resolves every display pin to its
GPIO (A..DP -> 32,33,25,26,27,14,12,13; DIG1..4 -> 15,2,4,5; COM -> GND) and
the live project now shows 12:00 on the real QEMU simulation. traceDetailed
is exported for the regression test, which drives the real store with the
real circuit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ESP-IDF compile path stages exactly the libraries declared in the
example (no transitive resolution), so Adafruit_GFX.h failing to find
Adafruit_I2CDevice.h broke esp32s3-ili9341-hello and esp32-oled-4pin-i2c
with 'Compilation produced no firmware'. The other ESP32 GFX examples
(esp32-oled, esp32-bmp280) already declare BusIO explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A part can land in the store at its FINAL position before its element
mounts: the agent streams add_component and the seating move in one batch,
and updateComponent's reseat then finds no DOM (computeSeating null) and
keeps the empty seating. Nothing re-derived it afterwards — the agent-side
seat correction skips when the position needs no nudge, and 'pininfo-change'
only fires on pin-SET swaps, not on plain init. Meanwhile run_simulation
executes right after the SSE round, before the correction's animation frame.
Net effect, reported by a user as a suspicion that turned out exactly right:
a clock the agent built and ran in one turn showed a dead display, while
reloading the project and running it worked — bb seating wires are persisted,
so on reload they exist before Run is pressed.
DynamicComponent now reseats once the element's pinInfo first becomes
measurable (same polling cadence as the pinInfo-ready effect), which closes
the hole for every path that stores a final position before mount: agent
batches, project load, undo. To keep that free on load,
reseatComponentOnBreadboard skips the store write when there is nothing
seated and nothing to clear — otherwise every off-board part would churn the
wires array identity once per mount.
Verified live end-to-end: agent adds + seats + wires + compiles + RUNS in a
single turn; the seated LED blinks immediately (4 transitions sampled), with
all 4 seated-pin markers present — no reload needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seating is otherwise invisible — a seated pin connects to its hole through a
zero-length `bb` wire that never renders — so a user couldn't tell a part
that merely sits ON the board from one whose pins are actually connected.
This was reported after placing parts that looked seated but gave no signal
they were wired in.
SeatedPinMarkers draws a small always-on green dot (Wokwi-style) on each pin
that has a `bb` wire, derived once per render from the store's wires
(component pin = wire start). Non-interactive layer below the wire-target
hit boxes; only breadboard-seated pins light up, so board-wired builtins stay
unmarked — exactly the "seated vs connected" distinction that was missing.
The per-pin rotation math (rotate about the wrapper centre, which the overlay
layers live outside of) is extracted from PinOverlay into a shared
`rotatePinLocal`, so the dots and the wire-target boxes can never drift apart
under rotation. A test asserts rotatePinLocal agrees with calculatePinPosition
at 0/90/180/270°.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent computes exact hole assignments server-side but can only send an
approximate canvas x/y, because the rotation pivot is the DOM wrapper centre
and the wrapper includes a text label the server cannot measure. Under
rotation that left seated parts off by up to ~4 px — enough that a diode
(pins 7.5 pitches apart) half-seated: computeSeating found no hole for the
far pin and it went electrically dead.
resolveSeatPosition corrects it in the browser by pure translation: read
where the anchor pin actually is from live DOM geometry (real pivot), read
where the solver put it, shift the whole part by the difference. Every other
pin follows because pin-to-pin offsets are pivot-free. It never re-solves, so
it cannot slide the part to different holes and the validated netlist holds.
The anchor target is the solver's anchor position in breadboard-element
space, WITH its sub-pitch centroid translation — not the hole centre.
Targeting the centre would re-break the diode (far pin 4.8 px out). Verified
against real rendered geometry in a browser: resistor and diode at 90° both
seat within the intrinsic lattice residual (0.6 / 2.4 px).
Applied via a `seat` payload on the move_component effect (velxio-prod
overlay); this commit is the resolver + tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three changes, all driven by a real project where a 4-digit 7-segment clock
was unreadable and half its parts were not actually seated.
Labels on hover only
--------------------
Eight vertical resistors at 19 px pitch rendered eight 93 px "Resistor 220 Ω"
labels on top of each other, hiding the parts and the breadboard holes; the
SPICE overlay added ~40 more `0uV` pills. Both are now revealed on hover:
hovering a part also lights up the voltages of every wire touching it.
The label is hidden with OPACITY and stays in flow. pinPositionCalculator
derives the rotation pivot from wrapper.offsetHeight, so taking it out of
flow would move the pins of every rotated component in every saved project.
Seat-on-drop
------------
The drag-time magnet only aligned the anchor pin and assumed the rest
followed, which is how parts ended up HALF-seated: some pins in holes, the
rest dead in the air. It looks mounted in a screenshot and silently breaks
the circuit. On release we now re-solve properly — nearest position where
EVERY pin is in a free hole, sliding past occupied columns — via the new
solvePlacement/seatOnDrop. Geometry comes from the element's own pinInfo,
so there is no part whitelist.
Sub-pitch translation
---------------------
solvePlacement first assigned pins to holes at half-pitch, then translates
by the centroid of the residuals before judging fit. Pinning the anchor dead
centre refused every off-lattice footprint: a diode spans 7.5 pitches, so
one leg landed 4.8 px out. Shifted 2.4 px, BOTH legs sit inside tolerance —
what bending the leads does on a real board. Measured over the catalog this
takes seatable parts from 87 to 125 of 152; diodes, transistors, regulators,
optocouplers and flip-flops are rescued with no artwork change.
Staying under SEAT_TOLERANCE (< half pitch) keeps each pin's nearest hole
unambiguous, so computeSeating resolves the same holes and the netlist is
unaffected by the small offset.
Also: refuse a placement that would put two of a part's own pins in one
strip. A column strip — and far worse, a power rail — is a single net, so
such a seating shorts the part to itself. Without it a 7-segment happily
lays its pins across a rail. And deduplicate pin names before solving:
calculatePinPosition resolves by name and returns the first match, so a
board carrying GND x5 collided with itself and was refused outright.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of the 'digits=4 display seated with the 1-digit COM pinout'
bug: property values arrive as STRINGS (agent set_component_property,
the property dialog's text inputs) and were assigned to the web
component verbatim — wokwi's 7segment does switch(this.digits) with
numeric cases, so el.digits='4' silently fell back to the 1-digit
pinout (and 'false' stayed truthy for boolean props like colon).
- DynamicComponent now coerces string values to the TYPE of the
metadata default for that key (number/boolean) before assigning.
- New pininfo-change listener: when a property swaps the element's pin
set (digits, flip, pins edge), the elements announce it — re-derive
the breadboard seating then, with the fresh pinout, instead of never.
Every resistor variant ('resistor' + 'resistor-<value>') now lands on
the canvas rotated 90 degrees: reads better, takes less horizontal
space, and drops straight into breadboard columns. Explicit rotations
in metadata defaults are respected. The breadboard auto-vertical drag
check widens from the two-entry set to the same prefix predicate, so
preconfigured variants (resistor-330 etc.) rotate on the board too.
Parts now plug INTO the breadboard instead of using it as a junction box:
- Drag magnetism: while dragging, the part's anchor pin snaps to the
nearest hole center (9 px range, 9.6 px grid) so parts land perfectly
aligned, like Wokwi.
- Seating: every pin within 4 px of a hole gets an invisible zero-length
wire (Wire.bb) from pin to hole — the exact model Wokwi persists as
["r1:1","bb1:6t.b","",["$bb"]]. Electrically they are ordinary
wires, so the netlist builder, digital trace and SPICE need zero
changes; they are simply not rendered and not hit-testable. Seating
re-computes on every move/rotation (updateComponent), and moving the
breadboard carries its seated parts along.
- Resistors auto-rotate to vertical when dragged over a breadboard
(their 58.8 px pin span bridges the center trench rows b-f exactly).
- Seat tolerance 4 px: absorbs the worst element pin-spacing residual
(~1.6 px) while staying under half the hole pitch, so a pin is never
ambiguous between holes.
Wokwi interchange fixes that fell out of the diagram.json research:
- import maps the top-level rotate attr onto properties.rotation
(previously every rotated part imported flat) and export emits it
back as rotate instead of leaking it into attrs;
- $bb / empty-color connections import as bb seating wires and export
back as ["$bb"] entries, so parts-on-breadboard projects round-trip;
- wokwi-breadboard-half aliases to the full breadboard (hole names are
a strict superset, so every connection stays valid).
Breadboard elements now export their pure hole grids and import cleanly
without a DOM (node tests); geometry + store seating covered by
breadboard-snap.test.ts and breadboard-seating.test.ts.
Three stale-project-identity fixes from reviewing the New-workspace flow:
- New workspace (web): handleNewClick cleared the workspace and the
current project but left the browser on the old /user/slug URL — a
refresh (or back-button pop) silently reloaded the OLD project over
the fresh unsaved workspace. Now replaceState's to the localized
/editor (replace, not push, so no back-entry points at the stale
project route).
- New workspace (desktop menu): same URL fix for the newProject menu
action, which cleared identity but never left the project route.
- .vlx import: importVlxFile mutated the stores WITHOUT clearing
currentProject — with a saved project open, autosave saw the
imported content as dirty edits on the old projectId and silently
PUT the .vlx contents over the user's saved project (and pushed the
clobber to GitHub on linked projects). Now severs identity first,
same guard loadExample.ts already documents.
verifyCircuitFromStore() builds the worst-case snapshot (every wired
digital pin driven HIGH) and solves it — extracted verbatim from
EditorToolbar's runVerification so programmatic runners (editor
extensions, agents) can gate their own run paths on the same rules.
No behavior change for the Run button.
Creating a wire with a direct pin-to-pin click (no user waypoints) now
routes around other components' bounding boxes instead of crossing
them. Routing happens exactly once, at creation: the routed corners are
stored as ordinary waypoints, so every later manual edit stays where
the user puts it — never re-routed.
Router (utils/wireAutoRoute.ts):
- tries the preview elbow first (clear -> keep existing behavior and
the WYSIWYG shape), then the opposite elbow, then A* over the
compressed grid spanned by pin coordinates and obstacle edges
inflated by an 8 px clearance, with a 40 px per-bend penalty so
straighter routes win
- obstacles are component boxes only (never boards — pins sit on both
board edges and detouring around a board produces absurd routes),
excluding the wire's own endpoint components, measured from the
rendered DOM; rects containing an endpoint are dropped
- any failure (walled-off target, oversized grid, no DOM) falls back
to the previous direct-elbow behavior
Hand-aligning a dragged segment could leave two parallel runs a pixel
or two apart, joined by a tiny perpendicular step, because alignment
snapping only ever targeted OTHER wires' geometry.
- Segment and bend-point drags now also snap (6 px threshold) against
the dragged wire's own points — excluding the ones being dragged —
so a run clicks into line with its neighbour and the exact
simplification fuses them into one segment on commit.
- fuseMicroJogs: parallel runs offset by under 2 px joined by a tiny
step are aligned automatically (the run not anchored to a wire
endpoint moves; shorter run yields when both are free). Applied at
render time and in renderedToWaypoints/normalizeWireWaypoints, so
already-saved crooked wires display straight without touching data.
Three wiring quality fixes:
- Rounded corners: every bend now renders as a quadratic curve
(radius 7, clamped to half the shorter adjacent segment), with
round line caps/joins. Segment/waypoint drag previews and the
in-progress preview use the same path builder so the look is
consistent everywhere.
- Degenerate geometry cleanup at render time: the expanded polyline
is simplified (duplicates, collinear runs, U-turns) before the
path is emitted, so wires saved with junk waypoints no longer
render on top of themselves. Stored data is untouched until the
user edits the wire.
- WYSIWYG commit: finishWireCreation materialises the final-leg
elbow exactly as the live preview drew it (longer axis first) and
normalises the stored waypoints. Previously the committed wire
fell back to horizontal-first and visibly changed shape on click.
simplifyOrthogonalPath moved to wireUtils (re-exported from
wireHitDetection for existing imports); the duplicated inline
expansions in SimulatorCanvas now use the shared helper. Waypoint
dots on idle wires removed (visual noise); endpoint dots stay.
Monaco's focus sink is a plain div (.native-edit-context under the
EditContext API), neither an input tag nor contentEditable, so the
typing guard missed it and a mapped letter typed into the code editor
pressed the button. Treat any keydown originating inside .monaco-editor
as typing.
Any pushbutton (pushbutton / pushbutton-6mm) can now be driven from the
keyboard. Assign a key from the component property dialog — a keycap
control captures the next keypress (Escape cancels, modifiers alone are
rejected) — and a keycap badge next to the component label shows the
mapping on the canvas. Several buttons may share one key on purpose;
the dialog shows a hint when that happens.
At runtime a global bridge translates keydown/keyup into the same
button-press / button-release DOM events the mouse fires on the wokwi
element, so every simulation path (avr8js pin logic, SPICE-driven
inputs, the QEMU GPIO bridge, the pressed visual) behaves identically
to a mouse click. Guards: ignored while typing in inputs or the code
editor, ignored with Ctrl/Alt/Meta held, auto-repeat collapses into one
long press, and window blur releases everything so no button sticks
after Alt-Tab.
The binding is stored as the component's 'key' property, so it
round-trips through project saves and .vlx exports and is undoable like
any other property edit. Strings added to all 9 locales.
Convert the remaining window.confirm() call sites to the in-app
MessageDialogHost, extended with a new confirm mode (Cancel + Confirm
buttons, optional danger styling) via showConfirmDialog().
Sites converted:
- New workspace (EditorPage)
- Load project / delete file (FileExplorer)
- Overwrite SPIFFS file (BoardOptionsModal)
- Delete VFS node (VirtualFileSystem)
All dialog strings are internationalized across the 9 supported locales
(en, es, pt-br, it, fr, zh-cn, de, ja, ru); the two previously
English-only modals now pull from i18n too.
A breadboard is the physical base of a circuit — boards, components and
wires all plug into it — so it should never cover them. Pin its group at
z-index -1 (below boards z 0, components z 1/2, wires z 35), ignoring
selection. Detected by metadataId prefix 'breadboard' (breadboard,
breadboard-mini). Its own pins stay wireable wherever it's not covered.
The dense-component threshold (>60 pins) meant boards like the Arduino
(31 pins) still painted every pin blue on hover — a wall of squares. Drop
the threshold: every component/board now keeps its squares invisible and
lights up only the ONE under the cursor (matching the breadboard, which
users already liked). Wiring mode still paints them all — all valid targets.
Removing isActive from board showPins (prev commit) exposed a latent bug:
BoardOnCanvas put onMouseEnter/onMouseLeave on the drag overlay, a SIBLING
of PinOverlay. Moving the cursor from the overlay onto a pin square fired
the overlay's mouseleave, cleared hoveredBoardId, and hid every pin right
as you reached one — so a board pin could never be clicked to start a wire
(breadboards were fine: their group wrapper owns both body and pins).
Move the hover handlers to the wrapper div that contains the board body,
the drag overlay AND the pin squares, so moving among them never fires
mouseleave. Board dragging (onMouseDown on the overlay) is unaffected.
Three UX fixes to the pin squares:
- Pins show on hover or while wiring only. The active board and the
selected component used to light every pin permanently.
- Dense components (>60 pins — breadboards) don't paint a wall of blue
on hover: squares stay invisible and light up individually under the
cursor. While a wire is in progress every square paints again since
they're all valid targets (new `wiring` prop threaded to PinOverlay).
- mousedown on a pin square stops propagation, so press-and-drag from a
pin no longer pans the canvas.
Two stacking bugs:
1. Pin overlays used a global z-index 30 while component bodies sit at
z 0-5, all in .canvas-world's single stacking context — so a covered
component's pins painted on top of whatever covered it (arduino pins
showing through a breadboard). Each component/board group is now a
zero-size positioned wrapper that forms its own stacking context
(boards z 0, components z 1, selected z 2): pins stay above their own
body but are hidden together with it. .components-area becomes
pointer-events: none so board pins/drag overlays (now trapped at z 0)
keep receiving clicks through it; component groups re-enable their own.
2. The Add Component / board picker overlays (z 1000/2000) rendered
behind the pro AI chat panel (z 8000). Both now portal to <body> at
z 9000.
Verified in-app: board drag, component drag, wire creation from board
pin to LED, covered pins hidden (0/14 leak), covering component's pins
still clickable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Sign-in links navigate with a full page load (they mount in a
separate React root without Router context), which wipes the in-memory
Zustand workspace. New utils/workspaceDraft stashes the whole workspace
(reusing the lossless .vlx serialisation) to sessionStorage before that
navigation and restores it once when the editor remounts after login —
so a user who was building a circuit and signs in lands back on their
work instead of the empty starter board.
Strictly scoped to the login round-trip by a one-shot restore flag (not
a general autosave), and skipped when a named project is already loaded
so it never clobbers one. EditorPage calls restoreStashedWorkspace() on
mount; the pro overlay's auth links call stashWorkspaceForAuth() before
navigating.
useMessageDialogStore + <MessageDialogHost /> (mounted once in App.tsx)
give a themed in-app dialog callable from anywhere — React components
and plain .ts modules alike via showMessageDialog(msg, {kind}). Swaps
the native alert() calls in FileExplorer (import errors) and the
desktop menu (.vlx open errors, updater status) for it; the pro overlay
can reuse the same store.
The 'Create with AI' button referenced the Pro AI agent (hardcoded
prompt, agent event) inside the anonymous OSS dialog — chat/agent logic
must live in the velxio-prod overlay, not here. CustomChipDialog now
just exposes a generic `data-velxio-slot="custom-chip-actions"`
extension point (empty in OSS) and hangs its close handler on the slot
element so the overlay can dismiss the dialog after acting. The button
itself, its prompt and the Pro entitlement gate move to the overlay.
- 'Create with AI' button in CustomChipDialog dispatches the generic
velxio:agent-prompt window event (no-op without a listener — the pro
overlay's chat panel picks it up and prefills the composer).
- chipCompileService maps the hosted deployment's 401 gate to a human
'sign in to compile custom chips' message instead of a raw status
line. Self-hosted OSS keeps the route open and never sees either.
Registers upstream @wokwi/elements, velxio-elements/ and the element
classes living next to their React wrappers in velxio-components/ —
without pulling any React component graph. Used by pin-metadata
introspection in tests/generators (the pro agent's metadata export).
Google's auto-generated sitelinks for "velxio" surfaced docs/blog pages
but not the Editor (the primary app) or Home. Sitelinks can't be forced,
but an explicit SiteNavigationElement ItemList naming the primary nav —
Editor, Examples, Documentation, Pricing, About, in that order — is the
recognized structured-data hint for what the site's main sections are.
The examples page showed only ~3 cards per row (the grid was capped at
max-width 1200px with minmax(300px) columns), a tall header, and three
stacked filter rows (16 board tabs + category + difficulty) that pushed
the actual examples far down the page. Card thumbnails also letterboxed
with black bars on the sides.
- Grid: widen to 1680px + minmax(232px) columns → ~6 cols on a 1600px
screen (was 3). Cards smaller/denser (radius, info padding, title).
- Thumbnails: fixed 5:3 aspect-ratio container + object-fit:cover so the
preview fills edge to edge — no more black side bars.
- Filters: replace the search row + 16 board tabs + two button rows with
ONE compact toolbar (search + Board/Category/Difficulty dropdowns +
live count) and removable filter chips (badges with ×) + Clear all.
- Header trimmed (smaller title, less margin) so cards start high.
The red viewport rectangle read the React `pan` prop, but the canvas
pans by mutating panRef + the .canvas-world transform directly (no
setState until pointer-up, for zero-lag dragging). So while you dragged
the canvas the rectangle sat frozen and only jumped at the end. The
minimap now also receives panRef/zoomRef and mirrors them via a
requestAnimationFrame loop that setStates only on change, so the rect
follows the canvas every frame while keeping the canvas render-free
during the gesture.
Also: boards were all drawn as one fixed 120x90 world rectangle
regardless of the actual board, misrepresenting the layout. Use the real
per-board BOARD_SIZE (now exported from BoardOnCanvas) so each footprint
is proportional. And enlarge the map 100x75 -> 160x120 (same 4:3 as the
4000x3000 world) so it's usable.
The profile Duplicate dialog passes the chosen name, description and
visibility to POST /projects/{id}/duplicate. Options are optional — an
empty call still clones with the source name + " (copy)".
Pairs with the pro-overlay endpoint that clones a project (row + file
groups) into the caller's account. Lives next to deleteProject — the
OSS client already fronts the pro-only projects API when overlaid.
New optional `featured` metadata flag: ComponentRegistry stable-sorts
featured components to the front after loading (and indexes categories
from the sorted list, so per-category views keep the same order). The
two breadboards are marked featured in component-overrides.json — they
are everyday parts and now open the component grid instead of sitting
at the bottom below every diode.
Swap the programmatic SVGs for the real Fritzing breadboard art
(breadboard2.svg / miniBreadboard.svg from fritzing/fritzing-parts),
served from /component-svgs/fritzing/ and scaled x4/3 so the hole pitch
is the wokwi-standard 9.6 CSS px. pinInfo is computed from the measured
Fritzing hole grid (terminal col 1 at x=10.92, rails at x=25.33 in
5-hole groups; wokwi rows a-e map onto fritzing J..F on the full board,
1:1 on the mini; the red stripe marks the + row of each rail pair), so
wire endpoints land exactly on the drawn holes. The element reserves its
final size immediately and falls back to a light programmatic SVG with
identical geometry if the asset can't load. ATTRIBUTION.md records the
CC-BY-SA 3.0 license of the two SVG files.
Verified in the app: fritzing art renders for both boards, the LED
circuit through full-board column + mini column + ground rail still
lights, SPICE overlay shows the merged nets (5.00V on the pin-8 net).
Two velxio-native passive parts, rendered as web components with
programmatic SVG + precomputed pinInfo (velxio-breadboard 830 holes,
velxio-breadboard-mini 170). Pin names follow the Wokwi convention
(holes `18t.d` / `17b.i`, rails `tp/tn/bp/bn.N`) and the metadata ids
are `breadboard` / `breadboard-mini`, so wokwi diagram.json zips
import/export with no aliasing.
Internal connectivity (5-hole column strips, full-length power rails)
is centralized in utils/breadboardNets.ts and wired into every net
consumer:
- NetlistBuilder: unionBreadboardGroups joins wired holes per group at
the union-find level in buildNetlist, buildWireNetMap and
buildBoardPinNetMap — SPICE, the circuit verifier and the voltage
overlay all see one net per strip/rail with no extra cards.
- DynamicComponent.traceDetailed: the digital trace hops through every
other wired hole of the entered group, so parts wired through a
breadboard still resolve their board pin (2-terminal
PASSIVE_PIN_PAIRS could not express N-hole groups).
Verified end-to-end in the app: Uno pin 8 -> full-board column ->
resistor -> mini-board column -> LED -> ground rail -> GND lights the
LED, and the HUD shows the 3 collapsed SPICE nets. 8 new unit tests
(breadboard-nets.test.ts); netlist-builder + circuit-verifier suites
stay green.
Hardware-SPI (FSPI/GPSPI2) ILI9341 draw from an ESP32-S3: fill + rounded
rect + text via Adafruit_GFX/ILI9341. Exercises the S3 GPSPI2 controller
end to end (worker SPI stream + DC/CS/RST -> the canvas TFT decoder).
When addBoard promotes a board to active (first board, or the previously
active one was removed) it set activeBoardId without syncing s.simulator,
unlike setActiveBoardId which sets both. Parts that read s.simulator - SPI
displays (ILI9341) attach spi.onByte to the active simulator - then wired
onto the previous board's bus and never received data, so a boards[] ESP32
example with a TFT rendered black. Sync simulator to the promoted board
(no-op when the active board is unchanged).
Adds `velxio-ssd1306-i2c-4pin`, a native 4-pin SSD1306 OLED module
(GND/VCC/SCL/SDA) — the cheap 0.96" I2C board most beginners actually have,
matching Wokwi's board-ssd1306. The 8-pin `wokwi-ssd1306` breakout stays; this
is the distinct 4-pin part (issue #215). Same SSD1306Core render pipeline
(imageData/redraw) so the display paints identically; I2C-only, address via the
i2cAddress property (default 0x3C). Styled after the existing 8-pin element
(blue PCB, dark screen, corner holes, star).
Ships four "SSD1306 OLED (4-pin I2C)" gallery examples wiring it over I2C on
Arduino Uno (A4/A5), ESP32 (21/22), Raspberry Pi Pico (GP4/GP5) and STM32 Blue
Pill (PB7/PB6).
English is the default locale and is served at the root with no prefix, so
/en/project/x (a natural guess by analogy with /es/, /zh-cn/, ...) matched no
route and rendered blank. Redirect /en/* -> /* (and /en -> /), preserving query
and hash, so those URLs land on the right page while the canonical prefix-free
English URLs stay put for SEO. The other 8 locales already work under their
/<locale>/ prefixes.
Follow-up to the SSD1306 picker consolidation. All 68 saved projects that used
the retired ssd1306-i2c / ssd1306-spi ids have been migrated to the single
`ssd1306` (metadataId rewritten, protocol pinned), so the simulation aliases
are no longer needed and are removed.
- Auto-detect refined to CS-only: chip-select is the SPI-exclusive signal;
DC does NOT imply SPI (on the 8-pin module DC doubles as the I2C address /
SA0 line, so many I2C circuits wire it). Fixes false-SPI on those circuits.
- The `ssd1306` part honors an explicit `protocol` property when present
(migrated legacy projects carry it) and auto-detects otherwise.
- loadProjectState normalizes any lingering ssd1306-i2c/spi ids (old .vlx
files, pre-migration snapshots) to `ssd1306` + the matching protocol, so
removing the aliases can never blank an old import.
The SSD1306 was three picker entries — a generic `ssd1306` with a protocol
selector plus `ssd1306-i2c` / `ssd1306-spi` shortcuts (issue #101) — all the
same 8-pin wokwi-ssd1306 element. That is confusing for one physical module
(issue #215). Wokwi ships a single I2C-only part; this goes one better: a
single part that auto-detects the protocol from the wiring, like a real
breadboard — CS or DC wired to a GPIO means SPI, otherwise I2C. No protocol
switch to set, just wire it up.
Works on every board with an I2C/SPI bus (AVR, RP2040, ESP32 Xtensa, STM32).
The ssd1306-i2c / ssd1306-spi ids stay as backward-compat simulation aliases
for projects saved before the merge, but are removed from the picker. Adds an
i2cAddress property (0x3c/0x3d) matching the real module and Wokwi.
Note: ESP32-C3, Raspberry Pi 3 and the bare RISC-V board do not emulate I2C/SPI
peripherals, so no I2C/SPI device (this or any other) attaches there yet.
Esp32C3Simulator already had the GPIO_IN plumbing (setPinState -> gpioIn ->
GPIO_IN_REG read) but never opted into connectDigitalInputsToMcu, so a pin
wired to a switch/button was never fed the solved circuit voltage and
digitalRead() ignored the real wiring. Enabling the flag (as AVRSimulator and
RP2040Simulator already do) completes the issue #247 fix: the ESP32-C3 now
reads GPIO2 from the SPICE solve, so toggling the slide switch flips the LED.
BasicParts' button/slide-switch seed already yields to spiceDriven(), so there
is no double-drive.
The slide-switch SPICE model only wired pin 1 <-> pin 2 (an SPST), ignoring
pin 3. The part is really an SPDT whose common wiper (pin 2) selects pin 1 at
value=0 or pin 3 at value=1, so a switch wired GND-1 / signal-2 / VCC-3 (the
natural Wokwi hookup) could never pull its signal high. Fixes the reported
ESP32-C3 case (issue #247) where only the green LED lit and the switch never
toggled the red one.
Second cause on that board: the ESP32-C3-DevKitM-1 exposes its supply as
3V3.1/3V3.2 and 5V.1/5V.2 (there is no bare 3V3/5V pin). VCC_PIN_RE has no
numeric-suffix branch on purpose (a dual-supply pin such as L293D VCC2 must
not collapse onto the shared logic rail), so those numbered pins floated at
0 V and the switch's HIGH side was dead. List them in boardPinGroups for
esp32-c3 / esp32-s3 / esp32-cam.
- componentToSpice: SPDT emission (both throws, complementary 0.01/1e9 R).
- digitalGateEngine: both driveSwitch paths (all-digital + mixed) made SPDT to
match, so the pure-digital paint and the ngspice solve agree.
- examples-digital / examples-circuits: rewire every slide-switch so the rail
feeds pin 3 and pin 1 is the value=0 throw, preserving value=ON=HIGH.
- spice-slide-switch-spdt-repro test reproduces issue #247 at the netlist level.
The top bar packs three zones onto one row: the view-mode toggle, the editor
actions (Compile/Run/Stop/...), and the canvas controls (board selector,
Serial, Scope, zoom, Add) portaled in from SimulatorCanvas. The editor zone
was flex:1 min-width:0 while the canvas zone was fixed-width, so when the bar
narrowed - mainly when the right-docked AI chat opens - the editor zone shrank
below its own buttons and painted them over the board selector / Serial /
Scope. The existing collapse logic was keyed to the viewport (@media 768px),
so it never fired on a wide screen with the chat open.
Make the shared bar a container-query context and collapse every zone by the
bar's own width instead of the viewport: view-mode labels drop to icons first,
then Serial/Scope/Add labels and the board selector ellipsizes, then the
component count and finally the zoom buttons (wheel-zoom still works). Floor
the editor zone at its collapsed content width so it can never underflow and
overlap; past that the lower-priority canvas controls yield toward the right
edge instead. Verified across bar widths 660-1140px with the AI chat open:
overlap eliminated, dropdown menus still render un-clipped.
The file-tabs strip in the toolbar center duplicated affordances that
already exist elsewhere: the file it showed is selected in the left file
explorer, and its board-owner label duplicated the board selector combo.
It also ate horizontal space and crowded the action row on narrow panes.
Remove the FileTabs component entirely; the left explorer is now the single
place to switch files. The toolbar center slot stays as an empty flex
spacer so the right action group remains pinned to the far right.
In a multi-board project the wired boards are one system, so running just
the active board almost never matches intent (a cross-wired UART pair only
comes alive when both run). The primary Run button now runs ALL boards when
there is more than one, with a split caret-menu to still run only the active
board. Single-board and board-less behaviour is unchanged, and the separate
Run-All double-triangle button is kept only for board+chip / chips-only
projects where the primary Run is not already a run-all.
The esp32-blink-led example wired the external red LED straight from GPIO4
to the anode with no current-limiting resistor. Add a 220 Ohm resistor in
series (GPIO4 -> R -> LED anode -> GND) so the example models correct
practice and matches the other LED examples.
WebSocket-backed boards (ESP32, STM32, Raspberry Pi) reach the electrical
simulation only through PinManager.triggerPinChange, which updated the pin
state + notified listeners but never requested an electrical re-solve. AVR
and RP2040 already resolve at their own toggle sites. As a result an analog
part on an MCU-driven net (e.g. a resistor-less LED whose brightness comes
from the SPICE solve) stayed at its first solved value until unrelated
activity (such as serial output) forced a solve — so an ESP32 blink with no
Serial in loop() left the LED stuck on.
Request an electrical re-solve after an 'mcu'-sourced pin edge, in one place
(triggerPinChange), covering all WS boards. Gated to source==='mcu' so the
solver's own input feedback (triggerPinChange with the default 'external'
source) can't loop; requestElectricalResolve coalesces overlapping ticks so
a per-edge call is cheap.
After deleting the default board and adding a different one via the canvas
picker, the editor kept editing the removed board's (now deleted) file group
while compile read the NEW board's default group — so code typed into the
editor was silently dropped and the board ran its default sketch ("compiles
fine but runs the old code"). addBoard now points the editor at the new
board's group when it becomes active, and removeBoard re-points it at whatever
board is active afterwards. setActiveBoardId already did this; the canvas
picker calls addBoard directly. Adds a regression test.
Extend the spice-driven input path (already live for AVR/ESP32) to RP2040 and
STM32 so digitalRead() of an INPUT pin reflects the actual wiring: a pin tied
to a rail reads that rail, and an INPUT_PULLUP button-to-GND reads idle-HIGH /
pressed-LOW instead of floating or inverted.
RP2040 (rp2040js, frontend-only): the GPIO listener now splits input vs output
mode. Input pins report their pad pull (InputPullUp/Down) via setPinPull and
seed the pull's idle level (rp2040js does not auto-apply the pad pull to the
readable input register); the SPICE solve then overrides via connectDigital-
InputsToMcu when the net is actually sourced. Output pins drive as before.
spiceDrivenInputs = true.
STM32 (backend QEMU): the worker now forwards a new gpio_pull event (from the
libqemu-arm picsimlab_pull_pin callback) so the netlist stamps the matching
weak resistor; Stm32Bridge surfaces it, Stm32BridgeShim opts into
spiceDrivenInputs, and collectPinStates maps PA0/PC13 names to the linear pin
so the pull is read. STM32 outputs stay on the part layer (unchanged).
Event-driven parts with no SPICE model (rotary encoder, keypad) remain
protected by the existing sourcedNets gate in the connector.
Re-do the AVR spice-driven digital inputs (reverted in c11c195) the right way so
INPUT_PULLUP buttons keep working. PinManager.updatePort now detects the AVR
internal pull-up (input DDR bit + PORT bit high) and sets the pin pull, so the
netlist stamps the 45k pull-up and an INPUT_PULLUP input reads HIGH at idle.
connectDigitalInputsToMcu drives a pin from the solve only when its net is
source-backed by a RAIL or a COMPONENT card (button switch, divider, cross-board
output) — NOT by the internal pull alone — so INPUT_PULLUP pins wired to
event-driven parts with no SPICE model (rotary encoder, keypad) are left to the
part layer and never clobbered. AVR only; RP2040/STM32 stay on the part-seed
until their pulls are modeled.
The spiceDrivenInputs change (e81450e + f4401cc) fixed plain-INPUT-wired-to-rail
reads but BROKE the far more common INPUT_PULLUP + button-to-GND pattern: the
internal pull-up is not modeled in the netlist, so the input floated LOW and read
as permanently pressed (verified live on the stm32-bluepill-button example).
Revert all the spice-driven-input changes to the pre-fix part-seed behaviour,
which handles INPUT_PULLUP correctly. Proper fix (model the internal pull-up per
board so BOTH patterns work) is a follow-up. Keeps the Pi LED fix.
Extend the source-backed SPICE-driven input fix to the Pico (RP2040) and STM32:
a GP/PA pin wired to a rail or button now reads the right level from the solve,
while floating event-part nets (encoder/keypad/dialer/dip/stepper) stay on the
part layer. RP2040 just opts in (spiceDrivenInputs); STM32 opts in via the
Stm32BridgeShim and connectDigitalInputsToMcu maps PA0/PC13 names to the linear
pin setPinState expects (stm32PinNameToLinear).
An Arduino input wired to a power rail read the wrong level: a pin tied to 5V
read LOW, and a button-to-5V read idle-HIGH / pressed-LOW. AVR inputs were never
fed the solved circuit voltage (only the ESP32 had spiceDrivenInputs), so a
bare-rail input had no driver and buttons fell back to a hardcoded active-low
pull-up seed that ignored the wiring.
Enable spiceDrivenInputs on AVRSimulator, and gate connectDigitalInputsToMcu on
a new NetlistBuilder sourcedNets set (rails, GPIO V-sources, pulls, and any net
a component card touches). Only source-backed input pins are driven from the
solve; floating nets are left to the part layer, so event-driven parts with no
SPICE model (rotary encoder, keypad, dialer, dip-switch, stepper) keep driving
their own pins instead of being forced LOW.
/about had its own seoMeta but was never in the entry-server prerender map,
so it was served as the SPA shell (homepage title/canonical) — a soft-404
risk for a page that is in the sitemap. Add it to ROUTE_COMPONENTS so
prerender-seo.mjs emits dist/about/index.html with the real About content
(now featuring the Velxio 3.0 release card) and a self-referencing canonical.
ESP32 digitalRead now reflects the actual circuit instead of a part-level
seed, so a button behaves like hardware — including breaking when it's
mis-wired.
- connectDigitalInputsToMcu: after each SPICE solve, threshold every ESP32
input pin's net voltage (3.3 V LVCMOS, hysteresis) and push the level into
QEMU. Only pins the MCU isn't driving as outputs are injected.
- Esp32BridgeShim advertises spiceDrivenInputs; the pushbutton / 6mm-button /
slide-switch parts skip their direct setPinState seed for such boards and
only flip the component property (pressed/value), which re-solves the
circuit. The connector then decides the level from the real wiring.
- makePinPullHandler no longer seeds the pin; it only records the pull
(netlist resistor) + requests a re-solve, so the read stays circuit-driven.
- GROUND_PIN_RE now matches bare numbered grounds (GND2, GND3) — the ESP32
DevKit element labels its second pad 'GND2', which previously floated.
Net effect: a correctly-wired INPUT_PULLUP button idles HIGH and reads LOW
pressed; a button mis-wired with GND on the wrong terminal reads stuck-LOW,
matching real silicon. AVR / RP2040 keep the legacy part-seed path.
The Pi bridge onPinChange was a no-op, so guest GPIO writes never reached the
PinManager / SPICE solver and wired LEDs stayed dark even though user scripts
printed 'LED on'. Mirror the ESP32 branch: forward to pm.triggerPinChange so
GPIO drives the canvas. Interconnect still preserves and calls this before its
own cross-board routing.
makePinPullHandler drove the post-boot INPUT_PULLUP seed through
getBoardBridge(), which only indexes the Pi bridge map (bridgeMap) — for
an ESP32 it returned undefined and the sendPinEvent seed silently no-op'd,
so the digital input stayed LOW even though the pull config was read and
the SPICE net showed the pulled voltage. ESP32 bridges live in
esp32BridgeMap; use getEsp32Bridge().
Completes the internal-pull emulation for the common case (a button on an
RTC-capable GPIO like 4/15/25/... with INPUT_PULLUP):
- Backend reads the RTC_IO pad RUE/RDE bits via the new
get_internals(QEMU_INTERNAL_RTCIO) and emits gpio_pull for RTC pins, so
pull-up/down on those pads is finally visible (it lives in RTC_IO, not
IO_MUX). IO_MUX path still covers non-RTC pins.
- The digitalRead path is driven by seeding the GPIO input level, not by
SPICE. The part-level INPUT_PULLUP seed (BasicParts) is sent at attach,
before the multi-second QEMU boot finishes, so it is lost and the pin
reads LOW. makePinPullHandler now drives the pin to the pull's idle level
via sendPinEvent when the guest programs the pull (post-boot), so it
sticks. A real button press/release still overrides it.
INPUT_PULLUP / INPUT_PULLDOWN had no effect in simulation: the ESP32's
internal pull resistors live inside QEMU and were invisible to the SPICE
solver, so an input wired to a button-to-GND floated to 0 V and read LOW
even at idle. The canonical active-low button never worked.
Read the pull config straight out of the running guest: the IO_MUX
register (FUN_PU bit 8 / FUN_PD bit 7) is already exposed read-only via
qemu_picsimlab_get_internals(3), so no QEMU rebuild is needed. The worker
scans it on the 100 ms poll thread and emits gpio_pull; the bridge feeds
it to PinManager; the netlist stamps a weak 45k resistor to the rail so
idle inputs read the correct level. 45k matches the real internal pull
and is weak enough that any external driver/pull dominates.
Verified with ngspice: idle ~3.3 V (HIGH), pressed ~0 V (LOW).
The pushbutton was modelled as a switch between only 1.l and 2.l; the
other two legs (1.r, 2.r) connected to nothing. Wiring GND/GPIO to those
legs silently produced a dead button, and the failure was invisible.
Model it like hardware: 1.l is internally shorted to 1.r and 2.l to 2.r,
and pressing bridges terminal 1 to terminal 2. Wiring to any leg now
works, and putting GPIO and GND on the same terminal is a dead short,
exactly as on a real tactile switch. Back-compat A/B variant preserved.
- boot_images manifest: bump arm64 rootfs (gpiozero/colorzero baked in,
hostname applied at boot, reworded MOTD)
- RaspberryPi3Bridge: onBooted shell-ready detector + sendAndWaitForPrompt
flow control (resets on disconnect)
- RaspberryPiWorkspace: distinct Booting overlay + piBooted-driven status,
inline SVG icons replacing emoji glyphs
- SerialMonitor: strip CSI/DSR escapes so the dumb console no longer shows
a literal [6n next to the prompt
- VirtualFileSystem: upload auto-starts the Pi and waits for the shell, then
flow-controls each command (no more dropped lines on large files)
- i18n: bootingTitle/bootingNote + reworded offlineNote2 across 9 locales
Sweep all off-brand blues (#007acc logo-blue, #0e639c, #3b82f6) and the neon
cyan family (#4fc3f7, #00b8d4, #00e5ff, rgba 0,184,212 / 0,122,204) across the
editor, simulator, examples gallery and pages to var(--color-action-primary).
The Code/Both/Circuit toggle, board chips, library manager, modals etc. now
match the Libraries / +Add buttons. Also keep the editor Libraries label
visible in the default split (lower the container-query threshold).
Unify the editor toolbar and landing page on the single #0071e3 accent
(token --color-action-primary). Replace the fluorescent cyan/green outline
treatment (Libraries pill, overflow icons, compile/run/stop, feature tiles,
pricing/licensing badges, classroom banner) with solid fills following the
+Add button. Green/red kept solid and semantic-only (run/success, stop/error).
recordUpdateWire was used by the wire colour palette and the right-click menu but
never destructured from the store, so every colour-swatch click threw
'recordUpdateWire is not defined' and did nothing. tsc would have caught this, but
build:docker skips type-checking, and the only prior caller (the touch-only palette)
was never exercised. Bind it like the other record* actions. Together with the
applyNow fix this makes wire colour changes from the UI actually work.
recordUpdateWire pushed its command with { applyNow: false }, so it recorded the
change for undo but never executed it. Its only callers (the wire colour palette
and the new right-click menu) pass the new colour and expect it applied — neither
pre-applies via the raw updateWire mutator. Net result: changing a wire colour
from the UI did nothing (only the 0-9/c/l/m/p/y keyboard shortcut, which calls
updateWire directly, worked). Drop applyNow:false so it applies like every other
record* command (recordRemoveWire etc.). Adds an undo/redo regression test.
Changing a wire's color on desktop was keyboard-only (select wire + 0-9/c/l/m/p/y)
with no visible control — the floating color palette (SelectionActionBar) only
rendered on touch devices, so desktop users had no way to discover it. Now:
- the wire SelectionActionBar (top-center, with the color palette) also shows on
desktop when a wire is selected and the sim is stopped (it is pinned top-center
so it never covers pins); component/board bars stay touch-only.
- right-clicking a wire opens a context menu with the color swatches + delete.
Keyboard shortcuts still work. Mirrors the existing board context-menu pattern.
SSD1306Core only handled horizontal/vertical addressing (0x20/0x21/0x22) and
defaulted memMode to horizontal. Page-mode drivers (Tiny4kOLED on ATtiny85,
U8g2 page buffer, classic SSD1306 libs) position the cursor with the single-byte
commands 0xB0-0xB7 (page) and 0x00-0x0F / 0x10-0x1F (column nibbles) and rely on
the SSD1306 power-on default of PAGE addressing — they never send 0x20. velxio
ignored those cursor commands and advanced in horizontal mode, so every setCursor
was a no-op and the hatching/border/text piled onto wrong rows -> garbled display.
Fix: default memMode=2 (datasheet power-on) and handle the page/column-set
commands. Adafruit_SSD1306 still works (it sends 0x20,0x00 + 0x21/0x22 explicitly).
Verified: decoded the real ATTinyCore Tiny4kOLED I2C stream renders a clean
border + '128x64'. Adds a page-addressing render test.
The ATtiny85 has no hardware TWI; TinyWireM/Tiny4kOLED drive I2C through the USI
peripheral (SDA=PB0, SCL=PB2). avr8js ships AVRUSI but velxio never instantiated
it, so the I2C bus had no master on the ATtiny85 and devices (e.g. SSD1306 OLED)
got no data — the display stayed blank (and wokwi-ssd1306 threw putImageData with
an empty framebuffer). New UsiI2cBridge instantiates AVRUSI and sniffs the SDA/SCL
lines, replaying START/STOP + 8-bit bytes onto the shared I2C bus as
start/connectToSlave/writeByte/stop (the same calls AVRTWI makes for the Uno).
Validated against real ATTinyCore Tiny4kOLED firmware: decodes addr 0x3C + SSD1306
init/data stream. Firmware tolerates NACK so the sniffer is passive.
arduinoPinToName() had no ATtiny85 case, so pin 1 reverse-mapped to "1"
instead of "PB1" (the wire/netlist name). The MCU-edge listener was never
attached (name not in pinsInCircuit) and the SPICE V-source was never altered
on digitalWrite, so a blink LED's branch current stayed at its HIGH value —
the LED latched ON and never turned off (and analogWrite duty changes never
re-solved). Map attiny85 pin N -> "PBN", mirroring pinNameToArduinoPin.
AVRSimulator used wrong ATtiny85 Timer0 data-space addresses: OCR0A 0x56
(=PINB), OCR0B 0x5c (=EECR), TCCR0A 0x4f (=TCNT1). analogWrite() writes
OCR0B at data 0x48, so pollPwmRegisters() read the wrong register and PWM
duty was never seen — attiny85-pwm-fade showed no fade. Corrected both
PWM_PINS_TINY85 and attiny85Timer0Config to TCCR0A=0x4A/OCR0A=0x49/OCR0B=0x48
(verified against the ATTinyCore analogWrite disassembly). delay()/millis
(overflow-based) was unaffected. Tests updated off the old 0x5c/0x56.
The live solver only re-solved on component/wire/board changes, so a runtime
burnout (which changes burntComponents, not components) wouldn't rebuild the
netlist — the burnt part stayed in the circuit until something else changed.
Trigger a re-solve on burntComponents change too. The monitor skips already-
burnt parts, so this converges (one extra solve).
- The live solver now excludes runtime-destroyed components from the netlist,
so a burnt part actually goes OPEN: its current stops and anything it fed
loses power (cascading failure), the way real hardware behaves once a part
burns out. Filter is in CircuitSimulationService.runSolve (no-op when nothing
is burnt).
- The LED's burnout now also marks it in the shared burntComponents set, so a
burnt LED gets the same charred + smoke-badge visual (and is opened in the
solve) as a resistor / capacitor, on top of going dark.
Generalizes the LED's burnout to passive parts via a centralized monitor that
watches the live electrical solve. When a part is stressed past its rating for
a sustained moment it's marked "destroyed": the canvas renders it charred with a
smoke badge and a fault is logged to the output console. Clears on Reset.
Follows the Fritzing-simulator precedent (smoke-on-component) wrapped in a
first-order thermal delay so a brief inrush spike doesn't destroy a part — only
sustained overload (or a catastrophic >=3x overload, instant) does.
- runtimeBurnout.ts: pure stress (resistor power, cap voltage / reverse) + a
thermal-delay burn decision, plus a monitor subscribed to the electrical +
simulator stores. Resistor burns past 2x rated (the verifier already warns at
1x for intentional teaching over-power); a cap bursts over its voltage rating
or on reverse polarity.
- useSimulatorStore: burntComponents set + mark/clear actions; cleared on
Reset / restartParts.
- DynamicComponent + SimulatorCanvas.css: charred filter + smoke badge.
Tests: thermal-delay decision (instant / sustained / spike / cooldown) + stress
computation (resistor power, cap over-voltage, reverse, unwired -> null).
- Power short (blocking error): a wire joining a VCC-type pin directly to a
GND-type pin shorts the supply to ground. The current-based short-circuit
rule only inspects battery/signal-generator/power-supply sources, so it
misses a board-rail-to-GND short with no such source -> name it structurally.
- Shorted-out part (warning): a 2-terminal part with both terminals on the same
node has no effect on the circuit.
Both graph-based, run before the solve. Zero false positives across the 69
gallery examples; gallery pre-flight tests still pass (no spurious blocking).
First slice of the connection ("malas conexiones") checks, graph-based and run
before the solve so they report even on circuits too incomplete to solve:
- Missing power: a rated peripheral (sensor/display) wired into the circuit but
missing its VCC or GND connection -> warning. Boards are excluded (they live
in input.boards and self-power).
- Dangling 2-terminal part: a resistor / LED / capacitor / diode / inductor
connected on only one side (the other terminal floating) -> warning.
Both non-blocking. Verified zero false positives across all 69 gallery
examples. Tests: dangling resistor warns, fully-wired doesn't, module missing
GND warns.
Extends the over-voltage rule to the two cases the previous slice deferred:
- Boards (ESP32 / Pico / Arduino / ...): a board's supply pins all collapse to
the self-driven vcc_rail net, so an external source on them makes the .op
singular rather than readable. Added a graph-based check (runs before the
solve): if a power source is wired to a board supply pin and its nominal
voltage exceeds that pin's rating, warn. Threaded boardKind into
BoardForSpice so the verifier can look up the board rating.
- Electrolytic capacitors: new `voltage` rating property (select, default 25V,
on capacitor-electrolytic + cap-elec-* presets, via component-overrides +
regenerated metadata). The verifier reads the DC voltage across the +/- pins
and warns on over-voltage (vent/burst) and on reverse polarity (a polarized
cap wired backwards). Defaults to 25V when the property is unset.
Tests: 9V battery -> ESP32 VIN warns, 1.5V doesn't; 24V across a 16V cap warns,
5V across a 25V cap doesn't; reverse-biased cap warns. All real-ngspice.
handleCompile() wiped all logs at the start, including the 'Circuit check'
group the pre-flight verifier had just logged (e.g. an over-voltage warning).
A Run auto-compiles right after verification, so the warning vanished. Preserve
circuit-check entries on compile, same as the boardless path already did.
Adds a non-blocking circuit-verifier rule: a component whose supply pin sees
more than its datasheet absolute-maximum voltage warns ("X V on the VIN pin --
above its Y V maximum; not emulated accurately"). This is the "fed too much
voltage" mistake the operator asked for (a 3.3-5V module wired to a 9V battery).
- New componentRatings.ts: per-PIN abs-max table (SSD1306/ILI9341 displays,
DHT/BMP280/HC-SR04/MPU6050 sensors, NeoPixel, servo). Per-pin thresholds so a
3V3 pin (3.6V) and a VIN pin (6V) are judged separately. Unknown parts are
simply not checked; an unwired or floating supply pin is skipped.
- circuitVerifier reads each rated part's supply-vs-ground voltage from the
solved nets (via pinNetMap) and warns when it exceeds the rating.
- VCC/VDD/3V3/5V pins ride the shared vcc_rail net (NetlistBuilder convention);
VIN is a normal net. Both handled.
- Tests: 9V on a module VIN warns; 5V on VIN does not; a 3.3V pin on a 5V rail
warns.
Boards (esp32/pico/arduino) carry ratings in the table but aren't checked yet
-- BoardForSpice doesn't thread its boardKind; follow-up.
Clicking Run runs a pre-flight circuit-verification SPICE solve before
compiling. On a cold ngspice worker that solve takes a second or two, but the
Run button kept showing the play icon and stayed enabled, so it looked dead
and got clicked repeatedly -- each click stacking another verification (the
reported 6x [handleRun] click).
- Add a `verifying` state: the Run button now shows the same spinner as the
Compile button and is disabled while the pre-flight solve runs.
- Synchronous re-entrancy guard (runInFlightRef) ignores re-clicks while a
verification is already in flight.
The runtime burnout / pre-flight messages used an inline `setMessage` toast
that rendered as a bar near the Run/Stop buttons and overlapped them. Route
all circuit findings into the compile output console instead — one unified,
red/orange diagnostics log next to the compiler output (Proteus-style):
- New "Circuit check" console group (CIRCUIT_CHECK_TARGET). checkOrBlock logs
every error (red) / warning (orange) there and opens the console; the
blocking modal is kept for the explicit Run-anyway / Cancel decision.
- Runtime `velxio-circuit-fault` events (LED burnout) log to the same group
instead of the toast; no auto-open (the continuous solver can fault on load).
- Warnings-only no longer pop a toast — the console entry is the record.
- The run-path clears preserve circuit-check entries so findings survive a
"Run anyway" auto-compile.
The Run (and Run All) buttons were wired `onClick={handleRun}`, so React
passed the click event as the first argument. handleRun(skipVerify=false)
then treated the truthy event as skipVerify=true and skipped checkOrBlock
entirely -- the pre-flight circuit verifier never ran on a button click.
This is the real reason a 9V battery wired straight to an LED ran with no
warning even though the verifier exists and is correct (project 2840fd12).
- onClick={() => handleRun()} and onClick={() => handleRunAll()} so
skipVerify stays at its false default.
- Give handleRunAll the same checkOrBlock pre-flight gate handleRun has.
Verification failing silently in production is otherwise hard to spot — the
rules read 0 A when branch currents are missing. Log the errors/warnings,
whether a solve landed, and which branch/node vectors came back.
The pre-flight circuit verifier reads branch currents via runNetlist ->
readAllCurrentVectors() (ngSpice_AllVecs enumeration). The production
Web-Worker ngspice WASM build does not surface voltage-source #branch
vectors through that enumeration for an .op plot, so branchCurrents came
back empty and every current rule (short-circuit, LED over-current) read
?? 0 -> no fault. The live solver avoided this by requesting each current
explicitly by name; the Node test build enumerates them, so the gap was
invisible to the suite. Net effect: a 9V battery wired straight to an LED
ran with no warning (reported on project 2840fd12).
- runNetlist: request every V_* source branch current explicitly by name
and merge with the enumeration, so source/LED currents are always present
regardless of the worker WASM's AllVecs behaviour.
- circuitVerifier: non-finite source/LED current -> blocking unstable-solve
fault ("could not solve a stable current - likely a short or a part with
no current limit, e.g. an LED with no series resistor").
- LED runtime (BasicParts): burn out on a non-finite current instead of
falling through to the digital fallback and glowing; raise burnout
threshold 20mA -> 100mA so high-power/RGB channels are not falsely
destroyed; clear the burnt latch on Reset (resetBoard bumps hexEpoch).
Tests: real-data repro, mocked non-finite verifier test, runtime
non-finite / high-power / latch-recovery tests.
All three bugs are rotated components whose pin geometry is computed in a
path that ignores the rotation, so pins/wire-starts land tens of pixels off
the visual pin tips. The live rotate action already recalculates correctly;
these are the paths that didn't.
#231 (context-menu 'Tap a pin to wire'): both onPinSelect handlers in
SimulatorCanvas computed the wire start as getBoundingClientRect().left +
pin.x — adding the UNROTATED pin offset to the ROTATED bounding-box corner.
On a 90-deg HC-SR04 that put the start ~70-100px off (measured). Replaced
with calculatePinPosition(id, x+6, y+6, rotation), the same rotation-aware
helper wires and the pin overlay use.
#232 (rotate -> delete -> undo): recordRemoveComponent's undo restored the
component + wires but never recalculated wire endpoints, so a rotated part's
wires kept the unrotated coords captured at delete time. Added a
requestAnimationFrame updateWirePositions(id) after restore.
#230 + #232 (pin boxes wrong after import / undo / load, 'fixes if rotated
again'): PinOverlay captured the wrapper's layout box (the rotation pivot)
once at mount. On import/undo/load the component mounts already-rotated and
its wokwi-element may not be sized on the mount tick, baking a wrong pivot
that only refreshed when rotation changed. PinOverlay now re-measures after
layout (rAF) and whenever it is about to become visible (showPins dep).
AVRSimulator never instantiated avr8js's AVREEPROM peripheral, so any
EEPROM.read/write/update hung the sketch: the Arduino EEPROM library spins
on `while (EECR & (1<<EEPE))` waiting for the write-complete bit to clear,
and with no peripheral driving EECR that bit never cleared (issue #203 —
EEPROM.update(0,123) + EEPROM.read(0) hangs instead of printing 123).
Wire AVREEPROM to the CPU in both loadHex() and reset() via a new
attachEeprom() helper. The EEPROMMemoryBackend is created once per
simulator instance and reused across firmware reloads and resets, so a
value written in one run is still readable on the next boot — matching
real hardware, where re-flashing leaves EEPROM intact. Sizes per variant
(Uno 1024 B, Mega2560 4096 B, ATtiny85 512 B); ATtiny85 gets its own
register map (EECR 0x3C / EEDR 0x3D / EEARL 0x3E / EEARH 0x3F) since
avr8js's default eepromConfig targets the ATmega328P.
Adds eeprom.test.ts: drives the EEPROM register protocol against the
production AVRSimulator (loadHex + step), asserting a byte round-trips,
the EEPE poll terminates (no hang), and contents survive a reset.
The NTC breakout's SPICE topology was inverted relative to the example
sketch's decode formula (rNtc = R_PULL * v / (5 - v)), which assumes a 10k
pull-up from VCC to OUT and the NTC from OUT to GND. The mapper had the NTC
on top (VCC->OUT) and the pull-down on the bottom, so the recovered
temperature ran backwards: dragging the slider to 100C made the sketch
print -25C. Swap the two resistors so V_OUT = 5 * Rntc / (Rntc + Rpull),
matching the sketch and the hand-built reference netlist in
spice-avr-mixed.test.ts (T=0 -> ADC 789, T=25 -> 511, T=50 -> 270).
Also replace the SensorParts linear approximation (2.5 - (t-25)*0.02) with
the same beta-model divider so the non-SPICE ADC injection decodes back to
the slider value, and drop the dead onInput path that treated the element's
value as a raw ADC count.
Reset now restores interactive sensors (temperature/lux/gas sliders) to
their configured defaults: resetBoard re-dispatches each sensor's default
into the running sim and bumps sensorResetNonce so the open
SensorControlPanel remounts and the slider snaps back. Previously a restart
left the NTC frozen at the last dragged temperature.
Updated the examples netlist snapshot for the swapped NTC cards.
The star banner used a single localStorage flag (velxio_star_prompted)
set identically whether the user clicked through to the repo or just
closed it, so once dismissed it never showed again and clickers and
closers were indistinguishable.
Now track three flags:
velxio_star_prompted - dismissed the first ask
velxio_star_prompted_v2 - dismissed the follow-up (stop forever)
velxio_star_clicked - clicked through to the repo (stop forever)
Anyone who dismissed the first ask without clicking through gets ONE
follow-up (round 2) with a stronger message; clicking the repo link at
any time opts them out permanently. Capped at two asks total.
Adds starBanner.title2/body2 copy in all 9 locales.
Commit 9360f95 deleted lib/proWifiGate.ts but left useSimulatorStore importing
it (the store edits weren't staged), so a clean checkout of master failed to
build (import of a deleted module). velxio.dev was unaffected — deploy.sh builds
from the working tree, which had the removal applied. Commit the removal so HEAD
is consistent.
The proWifiGate seam blocked a free user from running ANY Pico W WiFi sketch.
The product model changed: LOCAL WiFi (the chip associating to the simulator's
virtual AP) is now FREE — only REAL internet (the backend bridge) and the IoT
gateway are paid, gated inside the pro overlay's Cyw43PioPeripheral / backend.
So a free user must be allowed to run WiFi sketches. Remove the gate seam and
its two store call sites. The board-kind firmware variant + the run-time
peripheral re-attach (which fixed the plain-firmware import-network crash) stay.
Two robustness fixes for the paid-WiFi open-core split:
1. A pi-pico-w board now boots the RPI_PICO_W firmware variant (which has the
`network` module) based on its BOARD KIND, not on whether the WiFi
peripheral happens to be attached. Previously the variant was
`pioPeripheral ? 'pico-w' : 'pico'`, so any moment the peripheral was
absent (see #2) booted the plain Pico firmware and a Pico W sketch crashed
with "ImportError: no module named 'network'". Store boardKind in
attachPioPeripheral and pick the variant from it. (OSS: 'pico-w' isn't
registered, so firmwareConfig falls back to 'pico' — a self-hosted Pico W
has no WiFi engine anyway.)
2. Re-attach the PIO peripheral in loadMicroPythonProgram before loading
firmware. An example deep-link adds the board during render, which races the
pro overlay's async mountPro that installs the CYW43 factory — so the
board-add attach returned null and a PAID user's Pico W booted plain
firmware too. attachPioPeripheral is idempotent; by run time the factory is
installed, so a paid user gets the W peripheral and real WiFi.
Pico W WiFi is a paid overlay feature. A free/web user running a Pico W sketch
that uses WiFi had no peripheral attached -> the simulator picked the plain Pico
firmware (no `network` module) -> the run crashed on `import network` with a
raw Python traceback (on a public example page, no less).
Add lib/proWifiGate.ts (mirrors proBoardGate): a stable doorbell the overlay
fills in. useSimulatorStore gates both loadMicroPythonProgram (before loading
firmware) and startBoard (run backstop for example/loaded boards): if the gate
blocks, fire the upgrade prompt and skip the run. Non-WiFi Pico W sketches still
run for free. No-op in OSS (default 'allow' -> a Pico W runs as a plain Pico).
The Pico W WiFi showcase examples are a paid-overlay feature now (the WiFi
engine moved to the overlay). Read them through a build-time `@pro` seam so the
SSR prerender + gallery + sitemap include them when built with the overlay, and
OSS gets an empty stub.
- data/examples.ts: import { proExamples } from '@pro/data/proExamples' (static,
build-time) instead of the local examples-picow-wifi.ts; delete that file.
- src/__pro_stub__/data/proExamples.ts: OSS no-op (empty list) for the @pro alias.
- vitest.config.ts: mirror the @pro alias (stub by default / overlay when
VITE_PRO_BUILD) so tests loading examples.ts resolve it.
- scripts/generate-sitemap.mjs: also parse <PRO_OVERLAY_PATH>/data/proExamples.ts
when building with the overlay (the script reads example IDs from source text,
so it can't follow the alias).
- Tests: drop the picow-wifi import/usage from the 5 OSS example tests (they
validate the OSS set now); prune the 4 obsolete picow netlist snapshots. The
overlay's proExamples get their own coverage in pro/.../__tests__/.
The stored snapshot predated the relay-LED netlist emission (current-sense
V-source + LED diode model), so examples-netlist-snapshot failed on a clean
checkout regardless of any source change. Regenerate it to match the current
NetlistBuilder output. Unblocks the deploy test gate.
Move the CYW43439 (Pico W) WiFi emulation out of the open-source tree so it
can ship as a paid feature in a private overlay. OSS keeps a plain Pico W
(no WiFi); the overlay registers the cyw43 protocol + backend network stack
at runtime via generic seams.
Frontend:
- Add simulation/PioPeripheral.ts: a generic "PIO bus peripheral" seam
(feedWord / inDiscardableWriteData / resetFraming / hostWakeLevel /
onHostWake / onSimulationStart). No factory is installed in OSS, so
createPioPeripheral() returns null and a Pico W simulates as a plain Pico.
- RP2040Simulator: keep the fragile PIO-FIFO plumbing (it must re-run after
loadMicroPython swaps the chip) but drive it through PioPeripheral instead
of an inlined cyw43 import (attachCyw43 -> attachPioPeripheral, etc.).
- useSimulatorStore: generic attach/detach + setBoardWifiStatus; drop the
cyw43 bridge map.
- MicroPythonLoader: add registerFirmwareVariant() so an overlay can add the
RPI_PICO_W build; remove the OSS pico-w config + bundled .uf2.
- Delete simulation/cyw43/ (moved to the overlay).
Backend:
- core/hooks.py: add generic register_ws_sim_handler / dispatch_ws_sim_message
and register_gateway_proxy / dispatch_gateway_proxy seams.
- simulation.py: route start_picow / stop_picow / picow_packet_out through the
ws_sim_handler hook (the overlay handles + gates them).
- iot_gateway.py: resolve the Pico W gateway through the gateway_proxy hook.
- Delete services/picow_net/ + picow_net_bridge.py (moved to the overlay).
Tests: move the cyw43/picow suites to the overlay; update RP2040Simulator
mock stubs to attachPioPeripheral.
Static/docs/example routes are served as <route>/index.html and nginx
301-redirects the slash-less form to add the trailing slash. The sitemap
generator, the prerender canonical/og:url, and the client useSEO canonical
all emitted the slash-LESS form, so every sitemap URL was fetched as a
redirect (filed under 'Page with redirect' in Search Console) and each
canonical pointed at a redirecting URL.
Align all three to the trailing-slash form so sitemap URL == canonical ==
served URL == 200, with no redirect hop.
- generate-sitemap.mjs: append '/' to every <loc> (root stays '/')
- prerender-seo.mjs: withSlash() on canonical + og:url (routes + examples)
- useSEO.ts: withTrailingSlash() on canonical + og:url (covers dynamic
project pages too)
The canvas had two independent window keydown listeners. The wire handler
removed the selected wire and returned, but that return cannot stop the
separate component/board handler, which fell into 'else if (activeBoardId)'
and popped the board-removal confirmation. activeBoardId is not a visual
selection -- it is just the board whose code is open in the editor, so it is
effectively always set. Pressing Delete to remove a wire (or after deleting a
component) therefore always asked to remove the board.
Remove the keyboard board-delete branch entirely: Delete/Backspace now only
removes the selected component. Board removal stays on its deliberate paths
(right-click Remove board, touch pin-picker delete). Also add the text-field
guard (input/textarea/select/contenteditable) to the wire handler so Backspace
while typing in the AI chat no longer deletes a selected wire.
Every non-English page fell back to English when loaded directly (deep link,
refresh, SEO-indexed URL). Two causes:
1. index.ts seeded lng with the URL locale at init, but only the English
bundle is inlined. LocaleSync only fetches a locale bundle when
i18n.language !== target, which was already false on a direct non-default
load, so the bundle was never loaded. Init at DEFAULT_LOCALE and let
LocaleSync drive the locale from the URL (a real changeLanguage that
re-renders).
2. The region-coded locales (zh-cn, pt-br) never resolved their own bundle:
i18next's default code formatting rewrote them to zh-CN / pt-BR, which
failed the lowercase supportedLngs check and were dropped from the resolve
hierarchy. Add lowerCaseLng: true.
Verified in a production build: /zh-cn/* and /pt-br/* render translated;
toResolveHierarchy now returns ['zh-cn','en'] / ['pt-br','en'].
Opening the gateway in a new tab backgrounds the emulation tab and pauses
its rAF, freezing the chip — the page then 502s. The in-tab iframe is the
only way the Pico W device page stays reachable, so the panel no longer
offers an open-in-tab affordance (Reload + Close only).
Confirmed the RP2040 GPIO -> PinManager -> wokwi-led path is fully wired
(identical to the AVR path): a wokwi-led wired anode->GP2, cathode->GND
lights when MicroPython drives GP2 HIGH. The board's componentId is its
boardType ('pi-pico-w') since loadExample's addBoard gives the first
board of a kind id == kind.
Add a red LED on GP2 to picow-wifi-relay-web-server and flip the relay
logic to active-high (ON => GP2 HIGH => LED lit) so the toggle is visible
on the canvas, not just in the device panel.
The first iframe panel was a full-screen modal with a backdrop: it
covered the canvas and blocked the editor, so you couldn't watch the
board react or keep clicking buttons/wiring while it was open, and it
couldn't be moved.
Now it's a small floating panel docked top-right, draggable by its title
bar, resizable, with NO backdrop — the canvas and editor stay fully
interactive underneath.
Also: the relay and async-led device pages now show a big colored ON/OFF
indicator (these are web-server demos; the GP2/onboard LED isn't drawn on
the canvas, so the panel is where you see the state flip).
The Pico W emulation runs in THIS browser tab via requestAnimationFrame.
Opening the gateway with target=_blank / window.open backgrounds the
emulation tab; the browser then pauses its rAF, the simulated chip
freezes, and the gateway can no longer reach the server on it — the
request times out (502) and toggles do nothing.
Render the served page in a same-tab iframe panel (openDeviceGateway) for
the Pico W so the emulation stays in the foreground and keeps answering.
The ESP32 is unchanged (its server runs in QEMU on the backend, immune to
tab visibility), so it keeps opening in a new tab.
Also: the async-led page now shows the LED state (the board's onboard LED
isn't drawn on the canvas) so the toggle has visible feedback.
The served page loads under /api/gateway/<id>/, so absolute fetches like
fetch('/on') hit velxio.dev/on instead of the chip — the LED/relay/servo
controls did nothing. Use relative paths (fetch('on')) so they resolve
under the gateway. The relay example now has real ON/OFF buttons and
wraps its blocking accept loop in try/except so a dropped browser
connection can't kill it.
e2e now fires two sequential requests (first with a browser-sized header)
against a non-resilient blocking server and asserts both are served.
- SerialMonitor linkifies http://10.13.37.x (the Pico W subnet) the same
way it already does http://192.168.4.x for the ESP32, turning the
sketch's printed URL into an 'Open IoT Gateway' link.
- SimulatorCanvas shows the clickable WiFi badge for the Pico W too
(normalizing its 'started' status, which carries the fixed IP, to
got_ip so it reuses the ESP32 badge styling + launcher).
- async-led and servo-web examples print a clickable http://<ip>/ line
so the gateway link appears (relay-web-server already did).
Gated e2e (CYW43_GATEWAY_E2E=1) drives the real emulator + a running
backend and asserts the served page comes back through /api/gateway.
quotaModal.* (10), editor.share.updateFailed + visibility labels/hints (7),
editor.toolbar.exportBom + exportScreenshot (2) were present in en but absent
in de/fr/it/ja/ru. es/pt-br/zh-cn were already complete. Translated via
DeepSeek; interpolation tokens and JSON shape preserved, existing values
untouched.
Wi-Fi sketches on the emulated Pico W associate via the chip's built-in
virtual net (DHCP/ARP answered locally), but outbound traffic had no
route, so DNS/MQTT/HTTP failed with OSError -2.
Wire the emulator's outbound DATA path to the backend picow_net bridge:
- Cyw43Emulator forwards every outbound Ethernet frame EXCEPT DHCP/ARP
(still answered locally) to firePacketOut -> the WS bridge, which NATs
DNS/TCP/UDP to the real internet and injects replies back.
- The virtual net stays ON unconditionally and shares the backend's
subnet, gateway and gateway MAC (10.13.37.0/24, gw 10.13.37.1). Nothing
is mutually exclusive, so an absent or flaky bridge can never break the
Wi-Fi association -- it just falls back to no-internet, as before.
- useSimulatorStore opens the bridge (cyw43.connect()) for Wi-Fi sketches.
Validated end to end against a running backend: WiFi connect + DHCP, DNS
resolves example.com, TCP connect + HTTP GET returns 200 OK. Gated e2e in
picow-bridge-e2e.investigate.test.ts (CYW43_BRIDGE_E2E=1).
The CYW43439 F2 (radio frame) channel is word-oriented: the real chip
always drives frames padded up to a 4-byte boundary and the host reads
that word-aligned length, byte-swapping every 32-bit word on the way in.
encodeSdpcm built buffers of exactly 12 + payload bytes, so any frame
whose total length was not a multiple of 4 ended with a partial word.
The emulator's F2 read path (encodeFrameWords) byte-swaps whole words and
copies the leftover tail raw; the host's symmetric per-word swap then
mangles that final word, corrupting the last 1-3 bytes of the frame.
This was invisible for DHCP/ARP (UDP checksum 0 -> lwIP skips the check,
and the damage lands in trailing option padding) but silently dropped
every DNS answer and TCP segment (real checksum -> lwIP discards the
frame), so getaddrinfo()/connect() retried forever.
Pad the backing buffer to a 4-byte boundary while keeping the size header
at the true length, so the driver still parses exactly the real frame and
ignores the pad. Matches real hardware framing.
loadUserFiles passed content.length (UTF-16 code units) as the byte count
to lfs_write_file, but cwrap marshals the content to the heap as UTF-8. A
file with multi-byte chars (e.g. an em-dash in a comment) is then written
short by the multi-byte overhead, truncating the tail. The async-LED Wi-Fi
example (2 em-dashes) lost its last 4 bytes, turning the final
'asyncio.run(main())' into 'asyncio.run(main' -> SyntaxError at EOF. Use
the UTF-8 byte length so the whole file lands; ASCII files are unaffected.
For the first deploy, keep the chip emulator's built-in virtual DHCP/ARP
net ON and leave the backend internet bridge dormant (not validated end
to end yet). A Pico W board now associates and gets a link-local IP
locally (isconnected True); outbound internet (MQTT/HTTP) has no route
until the picow_net bridge is wired. Revert is a one-liner in the store
(cyw43.wifiEnabled = hasWifi; cyw43.connect()) + setVirtualNet(null).
Headless test that drives the REAL RP2040Simulator (attachCyw43 +
installCyw43PioHooks + lockstep PIO stepping in runFrameForTime), boots
the Pico W firmware, injects a WiFi-connect snippet over the raw REPL, and
asserts isconnected(). Result:
PYBOOT
ACTIVE False (this fw's active() getter reports link status)
CONN_OK 192.168.4.2 (DHCP-leased IP, isconnected() == True)
MAINPY_DONE
Reaches link-up in ~31s wall — the production lockstep PIO stepping is
faster than the harness's setTimeout-cranked PIO.
Also fixes a real production bug: the RP2040 logger was
ConsoleLogger(LogLevel.Error) which THROWS on rp2040js unaligned-read
warnings — lwIP reads the IPv4 header at ethernet offset 14 on every
received packet, so WiFi would have crashed on the first DHCP reply.
Now constructed with throwOnError=false.
Gated behind CYW43_PROD_HARNESS=1 (boots real firmware, ~30s).
Port the gSPI wiring proven in the boot harness into the real simulator
so WiFi works in the browser, not just the test:
- Non-dropping TX FIFO (head-pointer queue) in installCyw43PioHooks, so
the 260-word F2 IOCTL writes aren't truncated, with the firmware/
backplane bulk-write fast-path (inDiscardableWriteData) keeping the
~224 KB download cheap. Fully restorable on detach.
- Drive WL_HOST_WAKE (GPIO24) from emu.onHostWake, and re-sync the pin
level after installCyw43PioHooks (loadMicroPython resets GPIO while the
chip's queue persists).
- With a backend bridge attached, disable the built-in DHCP/ARP net so
the bridge owns the network.
Production steps the PIO in lockstep with the CPU (pioStepAccum), so no
PIO-rate crank is needed (that was harness-only). The emulator-side fixes
(host-wake, F2 byte order, join events, BDC header, virtual DHCP/ARP) are
already shared. Not yet exercised in a browser e2e — the headless harness
is the verification today.
The Pico W now connects end to end with NO backend: status reaches
CYW43_LINK_UP (3) and network.WLAN().isconnected() returns True.
After association the STA's lwIP broadcasts DHCP DISCOVER and ARPs the
gateway over the cyw43 DATA channel. A self-contained virtual network
(new virtualNet.ts) answers them:
- DHCP DISCOVER -> OFFER, REQUEST -> ACK (Ethernet+IPv4+UDP+BOOTP, valid
IPv4 header checksum, UDP checksum 0), leasing 192.168.4.2 with gateway
192.168.4.1.
- ARP who-has the gateway -> is-at the AP MAC.
On by default (Cyw43EmulatorOptions.virtualNet); pass null when an
external packet bridge owns the network.
Also fixes injectPacket to prepend the 4-byte BDC header that chip->host
DATA frames need (same as the event-frame fix), so injected packets parse.
Boot harness now reports: STEP_CONNECT_CALLED status=3 / POLL 0 status 3
conn True / HARNESS_DONE.
Known: receiving packets triggers ~30 rp2040js unaligned-read warnings
(lwIP reads the IPv4 header at ethernet offset 14); non-fatal here, but
the production RP2040Simulator must use a non-throwing logger.
The Pico W now joins the virtual AP end to end: active(True) returns,
connect() runs the full WPA/SET_SSID sequence, and the link comes up.
Root causes fixed (each blocked the join):
- mcast_list GET returned empty, so the driver read its own request bytes
as the address count (ASCII 'mcas' ~1.9e9) and looped ~2e9 times,
hanging wifi_on. GETs now return a zero-filled buffer of the asked-for
length (count 0 / status 0), never empty.
- Async event frames lacked the 4-byte BDC header the driver expects at
SDPCM header_length, so it read the broadcast-MAC byte as data_offset
and the payload pointed out of bounds (WRONG_PAYLOAD_TYPE). Prepend BDC.
- WLC_E_LINK signalled link-up via the reason field, but the driver
checks ev->flags & 1. encodeEventFrame now takes a flags arg; LINK uses
flags=1.
- Join needs WIFI_JOIN_STATE_KEYED, which only a WLC_E_PSK_SUP(status=6)
event sets (connect(ssid, "") still configures the WPA supplicant).
Emit it on a successful join.
- Join events were raised synchronously during the SET_SSID ioctl, so the
driver processed them before cyw43_wifi_join set wifi_join_state=ACTIVE,
wiping the bits. Defer events until just after the ioctl reply.
- Event-mask stored 4 bytes misaligned vs queueEvent's read offset.
- SET/GET kind bit is 0x2 (SDPCM_SET), not 0x1.
Remaining for status UP / isconnected: DHCP (needs the packet-transport
bridge or an emulator-side DHCP responder).
Add PioBusSniffer.inDiscardableWriteData(): true while framing a large
non-F2 write (firmware/backplane bulk write the chip discards). The boot
harness drops those data words (keeping ~4 so the PIO raises TXSTALL,
which is all the driver's write path waits for) instead of bit-banging
the full ~224 KB through the PIO. F2/SDPCM IOCTL writes and every
count/command word are retained in full, so the bring-up still completes
the 23-IOCTL wifi_on sequence (F1 framing 3613 -> 97, F2 unchanged).
Also adds IPSR + PC-histogram sampling: confirmed the post-mcast_list
stall is thread-mode (no GPIO IRQ storm) inside MicroPython's host-side
cyw43_cb_tcpip_init (lwIP), above the chip emulation.
Unblocks the full wifi_on IOCTL sequence in the boot harness (clm_load
through the 23-IOCTL bring-up, no crash):
- Drive WL_HOST_WAKE (GPIO24, active-high): the driver gates poll_device
on this pin until its first packet (had_successful_packet), so without
it the first IOCTL response is never read. Emulator now exposes
onHostWake(level) and toggles it with the inbound-frame queue.
- Encode F2/SDPCM frame reads per 32-bit word (encodeFrameWords), same
as register reads: the DMA-in sets channel bswap=true, so an un-encoded
frame landed byte-reversed -> header_length read back as garbage and
the driver dereferenced ioctl_header at an unaligned address (crash).
Guarded to boot mode pass-through (no F2 traffic there; keeps unit tests).
- Fix SET/GET detection: SDPCM_SET is bit 1 (0x2), not 0x1; echo the
kind bit in IOCTL responses.
- Add IOCTL/SDPCM debug counters + sequence log for the harness.
Harness (investigation, CYW43_HARNESS=1 only): non-dropping TX FIFO so
large F2 writes are not truncated, crank PIO steps/tick so the firmware
drains in wall-clock, GPIO24 host-wake wiring, CPU-fault + PC-histogram
+ PIO-state instrumentation.
Remaining: stall after mcast_list (#22) inside cyw43_cb_tcpip_init.
cyw43_spi_transfer calls pio_sm_restart before each transfer's count words, so
hooking restart() to reset the sniffer makes framing deterministic across the
firmware-stream fast-path (no phantom-transfer carryover). Verified: restarts
fire 3625x (once per transfer), F1 phantom count drops, and the CLM IOCTL write
now frames correctly (cmd decodes to F2, 'clmload' payload). Wired into
RP2040Simulator + the harness.
Remaining (next session): the CLM/IOCTL write doesn't complete its payload and
wifi_on still fails (active()=False) — bus_init stalls at/around clm_load with
only 2 STATUS reads and goes idle. Next: trace the CLM write's DMA/PIO drain and
the SDPCM IOCTL response path. See findings.md F-13.
debugInboundCount + STATUS-read tracking show initInbound=1, statusReads=2,
statusReadsWithPkt=2, finalInbound=1: the credit frame IS visible at both STATUS
reads (not a credit tight-loop). The driver reaches clm_load's F2-ready check
(passes) but the F2 IOCTL write never appears on the bus and bus_init returns.
Next: instrument the F2-write path. See findings.md F-13.
Pins the connect blocker: zero F2 transfers (F0=11 F1=97 F2=0), so the host
never sends an IOCTL — it stalls on SDPCM bus credits in clm_load (STATUS shows
no F2_PACKET_AVAILABLE) and times out, so wifi_on fails and active() stays
False. Next: make the credit-granting frame visible in SPI_STATUS during the
stall. See project/picow-wifi-emulation/findings.md F-13.
Brings the Pico W CYW43439 gSPI emulation from "fails at the first register
read" to "the chip boots fully and MicroPython's network.WLAN().active(True)
returns" — validated end-to-end against the real RPI_PICO_W firmware via a
headless boot harness.
What now works (Phases 1-2):
- PioBusSniffer rewritten to the real cyw43_bus_pio_spi framing
[out_bits][in_bits][cmd][write_data], skipping the two PIO loop-counter
words. Self-healing: validates count1 (= tx_length*8-1, 4-aligned, <=2052)
and skips non-conforming words — re-syncs after the extra word rp2040js
pushes on large writes AND fast-paths the ~224 KB firmware stream.
- Dual word-order regime: boot 16-bit-LE (swap16x2 / swap16) flips to 32-bit
big-endian (bswap32) at the SPI_BUS_CONTROL write. Calibrated empirically
against the firmware. Sniffer reads the mode via setModeProvider().
- Cyw43Emulator: encodeReadWord (per-regime), readBytes-sized backplane reads
with the value in the last word (response-delay pad), ALP+HT clocks and F2
always ready, AI core registers (IOCTRL/RESETCTRL), interrupt register
reports no errors, f1Mem echo store, SDPCM bus-credit granting + initial
frame.
- RP2040Simulator: serves chip responses on rxFIFO.pull (on-demand) instead of
racing the async DMA/PIO; passes readBytes through.
Not done yet (Phase 3+): connect() runs but stalls in the power-management /
save-restore phase before any F2/IOCTL traffic; packet transport (Tier 2) and
firmware-clocking perf are open. See project/picow-wifi-emulation/ for the full
research, phases, and findings.
The boot harness (picow-cyw43-boot-harness.investigate.test.ts) is gated behind
CYW43_HARNESS=1 so it stays out of the normal test run.
The RP2040 MicroPython loader always fetched the plain RPI_PICO build, which
ships no `network` module and no CYW43 WiFi driver. Every Pico W WiFi/MQTT
example therefore failed at `import network` ("no module named 'network'"),
which surfaced as a compile/run error in the editor.
- getFirmware()/loadUserFiles() are now variant-aware. pi-pico-w boards load
RPI_PICO_W-20230426-v1.20.0 (network/socket/ssl + the CYW43439 driver) and
write the LittleFS at the W board's flash offset (0x12c000, 212 blocks)
instead of the plain Pico's 0xa0000/352. The W firmware spans flash to
~0xab000 and would otherwise be clobbered by the filesystem. Each variant
gets its own IndexedDB cache key.
- The variant is selected by the presence of the already-wired CYW43 emulator
(attachCyw43 runs for pi-pico-w boards only).
- loadMicroPython swaps in a fresh RP2040 each run, so the CYW43 PIO-FIFO hooks
are re-installed on the new instance; otherwise the driver's gSPI traffic
never reaches the emulator and WiFi never comes up.
- Bundle micropython-rp2040w.uf2 as the offline fallback.
- Point the ThingsBoard example at the simulator's Velxio-GUEST network.
8 MicroPython examples that use `import network` (Blynk IoT relay, ThingsBoard
IoT, OTA update, DHT11 HTTP CSV logger, async LED control, web servo, websocket
LED, IoT relay web server) had boardType "raspberry-pi-pico". A plain Pico
(RP2040) has no WiFi and no `network` module, so they failed at runtime with
`ImportError: no module named 'network'` (the banner even shows "Raspberry Pi
Pico with RP2040"). Move them all to "pi-pico-w", which has WiFi + network.
Adds a self-contained ESP32 networking example for the /examples gallery
(addresses feature request #115). The sketch joins the emulator AP
"Velxio-GUEST", connects to a public MQTT broker (broker.hivemq.com:1883),
then publishes to its own topic and subscribes to it so each message
round-trips through the broker and toggles GPIO2 -- no external client or
local broker needed; just open the Serial Monitor.
Verified end to end in QEMU: WiFi associates (IP 192.168.4.15), DNS resolves
and outbound TCP to :1883 succeeds via slirp NAT. PubSubClient is auto-
installed via the example's `libraries` field.
Follow-up audit after the ESP32 fix: classifyPin() was run for every board
against the protocol pin labels its element actually exposes. One real gap
remained -- Arduino Mega. Its dedicated SDA/SCL pins are only labelled (not
numbered), so I2C links drawn on them came back 'digital' and never bridged.
Map every Mega function label (TX/RX, TX0-3/RX0-3, SDA/SCL) to its pin number.
Audit result for the rest (added as board-protocols-audit.test.ts):
- Arduino Uno/Nano, Pico/Pico-W, STM32 Blue Pill: already OK.
- ESP32 / ESP32-C3: fixed earlier (esp32-uart-pin-classify).
- Raspberry Pi 3/4/5: OK -- the element labels pins by physical number (1..40)
which normalize to BCM, so no function-label gap exists there.
Wiring two ESP32s TX2->RX2 (Serial2) or TX->RX for board-to-board serial
produced no data on the receiver: classifyPin() returned 'digital' for the
UART pins, so the Interconnect never installed the byte-level UART bridge.
Two causes in boardProtocols.ts normalizePinName:
- TX/RX aliases only matched boardKind === 'esp32' exactly, missing every
variant (esp32-devkit-c-v4, esp32-cam, esp32-s3), and TX2/RX2 were not
handled at all. Resolve them via startsWith('esp32') (esp32-c3 kept
separate) and map TX2/RX2 -> GPIO17/16.
- 'GPIO17'-style labels fell into the 'GP' (RP2040) branch first, where
parseInt('IO17') = NaN swallowed them to null. Exclude 'GPIO' from the
'GP' branch so the ESP32 GPIO-prefix handling runs.
Adds esp32-uart-classify.test.ts (6 cases, green).
The Discord release-notify workflow read the version from
frontend/package.json but never wrote it back, so every merge to release
announced the SAME version (the CHANGELOG ended up with two "[2.0.1]"
entries). Now, after generating the CHANGELOG and before announcing, the
workflow bumps the PATCH in frontend/package.json and commits it alongside
the CHANGELOG to release. Each merge advances the counter:
3.0.0 -> 3.0.1 -> 3.0.2 ...
Also sets the baseline to 3.0.0 so the next release is announced as v3.0.0.
To jump the major/minor, edit frontend/package.json on the release branch
(e.g. "version": "3.1.0") and the next merge continues from there.
Revert the earlier approach of widening the existing PWM-callback assertions to
accept the new timeMs arg — that masked a contract change rather than fixing it.
Instead, updatePwm now hands the optional timeMs only to listeners that declare
a 3rd parameter (cb.length >= 3) — i.e. the buzzer, which needs the precise
onset time. Plain (pin, dutyCycle) listeners, and the existing
toHaveBeenCalledWith(pin, dutyCycle) tests, see an unchanged 2-arg call, so the
original PwmCallback contract is preserved.
Add a PinManager test locking the dispatch: a 2-param listener stays 2-arg; a
3-param listener receives timeMs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sample-accurate scheduling (06526c7) added an optional 3rd `timeMs`
argument to PwmCallback / updatePwm, which broke 9 existing strict
toHaveBeenCalledWith(pin, duty) assertions (PinManager, AVRSimulator,
mega-emulation, attiny85). Match the real signature: PinManager drives
updatePwm directly with no timeMs (assert `undefined`); the AVR OCR-poll path
computes timeMs = cpu.cycles / 16000 (assert `expect.anything()`).
Leaves one pre-existing red — component-to-spice "custom-chip missing fixture"
— which fails on master too and is unrelated to this PR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A melody / continuous tone (consecutive tone() with no noTone() between) is
back-to-back nonzero-OCR PWM writes with no note-off, so startTone() overwrote
activeOsc without stopping the previous node — oscillators stacked and were
never stopped (reported: created 6, started 6, never stopped 6).
Add a monophonic guard at the top of startTone(): release the live note
(gain ramp + stop) before starting the new one, so a pitch change REPLACES
rather than STACKS. Extract a shared releaseActive(off) helper (also used by
stopTone). Add two melody tests: one asserts starts === stops (no orphans),
monotonic onsets and per-note pitch; one asserts a melody ending without a
trailing noTone() leaves only the final note ringing (stops === starts - 1).
The metronome path is unaffected (each click is an onset→note-off pair, so the
guard never fires there); the three existing metronome tests stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The panel was styled with light-theme CSS-var fallbacks that render wrong on
the editor's dark (#2d2d2d) property dialog:
- "Add files" button used `var(--surface, #f6f6f6)` + light border, so it
rendered a washed-out light-gray box that looked broken. Restyle it as a
primary action like `.rotate-button` (solid #007acc, white text, hover lift).
- Section divider and secondary text used light fallbacks (#e2e2e2 / #777);
switch to the dialog's dark values (#444 border, #aaa text).
Cosmetic only.
The `custom-chip` SPICE mapper emits its sources from getChipDrivenPins()
(the chip's live driven output pins), so a static pin/property fixture can
never exercise it -- it always returns null. The "every mapped metadataId
has a test fixture" check flagged it as missing a fixture, failing the
suite. Exclude it via a RUNTIME_STATE_MAPPERS set; custom-chip SPICE
behaviour is covered by the chip-bus integration tests.
Pre-existing since 4cb5748 (custom-chip first-class circuit nodes).
Add a working microSD card part backed by a FAT16 image, following the
Wokwi storage model: the project's own workspace files are auto-copied
onto the card (free), and an optional "SD Card" panel uploads extra
files (gated as a paid feature by the velxio.dev overlay; OSS default
allows it).
Frontend (in-browser AVR / RP2040):
- ProtocolParts.ts: rewrite the microsd-card part from a handshake stub
into a real SD-over-SPI device (reply-first Ncr timing, SDSC byte
addressing, single/multi-block read+write, CSD/CID, full CMD set).
- utils/fatImage.ts: dependency-free FAT16 super-floppy builder (8.3 + LFN).
- utils/sdCardFiles.ts: assemble the card image from workspace files plus
uploaded files; base64 helpers.
- components/simulator/SdCardPanel.tsx + ComponentPropertyDialog: upload UI.
- DynamicComponent + useSimulatorStore: build and inject the image on run.
- lib/proSdCardGate.ts: overlay-installable gate for the upload action.
- data/examples-storage-microsd.ts: Arduino Uno + ESP32 gallery examples.
Backend (ESP32 via QEMU):
- services/esp32_sd_slave.py: synchronous SD-over-SPI slave (Python port of
the browser part) with a sparse backing store, idle-state R1 tracking and
real CRC16 on data blocks when the host enables CRC (CMD59) -- both
required by ESP-IDF's sdspi driver.
- esp32_worker.py: route SPI bytes to the slave (returns MISO synchronously)
and feed write-only bulk transfers.
- esp32_lib_manager.py + routes/simulation.py: forward the FAT image
(sd_card.image_b64) from the start config into the worker.
Tested:
- frontend: protocol-parts, fat-image, sd-card-gate and microsd-real-firmware
(real Arduino SD.h on avr8js) -- 86 passing.
- backend: test_esp32_sd_slave (10) covering the ESP-IDF init sequence and
CRC16; validated end to end by running a real SD.h sketch in libqemu-xtensa
(mount, directory listing, read and write-readback).
The RP2040 core (125 MHz Cortex-M0) is ~8x heavier to emulate than the
AVR. The run loop used a FIXED per-frame cycle budget, and arduino-pico
delay() busy-waits the timer (no WFI), so a host that cannot sustain
125M instr/s rendered a 1s blink every 4-5s (sim ran in slow motion).
- Derive the frame budget from the MEASURED wall-clock delta (mirrors
AVRSimulator) instead of assuming a perfect 60fps.
- Add IdleSpinDetector: recognise a side-effect-free busy-wait spin and
advance the clock over it (capped at the next timer alarm / scheduled
pin change) instead of executing every idle cycle - the same idea the
WFI fast-path already uses for sleep(). Conservative: a bit-bang loop,
an input-poll that just saw its pin move, or a loop that calls out are
never elided; a false positive only ever advances time up to the
wall-clock budget, never past the next event.
- Bound WFI sleeps to the wall-clock budget so they advance in real
time across frames rather than leaping ahead.
Cuts emulation work for a delay-bound sketch ~1900x (125M -> ~65k
instructions per simulated second) so it tracks wall-time even on hosts
that cannot emulate 125 MHz in real time. Public API unchanged;
step()/stepCycles() untouched.
Adds rp2040-realtime.test.ts: IdleSpinDetector unit tests plus
end-to-end scheduler tests driving a real rp2040js core through a
hand-assembled busy-wait loop (no firmware fixture needed).
(1) The explorer's per-board manifest entry is renamed velxio.json -> libraries.json
and clicking it now opens a READ-ONLY JSON view of that board's declared libraries
(board.libraries) in the editor, instead of the modal. New editor state
manifestViewBoardId: when set, CodeEditor renders a read-only Monaco showing
{libraries:[...]} live; opening/activating any real file clears it. No file is
added to the workspace, so nothing touches compile or save. Library actions are
done in the Library Manager modal (toolbar button).
(2) Drop the 'Uninstall' button for shared index/cache libraries — you can't
uninstall a copy everyone shares (content-addressed cache). Only your own custom
.zip uploads keep a 'Remove' (per-user store). Index libs: just Add to / In project.
Builds on the previous commit; reworks the buzzer audio for glitch-free,
cross-browser playback and adds a metronome quality suite.
- Per-note oscillators with short attack/release ramps, instead of one
long-lived oscillator gated by gain: a fresh fixed frequency per note and no
gain/frequency automation on a persistent node — Firefox in particular clicks
and glitches the pitch otherwise.
- Schedule onsets by their SIMULATED inter-onset spacing (exact, even) with a
light latency hold, instead of a wall-clock average. Turning a control (BPM,
K…) re-locks immediately and the rhythm stays even — no bursts, no overlaps,
no audio drifting away from the display.
- Place each note-off relative to its own onset, preserving the exact click
length from the simulation (the onset scheduler now tracks onsets only).
- Poll PWM every 256 cycles (was 64): finer than any audible pulse, lighter on
the frame loop.
- New src/__tests__/buzzer-metronome.test.ts: drives the buzzer as a metronome
against a controllable audio clock and asserts even spacing, one oscillator
per click with no overlap, correct pitch per metric level, burst absorption,
and a clean re-lock on tempo change.
All simulation-parts + metronome tests pass (57).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A PWM-driven buzzer (analogWrite / Timer tones) was chaotic and unusable as a
metronome. Causes, all on the PWM path:
1. PWM was polled once per animation frame AFTER the cycle loop, so short clicks
that started and ended within one frame were merged or lost, and onsets were
quantised to the frame.
2. The buzzer started the oscillator with `oscillator.start()` (no scheduled
time) — frame-delivery jitter and per-onset oscillator churn.
3. The digital HIGH/LOW path also fired on the ~490Hz PWM carrier edges,
injecting spurious onsets (OCR read as 0 → 20kHz squeaks).
Fix:
- AVRSimulator: poll PWM sub-frame (every 256 cycles) so no pulse is merged or
lost; pass the precise simulated time through updatePwm.
- PinManager: PwmCallback / updatePwm carry an optional timeMs (backward compat).
- Buzzer: one continuous oscillator gated by the gain node, each on/off scheduled
on the AudioContext clock. The schedule predicts the next onset at a smoothed
interval (de-jittering the simulator's bursty per-frame delivery) and holds a
small bounded latency so the click stays aligned with the on-screen playhead
(driven from the same clock) instead of drifting behind it. A `pwmActive` flag
mutes the digital path once hardware PWM drives the pin.
Result: onset jitter for a firmware metronome drops from chaotic (σ ≈ 250ms,
dropped/extra beats, unbounded audio latency) to σ ≈ 15ms at ~30ms latency —
steady and aligned with the display. All 54 simulation-parts tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the 3 tabs (In project / Search / Installed). One list now: browse your
installed + custom libraries by default, search the index when you type. Each
row is state-aware:
+ Add to project — installs if needed, then declares it on the active board
In project (toggle) — click to remove from this board's manifest
Uninstall / Remove — free the cache / remove your custom upload
'Install' is folded into 'Add to project' (install-on-add) for simplicity. The
per-board manifest (board.libraries) stays the compile scope. The pro custom-zip
upload button still injects into .lib-modal-header. The in-modal velxio.json
editor tab is gone (the manifest is shown by the explorer's libraries.json file).
lcd-hello -> ['LiquidCrystal'], uno-servo -> ['Servo']. These were the only
non-ESP32 gallery examples using a USER library without a manifest; loading +
compiling them now sends the library scope (resolved from the content-addressed
cache) instead of falling back to the global scan-all. Every other non-ESP32
example is core-only (Wire/SPI are core-bundled; the RP2040 core bundles Servo,
so pico-servo needs no manifest) or already declared its libraries.
The Library Manager Installed tab + the velxio.json add-autocomplete now merge
the user's per-user custom uploads (getCustomLibraries -> GET /api/pro/libraries/
custom) with the shared global index list, so users can see and reuse their own
uploads (which live in the per-user store, not the global list). A custom lib's
button removes it via the per-user delete endpoint (not arduino-cli uninstall,
which would not find it). Degrades to [] for OSS/anon.
- compile.py: owner_id = project owner ELSE the requester (so an unsaved
compile resolves the libs the user just uploaded, which are their own);
threaded requester_id into _run_compile from both call sites.
- LibraryManagerModal: on a custom .zip upload, auto-add the lib to the active
board's velxio.json + show the Project tab, so the compile resolves it via the
owner per-user path (the upload now lands in the per-user store, not the
shared dir, so it must be declared to be found).
Moved the velxio.json entry out of a single top-level row (ambiguous about
which board it applied to) into EACH board's file group, next to that board's
sketch. Each board now shows its own velxio.json with its own declared-library
count; clicking it switches to that board and opens the Library Manager on its
list. Makes the per-board manifest model unambiguous.
Library manifests are now PER-BOARD (each board carries its own velxio.json),
so two boards in one project can use different (even conflicting) libraries
without clashing — the multi-board extension of the no-clash guarantee.
- board.libraries on BoardInstance + serialisableBoard: rides in boards_json,
so it round-trips, dirty-checks, autosaves and restores natively. This also
removes the load-restore hacks (useLibraryManifestStore + applyProjectManifest
deleted): the manifest is plain board state.
- loadProjectState now restores per-board boardOptions/spiffsFiles/libraries
(it previously dropped them).
- EditorToolbar single + compile-all send the COMPILING board's libraries.
- Backend compile.py prefers the client's per-board request.libraries; the
project-level libraries_json (now the union of all boards) is the fallback.
- buildLoadPayload migrates pre-per-board projects: seed each board with the
project union so they keep compiling scoped.
- Library Manager 'In project' tab edits the ACTIVE board's velxio.json (shows
the board name) and the add field is now an autocomplete (installed libs +
index search) so users pick from a list instead of typing names.
Deletes useLibraryManifestStore.ts + applyProjectManifest.ts.
End users can now configure a project's declared libraries (the compile scope):
- Library Manager gains an 'In project' tab = the project's velxio.json:
declared libs as removable rows, quick add-by-name, and a raw velxio.json
editor. Installing a library auto-adds it to the project. Installed-tab rows
get an 'Add to project' toggle.
- FileExplorer shows a velxio.json entry (with declared count) that opens the
Library Manager via a window event the toolbar listens for.
- applyProjectManifest(): restore a saved project's manifest into the store on
load so the editor/toolbar/Library Manager/velxio.json reflect it.
- computeProjectStateHash() includes the manifest so declaring a library marks
the project dirty and autosaves.
Note: the OSS ProjectByIdPage also calls applyProjectManifest for parity, but
velxio.dev routes the pro-overlay ProjectByIdPage (wired separately).
buildSavePayload omitted libraries_json=[] whenever the manifest store was empty
— so an autosave right after loading a project (whose manifest the store hadn't
restored) wiped the saved manifest. Now omit libraries_json entirely when the
store value is null (unknown), so the backend preserves the saved manifest. The
compiler reads it server-side regardless (get_project_libraries hook).
The inline manifest-restore in the load .then was being tree-shaken out of the
lazy ProjectByIdPage chunk (the deployed bundle had the save wiring but not the
load). Move it into buildLoadPayload, which is an exported helper (used by tests)
so its body is never dropped. Reloaded projects now re-send their manifest.
Saved projects now round-trip their declared library manifest (compile scope):
buildSavePayload includes libraries_json from useLibraryManifestStore; loading a
project restores it (and clears any stale example manifest). Existing projects
load with an empty manifest -> legacy scan-all (unchanged); new saves capture
whatever manifest is active. Pairs with the backend libraries_json column.
Activates manifest-scoped ESP-IDF resolution for the gallery. loadExample now
records the example's declared libraries in useLibraryManifestStore; EditorToolbar
passes them to compileCode, which sends them as `libraries` in the compile
request. The backend then merges exactly those libraries (P2.0 scope) instead of
picking a stray same-named lib from the shared dir.
Safe: a core-only example sends null (legacy scan-all); a stale/incomplete
manifest degrades to scan-all via the backend graceful fallback, never a wrong
build. Ignored by the backend for non-ESP32 (arduino-cli) boards. Example
manifests were completed (incl. transitive deps) in c671c9b.
Auto-completed the library manifests for the 9 ESP32-family examples that use
external libraries, so each declares its full dependency set (direct +
transitive). Found genuinely-missing deps that the previous fields omitted:
- esp32-dht22, c3-dht22: + Adafruit Unified Sensor
- esp32-mpu6050, esp32-bmp280, esp32-oled, esp32-doom: + Adafruit BusIO
- esp32cam-lcd-preview: add manifest [Adafruit GFX Library, Adafruit BusIO, Adafruit ILI9341]
Each completed manifest was validated by compiling the example against ONLY
its manifest (manifest-scoped resolution, no fallback). esp32-servo / c3-servo
were already complete. This unblocks turning on scoped resolution for the
gallery (P2.3): with complete manifests, scope picks the declared libs and
excludes strays, and the P2.3-safety fallback covers any residual gap.
A board example proving digital and analog coexist in ONE circuit: the Arduino
drives two logic levels, a physical AND gate combines them, and the AND output
switches an NPN 2N2222 transistor that drives the "motor" LED. Verified live: it
compiles, the MCU drives the AND gate (5 V), the transistor conducts and the LED
lights — MCU -> logic gate -> transistor -> load works across the digital and
ngspice motors together.
Known limitation (sim-mixedmode step 2, pending): the ngspice side does not
re-solve on every MCU pin edge, so a fast (1 Hz) blink does not track in real
time — the analog output changes on a slower cadence. User-driven / slow changes
track fine. Snapshot updated for the new example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The AND Gate Alarm needed BOTH inputs HIGH at once, but it used momentary
pushbuttons buffered through an Arduino — with one mouse you can only hold one
button at a time, so the AND never fired and the alarm could never be
demonstrated.
Rebuilt it as a board-less digital circuit: two SLIDE switches (they latch) feed
a real AND gate that drives the alarm LED. Slide both switches ON and they stay,
so the alarm arms. No MCU / compilation — it runs on the digital gate engine.
Verified live: the LED lights only on 11 (00/01/10 -> off, 11 -> on).
Snapshot updated: the new board-less and-gate-alarm netlist, plus the digital
bucket count label (38 -> 39) from the earlier ripple-counter example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the first board-less SEQUENTIAL gallery example (digital-ripple-counter-4bit):
four T flip-flops chained into a ripple counter, LEDs showing the binary count,
clocked by a slide switch. Impossible on the SPICE engine (no edge detection at
DC) - it runs on the digital gate engine.
Controller fix (found by testing the counter live): the controller rebuilt the
network on every change, which reset flip-flop state so a counter never counted.
Now the network is built once and KEPT ALIVE; a switch toggle applies
incrementally via setSwitch (preserving sequential state), and a rebuild happens
only on a structural change (components/wires). Correct for combinational AND
sequential circuits.
examples-digital.test.ts: flip-flop examples are digital-engine-only, so they are
exempt from the SPICE-mapping / has-a-gate / netlist checks (the "logic" check
now accepts a gate OR a flip-flop). digitalgate-engine-examples: a correctness
test clocks the real counter example and asserts it counts 1..15,0 in binary.
Verified live (?digitalgates default ON): the counter counts 0..6 on the canvas;
and the complex examples all work - comparator-4bit (A=B correct), decoder-3to8
(perfect one-hot x8), alu-slice-1bit (32 combos deterministic), multiplier-2x2
(3*3=9, 7 distinct products), adder-subtractor-4bit (5+3=8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip-flops are edge-triggered and hold state, which the combinational settle
kernel cannot model alone. buildDigitalNetwork now gives each flip-flop explicit
state + rising-CLK-edge detection (reusing the LogicGateParts sample semantics):
sample the data nets on the edge, drive Q + Qbar. Because a flip-flop only
updates on the clock edge, a Q->D / Q->CLK feedback (counter / shift register)
does not oscillate the settle loop. isAllDigital now accepts a gate OR a
flip-flop, so pure sequential circuits qualify.
Test digitalgate-sequential (4): D (capture + hold), T (toggle), JK
(hold/set/reset/toggle), and a 2-bit ripple counter (FF0.Qbar clocks FF1)
counting 1,2,3,0,1 - impossible on the SPICE path (no edge detection at DC, no
SPICE mapper). The controller already routes all-digital circuits through
buildDigitalNetwork, so a board-less counter/shift-register example would run
live; authoring those gallery examples is the only follow-up. Full digitalgate
+ examples-digital + circuit-simulation-service suites green (127 tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildMixedNetwork evaluates the gate (digital) side of a MIXED circuit on the
settle kernel and exposes the boundary with the analog (ngspice) domain. Unlike
buildDigitalNetwork it does not bail on non-primitive components - those are the
analog side; their pins mark the nets they touch as boundary. Exposes
boundaryNets, readBoundary(net) (digital->analog: the gate-driven level to seed
an ngspice voltage source) and setBoundaryInput(net, level) (analog->digital:
ngspice's solved+thresholded level, which re-evaluates downstream gates).
Test digitalgate-mixed-boundary (4): the boundary nets are exactly the
digital/analog bridges; both directions track; a digital->analog->digital
coupler loop converges. No ngspice needed - the analog side is supplied by the
test. Wiring the handoff to the live ngspice netlist (0/Vcc sources + threshold
+ settle<->solve iteration) is the remaining step; it needs the running solver
(the node loader is broken by a pre-existing path bug) and a mixed example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4 (brought forward before the mixed-mode boundary). digitalgate-sweep
proves the engine handles 38/38 gallery digital examples: every one builds,
resolves every LED, and never oscillates. Tightened isAllDigital to also require
at least one logic gate, so a degenerate analog {source, resistor, LED} circuit
stays on ngspice rather than being claimed by the digital path. Flipped
digitalGatesEnabled() default to ON (override with ?digitalgates=off).
Full frontend suite 2120 pass / 5 fail — the 5 are the same pre-existing
unrelated failures (ngspice node-path, attiny85 arduino-cli, component-to-spice
catalog); the default flip adds no new breakage and examples-digital +
circuit-simulation-service stay green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Board-less digital circuits (logic gates + switches + LEDs) run today as ngspice
analog B-sources, which is fragile for deep logic: a 4-bit ripple adder re-solves
but never lights its result LEDs live. This adds an event-driven digital motor
that reuses the multichip-bus settle kernel, so the same engine that boots a Z80
over a chip bus evaluates a gate network exactly and instantly.
Phases 0-2 (project/digital-gate-engine/), all behind ?digitalgates=on (default
OFF — flag off is byte-for-byte the old behaviour):
- digitalGateEngine.ts: buildDigitalNetwork(components, wires) does union-find
over the wires (merging pass-through resistors), identifies the rail/gnd from
the signal-generator, registers drivers (rail STRONG-1, gnd 0, pull resistors
PULL, slide-switch as a pass-gate) and event-driven gates (reusing the
LogicGateParts boolean semantics), settles on busKernel, and exposes
setSwitch / readLed / netOf. Tolerant of both the raw example `type` and the
store `metadataId`. Returns {ok:false} for any non-primitive, so mixed/analog
circuits stay entirely on ngspice.
- digitalGateController.ts + a SimulatorCanvas useEffect: when the flag is on and
the circuit is all-digital, rebuild from the store on switch-toggle / load
(rAF-coalesced) and paint the wokwi-led DOM. CircuitSimulationService.tick()
skips the SPICE solve for all-digital circuits when the flag is on, so the two
motors never fight over the LEDs.
Tests: digitalgate-kernel (22 — single gates -> half/full adder -> 4-bit
adder/subtractor -> exhaustive ADD 256 -> mux/decoder/comparator/parity/
multiplier) and digitalgate-engine-examples (6 — the real gallery data for
and/or/xor/not + the full adder/subtractor). Verified live: ?digitalgates=on
lights the adder's result LEDs that the SPICE path leaves dark. Full suite
2117 pass / 5 pre-existing unrelated fails.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gallery example loaded but the Z80 never visibly ran: the screen stayed
frozen on garbage. Two multi-chip async-load races, neither caught by the
existing headless tests (which drive RESET manually and attach the display
before boot):
1. RESET edge-vs-level race. The Z80 only left reset on the RISING edge of
RESET (a pin watch). In the browser the 7 chips instantiate asynchronously,
so the small power-on-reset chip releases RESET before the larger Z80 has
registered its watch -> the edge is lost and the CPU stays in reset forever.
Fix: on_clock samples the RESET level (hardware-accurate; RESET is
level-sensitive) so a missed edge self-corrects. An undriven RESET reads low,
so the CPU safely stays in reset until something drives it high.
Repro/guard: chipbus-galaksija-reset-race (race ordering must still boot).
2. Display-snoop load-order race. galaksija-display was a passive write-snoop;
the ROM paints the screen ONCE at boot then idles, so a display that comes up
late misses every write and shows stale content forever. A snoop cannot
recover writes it never saw. Fix: fold the screen into the RAM chip
(galaksija-ram-display) and render from the ACTUAL video RAM (0x2800-0x2BFF,
internal 0x0800 with A0-A12 wiring) on a ~30 fps timer - correct regardless
of load order, exactly how the real machine scans video RAM.
Repro/guard: chipbus-galaksija-display-snoop-race (late snoop shows nothing)
+ chipbus-galaksija-ram-display (renders even when first paint is post-boot).
The example now has 6 chips (RAM+display merged, gdisp dropped), 76 wires.
Verified live in the browser: boots to "@'READY", shows the ">" prompt, and
pressing A echoes ">A_" through keyboard -> Z80 -> video RAM -> display. The
full chipbus suite is 45/45.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a memory-mapped keyboard so you can type into the Galaksija. Based on
the libretro Galaksija core's scheme (not guessed): reading 0x2000+offset
returns 0xFE when the key at that matrix offset is held, 0xFF otherwise;
the keyMap gives the offset per key ('A'=1 ... Enter=48, Space=31, etc.).
- galaksija-keyboard.c: drives reads of 0x2000-0x203F from a keys[] table and
exports set_key(offset, down) for the host to push key events. Never drives
outside the keyboard range.
- galaksija-ram.c: ram-64k variant that yields reads of 0x2000-0x203F to the
keyboard (writes still go to RAM), so the two never fight for the bus.
- ChipRuntime: ChipInstance.hasKeyboard + setKey() expose the chip's set_key.
- CustomChipPart: bridges browser keydown/keyup (by KeyboardEvent.code, via
GALAKSIJA_KEY_OFFSET) into the chip, ignoring keystrokes while the code
editor or an input is focused so typing code is never hijacked.
- The gallery example gains the keyboard chip (now 7 chips, 99 wires) and uses
galaksija-ram.
Test chipbus-galaksija-keyboard: pressing 'A' (offset 1) makes the BASIC
monitor echo "A" after its ">" prompt and advances the cursor. 41 chipbus
tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ships the full Galaksija (1983 Z80 home computer) as a runnable Retro
gallery example, plus the pieces needed to run a multi-chip bus live in the
browser.
Gallery example (examples-retro-intel.ts, id 'galaksija-z80-computer'):
Z80 + galaksija-rom (public-domain ROM A+B) + ram-64k + inverter (A13
decode) + galaksija-display + a power-on reset chip, wired chip-to-chip
over the bus (76 wires), no board. Click Resume and it boots the real ROM
to the "READY" prompt on the green display. Chip wasm is embedded
(wasmBase64) so it runs without a backend compile.
- ChipRuntime.tickTimers gains a wall-clock budget (CustomChipPart passes
6 ms): a faithful-but-slow event-driven bus can't run a real-time CPU in
one animation frame, so without a cap a Z80 fetching over the settle
kernel froze the tab. With the budget the sim advances slower than real
time (boots over a few seconds) and the UI stays responsive; fast
single-chip examples finish under budget and are unaffected.
- galaksija-display: blits its framebuffer on a ~30 fps timer instead of on
every character write, so a clear-screen burst doesn't flood the canvas.
- reset-gen: power-on reset (pulses RESET high, ties WAIT/BUSREQ/INT/NMI
high) so the machine boots on Resume without a manual reset.
- chipbus flag now defaults ON (override with ?chipbus=off): chip-to-chip
buses are a core capability; single-chip and board nets never take this
path, so the only thing enabled is multi-chip buses, previously broken.
Verified live in the browser: the example boots and renders "@'READY" with
the ">_" prompt, responsive. Full suite 2084 pass (5 pre-existing,
unrelated env failures).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Galaksija stores ASCII codes in its 0x2800 video RAM (verified by snooping
the boot: it writes "@'READY" + ">_" prompt). The original CHRGEN ROM uses
a hardware-specific addressing that does not map char-code*8 to a glyph, so
rendering through it produced garbled output. Render the ASCII codes with
the public-domain IBM/VGA 8x8 font (font8x8 by Daniel Hepper / Marcel
Sondaar) instead -- legible green-on-black phosphor text. The boot screen
now reads "@'READY" with the ">_" input prompt, exactly like a real
Galaksija. Tests updated to check the bright-green channel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
galaksija-display.c: a 32x16 text video chip that renders the Galaksija
video RAM. It is a passive bus snoop -- watches WR + address + data, and on
a write into the 0x2800 video region stores the character and renders that
cell into a 256x128 framebuffer using the public-domain CHRGEN font (code*8,
bit 0 = lit). It never drives the bus. The host blits the framebuffer to the
chip canvas (vx_framebuffer_init / vx_buffer_write).
Two tests:
- chipbus-galaksija-display: snoop+render smoke test (a write of 'R' to
0x2802 lights its cell; unwritten cells stay blank).
- chipbus-galaksija-computer: the COMPLETE machine over the chip-to-chip bus
(Z80 + galaksija-rom + ram-64k + inverter decode + galaksija-display) boots
the public-domain ROM and renders the monitor's "READY" prompt on screen.
40 chipbus tests across 10 files pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The public-domain Galaksija ROM (Voja Antonic; ROM A monitor + integer
BASIC, ROM B float BASIC, 8 KB) runs on a standalone Z80 + external ROM +
RAM + an inverter for address decode, all chip-to-chip over the shared bus,
no board:
ROM 0x0000-0x1FFF rom.CE = A13
RAM 0x2000-0x3FFF ram.CE = NOT A13 (the inverter chip)
RD -> both OE ; WR -> RAM WE
Pin-level boot proof (mirrors test_intel/test_z80/galaksija.test.js): watch
M1, read the address bus on each opcode fetch, and confirm the Z80 leaves
the reset vector (DI; SUB A; JP 0x03DA), reaches the init routine at 0x03DA,
and runs 1000+ fetches across 50+ distinct ROM addresses -- the real
firmware executing end-to-end through the settle-kernel bus. The on-screen
"READY" prompt is the next milestone (needs the video display chip
rendering the 0x2800 video RAM).
galaksija-rom.c embeds the public-domain ROM A+B image. 38 chipbus tests
across 8 files pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The architectural heart of the retro computer, proven on real chips. A Z80,
a 32K ROM, a 64K RAM and an inverter (address-decode glue) are wired
chip-to-chip over a shared address + data bus, no board:
ROM at 0x0000-0x7FFF rom.CE = A15
RAM at 0x8000-0xFFFF ram.CE = NOT A15 (the inverter chip)
RD -> both OE ; WR -> RAM WE
The ROM program writes 0x5A to RAM at 0x8000, clears A, reads it back, and
HALTs only if the byte survived. HALT going low proves the full core works:
the Z80 runs from ROM, the inverter decodes A15 to select RAM (the settle
kernel drives the combinational glue across hops), and the RAM latches a
write and returns it on a read over the shared tri-state bus, all within
synchronous bus cycles. Adds z80-ram-rom.c (boot image) + ram-64k/inverter
fixtures. 37 chipbus tests across 7 files pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
End-to-end validation of Phases 0-2 on an actual CPU. The real Z80
(examples/intel/z80.c) and a 32K EPROM (z80-boot-rom.c, a rom-32k variant
holding JP 0x0006 / HALT) are wired chip-to-chip over a shared address +
data bus with no board. RD drives the ROM's OE; CE is left enabled.
Booting exercises all three phases at once: the Z80 drives the address ->
the ROM reacts on the shared net key (Phase 0); asserts RD -> the ROM
tri-state-drives the data bus while the Z80 released it (Phase 1); and reads
the data bus in the SAME tickTimers step, getting the settled byte
(Phase 2 settle-before-read). The Z80 fetches C3,06,00, jumps to 0x0006,
fetches 76, and HALTs -> drives HALT low, which the test observes.
z80.wasm is compiled from the committed examples/intel/z80.c; the boot ROM
source + chip.json live in test_custom_chips/sdk/examples. All 36 chipbus
tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes root cause B: a CPU bus cycle drives address+strobe then reads the
data bus in the same tickTimers call, so the memory chip must react before
the read. Phase 0/1 applied each net change by firing PinManager listeners
immediately, which recurses one JS frame per hop - deep glue chains are
deep recursion and a combinational loop overflows the stack.
- busKernel.ts: a delta-cycle settle loop. A net change is recorded in a
pending set, not applied recursively; settle() drains it in batches
(deltas), applying each and letting the driven chips re-dirty the next,
until a fixed point or DELTA_CAP trips (oscillation -> warn, not hang).
Two-phase: a drive lands in pending and is applied on the next delta, so
a chip evaluating mid-settle reads last-stable nets. The first drive of a
cycle settles synchronously before returning to the chip's C code, so the
in-cycle vx_pin_read sees settled data.
- busNets: publishes resolved levels through the kernel instead of calling
triggerPinChange directly.
Tests (chipbus-buskernel): multi-hop chain settles; settle-before-read; a
5000-hop chain settles without stack overflow; a ring oscillator trips the
cap and warns instead of hanging. The two-real-chip integration still
exchanges 0xA5 through the kernel. Full suite 2079 pass flag-off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A chip-to-chip net is now resolved by (value, strength), not last-writer-
wins, so a real multi-driver bus works: many chips on one data line, only
the enabled one drives, the rest release to Hi-Z.
- busLogic.ts: 4-valued (0/1/Z/X) + drive-strength resolution. Strongest
driver wins; equal strength + opposite = X (contention); no driver = Z;
pull resistor = pull strength. modeToDrive maps VX_OUTPUT -> strong,
VX_INPUT -> Hi-Z (the rom/ram/8255 "release by input" idiom becomes real
tri-state), VX_INPUT_PULLUP/DOWN -> pull.
- busNets.ts: per-net driver registry; resolves and pushes the resolved
level into PinManager; warns once on contention.
- syntheticPins.ts: isSyntheticNetPin distinguishes bus net keys.
- ChipRuntime.ts: pin register/write/set_mode route bus-net pins through
busNets (gated by chipBusEnabled + isSyntheticNetPin); non-bus pins keep
the legacy path; dispose releases the chip's bus drivers. SPICE source
emission is skipped for bus pins (digital fast path beside SPICE).
Tests: busLogic (14), busNets (6, incl. tri-state hand-off + contention),
and the two-real-chip integration now exchanges 0xA5 through the registry.
Full suite 2074 pass with the flag off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
End-to-end proof of the chip-to-chip net-key fix through the real
ChipRuntime + PinManager (not a unit stub). Two chips compiled from C
with wasi-sdk:
- bus-driver.c: drives 0xA5 onto D0..D7 at setup.
- bus-reader.c: polls D0..D7 on a 1ms timer, mirrors onto OUT0..OUT7.
Wired chip-to-chip with no board; with the chipbus flag both chips' Dn
pins resolve to one shared net key, so the reader reproduces 0xA5.
- sdk/examples/bus-{driver,reader}.{c,chip.json}: the proof chips.
- __tests__/fixtures/chipbus/*.wasm: committed fixtures (regenerate with
the test_intel/scripts/compile-chip.sh flags).
- __tests__/chipbus-twochip-integration.test.ts: loads the fixtures via a
relative path; skipIf they are absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes root cause A of the multi-chip digital bus track
(project/multichip-bus/): chip-to-chip nets were keyed per-endpoint by
syntheticChipPin(chipId, pinName), so two chips on one wire resolved to
two different PinManager keys and never shared a net.
- chipNets.ts: union-find over the wire graph mints one canonical
syntheticNetPin per net; resolveChipNetKey returns it only for pure
chip-to-chip nets (>=2 chip endpoints, no board pin). Reuses the
existing spice/unionFind.ts.
- syntheticPins.ts: add syntheticNetPin(netId), same allocator/space.
- DynamicComponent.tsx: traceDetailed consults resolveChipNetKey at
depth 0 before the chipNeighbour fallback. Board priority (rule 1) and
chip-to-component (rules 2/3) are unchanged.
- Gated behind ?chipbus=on / localStorage.velxio.chipbus (off by default).
Proof (D-008 go/no-go): __tests__/chipbus-netkey.test.ts - a byte written
on one chip's keys is visible synchronously to another via PinManager.
9 new tests; 85 resolver/PinManager/parts regression tests green flag-off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 7.5" 800x480 dashboard (GxEPD2_750_T7) rendered blank: it is a UC8179 /
GD7965 controller, but the panel config claimed controllerFamily 'ssd168x',
so the SSD168x decoder (which only reads 0x24/0x26/0x44/0x45) ignored its
0x10/0x13 DTM stream.
- Add a Uc8179 decoder (worker Uc8179EpaperSlave + browser Uc8179Decoder).
UC8179 is the same UltraChip command family as the UC8159c (0x10/0x13 DTM,
0x12 refresh) but mono (1 bit/px). GxEPD2 writes the visible image to 0x13
(DTM2 "current"; 0x10 is the ignored "previous"), framed by 0x91/0x90
(partial window, pixel coords MSB-first)/0x13 data/0x92. Data lands at
absolute pixel coords inside the window, so compose is just the RAM. The
Frame reuses the SSD168x palette (0=black, 1=white) so paintFrame renders it.
- EPaperPanels.ts: add the 'uc8179' family and point epaper-7in5-bw at it.
EPaperPart.ts + esp32_worker.py dispatch 'uc8179' to the new decoder.
- Fix the BUSY polarity: UC8179 (like the UC8159c) idles BUSY HIGH, not LOW.
The worker seeded BUSY LOW for every non-uc8159c panel, so GxEPD2_750_T7's
_PowerOn()/_InitDisplay() busy-wait timed out (~10 s, "Busy Timeout!") on
every refresh. Now _PowerOn returns in ~129 us.
- esp32_worker.py: the runtime sensor_attach epaper path still emitted the
epaper_update payload nested under 'data' (the old double-wrap bug); emit
it flat like the init path.
The 5.65" ACeP UC8159c example already rendered (it has its own decoder and
got the WS-plumbing fix); verified the 7 colour bars are correct.
The 2.9" tri-colour ESP32 alert badge rendered the red ALERT pill as white:
the red plane (0x26) was received but landed out of bounds and was dropped.
GxEPD2_3C writes the 0x24 (black) plane then the 0x26 (red) plane WITHOUT
re-seeking the RAM address counter between them — it relies on the SSD168x
counter wrapping back to the window start after the last byte of the window.
Our decoder advanced Y past the window end instead of wrapping, so every
0x26 byte hit y >= rows and was discarded (red_ram stayed all-init).
Mirror the hardware: when the X cursor wraps at the end of a row, advance Y
with a wrap at the active window boundary (yrange), honouring the data-entry
Y direction. Applied identically to the worker slave, the browser decoder,
and the Python golden reference so the three stay in lockstep. No regression
on the mono panels (their counter is re-seeked per plane, so the wrap is a
no-op for them); verified the tri-colour pill now renders red and the 2.9"
weather / 2.13" clock / 1.54" hello panels are unchanged.
ePaper panels rendered rotated/misaligned on AVR and RP2040 (e.g. the 2.13"
Pico clock came out sideways and clipped). The ESP32 worker decoder was just
taught to compose in the controller's native RAM geometry and rotate to the
display orientation, but the browser-side SSD168xDecoder (used by AVR/RP2040)
still composed at display dims with no rotation, so the two diverged.
- SSD168xDecoder.ts: port the worker's native-window compose + rotation.
* Size RAM to the longer side both ways so a rotated native layout
(128x296 behind a 296x128 panel) isn't truncated.
* Compose in the active RAM window, then rotate via the inverse of
Adafruit_GFX setRotation(1). Detect orientation by BYTE width so a
non-multiple-of-8 native width (the 2.13" panel is 122 px) is handled.
* Track the UNION of windows per frame: paged drivers (GxEPD2 page height
< panel) set one partial window per page, so compose must use the full
native area, not just the last page's strip. Fixes the all-white render
on paged panels (1.54" Uno, 4.2" Pico, 7.5" ESP32).
* Add an isBwr option: B/W panels treat 0x26 as a 2nd mono plane (white
only if both planes white), tri-colour panels keep red-wins.
* Default the active window to display geometry; the firmware overrides it.
- EPaperPart.ts: pass isBwr = cfg.palette === 'bwr' to the decoder.
- esp32_spi_slaves.py / esp32_worker.py: mirror the byte-aware rotation +
window-union in the worker, and derive is_bwr from panel_kind on the
runtime sensor_attach path too (fixes the tri-colour ESP32 alert badge).
- test_epaper/ssd168x_decoder.py: re-port the golden reference to match
(keeps the 3-way TS/Python/worker identity invariant). Tests updated to
construct tri-colour cases with is_bwr/palette='bwr'.
- examples-displays-epaper.ts: the Pico VCC wire referenced '3V3(OUT)',
which the velxio-pi-pico-w element doesn't expose (it has '3V3'), so the
wire snapped to the board corner. Use '3V3'.
A tag-based 'Retro' tab (next to All) collects the Z80 / Intel / vintage-CPU
examples via their 'retro' tag, regardless of board filter (they still also
appear under Digital). One-file change: BOARD_TABS + an isRetro predicate
special-cased in the filter and the tab count.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported: on a running circuit, clicking a pushbutton SELECTED the wire under it
instead of pressing the button — and you could still move wires / pick pins to
make connections during a run.
Root cause: component dragging was already locked during a run, but the
canvas-level onClick (wire selection via findWireNearPoint) wasn't — so a click
on a button bubbled to the canvas and selected the wire. The button press itself
fired (shadow DOM), but the wire-select made it feel broken.
Gate every EDIT interaction on the existing interactionRunning predicate while
keeping part interaction (buttons/switches/pots) and pan/zoom:
- canvas onClick wire-selection + onDoubleClick waypoint-insert
- wire segment / waypoint drag handles (mouse + touch)
- pin-click wire creation
- touch tap wire-selection
- hide the PinOverlay (was gated on !running, so board-less runs still showed
clickable pins) and skip wire-hover highlighting while running
- clear any wire/component selection when a run starts so leftover handles don't
linger over the live circuit
Component drag + property dialog were already gated on interactionRunning; this
extends the same 'freeze to edit, run to interact' model to wires and pins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4 of the run-system work. The compile console now groups output into a
section per run target (board or chip) with a status glyph and label, the way
multiple Arduinos already stream — instead of one flat list.
- CompilationLog gains an optional target { id, label, kind: 'board'|'chip' }.
message/type are unchanged so the pro overlay (diagnose-with-AI prompt +
errorCount slot) and the console's length-based clear/auto-error heuristics
are untouched. parseCompileResult stamps the target on every produced line.
- Producers stamp their lines: compileAllBoards (per-board, dropping the old
'<label>: ' string prefix the header now carries), prepareCustomChips
(per-chip, WASM + ROM), handleCompile + handleRun MicroPython (single board) —
including the Pi / MicroPython / FQBN / error paths so a target's lines never
fragment across sections.
- CompilationConsole groups filteredLogs into consecutive-run sections at RENDER
time only (the flat array is unchanged); each target section shows ✓/✕/▸ +
name + kind tag, with no-target lines ('Compiling all targets', 'Done') as
plain narration around them.
Reviewed by an adversarial pass; the flagged un-stamped edge paths (Pi /
MicroPython / single-board errors) are now stamped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3 of the run-system work. Generalises the boards-only Compile-All/Run-All
to RUN TARGETS = boards + programmable custom-chips, so a board+chip or several
chips compile and run together, the same way multiple Arduinos do.
- targetCount = boards + programmable chips; the Compile-All/Run-All buttons now
appear when targetCount > 1 (was boards.length > 1). Cheap string predicate
(no JSON.parse) since the selector runs on every sim tick.
- compileAllBoards builds chips (WASM+ROM) AND boards; works with zero boards;
prepareCustomChips now returns a failure count folded into the Done summary so
a failed chip no longer shows green / calls markCompiled.
- handleRunAll: compiles all targets, starts every board, then restartParts() so
chips pick up fresh WASM/ROM, and resumes the electrical solver when NO board
actually started (board-less, or a board that compiled to nothing) so chips
aren't left frozen.
Review fixes (2-agent adversarial pass):
- Stop now stops EVERY running board (Run-All can start several); otherwise a
non-active board kept the chip ticking after Stop.
- Run-All / Stop disabled gates use anyBoardRunning (+ digitalRunning) instead of
the flat active-board flag, which misreports multi-target runs.
- shared isQemuBoardKind() helper so handleRun and handleRunAll can't drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 of the run-system/UX work.
- BoardInstance gains an optional user ; boardDisplayName(board) resolver
(name || kind label) routes every INSTANCE-label surface: file-explorer
section header, compile console (EditorToolbar), canvas selector/tooltip/
context-menu, Serial Monitor tabs, Oscilloscope board picker, Board Options
subtitle. Board/component pickers keep the KIND label (they pick new boards).
- Inline rename on board AND chip section headers (double-click the name, or a
hover pencil button). Board -> updateBoard(id,{name}); chip -> chipName in
properties. Enter commits, Escape cancels (cancel-flag ref guards the
unmount-fires-onBlur footgun), empty clears to the kind / 'Custom Chip'.
- FileTabs shows an owner badge naming the board/chip whose files are shown
(resolved as a selector so it doesn't re-render on every sim pin toggle).
- CustomChipDialog no longer clobbers a user-given chipName: chip.json's name
only seeds the blank defaults (My Chip / Custom Chip); loading an example
relabels explicitly.
- Persistence: board name round-trips via projectPayload (+ dirty hash),
vlxFile, ProjectByIdPage load + loadProjectState; chipName rides components_json.
- Drive-by: fixed a pre-existing rules-of-hooks violation in BoardOptionsModal
(early return before a useCallback).
Reviewed by a 3-agent adversarial pass (completeness / persistence / correctness);
all major findings folded in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pushbutton SPICE mapper reads pins '1.l' and '2.l', but killbits/counter
wired the power side to '2.r' (an un-unioned sub-pin), so netLookup('2.l')
returned null and the button was omitted from the netlist entirely — pressing
did nothing electrically board-less. Wire the power side via '2.l' so the
button becomes a real (pressed -> 0.01 ohm) bridge to VCC, which the pull-down
+ connectChipInputsToSolve then turn into a HIGH the chip reads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The chip-output board-less path existed (chipPinDrives -> SPICE voltage sources
-> LEDs). The INPUT direction was missing: a chip pin wired to a pushbutton had
its net solved by ngspice, but nothing fed that net's state back to the
PinManager key the chip reads via vx_pin_read. So a board-less chip could light
LEDs but never read a button (verified: i8080 counter stayed at 0 on press).
connectChipInputsToSolve subscribes to the electrical store and, after each
solve, thresholds every wired chip input pin's net voltage to HIGH/LOW and
triggerPinChange()s the chip's synthetic pin — updating getPinState (polling)
and firing onPinChange edges. Pins the chip is actively driving are skipped so
it never fights its own outputs. Hooked alongside connectAnalogInputsToMcu in
start.ts. Solver-agnostic; reads only the electrical store shape.
Also gives the board-less button examples a pull-down on each chip BTN pin so
they read a clean LOW when open (a button-to-VCC floats HIGH otherwise):
i8080-button-counter (2) and i8080-killbits (8).
- new connectChipInputsToSolve.ts; start.ts wiring.
- examples-retro-intel: pull-down resistors + wires for the button examples.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1 of the run-system/UX work.
Stop bug: a programmable chip kept running after Stop when a board was present.
The chip rAF tick gated only on board presence (!boardless), so with a board it
ticked forever. Now it gates on the actual run state: board-less -> electrical
paused flag; with board(s) -> board.running. handleStop also clears every chip's
output drives (clearAllChipDrives) and re-solves so chip-driven LEDs go dark on
Stop instead of freezing at their last frame.
Examples to board-less (regulated power supply, no Arduino — the Arduino only
ever supplied 5V):
- z80-larson-scanner -> 'Z80 Comet Scanner': board-less, a faster TWO-LED comet
(scanner.s) so it's visually distinct from z80-larson-no-board's single-bit
walk; green/blue LEDs.
- i8080-killbits -> board-less (psu + resistors), keeps killbits.s as the chip's
editable program; buttons re-powered from the supply.
- i8080-button-counter -> board-less (psu + resistors); behaviour chip, program
baked in, so it shows a note (no editable file) and runs standalone.
banner-streamer stays Arduino-based (its TX/RX go through the AVR USART bridge).
- CustomChipPart: run-state-aware tick gate.
- EditorToolbar: clearAllChipDrives() helper + handleStop clears chip drives.
- examples-retro-intel: 3 conversions; drop now-unused sketch consts; add the
larsonScannerAsm comet program.
- Tests: board+chip routing now uses an inline synthetic example (gallery chip
examples are all board-less).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes from live testing feedback:
1. Adding a programmable chip (Z80/8080) from the gallery created NO program
group — only the chip(s) from the example had one. Root cause: 'programmable'
was detected by a non-empty programFile, but a fresh chip's programFile is
empty until the user writes one. Now detection uses the canonical signal —
chip.json's programTargets — via isProgrammableChip(). When such a chip
lands with no program yet, the file explorer seeds an editable program.c
(DEFAULT_CHIP_PROGRAM_C, a working walking-LED skeleton) into its own group
and stamps programFile/programTarget onto the component so Compile/Run can
build it. Behaviour/driver and predefined chips (no programTargets) still
get no group — edited in the chip designer.
2. z80-led-chaser-c now runs board-less on a regulated power supply (no Arduino,
mirroring z80-larson-no-board) — the Arduino only ever supplied 5V and added
confusion. chaser.c stays the chip's editable program in its own section.
- romCompileService: isProgrammableChip(), DEFAULT_CHIP_PROGRAM_FILE/_C.
- FileExplorer: detect by programTargets; auto-seed program.c + persist
programFile/programTarget for fresh chips.
- examples-retro-intel: chaser-c -> board-less (psu + 8 resistors + 8 LEDs),
drop the now-unused Arduino sketch const; fix a stale sdcc --code-loc comment.
- Tests: board+chip case moved to z80-larson-scanner (still board-based);
isProgrammableChip unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A programmable custom-chip (a CPU emulator that runs a ROM/program, e.g. the
Z80 or 8080) now keeps its program (larson.s, chaser.c, ...) in a dedicated
editor file group — group-chip-<chipId> — rendered as its own collapsible
section in the file explorer, exactly like each board owns its sketch group.
Behaviour/driver chips and predefined chips carry no programFile and get no
group; they stay editable only in the chip designer.
Fixes two reported issues on the Z80 examples:
- /example/z80-larson-no-board: the board-less chip example now opens its
program (larson.s) as the active group, editable on the left — previously
the editor showed but no file appeared.
- /example/z80-led-chaser-c: the chip program (chaser.c) no longer shows as
a sibling tab inside the Arduino sketch group; it sits in its own chip
section instead. The board group shows only sketch.ino.
Details:
- useEditorStore: chipFileGroupId()/CHIP_GROUP_PREFIX helpers.
- loadExample: seedChipProgramGroups() routes each chip's programFile into its
own group (seeded from the example files), sweeps stale chip groups, keeps
the program OUT of the board group, and for a board-less chip example makes
the chip group active so the program is the editable file shown.
- EditorToolbar.prepareCustomChips: resolves the program from the chip's own
group (falls back to board files for older projects) before assembling ROM.
- FileExplorer: renders one collapsible section per programmable chip with an
IC icon; clicking switches the editor to the chip group. Lazy-creates a
group for chips dropped on the canvas.
- projectPayload + vlxFile: serialise chip groups alongside board groups and
include them in the dirty-check hash, so chip-program edits persist on
save / autosave / .vlx export and round-trip via replaceFileGroups on load.
- Regression tests for board-less + board+chip routing and stale-group sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two UX bugs in the board-less "Z80 Larson Scanner (no board)" example:
- It loaded "running" (electrical sim defaults to paused=false), so Run was
disabled and Stop enabled even though the chip hadn't started — the user had
to Stop then Run. loadExample now starts a board-less example that contains a
custom chip in the STOPPED state (paused=true) so Run is enabled; pure
analog/digital circuits stay live.
- The chip's program wasn't editable: it shipped a pre-baked ROM and the
board-less loader only setCode'd into an orphan file group (no-op → blank
editor). The example now ships larson.s as a real file (programFile), and
the board-less loader points the editor at the default group and loadFiles()
the example's files, so the program shows on the left and is editable, like
the board-backed examples. Run compiles it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Velxio can now simulate one or more custom-chip CPUs with NO Arduino/ESP32
board on the canvas — a general-purpose electronics simulator, not an
MCU-only one.
- DynamicComponent: board-less parts get the real shared flat PinManager
(instead of a no-op stub) so a custom chip's digital pin writes/reads reach
the LEDs/inputs wired to it.
- CustomChipPart: the rAF tick respects board-less Run/Stop (freezes while
the electrical sim is paused); board behaviour is unchanged.
- EditorToolbar.handleRun: board-less Run compiles each chip's WASM/ROM and
re-attaches the parts (restartParts) so they pick up the fresh WASM, then
resumes the solver.
- useSimulatorStore.restartParts(): bump hexEpoch to force part re-attach.
- New example "Z80 Larson Scanner (no board)": a programmable Z80 + 8 LEDs +
the adjustable power-supply component, no MCU. The chip drives the LEDs
through the synthetic-pin + ngspice path added earlier.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SDCC treats plain `char` as unsigned on Z80, so `dir = -1` read back as 255,
`if (dir > 0)` was always true, the "walk right" branch never ran, and the
bit just shifted left until it fell off the end and the LEDs went dark after
one pass. Use `signed char dir`. Verified in a chip-WASM harness: with plain
char the chaser does 8 LED writes then stops; with signed char it walks the
bit back and forth continuously (14894 writes). Completes the C example fix
together with dropping --code-loc 0x100 in c_compile.py.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SDCC's z80 crt0 sets SP=0x0000 and makes its first stack push at 0xFFFF.
The chip only mapped RAM at 0x8000-0xBFFF (0xC000+ was MMIO/ignored), so the
stack landed on unmapped memory and a plain C program crashed inside crt0 —
before main — which is why z80-led-chaser-c compiled but drove nothing.
Extend RAM to cover 0x8000-0xFFFF (32 KB) with the MMIO window 0xC000-0xC0FF
carved out and checked first, in scripts/make-z80-cpu.py + regenerated
z80-cpu.c. Now SDCC's default stack works and "write C from scratch, click
Run" just works — no manual `LD SP` needed (dropped from chaser.c). Bumped
the chip WASM initial memory to 4 pages to hold the larger RAM buffer. Larson
(asm, SP=0xBFFF, LED at 0xC000) is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SDCC's z80 crt0 defaults SP to 0x0000; on the z80-cpu chip's memory map
(RAM 0x8000-0xBFFF, MMIO at 0xC000+) the stack would grow into unmapped
high memory and the program crashed on the first CALL (delay), so the LEDs
never moved. Set SP to the top of RAM (0xBFFF) at the start of main, the
same thing the asm Larson example does with "LD SP, 0xBFFF". Verified the
ROM runs and walks the LEDs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`(*(volatile unsigned char __at(0xC000)))` uses __at as a cast operator,
which neither avr-gcc nor sdcc accept (sdcc: "syntax error: token -> ')'").
__at is a storage specifier, not an operator. Use the portable absolute-
address pointer form `(*(volatile unsigned char *)0xC000)`, which sdcc -mz80
compiles cleanly. Verified: produces a 462-byte ROM.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>