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; + } +}