An STM32CubeMX-style pinout, peripheral and clock configurator for WCH RISC-V microcontrollers.
Pick a part, click a pin, choose a signal. The app shows you which peripherals can reach which pins on that exact package, colours the conflicts orange, and tells you before you click when a choice would collide with something you have already set.
This project is mostly vibe-coded. The application was written by AI coding agents under a written working agreement, with a human setting direction. It is fast and internally consistent, and it has one specific, well-documented blind spot: an AI will confidently write plausible-looking code for a chip it has never seen. It has shipped exactly that bug three times — a wrong SDK header, a GPIO speed macro that does not exist, and a drive mode the silicon cannot do. All three were caught by building something, never by reading code.
The same blind spot has a second shape, and it is the one to worry about: correct code that never runs. On CH32H417 the DMA init struct was filled with every field the user chose and then never applied — no
DMA_Init(), noDMA_Cmd(). A configured DMA transfer compiled clean, reported zero conflicts, and moved nothing, with the coverage ledger at zero, the validator at zero errors and every test green over it. It was one of three instances of that shape found in a single cycle. The cause is structural: most of what each part models has never been generated-and-compiled by any fixture — CH32X035's one fixture configures zero of its 27 peripherals — so a green suite says the files are consistent, never that the code runs. Seetests/evidence/round6/for the per-part map of what has actually been exercised.And nothing here has ever been flashed. Every green result in this repository is a compile. No board has been attached. Hardware behaviour is unverified.
→ docs/HOW-IT-WORKS.md explains both in detail, and is worth ten minutes before you rely on anything below.
Four tabs, and each answers a different question.
Pinout & Configuration — the chip drawn to scale for the package, the peripheral tree, and
the GPIO table. Hovering a peripheral says what its name stands for (SDIO → SD input/output
interface) and selecting it describes what that kind of block does; hovering a pin says what
the pad is, what voltage it takes and what to connect to it (VDDIO, VREFP, VSSA). The tree
is grouped by category and sorted, so USART1–USART8 sit together and TIM2 comes before
TIM10. The Power setup under System Core lists every supply domain with its pads, its
range and its citation — including the constraints a pin table cannot carry, such as VDDA never
exceeding VDD.
Clock Configuration — the clock tree, drawn and computed. On CH32H417 that means five PLL
blocks (the SYS PLL plus USBHS, Ethernet, USB SS and SerDes) and all eight per-peripheral source
muxes (USBFS, RNG, I2S2, I2S3, HSADC, UHSIF, LTDC, ETH1G). Every tap either
reports a number or says why it cannot; a tap that would silently ignore a mux does not render a
plausible figure instead.
Project Manager — everything that will be generated, previewed file by file before you commit to it, with the generator's own TODOs marked in the preview. One Generate button.
Tools — what this MCU file does not say: pads nobody has extracted yet and who owns them,
signals that are routed but no setting can claim, peripherals with no description, and every
place a value falls back to a default because the file is silent (no gpio.speeds, no
codegen: block, a part number with no PlatformIO board). It reads none on a finished part,
and its job is to make sure it keeps saying so.
| Document | Read it when |
|---|---|
| docs/HOW-IT-WORKS.md | You want to understand the design and exactly how much of it to trust. Start here. |
| docs/ADDING-A-PART.md | Your microcontroller is not supported. The whole process: drop the vendor files in, let an AI extract them, verify. |
| docs/COVERAGE.md | You are extracting a part, or asking whether one is finished. The coverage ledger: every function on every pin the datasheet lists is modelled, declared absent with a citation, or open — and a part is not done while a row is open. |
| docs/WCH-MCU-CATALOG.md | You are asking which part to add next, or whether one is already modelled. Every MCU WCH publishes, read from the vendor's own product API and joined against data/mcus/. A pointer, never a citation. Its progress table says which parts are done, which two are queued, and which have not been started — 11 of WCH's 87 catalogue rows have data. |
| data/FORMAT.md | You are writing or editing an MCU file. The field-by-field schema. |
| PROGRESS.md | What is actually done, broken, and never tested. |
| docs/ | Index of all of the above. |
Supported parts — the list is data/mcus/, and adding to it does not require touching app/.
Six, across three families. That is 11 of the 87 rows in WCH's own catalogue;
docs/WCH-MCU-CATALOG.md has
the full progress table — what is done, what is queued, and what has not been started.
Peripheral and package counts below are what the engine reports after loading each file, not what a header says:
| Part | Peripherals | Packages | Why it is in the list |
|---|---|---|---|
| CH32V006 | 19 | 5 | the reference part; every other one is checked against it and none is copied from it |
| CH32V005 | 17 | 4 | defined as a delta on CH32V006 — its own file holds one peripheral and the rest is inherited |
| CH32V003 | 16 | 4 | the same family, a different die: no GPIOB at all, and no OPA interrupt where CH32V006 has one |
| CH32X035 | 27 | 7 | a deliberately different family (24-bit ports, no HSE, named remap macros) that exists to prove the engine is data-driven rather than shaped around one chip |
| CH32L103 | 33 | 5 | low-power, with USB PD and three comparators sharing one vector |
| CH32H417 | 78 | 3 | dual-core, with SerDes, Ethernet, LTDC and USB SS — the part that stops "it works on the small ones" being the whole claim |
All six read status: complete in the coverage ledger (python tools/coverage.py --quiet): every
function the datasheet puts on a pin is modelled, or declared absent with a file:line. That is
not the same as every peripheral being configurable to the same depth — a peripheral with no
params: still has its mode choices, but no numbers to set, and some of those are deliberate
(a CRC unit has no init struct to fill):
| Part | Peripherals with params: |
Deliberately without |
|---|---|---|
| CH32V006 | 14 of 19 | SYS, RCC, DMA1, EXTI, TKEY — choices only |
| CH32V005 | 13 of 17 | the same four as CH32V006, inherited with the rest of the part |
| CH32V003 | 11 of 16 | as CH32V006, plus OPA1 |
| CH32X035 | 14 of 27 | the comparators, op-amps and PIOC are choice-driven; FLASH, USBFS, USBPD declared absent |
| CH32L103 | 26 of 33 | SYS, RCC, EXTI, DMA1, USBFS, USBPD, OPA1 |
| CH32H417 | 67 of 78 | 9 are ABSENT with a citation; DMA1/DMA2 are the remaining gap |
The per-peripheral table is
tests/evidence/round6/2026-09-13-h417-peripheral-map.md.
WCH_CubeMX/
├── dist/index.html BUILT app - open it in any browser, no server, no network
├── app/
│ ├── template.html the page: CSS, markup, and the render/UI script
│ ├── engine/*.js the engine as ES modules (model, conflicts, clock, project, export)
│ └── vendor/js-yaml.js bundled YAML parser (MIT), so the app works offline
├── data/
│ ├── mcus/*.yaml one file per MCU - the whole part definition
│ ├── packages/packages.yaml package geometries (SOP, TSSOP, QFN, LQFP...)
│ ├── sources/<PART>/ the DS, the RM and the EVT package
│ └── firmware/ PlatformIO project - compiles the generated C for real silicon
├── docs/ how it works, how to add a part, the coverage ledger (with pictures)
├── build.py inlines the engine and the YAML into dist/index.html
├── tests/ QA suites + the runner (npm test)
├── app/tests/ engine unit tests
├── tools/ the gates and the headless CLI: validate_mcu.py, coverage.py,
│ ledger.py, verify_sdk_names.py, wchcube_cli.js
├── graft/ the context graph for AI agents (git-ignored, regenerable)
├── src-tauri/ desktop shell (Tauri 2)
├── PROGRESS.md where the project stands, long form - read this first
├── TASKS.md what is claimed, done and next
└── agents/ README (the agreement) + STATUS (the single source of
truth) + BOARD (the log). See "How this repo is built"
Open dist/index.html. That is the whole instruction — it is a single self-contained
file with no external requests, so it works from a USB stick or an offline machine.
After changing anything in app/ or data/, rebuild:
python build.py(python3 on macOS and Linux. Never edit dist/index.html by hand — it is generated.)
The desktop build is the same page in a native window, plus the things a browser cannot do: it reads the MCU folder from disk, uses native file dialogs, and reloads a part the moment you save its YAML in another editor.
cd src-tauri
cargo run # development
cargo tauri build # installers in src-tauri/target/release/bundle/You need Rust and the
Tauri prerequisites for your OS. build.py runs
automatically before a release build.
MCU files are read from two places, and the second wins:
| Folder | What it is |
|---|---|
the app's bundled data/mcus/ |
the parts that ship with the release |
~/.wch_cubemx/mcus/ |
your own parts and overrides |
Drop a YAML in the second folder and it appears in the MCU list. Edit one while the app is open and the part reloads in place, keeping your package selection.
New project — pick the MCU, then the exact part number and package. The dialog shows flash, SRAM, I/O count and temperature grade for each variant.
Save project (Ctrl+S) writes <name>.wchproj: readable YAML holding the MCU, variant,
package and every setting, remap and pin. In the browser it downloads; on the desktop you
get a native save dialog.
Open project restores all of it. One project is one MCU on one package. The matching MCU file has to be loaded first — bundled, in your override folder, or via Open MCU file….
One button, on the Project Manager tab, producing one of two scopes.
The project (default) is a PlatformIO folder you can open and flash:
<name>/
├── platformio.ini, README.md, .gitignore
├── src/main.c
├── lib/wchcube_generated/include/{wchcube_init.h, BoardPins.h}
├── lib/wchcube_generated/src/wchcube_init.c
└── docs/ pin table (md + csv), KiCad pin table, pinout SVG, clock summary
Only the pin map turns off everything above and outputs BoardPins.h alone. Use it when
you already have a project of your own: the file names every pad the configuration uses, so
you can write BOARD_USART1_TX instead of GPIO_Pin_9. It configures nothing — it is the
map, not the driver.
#define BOARD_USART1_TX_PORT GPIOA
#define BOARD_USART1_TX_PIN GPIO_Pin_9
#define BOARD_USART1_TX_AF GPIO_AF7
#define BOARD_STATUS_LED_PIN GPIO_Pin_13 /* your own label */Because that scope needs no board, it also works on a package no PlatformIO board exists for — a part number is only needed to pick one.
Everything the app can emit is also reachable from a script, which is how CI drives it:
node tools/wchcube_cli.js CH32V006 --package TSSOP20 --format all --out build/--format |
What it writes |
|---|---|
pins-md, pins-csv |
the pin table as Markdown, and as CSV |
pins-kicad |
a KiCad Symbol Editor pin table, for pasting into a symbol |
pins-svg |
a standalone pinout diagram, self-contained and self-styled |
pins-h |
BoardPins.h — the pin map as C macros |
clocks-md |
the clock tree and the frequency at each tap |
c |
wchcube_init.c/.h, the initialisation code |
json |
machine-readable state: pins, conflicts, clocks, peripheral status |
(all is every one of those except json, which CI asks for by name.)
--option <key>=<value> sets any generator option for one run (--option list prints them
all), and --strict exits non-zero if the configuration conflicts or the generated C carries
a TODO — that is the flag CI gates on.
The pinout SVG is also a button on the chip toolbar (⤓), and the same renderer backs the browser's print view, so what you print is the drawing you were looking at rather than a second implementation of it.
-
Start from
data/mcus/CH32V006.yaml— a complete real part with datasheet citations on every block — and readdata/FORMAT.mdfor the schema. (data/mcus/holds real silicon only; the synthetic layout fixture lives intests/fixtures/mcus/.) -
Fill in
mcu:, thepackages:pin tables, thepins:list and theperipherals:. -
Check it:
python tools/validate_mcu.py data/mcus/YOUR_PART.yaml
-
python build.py, then reload the page. Or skip the rebuild entirely and use Open MCU file… to load the YAML straight from disk.
The one idea worth knowing: alternate functions are never listed per pin. Each
peripheral carries a remaps: table saying which pin each signal lands on for each value
of the AFIO remap field. The app derives every pin's signal list, and every conflict, from
those tables. One place to edit, and the conflicts are computed rather than maintained.
- Close the coverage ledger —
python tools/coverage.py YOUR_PARTlists every function the datasheet puts on a pin that the file does not model, every RM chapter and every SDK instance it does not account for, and exits 1 while any remain. Zero open rows is what "done" means here;docs/COVERAGE.mdis the loop.
tools/validate_mcu.py checks schema, that every package has a geometry to draw it with,
that pin numbers run 1..N with no gaps, that every signal a mode can request is actually
routed somewhere, that remap indices are in range, and that each variant's datasheet I/O
count matches the pin table. Use --strict to treat warnings as errors.
npm install # jsdom + js-yaml, test-only
npm test # builds, then runs every suitenpm test runs node tests/run.js, which executes the engine unit tests in app/tests/ and
the QA suites in tests/ — around seventy suites in all. The ones worth knowing about:
| Suite | What it protects |
|---|---|
app/tests/* |
the engine: model, conflicts, clock, codegen, project round-trip, undo, the pin map |
tests/coverage.test.js |
the coverage ledger — every part meets the status it declares, and the ledger's own tool is proven to catch every planted break |
tests/strict.test.js |
--strict exits 0 on every fixture, and exits 2 on a planted conflict |
tests/completeness.test.js |
every peripheral of every part is reachable: a setting, a clock bit, a vector, and no routed signal that no choice can claim |
tests/codegen_compile.test.js |
the generated C for each part actually compiles against the vendor SDK |
tests/generated_project.test.js |
a generated project is self-contained and builds with pio run |
tests/cross_loader.test.js |
the Python gates and the app's YAML parser agree about the same file |
tests/data.test.js |
every MCU file is valid, drawable and loadable |
tests/build.test.js |
dist/index.html is complete, current, offline, and has no duplicate declarations |
tests/layout.test.js |
the CubeMX layout, the chip fitting its canvas, and that no panel grid is mis-sized |
tests/desktop.test.js |
the Tauri config and bridge — including that it does nothing at all in a browser |
tests/smoke.js |
every MCU on every package: load, switch, click, assign, zero console output |
Filter with node tests/run.js <text>, e.g. node tests/run.js smoke.
If npm install cannot write to this folder — a known npm failure on Google Drive and
network shares (EBADF) — install the two test dependencies anywhere and the tests will
find them:
npm install --prefix "%LOCALAPPDATA%\wchcube-deps" jsdom@25 js-yaml@4Set WCHCUBE_DEPS to point somewhere else. The Rust in src-tauri/ is compiled by CI;
tests/desktop.test.js covers everything about the shell that does not need a toolchain.
data/firmware/ is a PlatformIO project that compiles for real CH32V003 / CH32V005 /
CH32V006 / CH32X035 / CH32L103 / CH32H417 silicon with the WCH EVT NoneOS SDK. It is where the configurator's generated
wchcube_init.c/.h becomes an ELF — and the only place the claim "the generated C
compiles" can be checked instead of asserted.
cd data/firmware
pio run # default environment: CH32V006F8P6 (TSSOP20)
pio run -t upload # over WCH-LinkEight part environments ship — CH32V006F8P6, CH32V006K8U6, CH32V005F6P6, CH32V003F4P6,
CH32X035G8U6, CH32L103K8U6, CH32H417QEU6, CH32H417WEU6 — plus native, which compiles
the HAL on the host so its logic can be tested without a board. pio run -e <env> picks one.
In VS Code, open data/firmware as the folder (PlatformIO needs platformio.ini at the
workspace root), or open data/firmware/wchcube-firmware.code-workspace to get the repo
and the firmware side by side.
Feed it a configuration from the repo root:
node tools/wchcube_cli.js CH32V006 --package TSSOP20 --format c --out data/firmware/lib/wchcube_generated/srcsrc/main.c picks it up through __has_include and calls WCHCube_Init(); with the drop
zone empty the firmware still builds and says so at startup. data/firmware/README.md has
the details and data/firmware/ARCHITECTURE.md has the layering and ownership rules.
Three Claude Code agents work on it in parallel — DATA, APP and QA + RELEASE —
each owning a part of the tree so they never edit the same file. agents/ is the single agent
working directory and it is three files, which every agent reads every time:
agents/README.md |
the working agreement — what the product is, who owns what, the work cycle, the gates, the handoff protocol |
agents/STATUS.md |
the single source of truth — the measured state with the command that printed each number, the open list with an owner per row, what each agent has in flight right now, the acceptance script, what only a human can do, the backlog |
agents/BOARD.md |
the append-only log: decisions, findings, requests between agents |
Work is claimed and ticked in TASKS.md, and QA is the only agent that ticks a
line in STATUS.md §2 — on evidence, named on the line. Closed rounds are archived under
agents/history/ and are never read during a work cycle. If you are
picking the project up by hand, read agents/STATUS.md first; it is written so that somebody
who has never seen the repo can resume whatever was in flight when the last session ended.
The repo is indexed by graft, which
builds a context graph as linked markdown under the git-ignored graft/ directory — a local,
regenerable cache that agents query instead of re-reading source files. graft build after a
big change; graft ask "<question>", graft callers <symbol> and graft map are the entry
points. It is a development convenience and nothing in app/, data/ or the tests depends on
it.
The two things that would help most, in order:
- Flash one generated project and report what happened. It is a five-minute job with any
CH32V003/CH32V005/CH32V006/CH32X035/CH32L103/CH32H417 board and a WCH-Link, and it is the
only claim in this repository that nobody here can close. See
agents/STATUS.md§6. - Add a part. If your microcontroller is missing,
docs/ADDING-A-PART.mdis the whole process — it is a data job, not a programming job, and it does not require understanding the engine.
Before opening a PR, the gate is two commands and both must be green:
python build.py && node tests/run.jsBeyond that, the rules that matter and the reasons for them are in
agents/README.md. The short version:
- A hardware fact needs a citation — a datasheet table, an RM section, or an EVT
file:line. Precedence is EVT → Reference Manual → Datasheet. "The other CH32 parts have it" is not a citation, and it is precisely how all three of the shipped data defects happened. - If you changed what the generator emits, compile it on a configuration that assigns pins. A default configuration generates an empty function and proves nothing.
- If you changed the UI, look at it in a real browser. jsdom has no layout engine and cannot see clipped text; it is necessary, not sufficient.
- Never delete a data file or a test, and never lower a threshold to make something pass.
dist/index.htmlis generated — never hand-edit it.- Nothing in
app/may name a part. If behaviour differs between parts, it belongs in the data.
The PR template walks through these. Answering "I did not run that" is much more useful here than a guess.
Plain JavaScript, SVG and YAML. No framework and no bundler — build.py concatenates the
engine modules and inlines the data, which is why the result is one file you can email to
someone. The desktop wrapper is Tauri 2 (Rust), and it loads the very same file.
