Skip to content

RP2350 / Hazard3 RISC-V core (re-based on latest upstream + reviewed)#1

Merged
prat96 merged 9 commits into
mainfrom
rp2350-riscv
Jun 27, 2026
Merged

RP2350 / Hazard3 RISC-V core (re-based on latest upstream + reviewed)#1
prat96 merged 9 commits into
mainfrom
rp2350-riscv

Conversation

@prat96

@prat96 prat96 commented Jun 27, 2026

Copy link
Copy Markdown

What this is

The foundation of rp2350js — c1570's RP2350 / Hazard3 RISC-V emulation core re-based onto the latest upstream wokwi/rp2040js, with the RISC-V core put under an adversarial spec review and corrected.

This is the first reviewable cut. The RISC-V core is in good shape and fully tested; the RP2350 peripheral layer is the next phase (tracked in ROADMAP.md).

Lineage & attribution (all MIT)

  1. wokwi/rp2040js (Uri Shaked) — the RP2040 base.
  2. c1570/rp2040js rp2350js/WIP — added the entire RP2350 / Hazard3 RISC-V core (~50 h). Imported here in a commit authored to c1570, with CREDITS.md and a full-history git bundle preserving provenance.
  3. This fork — re-base + RISC-V core fixes.

Commits

Commit What
1b761ac Import c1570's RP2350 / Hazard3 engine (27 files) — authored to c1570
ed0bfd6 ESM-adapt to upstream's NodeNext (mechanical, no logic change)
90487da Fix the RISC-V core — 13 defects + Zcb + mstatus, each with a falsifiable test
bca1a2e Fork identity — CI, LICENSE/CREDITS, README, ROADMAP, demo fixtures

RISC-V core correctness

An adversarial spec review (7 ISA-area reviewers, every finding independently verified) surfaced 19 confirmed defects in c1570's WIP core. All fixed, each guarded by a regression test in src/riscv/test/cpu-fixes.spec.ts proven to fail on the pre-fix engine (negative control). Highlights:

  • The whole RV32M multiply familyMUL used JS float64 * (silently wrong for products > 2^53); MULHSU was undecoded and crashed the core. Now exact via Math.imul / BigInt.
  • Synchronous trap entryECALL/EBREAK landed at mtvec + ilen, skipping the handler's first instruction.
  • Hazard3 external IRQ 0 (TIMER0) was never delivered (NOIRQ tested via irq==0 instead of the meinext sign bit).
  • Plus SLTIU sign-extension, JALR LSB masking, CSRRC write gating, warm-reset PC, mip.MEIP preemption gating, the trap mstatus update, and the Zcb compressed instructions the bootrom needs.

See ROADMAP.md for the full defect table.

Tests

345 pass, 3 skipped. The 3 skips are chip-level integration tests (blink_simple, hello_timer, pio_blink) that depend on the RP2350-parameterized peripherals — they fail identically on the pristine import, i.e. a peripheral gap, not a CPU regression. CI runs vitest on Node 20 & 22.

Next

RP2350 peripheral parameterization (port c1570's IRPChip IRQ/DREQ abstraction onto the upstream peripherals) → type-clean tsc build and un-skip the 3 integration tests. See ROADMAP.md.

c1570 and others added 5 commits June 27, 2026 12:23
These 27 files are the RP2350 (Hazard3 RISC-V, machine-mode) emulation
core, imported verbatim from c1570's fork of rp2040js:

  source:      https://github.com/c1570/rp2040js  branch rp2350js/WIP
  at commit:   2f0eebc
  forked from: upstream wokwi/rp2040js at 554f23e (merge-base)

All RP2350-RISC-V work here is c1570's (MIT, same license as upstream).
The full 253-commit history of his branch is preserved as a git bundle in
the GhostLabs project that seeds this fork; see CREDITS.md for attribution.

This commit is the engine verbatim (pre-ESM, as in his tree). Subsequent
commits adapt the imports to upstream's ESM build, wire RP2350 into the
barrel exports + shared peripherals, and apply the Zcb / trap-entry fixes.

Contents: the RISC-V CPU + assembler (src/riscv/*), the RP2350 chip and
its SIO / IRQ / core abstraction (rp2350.ts, rpchip.ts, core.ts, sio*.ts,
irq_rp2350.ts), and the RP2350 peripheral variants (*_rp2350.ts, bootram,
powman, syscfg, sysinfo, pll, pads, io, dma).

Signed-off-by: Pratheek Balakrishna <pratheekb96@gmail.com>
The engine was imported verbatim from c1570's pre-ESM tree. Upstream
rp2040js uses "module": "NodeNext", which requires explicit .js
extensions on relative import specifiers. Mechanical codemod over the
27 imported files: 80 relative specifiers across 22 files.

No logic changes. Verified: c1570's RISC-V unit specs (41) and the full
upstream + RP2350 vitest suite (333 tests) pass on the upstream base.

Signed-off-by: Pratheek Balakrishna <pratheekb96@gmail.com>
…fects

The imported engine is c1570's WIP. An adversarial spec review (7 ISA-area
reviewers, each finding independently verified) found 19 confirmed defects,
clustered in the M extension and trap/PC handling. This applies the fixes,
each guarded by a falsifiable test (src/riscv/test/cpu-fixes.spec.ts) proven
to fail on the pre-fix engine and pass on the fixed one.

RV32M (multiply was computed with JS float64, wrong above 2^53):
- MUL  low word via Math.imul (was a rounded float64 product; ~95% of random
  operands wrong).
- MULH upper word via BigInt (float64 `/2^32 >>> 0` truncates toward zero, so
  every negative product was wrong).
- MULHSU: implement it (was undecoded -> threw and aborted the core).
- MULHU upper word via BigInt (float64 division was off-by-one near 2^32).

Trap / control transfer (the next_pc==0 sentinel collided with real targets):
- Synchronous traps (ECALL/EBREAK) now execute the handler's first instruction
  at mtvec instead of mtvec+ilen, via an explicit branch_taken flag.
- Control transfers to address 0 (bootrom base) are honoured, not dropped.
- CPU.reset() restores the PC so a warm reset re-enters the boot vector.
- Trap-entry mstatus clears only MIE (was `&= 1<<7`, which wiped MPP etc.).

RV32I / Zicsr:
- SLTIU sign-extends the immediate before the unsigned compare (was comparing
  against the zero-extended 0..4095 immediate).
- JALR clears bit 0 of the computed target (spec-mandated mask was missing).
- CSRRC write-suppression keyed on the rs1 index, not its runtime value.

Hazard3 Xh3irq:
- External IRQ index 0 (TIMER0_IRQ_0) is now deliverable (NOIRQ tested via
  meinext bit 31, not irq==0).
- mip.MEIP gated by PREEMPT, not PPREEMPT (was spuriously asserted in a frame).

Plus the Zcb decoder completion (c.lbu/lhu/lh/sb/sh, c.zext/sext.b/h, c.mul,
c.not) the RP2350 bootrom needs.

Findings credited to the review; the engine remains c1570's work. Full suite:
342 tests pass (was 333; +9 fix tests). Illegal-instruction encodings still
throw rather than trapping mcause=2 - an intentional debug aid, documented.

Signed-off-by: Pratheek Balakrishna <pratheekb96@gmail.com>
- package.json: rename to rp2350js, point at the GhostRoboticsLab repo, and move
  the build off `prepublish` (which ran on every `npm install` and failed because
  the peripheral type-clean is still pending) to `prepublishOnly`.
- LICENSE: add c1570 (RP2350 RISC-V) and GhostRoboticsLab copyright lines.
- CREDITS.md: the wokwi -> c1570 -> this-fork lineage, attribution intact.
- README.md / ROADMAP.md: status, the RISC-V core fixes, and the staged RP2350
  peripheral parameterization work.
- .github/workflows/ci.yml: run the vitest suite on node 20/22; drop the three
  upstream RP2040 workflows (micropython / pico-sdk / lint) that need toolchains
  a fork can't satisfy.
- Import c1570's RP2350 demo fixtures (bootrom + 3 RISC-V firmware images) and
  enable src/rp2350.spec.ts. Its three firmware-integration cases are skipped
  pending the peripheral parameterization (they fail identically on the pristine
  import, so they are not a RISC-V core regression).

Full suite: 345 pass, 3 skipped.

Signed-off-by: Pratheek Balakrishna <pratheekb96@gmail.com>
Make the peripheral layer chip-aware so the RP2350 (and RP2040) share one
peripheral set through a common interface. `npx tsc --noEmit` goes from 63
errors to 0, and the `blink_simple` firmware-integration test now passes
(RP2350 boots its A2 bootrom and drives GPIO via SIO).

Core abstraction:
- RP2040 now `implements IRPChip`; `BasePeripheral` holds an `IRPChip` instead
  of the concrete `RP2040`, so both chips construct the shared peripherals.
- `GPIOPin` delegates its function-select -> peripheral-output dispatch to the
  chip via a narrow `IGPIOChipHost` (each chip owns its function map; RP2350
  adds `FUNCTION_PIO2`). Fixes the construction-order trap by seeding
  `lastValue` to a constant.

Peripheral parameterization (RP2350 numbers differ from RP2040):
- RESETS: per-chip `reset_mask` (RP2350 = 0x1fffffff, 29 blocks). This was the
  boot-hang: the bootrom de-asserts a high-numbered block and spins on
  RESET_DONE for it, which the 25-bit RP2040 mask never set.
- TIMER: `timer_irq_base` param + RP2350 INTR/INTE/INTF/INTS offsets (shifted
  +8). With this the RP2350 timer interrupt fires.
- PWM / ADC / USB: IRQ / DREQ base constructor params.
- PIO: `IRPChip`, `dreq{Rx,Tx}_base`, `getPinValue`/`getPinOutputEnabled`, a
  `gpiobase` field for the RP2350 GPIOBASE window.
- UART / SPI / I2C: `IRPChip`; numeric DREQ ids (the chips' DREQChannel enums
  differ). DMA DREQ raised via `IRPChip.dma_{set,clear}DREQ`.
- IClock gains `tick()`; RP2040-only syscfg/ppb narrow the chip ref to reach
  the ARM core.

cpu.ts: avoid ES2020 BigInt literals (target is es2018) and make the MSTATUS
trap store explicit (no switch fallthrough).

Tests: 346 pass, 2 skipped. `blink_simple` un-skipped and passing; the ~300
RP2040 tests are unaffected by the shared gpio-pin / rp2040 refactor. The two
remaining skips (hello_timer timing/runtime, pio_blink GPIOBASE) are documented
in ROADMAP.md.

Signed-off-by: Pratheek Balakrishna <pratheekb96@gmail.com>
@prat96

prat96 commented Jun 27, 2026

Copy link
Copy Markdown
Author

Update: RP2350 peripheral parameterization landed (99fa5ff)

The peripheral layer is now multi-chip, and npx tsc --noEmit is clean (63 → 0 errors).

  • RP2040 and RP2350 both implement IRPChip; BasePeripheral depends on IRPChip, and GPIOPin delegates function-select dispatch to the chip via a narrow IGPIOChipHost.
  • Per-chip parameterization where RP2350 differs: RESETS reset_mask (the boot-hang — bootrom spun on RESET_DONE for a block above RP2040's 25-bit mask), TIMER IRQ base + the RP2350 INT register offsets, plus IRQ/DREQ params on PWM/ADC/USB/PIO/UART/SPI/I²C and DREQ routed through IRPChip.dma_*DREQ.

blink_simple now passes — the RP2350 boots its A2 bootrom and drives GPIO via SIO. 346 tests pass, 2 skipped; the ~300 RP2040 tests are unaffected by the shared gpio-pin/rp2040 refactor.

Two integration tests stay skipped with documented reasons (see ROADMAP.md): hello_timer (the timer IRQ now fires — went 0 → working — but the exact long-run repeat count needs an idle clock fast-forward, and the 250M-step loop is slow), and pio_blink (needs PIO GPIOBASE for GPIO32 + a misaligned-PC fault to diagnose).

prat96 added 3 commits June 27, 2026 15:38
Tracing showed the RP2350 timer interrupt fires and delivers cleanly (5 fires);
the stall after ~4 iterations is a multicore inter-core-FIFO interaction
(read-on-empty state sensitive to core0/core1 step interleaving), not the
idle/clock-stepping model. Update the skip note + ROADMAP accordingly.

Signed-off-by: Pratheek Balakrishna <pratheekb96@gmail.com>
Found by lockstepping this engine against c1570's original (which passes both
firmware tests) and bisecting the first architectural divergence.

1. MEINEXT must reset to NOIRQ, not 0.
   A zeroed MEINEXT reads as "IRQ 0, NOIRQ clear". The IRQ-0-delivery gate
   (!NOIRQ) then treats it as a real pending IRQ 0 the instant the firmware
   enables interrupts — a phantom external trap before any IRQ has fired. That
   trap corrupts the stack and, several layers down, is what stalled hello_timer
   after a few timer periods. Resetting MEINEXT to NOIRQ keeps index 0
   deliverable (a real IRQ 0 sets MEINEXT=0/NOIRQ-clear) while the idle state
   reads correctly.

2. Trap targets must mask the MTVEC mode bits (mtvec[1:0]).
   Exceptions and direct-mode interrupts go to BASE = mtvec & ~3; only vectored
   interrupts add an offset. The old code jumped to the raw mtvec, so a vectored
   mtvec (RP2350 firmware sets 0x20000001) sent an EBREAK/exception to the odd
   address 0x20000001 -> fetch of a misaligned "instruction 0" -> crash. This is
   the pio_blink early-crash; with the mask it correctly vectors to 0x20000000.

Also reverts the updateMEINEXT mip.MEIP gating from PREEMPT back to PPREEMPT
(matching c1570 / the Hazard3 reference) — that earlier change was untested and
firmware-visible; PPREEMPT is the validated behavior.

Tests: 347 pass, 1 skipped. hello_timer is un-skipped and passing (250M-step
firmware run, exactly 19 timer-driven prints). pio_blink stays skipped — its
crash is fixed, but driving GPIO from PIO on RP2350 still needs the RP2350 PIO
feature set (GPIOBASE, 32-bit pin mask, IN_COUNT) ported from c1570.

Signed-off-by: Pratheek Balakrishna <pratheekb96@gmail.com>
Signed-off-by: Pratheek Balakrishna <pratheekb96@gmail.com>
@prat96

prat96 commented Jun 27, 2026

Copy link
Copy Markdown
Author

Update: hello_timer now passes — two RISC-V core trap bugs fixed (8d71e34)

I found these by lockstepping this engine against c1570's original (which runs both firmware tests) and bisecting the first architectural divergence — not by reading the spec. Both turned out to be in cpu.ts, from my own earlier "fixes" interacting badly:

  1. MEINEXT must reset to NOIRQ, not 0. A zeroed MEINEXT reads as "IRQ 0, NOIRQ clear", so the !NOIRQ IRQ-0-delivery gate took a phantom IRQ 0 the instant the firmware enabled interrupts — corrupting the stack and, several layers down, stalling hello_timer after a few timer periods. (I'd first wrongly hypothesised an idle-clock issue, then a multicore-FIFO one; the lockstep disproved both.)
  2. Trap targets must mask the MTVEC mode bits (mtvec & ~3). A vectored mtvec — RP2350 firmware sets 0x20000001 — otherwise sent an EBREAK/exception to the odd address 0x20000001 → misaligned fetch → crash. This was the pio_blink early crash.

Also reverted the (untested, firmware-visible) mip.MEIP-by-PREEMPT gating back to PPREEMPT, matching c1570 / the Hazard3 reference.

hello_timer is now un-skipped and passing — a full 250M-step firmware run producing exactly 19 timer-interrupt-driven prints. Suite: 347 pass, 1 skip.

pio_blink stays skipped: its crash is fixed, but driving GPIO from PIO on RP2350 needs the RP2350-specific PIO feature set ported from c1570 (GPIOBASE register for GPIO32, the 32-bit pin mask, IN_COUNT, the RP2350 PIO IRQ-index mode). That's the next chunk — see ROADMAP.md.

The last skipped firmware integration test, pio_blink, drives two PIO blocks:
one toggles GPIO3/4, the other GPIO32/33. GPIO32 is only reachable once a PIO
block re-bases its internal 32-pin window onto chip GPIO16..47 via the RP2350
GPIOBASE register. The early crash was already fixed (the MTVEC mask); what
remained was this pin-window feature, which c1570 implemented and the modern
upstream lacks.

Surgical port (does NOT adopt c1570's SM step-model rewrite):

1. GPIOBASE register (0x168), RP2350-only: read returns gpiobase; write sets
   gpiobase = value & 16 (the GPIOBASE bit is bit 4, so 0 or 16). On RP2040 the
   address falls through to BasePeripheral unchanged.
2. checkChangedPins now iterates the PIO's internal 32-pin space (0..31) and
   notifies gpio[pinIndex + gpiobase]. The old loop ran gpioIndex over
   gpio.length and did `1 << gpioIndex`, which aliases pins 32..47 back onto
   0..15 (JS shifts are mod-32) and never applied the offset, so GPIO16..47 were
   never notified. RP2040 behaviour is unchanged (gpiobase 0, bounds-checked).
3. 32-bit pad mask in pinValuesChanged/pinDirectionsChanged
   (isRp2040 ? 0x3fffffff : 0xffffffff); RP2040 keeps its 30-bit mask.
4. isRp2040 discriminator (this.rp2040.identifier === 'rp2040') gates the above.

Why no step-model change: the blink program uses the default clock divider
(1.0), at which the new fork's one-instruction-per-cycle PIO model and c1570's
clock-divided model are identical, so the working RP2040 PIO step model is left
untouched. pio_blink's `2 toggles / 2M steps` calibration therefore holds.

Deferred (not exercised by pio_blink, each to land with its own test): IN_COUNT
masking, the PIO IRQ-index mode (resolveIrqTarget), neighbour-SM synchronous
restart, FJOIN RX/TX, and the WAIT PIN gpiobase offset. See ROADMAP.md.

Verification: pio_blink un-skipped and passing (gpio3toggle == 2,
gpio32toggle == 2). Full suite 348/348, none skipped; tsc clean. Adversarially
verified: reverting either checkChangedPins or the GPIOBASE write drives the
gate red (gpio32toggle -> 0), GPIOBASE=16 is observed at runtime, and no c1570
step-model fields were introduced.

Signed-off-by: Pratheek Balakrishna <pratheekb96@gmail.com>
@prat96

prat96 commented Jun 27, 2026

Copy link
Copy Markdown
Author

Update: pio_blink now passes — RP2350 GPIOBASE pin-window ported (c8e208e). Suite is now 348/348, none skipped.

The last skipped firmware test drives two PIO blocks — one toggling GPIO3/4, one toggling GPIO32/33 — and asserts exactly two rising edges on each. GPIO32 can't be reached until a PIO block re-bases its internal 32-pin window onto chip GPIO16..47 via the RP2350 GPIOBASE register. The earlier crash was already gone (the MTVEC mask); this was the missing pin-window feature.

Surgical port (deliberately not adopting c1570's SM step-model rewrite):

  1. GPIOBASE register (0x168, RP2350-only): read returns gpiobase; write sets gpiobase = value & 16. On RP2040 the address falls through to BasePeripheral unchanged.
  2. checkChangedPins offset. It now iterates the PIO's internal 32-pin space (0..31) and notifies gpio[pinIndex + gpiobase]. The old loop ran gpioIndex over gpio.length and did 1 << gpioIndex — which aliases pins 32..47 back onto 0..15 (JS shifts are mod-32) and never applied the offset, so GPIO16..47 were never notified.
  3. 32-bit pad mask (isRp2040 ? 0x3fffffff : 0xffffffff); RP2040 keeps its 30-bit mask.

Why no step-model change: the blink program uses the default clock divider (1.0), at which the new fork's one-instruction-per-cycle PIO model and c1570's clock-divided model are identical — so the working RP2040 step model is untouched and the 2 toggles / 2M steps calibration holds.

Adversarially verified (an independent skeptic pass tried to break it): reverting either the checkChangedPins rewrite or the GPIOBASE write drives the gate red (gpio32toggle → 0); GPIOBASE=16 is observed actually being written at runtime; no c1570 step-model fields (curClockInt, remainingDelay, machinesRunning, …) were introduced; only pio.ts + the spec un-skip changed; 348/348 pass; tsc clean. (It also caught that the SDK's free-SM allocator puts the GPIO0..31 program on PIO2, not PIO0 — both have gpiobase 0, so routing is correct; the docs say PIO2.)

Deferred (not exercised by this plain counter-blink, each to land with its own falsifiable test): IN_COUNT masking, the PIO IRQ-index mode (resolveIrqTarget), neighbour-SM synchronous restart, FJOIN_RX/TX, and the WAIT PIN gpiobase offset. See ROADMAP.md.

That closes the integration-test arc: blink_simple (SIO), hello_timer (timer IRQ), and pio_blink (PIO + GPIOBASE) all green.

@prat96
prat96 merged commit dd16659 into main Jun 27, 2026
4 checks passed
@prat96
prat96 deleted the rp2350-riscv branch June 27, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants