diff --git a/src/RP2040Sharp/Peripherals/Ppb/PpbPeripheral.cs b/src/RP2040Sharp/Peripherals/Ppb/PpbPeripheral.cs
index 6fb50cb..62d0c6d 100644
--- a/src/RP2040Sharp/Peripherals/Ppb/PpbPeripheral.cs
+++ b/src/RP2040Sharp/Peripherals/Ppb/PpbPeripheral.cs
@@ -44,7 +44,8 @@ public sealed class PpbPeripheral : IMemoryMappedDevice, ITickable
// SysTick state
private uint _systCsr;
private uint _systRvr;
- private long _systCvr; // kept as long to handle large delta gracefully
+ private long _systCvr; // kept as long to handle large delta gracefully
+ private long _systAnchor; // _cpu.Cycles at which _systCvr was last brought up to date
// NVIC priority registers — 8 × uint → 32 IRQs, 2 priority bits each (bits 7:6)
private readonly uint[] _nvicIpr = new uint[8];
@@ -58,20 +59,32 @@ public PpbPeripheral(CortexM0Plus cpu)
// ── ITickable ────────────────────────────────────────────────────
- /// Advance SysTick by cycles.
- public void Tick(long deltaCycles)
+ /// Bring SysTick up to date with the owning core's cycle counter, firing COUNTFLAG and
+ /// the SysTick exception for any reload boundaries crossed since the last sync.
+ /// SysTick counts off the core clock, so it must be sampled against
+ /// and never against the peripheral tick quantum: firmware busy-waits on CVR
+ /// (machine.bitstream times WS2812 bit widths this way), and a CVR that only moves on the
+ /// tick boundary makes every such wait expire at the same instant.
+ /// is ignored — the router ticks both cores' PPBs with a shared
+ /// delta, but each core's SysTick advances with that core's own cycles.
+ public void Tick(long deltaCycles) => SyncSysTick();
+
+ private long SystReload => _systRvr > 0 ? _systRvr : 0xFFFFFF;
+
+ private void SyncSysTick()
{
- if ((_systCsr & 1) == 0) return; // SysTick not enabled
+ var now = _cpu.Cycles;
+ var delta = now - _systAnchor;
+ _systAnchor = now;
+
+ if ((_systCsr & 1) == 0 || delta <= 0) return; // SysTick not enabled, or nothing elapsed
- _systCvr -= deltaCycles;
+ _systCvr -= delta;
- // Handle one or more rollovers (usually 0 or 1 per Tick call)
while (_systCvr <= 0)
{
_systCsr |= 1u << 16; // COUNTFLAG
-
- long reload = _systRvr > 0 ? (long)_systRvr : 0xFFFFFF;
- _systCvr += reload;
+ _systCvr += SystReload;
if ((_systCsr & 2) != 0) // TICKINT
_cpu.TriggerSysTick();
@@ -87,6 +100,9 @@ public uint ReadWord(uint address)
if (offset >= NVIC_IPR0 && offset <= NVIC_IPR7)
return _nvicIpr[(offset - NVIC_IPR0) >> 2];
+ if (offset is SYST_CSR or SYST_CVR)
+ SyncSysTick();
+
return offset switch
{
SYST_CSR => _systCsr,
@@ -130,15 +146,21 @@ public void WriteWord(uint address, uint value)
switch (offset)
{
case SYST_CSR:
+ SyncSysTick();
_systCsr = value & 0x7; // ENABLE | TICKINT | CLKSOURCE
break;
case SYST_RVR:
+ SyncSysTick();
_systRvr = value & 0x00FFFFFF;
break;
case SYST_CVR:
- _systCvr = 0;
+ SyncSysTick();
+ // ARMv6-M: a CVR write clears the counter and COUNTFLAG and must not raise a SysTick
+ // exception. Parking at 0 would instead roll over on the very next cycle, so seed the
+ // reload the hardware performs on the following clock.
+ _systCvr = SystReload;
_systCsr &= ~(1u << 16); // clear COUNTFLAG
break;
diff --git a/tests/RP2040Sharp.IntegrationTests/Tests/MicroPythonBitstreamTests.cs b/tests/RP2040Sharp.IntegrationTests/Tests/MicroPythonBitstreamTests.cs
new file mode 100644
index 0000000..8cbf6ac
--- /dev/null
+++ b/tests/RP2040Sharp.IntegrationTests/Tests/MicroPythonBitstreamTests.cs
@@ -0,0 +1,113 @@
+using FluentAssertions;
+using RP2040Sharp.IntegrationTests.Infrastructure;
+
+namespace RP2040Sharp.IntegrationTests.Tests;
+
+///
+/// Regression tests for machine.bitstream (the transport under the neopixel module).
+/// The rp2 port encodes each bit as a high-pulse width and busy-waits on SysTick's CVR between the
+/// pin_high and pin_low writes, so a SysTick that only advances on the peripheral tick boundary
+/// collapses both delays to zero and every bit leaves the chip with the same width.
+///
+[Trait("Category", "Integration")]
+public sealed class MicroPythonBitstreamTests
+{
+ private static bool ShouldSkip =>
+ Environment.GetEnvironmentVariable("SKIP_INTEGRATION_TESTS") == "1";
+
+ private const string Version = "v1.21.0";
+ private const int DataPin = 2;
+
+ ///
+ /// Drives a 3-pixel WS2812 frame whose GRB bytes are a known mix of 0x00 and 0xFF, so the
+ /// wire carries 24 one-bits and 48 zero-bits, and asserts that the captured high-pulse widths
+ /// fall into two separated clusters with a ~2x ratio (WS2812 T0H 0.4us vs T1H 0.8us).
+ ///
+ [Fact]
+ public async Task Bitstream_ZeroAndOneBits_ProduceDistinguishableHighPulses()
+ {
+ if (ShouldSkip) return;
+
+ await using var runner = await MicroPythonRunner.CreateAsync(Version);
+ if (runner is null) return;
+
+ runner.WaitForPrompt().Should().BeTrue("MicroPython must reach the REPL");
+
+ runner.ExecuteAndWait("import machine, neopixel", ">>> ").Should().BeTrue();
+ runner.ExecuteAndWait($"np = neopixel.NeoPixel(machine.Pin({DataPin}), 3)", ">>> ").Should().BeTrue();
+ runner.ExecuteAndWait("np[0] = (255, 0, 0); np[1] = (0, 255, 0); np[2] = (0, 0, 255)", ">>> ")
+ .Should().BeTrue();
+
+ var widths = CaptureHighPulseWidths(runner);
+
+ widths.Count.Should().BeGreaterThanOrEqualTo(72,
+ "a 3-pixel frame carries 72 bits, each one a high pulse");
+
+ var shortest = widths.Min();
+ var longest = widths.Max();
+
+ longest.Should().BeGreaterThan(shortest,
+ "a zero-bit and a one-bit must not leave the pin with the same high time");
+
+ var ratio = (double)longest / shortest;
+ ratio.Should().BeInRange(1.5, 3.0,
+ "WS2812 encodes the bit as T1H/T0H ~ 2x; got {0} vs {1}", longest, shortest);
+
+ // Every pulse must belong to one of the two clusters — a spread continuum would mean the
+ // widths are noise rather than an encoding.
+ var midpoint = (shortest + longest) / 2.0;
+ var zeros = widths.Where(w => w < midpoint).ToList();
+ var ones = widths.Where(w => w >= midpoint).ToList();
+
+ (zeros.Max() - zeros.Min()).Should().BeLessThan((long)((longest - shortest) / 4.0),
+ "the zero-bit pulses must form a tight cluster");
+ (ones.Max() - ones.Min()).Should().BeLessThan((long)((longest - shortest) / 4.0),
+ "the one-bit pulses must form a tight cluster");
+
+ // The point of the encoding is that a downstream LED can recover the payload: threshold the
+ // widths at the midpoint and the frame must decode back to the GRB bytes that were written.
+ var decoded = new byte[9];
+ for (var bit = 0; bit < 72; bit++)
+ if (widths[bit] >= midpoint)
+ decoded[bit / 8] |= (byte)(0x80 >> (bit % 8));
+
+ decoded.Should().Equal(new byte[]
+ {
+ 0x00, 0xFF, 0x00, // pixel 0 = (255, 0, 0) sent G, R, B
+ 0xFF, 0x00, 0x00, // pixel 1 = (0, 255, 0)
+ 0x00, 0x00, 0xFF, // pixel 2 = (0, 0, 255)
+ }, "the pulse widths must carry the frame the firmware wrote");
+ }
+
+ ///
+ /// Runs np.write() while recording, for every SIO write that flips the data pin, the
+ /// core-0 cycle count, and returns the width of each high pulse.
+ ///
+ private static List CaptureHighPulseWidths(MicroPythonRunner runner)
+ {
+ var machine = runner.Simulation.Rp2040;
+ var sio = machine.Sio;
+ var cpu = machine.Cpu;
+
+ var widths = new List();
+ var level = sio.GetGpioOut(DataPin);
+ long? roseAt = null;
+
+ sio.OnGpioChanged = () =>
+ {
+ var now = sio.GetGpioOut(DataPin);
+ if (now == level) return;
+ level = now;
+ if (now)
+ roseAt = cpu.Cycles;
+ else if (roseAt is { } start)
+ widths.Add(cpu.Cycles - start);
+ };
+
+ runner.Execute("np.write()");
+ runner.Simulation.RunMilliseconds(200);
+ sio.OnGpioChanged = null;
+
+ return widths;
+ }
+}