diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 149162c..05166fe 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -66,6 +66,22 @@ jobs:
--no-build
--output ./nupkgs
+ # ── BUSL-1.1 package: CYW43439 Wi-Fi + BLE virtualization ───────────────
+ - name: Pack RP2040.Wireless
+ run: >
+ dotnet pack src/RP2040.Wireless/RP2040.Wireless.csproj
+ -c Release
+ --no-build
+ --output ./nupkgs
+
+ # ── BUSL-1.1 package: bundles the NanoSymbols generator as an analyzer ───
+ - name: Pack RP2040Sharp.TestKit.NanoFramework
+ run: >
+ dotnet pack src/RP2040.NanoFramework.TestKit/RP2040.NanoFramework.TestKit.csproj
+ -c Release
+ --no-build
+ --output ./nupkgs
+
# ── Push to nuget.org (tags, or a manual push_public run) ───────────────
- name: Push to nuget.org
env:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67f9ef1..425ae8f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.0.1-beta.2] - 2026-06-25
+
+Third prerelease of the `1.0.1` line. Ships two new **BUSL-1.1** packages and the CI
+wiring to publish them, on top of the CYW43439 wireless stack, a second bootrom revision,
+and a nanoFramework integration-test harness landed since `1.0.1-beta.1`.
+
+No breaking changes to the MIT core — `RP2040Sharp` and `RP2040Sharp.TestKit` remain MIT.
+
+### Added
+
+- **`RP2040.Wireless` (BUSL-1.1)** — firmware-agnostic CYW43439 Wi-Fi + Bluetooth LE
+ virtualization for the Raspberry Pi Pico W (gSPI/PIO, SDIO backplane, WHD/SDPCM, BT HCI),
+ now packaged and published to nuget.org.
+- **`RP2040Sharp.TestKit.NanoFramework` (BUSL-1.1)** — boot a deployed .NET nanoFramework
+ app on the emulator and assert against it: managed static/instance fields by name,
+ run-until-managed-method, and a bundled **NanoSymbols** source generator (shipped as an
+ analyzer with a `buildTransitive` targets file) that emits strongly-typed symbols for the
+ app's methods and static fields.
+- **B2 bootrom + revision selection**, **PIO FIFO probe**, and an emulator **strict mode**
+ that surfaces silent accuracy gaps.
+
+### Changed
+
+- `publish.yml` now packs and publishes the two BUSL-1.1 packages alongside the MIT core.
+
+### Fixed
+
+- **SSI direct-command flash RX** so the bootrom/CLR flash-access path works.
+- **USB CDC** OUT delivery moved to a push model; `EP_ABORT` no longer drops deferred
+ completions.
+
## [1.0.0] - 2026-06-06
First stable release. Promotes `1.0.0-rc.2` after the TestKit gained CI guardrails for
@@ -81,7 +112,8 @@ First public release candidate. A high-performance RP2040 emulator in modern C#
- Flash programming uses C# hooks rather than the SSI XIP hardware path.
- USB host support is CDC-only (sufficient for the MicroPython REPL).
-[Unreleased]: https://github.com/PyMCU/RP2040Sharp/compare/v1.0.0...HEAD
+[Unreleased]: https://github.com/PyMCU/RP2040Sharp/compare/v1.0.1-beta.2...HEAD
+[1.0.1-beta.2]: https://github.com/PyMCU/RP2040Sharp/compare/v1.0.1-beta.1...v1.0.1-beta.2
[1.0.0]: https://github.com/PyMCU/RP2040Sharp/compare/v1.0.0-rc.2...v1.0.0
[1.0.0-rc.2]: https://github.com/PyMCU/RP2040Sharp/compare/v1.0.0-rc.1...v1.0.0-rc.2
[1.0.0-rc.1]: https://github.com/PyMCU/RP2040Sharp/releases/tag/v1.0.0-rc.1
diff --git a/RP2040.sln b/RP2040.sln
index 0f74577..85e9137 100644
--- a/RP2040.sln
+++ b/RP2040.sln
@@ -18,6 +18,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RP2040Sharp.Demo.CircuitPyt
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RP2040Sharp.Runner", "src\RP2040Sharp.Runner\RP2040Sharp.Runner.csproj", "{2C4890EB-07AD-4197-BE40-6268A2C1DD94}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RP2040.Wireless", "src\RP2040.Wireless\RP2040.Wireless.csproj", "{62DDE19A-912F-4A89-BC09-747DE5E05345}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RP2040.NanoFramework.TestKit", "src\RP2040.NanoFramework.TestKit\RP2040.NanoFramework.TestKit.csproj", "{F81F075C-6A42-40ED-8061-2272EC8C3B3E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RP2040.NanoFramework.TestKit.SourceGen", "src\RP2040.NanoFramework.TestKit.SourceGen\RP2040.NanoFramework.TestKit.SourceGen.csproj", "{54BD74A3-CFF4-444C-95F6-F7A3DD5D6837}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -112,6 +118,42 @@ Global
{2C4890EB-07AD-4197-BE40-6268A2C1DD94}.Release|x64.Build.0 = Release|Any CPU
{2C4890EB-07AD-4197-BE40-6268A2C1DD94}.Release|x86.ActiveCfg = Release|Any CPU
{2C4890EB-07AD-4197-BE40-6268A2C1DD94}.Release|x86.Build.0 = Release|Any CPU
+ {62DDE19A-912F-4A89-BC09-747DE5E05345}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {62DDE19A-912F-4A89-BC09-747DE5E05345}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {62DDE19A-912F-4A89-BC09-747DE5E05345}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {62DDE19A-912F-4A89-BC09-747DE5E05345}.Debug|x64.Build.0 = Debug|Any CPU
+ {62DDE19A-912F-4A89-BC09-747DE5E05345}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {62DDE19A-912F-4A89-BC09-747DE5E05345}.Debug|x86.Build.0 = Debug|Any CPU
+ {62DDE19A-912F-4A89-BC09-747DE5E05345}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {62DDE19A-912F-4A89-BC09-747DE5E05345}.Release|Any CPU.Build.0 = Release|Any CPU
+ {62DDE19A-912F-4A89-BC09-747DE5E05345}.Release|x64.ActiveCfg = Release|Any CPU
+ {62DDE19A-912F-4A89-BC09-747DE5E05345}.Release|x64.Build.0 = Release|Any CPU
+ {62DDE19A-912F-4A89-BC09-747DE5E05345}.Release|x86.ActiveCfg = Release|Any CPU
+ {62DDE19A-912F-4A89-BC09-747DE5E05345}.Release|x86.Build.0 = Release|Any CPU
+ {F81F075C-6A42-40ED-8061-2272EC8C3B3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F81F075C-6A42-40ED-8061-2272EC8C3B3E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F81F075C-6A42-40ED-8061-2272EC8C3B3E}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F81F075C-6A42-40ED-8061-2272EC8C3B3E}.Debug|x64.Build.0 = Debug|Any CPU
+ {F81F075C-6A42-40ED-8061-2272EC8C3B3E}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F81F075C-6A42-40ED-8061-2272EC8C3B3E}.Debug|x86.Build.0 = Debug|Any CPU
+ {F81F075C-6A42-40ED-8061-2272EC8C3B3E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F81F075C-6A42-40ED-8061-2272EC8C3B3E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F81F075C-6A42-40ED-8061-2272EC8C3B3E}.Release|x64.ActiveCfg = Release|Any CPU
+ {F81F075C-6A42-40ED-8061-2272EC8C3B3E}.Release|x64.Build.0 = Release|Any CPU
+ {F81F075C-6A42-40ED-8061-2272EC8C3B3E}.Release|x86.ActiveCfg = Release|Any CPU
+ {F81F075C-6A42-40ED-8061-2272EC8C3B3E}.Release|x86.Build.0 = Release|Any CPU
+ {54BD74A3-CFF4-444C-95F6-F7A3DD5D6837}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {54BD74A3-CFF4-444C-95F6-F7A3DD5D6837}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {54BD74A3-CFF4-444C-95F6-F7A3DD5D6837}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {54BD74A3-CFF4-444C-95F6-F7A3DD5D6837}.Debug|x64.Build.0 = Debug|Any CPU
+ {54BD74A3-CFF4-444C-95F6-F7A3DD5D6837}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {54BD74A3-CFF4-444C-95F6-F7A3DD5D6837}.Debug|x86.Build.0 = Debug|Any CPU
+ {54BD74A3-CFF4-444C-95F6-F7A3DD5D6837}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {54BD74A3-CFF4-444C-95F6-F7A3DD5D6837}.Release|Any CPU.Build.0 = Release|Any CPU
+ {54BD74A3-CFF4-444C-95F6-F7A3DD5D6837}.Release|x64.ActiveCfg = Release|Any CPU
+ {54BD74A3-CFF4-444C-95F6-F7A3DD5D6837}.Release|x64.Build.0 = Release|Any CPU
+ {54BD74A3-CFF4-444C-95F6-F7A3DD5D6837}.Release|x86.ActiveCfg = Release|Any CPU
+ {54BD74A3-CFF4-444C-95F6-F7A3DD5D6837}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -124,5 +166,8 @@ Global
{0B49F4F8-6B4B-40F0-978D-B8799AABC99C} = {F168743D-BA33-466E-AFAF-BFC9DD2AF698}
{9D8E08C3-BF88-41FB-BC88-DE36F64A6157} = {F168743D-BA33-466E-AFAF-BFC9DD2AF698}
{2C4890EB-07AD-4197-BE40-6268A2C1DD94} = {F168743D-BA33-466E-AFAF-BFC9DD2AF698}
+ {62DDE19A-912F-4A89-BC09-747DE5E05345} = {F168743D-BA33-466E-AFAF-BFC9DD2AF698}
+ {F81F075C-6A42-40ED-8061-2272EC8C3B3E} = {F168743D-BA33-466E-AFAF-BFC9DD2AF698}
+ {54BD74A3-CFF4-444C-95F6-F7A3DD5D6837} = {F168743D-BA33-466E-AFAF-BFC9DD2AF698}
EndGlobalSection
EndGlobal
diff --git a/rp2040js-issues/README.md b/rp2040js-issues/README.md
new file mode 100644
index 0000000..0ed51a0
--- /dev/null
+++ b/rp2040js-issues/README.md
@@ -0,0 +1,39 @@
+# Candidate upstream issues for rp2040js
+
+Drafts for issues to file against [rp2040js](https://github.com/wokwi/rp2040js) (Uri Shaked),
+the project RP2040Sharp / RP2350Sharp were ported from.
+
+## Method (and a caveat)
+
+These were sourced from the C# ports' "fix" commits and `rp2040js`-referencing comments, **then each
+candidate was verified against the actual rp2040js 1.3.2 source** before being written up. That
+verification step matters: most apparent "divergences" between the C# port and rp2040js turned out
+**not** to be upstream bugs. They were either:
+
+- bugs the C# port introduced itself and later fixed (rp2040js was correct all along), or
+- internal representation/convention differences that are functionally equivalent
+ (e.g. the port counts OSR bits *remaining* where rp2040js counts bits *shifted* — `OsrCount > 0`
+ and `outputShiftCount < pullThreshold` express the same `!OSRE` condition).
+
+Examples that were investigated and **dismissed** as upstream bugs (rp2040js is correct):
+JMP PIN (reads `inputValue`), JMP `!OSRE`, IN/OUT shift-by-32 (handled via the `bitCount === 0`
+special case — JS bit-ops are mod-32 too, but the encoding never passes a literal 32), autopull
+timing (already checked at the start of OUT), autopush stall (ISR value is preserved via
+`wait(..., inputShiftReg)` and re-pushed on wake), PUSH/PULL no-block, exception-return
+`eventRegistered`, `IC_COMP_PARAM_1` (returns 0), PWM phase-correct start direction
+(`countingUp = true` default + `TimerMode.ZigZag`).
+
+## Confirmed issues
+
+| File | Component | Confidence |
+|---|---|---|
+| `adc-rrobin-ainsel-mask.md` | ADC `activeChannel` setter masks with `CS_AINSEL_SHIFT` not `CS_AINSEL_MASK` | **High** — one-line typo, root-caused in source |
+
+## Finding more, properly
+
+The reliable way to surface genuine upstream bugs is **differential testing**, not diffing the port's
+comments: run rp2040js and a reference (QEMU `mps2-an385` Cortex-M0, or real Pico 1 silicon) on the
+same firmware/instruction stream and compare register/memory state — the same technique used to
+validate RP2350Sharp bit-for-bit against QEMU. That campaign is the recommended next step if more
+upstream issues are wanted; the comment-mining approach has a high false-positive rate (≈1 real bug
+in ~20 candidates here).
diff --git a/rp2040js-issues/adc-rrobin-ainsel-mask.md b/rp2040js-issues/adc-rrobin-ainsel-mask.md
new file mode 100644
index 0000000..76c7644
--- /dev/null
+++ b/rp2040js-issues/adc-rrobin-ainsel-mask.md
@@ -0,0 +1,78 @@
+# ADC: `activeChannel` setter masks with `CS_AINSEL_SHIFT` instead of `CS_AINSEL_MASK` (breaks round-robin / corrupts AINSEL)
+
+**Component:** `src/peripherals/adc.ts`
+**Version checked:** 1.3.2 (also present on current `main` at time of writing)
+
+## Summary
+
+The private `activeChannel` setter writes the channel number into `CS.AINSEL` using the wrong
+constant: it ANDs the channel with `CS_AINSEL_SHIFT` (the bit *position*, `12`) instead of
+`CS_AINSEL_MASK` (the field *mask*, `0x7`). This corrupts the stored channel for most values, so
+round-robin conversion advances to the wrong channel (or gets stuck), and any internal update of
+`AINSEL` reads back a wrong channel.
+
+## Location / root cause
+
+```ts
+// src/peripherals/adc.ts
+const CS_AINSEL_MASK = 0x7; // line 20
+const CS_AINSEL_SHIFT = 12; // line 21
+
+private set activeChannel(channel: number) {
+ this.cs &= ~(CS_AINSEL_MASK << CS_AINSEL_SHIFT);
+ this.cs |= (channel & CS_AINSEL_SHIFT) << CS_AINSEL_SHIFT; // line 151 ← BUG
+}
+```
+
+`channel & CS_AINSEL_SHIFT` is `channel & 12` (`0b1100`), not `channel & 0x7`. For the valid
+channel range 0–4 this yields:
+
+| channel in | `channel & 12` (stored) |
+|---|---|
+| 0 | 0 |
+| 1 | 0 |
+| 2 | 0 |
+| 3 | 0 |
+| 4 | 4 |
+
+So setting the active channel to 1, 2 or 3 silently stores **0**.
+
+## Impact
+
+The round-robin sampler computes the next channel correctly but then writes it through this
+setter:
+
+```ts
+// src/peripherals/adc.ts ~line 213
+const round = (this.cs >> CS_RROBIN_SHIFT) & CS_RROBIN_MASK;
+if (round) {
+ let channel = this.activeChannel + 1;
+ while (!(round & (1 << channel))) {
+ channel = (channel + 1) % this.numChannels;
+ }
+ this.activeChannel = channel; // <-- corrupted by the buggy setter
+}
+```
+
+Because the setter drops the low channel bits, `AINSEL` never lands on channels 1–3, so:
+- ADC round-robin does not visit channels 1, 2, 3 as configured.
+- `CS.AINSEL` read back by firmware after an internal update is wrong.
+- The conversion result comes from the wrong input.
+
+(The matching getter on line 146 is correct — it uses `CS_AINSEL_MASK` — which is why the typo is
+easy to miss.)
+
+## Suggested fix
+
+```ts
+private set activeChannel(channel: number) {
+ this.cs &= ~(CS_AINSEL_MASK << CS_AINSEL_SHIFT);
+ this.cs |= (channel & CS_AINSEL_MASK) << CS_AINSEL_SHIFT; // mask, not shift
+}
+```
+
+## How it was found
+
+Surfaced while building a C# RP2040 emulator (an independent re-implementation): an ADC round-robin
+test visited only channel 0 and channel 4. Tracing the divergence to the `AINSEL` field led to the
+`& CS_AINSEL_SHIFT` typo in the upstream setter, confirmed against the 1.3.2 source above.
diff --git a/src/RP2040.NanoFramework.TestKit.SourceGen/NanoSymbolsGenerator.cs b/src/RP2040.NanoFramework.TestKit.SourceGen/NanoSymbolsGenerator.cs
new file mode 100644
index 0000000..e40dd83
--- /dev/null
+++ b/src/RP2040.NanoFramework.TestKit.SourceGen/NanoSymbolsGenerator.cs
@@ -0,0 +1,263 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Reflection.Metadata;
+using System.Reflection.PortableExecutable;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace RP2040.NanoFramework.TestKit.SourceGen
+{
+ ///
+ /// Emits a strongly-typed symbols class for a nanoFramework app, so integration tests reference real
+ /// type/field/method names instead of raw strings. The app's built managed assembly (.exe) is fed in
+ /// as an AdditionalFile flagged NanoSymbols (see the package .targets); we read it through
+ /// System.Reflection.Metadata and emit:
+ /// • Assembly — the assembly name (use with FindAssembly / RunUntilStatic).
+ /// • Fields.X — each static field's simple name (use with RunUntilStatic / ReadStatic).
+ /// • Methods.X — "Assembly!Method" (use with RunUntilNativeCall-style helpers).
+ ///
+ [Generator]
+ public sealed class NanoSymbolsGenerator : IIncrementalGenerator
+ {
+ private const string FlagKey = "build_metadata.AdditionalFiles.NanoSymbols";
+ private const string NamespaceKey = "build_metadata.AdditionalFiles.NanoSymbolsNamespace";
+ private const string ClassKey = "build_metadata.AdditionalFiles.NanoSymbolsClass";
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var specs = context.AdditionalTextsProvider
+ .Combine(context.AnalyzerConfigOptionsProvider)
+ .Select((pair, ct) =>
+ {
+ var options = pair.Right.GetOptions(pair.Left);
+ if (!options.TryGetValue(FlagKey, out var flag) ||
+ !string.Equals(flag, "true", StringComparison.OrdinalIgnoreCase))
+ {
+ return (Spec?)null;
+ }
+
+ options.TryGetValue(NamespaceKey, out var ns);
+ options.TryGetValue(ClassKey, out var cls);
+ return new Spec(pair.Left.Path, ns ?? "", cls ?? "");
+ })
+ .Where(s => s.HasValue)
+ .Select((s, ct) => s!.Value);
+
+ context.RegisterSourceOutput(specs, static (spc, spec) => Emit(spc, spec));
+ }
+
+ private readonly struct Spec
+ {
+ public Spec(string assemblyPath, string ns, string className)
+ {
+ AssemblyPath = assemblyPath;
+ Namespace = ns;
+ ClassName = className;
+ }
+
+ public string AssemblyPath { get; }
+ public string Namespace { get; }
+ public string ClassName { get; }
+ }
+
+ private static readonly DiagnosticDescriptor CannotRead = new(
+ id: "NANO001",
+ title: "nanoFramework app assembly not readable",
+ messageFormat: "Could not read the nanoFramework app assembly '{0}'. Build the .nfproj first.",
+ category: "NanoSymbols",
+ DiagnosticSeverity.Warning,
+ isEnabledByDefault: true);
+
+ private static void Emit(SourceProductionContext spc, Spec spec)
+ {
+ string assemblyName;
+ var methods = new SortedSet(StringComparer.Ordinal);
+ var staticFields = new SortedSet(StringComparer.Ordinal);
+ // Per-type structure extracted from the project: type name -> its readable fields
+ // (static + instance, non-const), so tests can address instance fields by type.
+ var types = new SortedDictionary>(StringComparer.Ordinal);
+
+ try
+ {
+ using var stream = File.OpenRead(spec.AssemblyPath);
+ using var pe = new PEReader(stream);
+ var mr = pe.GetMetadataReader();
+ assemblyName = mr.GetString(mr.GetAssemblyDefinition().Name);
+
+ foreach (var typeHandle in mr.TypeDefinitions)
+ {
+ var type = mr.GetTypeDefinition(typeHandle);
+ var typeName = mr.GetString(type.Name);
+ if (!IsIdentifier(typeName))
+ {
+ continue; // skip compiler-generated types
+ }
+
+ var typeFields = new SortedSet(StringComparer.Ordinal);
+
+ foreach (var mh in type.GetMethods())
+ {
+ var m = mr.GetMethodDefinition(mh);
+ if ((m.Attributes & (MethodAttributes.SpecialName | MethodAttributes.RTSpecialName)) != 0)
+ {
+ continue; // ctors/.cctor, accessors, operators
+ }
+
+ var name = mr.GetString(m.Name);
+ if (IsIdentifier(name))
+ {
+ methods.Add(name);
+ }
+ }
+
+ foreach (var fh in type.GetFields())
+ {
+ var f = mr.GetFieldDefinition(fh);
+ if ((f.Attributes & FieldAttributes.Literal) != 0)
+ {
+ continue; // const → not a runtime cell
+ }
+
+ var name = mr.GetString(f.Name);
+ if (!IsIdentifier(name))
+ {
+ continue;
+ }
+
+ typeFields.Add(name); // both static and instance fields are readable
+ if ((f.Attributes & FieldAttributes.Static) != 0)
+ {
+ staticFields.Add(name);
+ }
+ }
+
+ if (typeFields.Count > 0)
+ {
+ types[typeName] = typeFields;
+ }
+ }
+ }
+ catch
+ {
+ spc.ReportDiagnostic(Diagnostic.Create(CannotRead, Location.None, spec.AssemblyPath));
+ return;
+ }
+
+ var className = !string.IsNullOrEmpty(spec.ClassName) ? spec.ClassName : Sanitize(assemblyName) + "Symbols";
+ spc.AddSource($"{className}.NanoSymbols.g.cs", Render(spec.Namespace, className, assemblyName, methods, staticFields, types));
+ }
+
+ private static string Render(string ns, string className, string assemblyName,
+ SortedSet methods, SortedSet fields,
+ SortedDictionary> types)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("// Generated by RP2040.NanoFramework.TestKit (NanoSymbolsGenerator). Do not edit.");
+ sb.AppendLine("#nullable enable");
+ var indent = "";
+ var hasNs = !string.IsNullOrEmpty(ns);
+ if (hasNs)
+ {
+ sb.AppendLine($"namespace {ns}");
+ sb.AppendLine("{");
+ indent = " ";
+ }
+
+ sb.AppendLine($"{indent}/// Strongly-typed symbols for the '{assemblyName}' nanoFramework app.");
+ sb.AppendLine($"{indent}internal static class {className}");
+ sb.AppendLine($"{indent}{{");
+ sb.AppendLine($"{indent} /// The app assembly name.");
+ sb.AppendLine($"{indent} public const string Assembly = \"{Escape(assemblyName)}\";");
+ sb.AppendLine();
+ sb.AppendLine($"{indent} /// Methods as \"{Escape(assemblyName)}!Method\".");
+ sb.AppendLine($"{indent} public static class Methods");
+ sb.AppendLine($"{indent} {{");
+ foreach (var m in methods)
+ {
+ sb.AppendLine($"{indent} public const string {Member(m)} = \"{Escape(assemblyName)}!{Escape(m)}\";");
+ }
+
+ sb.AppendLine($"{indent} }}");
+ sb.AppendLine();
+ sb.AppendLine($"{indent} /// Static fields by simple name (use with RunUntilStatic / ReadStatic).");
+ sb.AppendLine($"{indent} public static class Fields");
+ sb.AppendLine($"{indent} {{");
+ foreach (var f in fields)
+ {
+ sb.AppendLine($"{indent} public const string {Member(f)} = \"{Escape(f)}\";");
+ }
+
+ sb.AppendLine($"{indent} }}");
+ sb.AppendLine();
+ sb.AppendLine($"{indent} /// Each type and its readable fields, extracted from the project structure.");
+ sb.AppendLine($"{indent} public static class Types");
+ sb.AppendLine($"{indent} {{");
+ foreach (var t in types)
+ {
+ sb.AppendLine($"{indent} /// Type '{t.Key}'.");
+ sb.AppendLine($"{indent} public static class {Member(t.Key)}");
+ sb.AppendLine($"{indent} {{");
+ sb.AppendLine($"{indent} public const string Name = \"{Escape(t.Key)}\";");
+ sb.AppendLine($"{indent} public static class Fields");
+ sb.AppendLine($"{indent} {{");
+ foreach (var f in t.Value)
+ {
+ sb.AppendLine($"{indent} public const string {Member(f)} = \"{Escape(f)}\";");
+ }
+
+ sb.AppendLine($"{indent} }}");
+ sb.AppendLine($"{indent} }}");
+ }
+
+ sb.AppendLine($"{indent} }}");
+ sb.AppendLine($"{indent}}}");
+
+ if (hasNs)
+ {
+ sb.AppendLine("}");
+ }
+
+ return sb.ToString();
+ }
+
+ private static bool IsIdentifier(string name)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ return false;
+ }
+
+ if (!char.IsLetter(name[0]) && name[0] != '_')
+ {
+ return false;
+ }
+
+ for (var i = 1; i < name.Length; i++)
+ {
+ if (!char.IsLetterOrDigit(name[i]) && name[i] != '_')
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static string Member(string name) =>
+ SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None ||
+ SyntaxFacts.GetContextualKeywordKind(name) != SyntaxKind.None ? "@" + name : name;
+
+ private static string Sanitize(string name)
+ {
+ var chars = name.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c : '_').ToArray();
+ var s = new string(chars);
+ return char.IsDigit(s[0]) ? "_" + s : s;
+ }
+
+ private static string Escape(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"");
+ }
+}
diff --git a/src/RP2040.NanoFramework.TestKit.SourceGen/RP2040.NanoFramework.TestKit.SourceGen.csproj b/src/RP2040.NanoFramework.TestKit.SourceGen/RP2040.NanoFramework.TestKit.SourceGen.csproj
new file mode 100644
index 0000000..3956868
--- /dev/null
+++ b/src/RP2040.NanoFramework.TestKit.SourceGen/RP2040.NanoFramework.TestKit.SourceGen.csproj
@@ -0,0 +1,20 @@
+
+
+
+ netstandard2.0
+ disable
+ enable
+ true
+
+ false
+
+ false
+ false
+ $(NoWarn);RS2008
+
+
+
+
+
+
+
diff --git a/src/RP2040.NanoFramework.TestKit/ClrInspector.cs b/src/RP2040.NanoFramework.TestKit/ClrInspector.cs
new file mode 100644
index 0000000..5d31643
--- /dev/null
+++ b/src/RP2040.NanoFramework.TestKit/ClrInspector.cs
@@ -0,0 +1,235 @@
+using System.Text;
+using RP2040.Peripherals;
+
+namespace RP2040.NanoFramework.TestKit;
+
+///
+/// Reads managed state out of a running nanoCLR by walking its in-RAM data structures with a
+/// . Currently resolves a managed static field by name — assembly
+/// (g_CLR_RT_TypeSystem.m_assemblies) → type/field (the .pe metadata tables) → the
+/// cross-reference slot → the CLR_RT_HeapBlock that holds the value.
+///
+public sealed class ClrInspector
+{
+ private readonly RP2040Machine _m;
+ private readonly ClrLayout _l;
+ private readonly uint _typeSystem;
+
+ public ClrInspector(RP2040Machine machine, uint typeSystemAddress, ClrLayout? layout = null)
+ {
+ _m = machine;
+ _typeSystem = typeSystemAddress;
+ _l = layout ?? ClrLayout.Default;
+ }
+
+ private uint Rd(uint a) => _m.Bus.ReadWord(a);
+ private ushort Rh(uint a) => _m.Bus.ReadHalfWord(a);
+ private byte Rb(uint a) => _m.Bus.ReadByte(a);
+
+ private string CStr(uint a)
+ {
+ var sb = new StringBuilder();
+ for (byte c = Rb(a); c != 0; c = Rb(++a))
+ {
+ sb.Append((char)c);
+ }
+
+ return sb.ToString();
+ }
+
+ /// The CLR_RT_Assembly pointer for the loaded assembly named (0 if none).
+ public uint FindAssembly(string name)
+ {
+ uint max = Rd(_typeSystem + _l.TS_AssembliesMax);
+ for (uint i = 0; i < max && i < 64; i++)
+ {
+ uint asm = Rd(_typeSystem + _l.TS_Assemblies + i * 4);
+ if (asm == 0)
+ {
+ continue;
+ }
+
+ uint namePtr = Rd(asm + _l.ASM_Name);
+ if (namePtr != 0 && CStr(namePtr) == name)
+ {
+ return asm;
+ }
+ }
+
+ return 0;
+ }
+
+ ///
+ /// Resolves the static-field slot (the cross-reference offset into m_pStaticFields) for the
+ /// static field named in assembly . Searches every
+ /// type's static fields; pass to disambiguate a repeated field name.
+ ///
+ public bool TryResolveStaticSlot(uint asm, string fieldName, out int slot, string? typeName = null)
+ {
+ slot = -1;
+ uint header = Rd(asm + _l.ASM_Header);
+ uint sot = header + _l.SOT;
+ uint typeDefTable = header + Rd(sot + (uint)_l.TBL_TypeDef * 4);
+ uint fieldDefTable = header + Rd(sot + (uint)_l.TBL_FieldDef * 4);
+ uint stringHeap = header + Rd(sot + (uint)_l.TBL_Strings * 4);
+
+ uint xref = Rd(asm + _l.ASM_XrefFieldDef);
+ if (xref == 0 || Rd(asm + _l.ASM_StaticFields) == 0)
+ {
+ return false; // the CLR has not finished allocating this assembly's statics yet
+ }
+
+ // TypeDef record count = byte span to the next table / record size (tables are contiguous).
+ uint typeDefBytes = Rd(sot + (uint)(_l.TBL_TypeDef + 1) * 4) - Rd(sot + (uint)_l.TBL_TypeDef * 4);
+ int typeCount = (int)(typeDefBytes / _l.TD_Size);
+
+ for (int t = 0; t < typeCount; t++)
+ {
+ uint td = typeDefTable + (uint)t * _l.TD_Size;
+ if (typeName != null && CStr(stringHeap + Rh(td + _l.TD_Name)) != typeName)
+ {
+ continue;
+ }
+
+ int first = Rh(td + _l.TD_SFieldsFirst);
+ int num = Rb(td + _l.TD_SFieldsNum);
+ for (int fi = first; fi < first + num; fi++)
+ {
+ uint fd = fieldDefTable + (uint)fi * _l.FD_Size;
+ if (CStr(stringHeap + Rh(fd + _l.FD_Name)) == fieldName)
+ {
+ slot = Rh(xref + (uint)fi * 2); // CLR_RT_FieldDef_CrossReference[fi].m_offset
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /// A static field's stored cell: its nanoCLR data type and the raw 32 bits of value.
+ public readonly record struct HeapValue(byte DataType, uint Raw)
+ {
+ public int AsInt32 => (int)Raw;
+ public uint AsUInt32 => Raw;
+ public bool AsBoolean => Raw != 0;
+ }
+
+ /// Reads the static field's storage cell (its data type byte + the value's low 32 bits).
+ public HeapValue ReadStatic(uint asm, int slot)
+ {
+ uint heapBlock = Rd(asm + _l.ASM_StaticFields) + (uint)slot * _l.HB_Size;
+ byte dataType = Rb(heapBlock); // CLR_RT_HeapBlock.m_id.dataType @ 0
+ return new HeapValue(dataType, Rd(heapBlock + _l.HB_Data));
+ }
+
+ /// Reads a managed static int field as a 32-bit value.
+ public int ReadStaticInt32(uint asm, int slot) => ReadStatic(asm, slot).AsInt32;
+
+ /// Reads a managed static long field as a 64-bit value (the cell's 8 data bytes).
+ public long ReadStaticInt64(uint asm, int slot)
+ {
+ uint hb = Rd(asm + _l.ASM_StaticFields) + (uint)slot * _l.HB_Size;
+ ulong lo = Rd(hb + _l.HB_Data);
+ ulong hi = Rd(hb + _l.HB_Data + 4);
+ return (long)(lo | (hi << 32));
+ }
+
+ // ---- instance fields (by name) ------------------------------------
+
+ ///
+ /// Resolves the instance-field slot for of
+ /// (the offset, in heap blocks, of the field within an object of that type).
+ ///
+ public bool TryResolveInstanceSlot(uint asm, string typeName, string fieldName, out int slot)
+ {
+ slot = -1;
+ uint header = Rd(asm + _l.ASM_Header);
+ uint sot = header + _l.SOT;
+ uint typeDefTable = header + Rd(sot + (uint)_l.TBL_TypeDef * 4);
+ uint fieldDefTable = header + Rd(sot + (uint)_l.TBL_FieldDef * 4);
+ uint stringHeap = header + Rd(sot + (uint)_l.TBL_Strings * 4);
+ uint xref = Rd(asm + _l.ASM_XrefFieldDef);
+ if (xref == 0)
+ {
+ return false;
+ }
+
+ uint typeDefBytes = Rd(sot + (uint)(_l.TBL_TypeDef + 1) * 4) - Rd(sot + (uint)_l.TBL_TypeDef * 4);
+ int typeCount = (int)(typeDefBytes / _l.TD_Size);
+
+ for (int t = 0; t < typeCount; t++)
+ {
+ uint td = typeDefTable + (uint)t * _l.TD_Size;
+ if (CStr(stringHeap + Rh(td + _l.TD_Name)) != typeName)
+ {
+ continue;
+ }
+
+ int first = Rh(td + _l.TD_IFieldsFirst);
+ int num = Rb(td + _l.TD_IFieldsNum);
+ for (int fi = first; fi < first + num; fi++)
+ {
+ uint fd = fieldDefTable + (uint)fi * _l.FD_Size;
+ if (CStr(stringHeap + Rh(fd + _l.FD_Name)) == fieldName)
+ {
+ slot = Rh(xref + (uint)fi * 2);
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Reads instance field of the object at . The slot is
+ /// the field's cross-reference m_offset, which already counts past the object header (the nanoCLR does
+ /// res = Dereference(); res += m_offset), so it is not additionally offset by the header.
+ ///
+ public HeapValue ReadInstance(uint obj, int slot)
+ {
+ uint hb = obj + (uint)slot * _l.HB_Size;
+ return new HeapValue(Rb(hb), Rd(hb + _l.HB_Data));
+ }
+
+ // ---- methods (stack frames) ---------------------------------------
+
+ private uint StringHeapOf(uint asm)
+ {
+ uint header = Rd(asm + _l.ASM_Header);
+ return header + Rd(header + _l.SOT + (uint)_l.TBL_Strings * 4);
+ }
+
+ ///
+ /// The managed method a CLR_RT_StackFrame is running, as "Assembly!Method"
+ /// (m_call.m_assm.m_szName + m_call.m_target's name). "" if is not one.
+ ///
+ public string MethodAt(uint stackFrame)
+ {
+ uint asm = Rd(stackFrame + _l.SF_CallAssm);
+ if (asm == 0)
+ {
+ return string.Empty;
+ }
+
+ uint namePtr = Rd(asm + _l.ASM_Name);
+ uint md = Rd(stackFrame + _l.SF_CallTarget);
+ if (namePtr == 0 || md == 0)
+ {
+ return string.Empty;
+ }
+
+ string asmName = CStr(namePtr);
+ string method = CStr(StringHeapOf(asm) + Rh(md + _l.MD_Name));
+ return asmName + "!" + method;
+ }
+
+ // ---- instance heap objects (arrays) -------------------------------
+
+ /// Element count of a managed array object (its CLR_RT_HeapBlock_Array header).
+ public uint ReadArrayLength(uint arrayObject) => Rd(arrayObject + _l.ARR_NumElems);
+
+ /// Reads element of a managed int[] object.
+ public int ReadArrayInt32(uint arrayObject, int index) => (int)Rd(arrayObject + _l.ARR_DataOff + (uint)index * 4);
+}
diff --git a/src/RP2040.NanoFramework.TestKit/ClrLayout.cs b/src/RP2040.NanoFramework.TestKit/ClrLayout.cs
new file mode 100644
index 0000000..11ed11e
--- /dev/null
+++ b/src/RP2040.NanoFramework.TestKit/ClrLayout.cs
@@ -0,0 +1,97 @@
+namespace RP2040.NanoFramework.TestKit;
+
+///
+/// In-RAM struct offsets of a specific nanoCLR build, used to read managed state (e.g. a static
+/// field) out of the emulator's memory. The values come from the firmware's DWARF
+/// (arm-none-eabi-gdb -batch nanoCLR.elf -ex 'ptype /o struct …') and are therefore tied to
+/// the build — keep them next to the firmware. matches the vendored RP_PICO build.
+///
+public sealed record ClrLayout
+{
+ // CLR_RT_TypeSystem (g_CLR_RT_TypeSystem)
+ public uint TS_Assemblies { get; init; } // m_assemblies[64]
+ public uint TS_AssembliesMax { get; init; } // m_assembliesMax
+
+ // CLR_RT_Assembly
+ public uint ASM_Header { get; init; } // m_header (-> CLR_RECORD_ASSEMBLY in flash)
+ public uint ASM_Name { get; init; } // m_szName (const char*)
+ public uint ASM_StaticFields { get; init; } // m_pStaticFields (CLR_RT_HeapBlock[])
+ public uint ASM_XrefFieldDef { get; init; } // m_pCrossReference_FieldDef (CLR_RT_FieldDef_CrossReference[])
+
+ // CLR_RT_HeapBlock
+ public uint HB_Size { get; init; } // sizeof(CLR_RT_HeapBlock)
+ public uint HB_Data { get; init; } // m_data (numeric value lives here)
+
+ // CLR_RECORD_ASSEMBLY (.pe header)
+ public uint SOT { get; init; } // startOfTables[16] (CLR_OFFSET_LONG, 4 bytes each)
+
+ // metadata table indices
+ public int TBL_TypeDef { get; init; }
+ public int TBL_FieldDef { get; init; }
+ public int TBL_Strings { get; init; }
+
+ // CLR_RECORD_TYPEDEF
+ public uint TD_Size { get; init; }
+ public uint TD_Name { get; init; } // CLR_STRING (offset into the string heap)
+ public uint TD_SFieldsFirst { get; init; } // first static FieldDef index
+ public uint TD_SFieldsNum { get; init; } // static field count (1 byte)
+ public uint TD_IFieldsFirst { get; init; } // first instance FieldDef index
+ public uint TD_IFieldsNum { get; init; } // instance field count (1 byte)
+
+ // CLR_RECORD_FIELDDEF
+ public uint FD_Size { get; init; }
+ public uint FD_Name { get; init; } // CLR_STRING
+
+ // CLR_RECORD_METHODDEF
+ public int TBL_MethodDef { get; init; }
+ public uint MD_Size { get; init; }
+ public uint MD_Name { get; init; } // CLR_STRING
+
+ // CLR_RT_StackFrame.m_call (CLR_RT_MethodDef_Instance)
+ public uint SF_CallAssm { get; init; } // m_call.m_assm (CLR_RT_Assembly*)
+ public uint SF_CallTarget { get; init; } // m_call.m_target (CLR_RECORD_METHODDEF*)
+
+ // Instance object / array layout (object data is a CLR_RT_HeapBlock[]; arrays add a header)
+ public uint OBJ_FieldsOff { get; init; } // object pointer -> first instance field
+ public uint ARR_NumElems { get; init; } // CLR_RT_HeapBlock_Array.m_numOfElements
+ public uint ARR_SizeElem { get; init; } // .m_sizeOfElement
+ public uint ARR_DataOff { get; init; } // first element
+
+ // CLR_RT_Assembly.m_pTablesSize[16] — per-table record counts.
+ public uint ASM_TablesSize { get; init; }
+
+ /// Offsets for the vendored RP_PICO (RP2040) nanoCLR build.
+ public static ClrLayout Default { get; } = new()
+ {
+ TS_Assemblies = 0,
+ TS_AssembliesMax = 256,
+ ASM_Header = 20,
+ ASM_Name = 24,
+ ASM_TablesSize = 32,
+ ASM_StaticFields = 96,
+ ASM_XrefFieldDef = 128,
+ HB_Size = 12,
+ HB_Data = 4,
+ SOT = 40,
+ TBL_TypeDef = 4,
+ TBL_FieldDef = 5,
+ TBL_Strings = 11,
+ TD_Size = 24,
+ TD_Name = 0,
+ TD_SFieldsFirst = 16,
+ TD_SFieldsNum = 20,
+ TD_IFieldsFirst = 18,
+ TD_IFieldsNum = 21,
+ FD_Size = 8,
+ FD_Name = 0,
+ TBL_MethodDef = 6,
+ MD_Size = 16,
+ MD_Name = 0,
+ SF_CallAssm = 44,
+ SF_CallTarget = 48,
+ OBJ_FieldsOff = 12,
+ ARR_NumElems = 12,
+ ARR_SizeElem = 17,
+ ARR_DataOff = 20,
+ };
+}
diff --git a/src/RP2040.NanoFramework.TestKit/NanoApp.cs b/src/RP2040.NanoFramework.TestKit/NanoApp.cs
new file mode 100644
index 0000000..7ae00db
--- /dev/null
+++ b/src/RP2040.NanoFramework.TestKit/NanoApp.cs
@@ -0,0 +1,99 @@
+using System.Buffers.Binary;
+
+namespace RP2040.NanoFramework.TestKit;
+
+///
+/// A nanoFramework deployment image assembled from build output. The Metadata Processor emits one
+/// .pe per assembly (mscorlib, each referenced library, and the app); a deployment is the
+/// 4-byte-aligned concatenation of those .pe blobs in load order — mscorlib first, the app
+/// last. No recompilation: the .pe bytes are reused as-is (the CLR matches native checksums).
+///
+public sealed class NanoApp
+{
+ /// The assembled deployment blob to write to flash at the deployment offset.
+ public byte[] DeploymentBytes { get; }
+
+ /// Each included assembly's declared native-methods checksum, keyed by assembly name.
+ public IReadOnlyDictionary NativeChecksums { get; }
+
+ /// The assemblies included, in deployment (load) order.
+ public IReadOnlyList Assemblies { get; }
+
+ private NanoApp(byte[] deployment, IReadOnlyList assemblies, IReadOnlyDictionary checksums)
+ {
+ DeploymentBytes = deployment;
+ Assemblies = assemblies;
+ NativeChecksums = checksums;
+ }
+
+ ///
+ /// Assembles the deployment from every .pe in , ordered by
+ /// convention: mscorlib first, last, other libraries
+ /// (sorted) in between. Each .pe's native checksum is read for the compatibility guard.
+ ///
+ public static NanoApp FromPeDirectory(string peDirectory, string appAssemblyName)
+ {
+ if (!Directory.Exists(peDirectory))
+ {
+ throw new DirectoryNotFoundException($"No .pe directory at '{peDirectory}'.");
+ }
+
+ var names = Directory.GetFiles(peDirectory, "*.pe")
+ .Select(Path.GetFileNameWithoutExtension)
+ .Where(n => n is not null)
+ .Select(n => n!)
+ .ToList();
+
+ if (names.Count == 0)
+ {
+ throw new FileNotFoundException($"No .pe assemblies found in '{peDirectory}'.");
+ }
+
+ // Load order: mscorlib first, the app last, the rest (sorted) in between.
+ var ordered = names
+ .OrderBy(n => n.Equals("mscorlib", StringComparison.OrdinalIgnoreCase) ? 0
+ : n.Equals(appAssemblyName, StringComparison.OrdinalIgnoreCase) ? 2 : 1)
+ .ThenBy(n => n, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ return FromPeFiles(peDirectory, ordered);
+ }
+
+ /// Assembles the deployment from the named assemblies, in the exact order given.
+ public static NanoApp FromPeFiles(string peDirectory, IReadOnlyList assemblyOrder)
+ {
+ var checksums = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ using var ms = new MemoryStream();
+
+ foreach (var name in assemblyOrder)
+ {
+ string path = Path.Combine(peDirectory, name + ".pe");
+ byte[] pe = File.ReadAllBytes(path);
+ checksums[name] = ReadNativeChecksum(pe, name);
+
+ // 4-byte align each assembly within the deployment image.
+ while (ms.Length % 4 != 0)
+ {
+ ms.WriteByte(0);
+ }
+
+ ms.Write(pe, 0, pe.Length);
+ }
+
+ return new NanoApp(ms.ToArray(), assemblyOrder, checksums);
+ }
+
+ // nanoFramework PE header: marker "NFMRK1" at 0, nativeMethodsChecksum (UINT32 LE) at offset 20.
+ private const int NativeChecksumOffset = 20;
+ private static readonly byte[] Marker = "NFMRK1"u8.ToArray();
+
+ private static uint ReadNativeChecksum(byte[] pe, string name)
+ {
+ if (pe.Length < NativeChecksumOffset + 4 || !pe.AsSpan(0, Marker.Length).SequenceEqual(Marker))
+ {
+ throw new InvalidDataException($"'{name}.pe' is not a nanoFramework assembly (missing NFMRK1 header).");
+ }
+
+ return BinaryPrimitives.ReadUInt32LittleEndian(pe.AsSpan(NativeChecksumOffset, 4));
+ }
+}
diff --git a/src/RP2040.NanoFramework.TestKit/NanoChecksumMismatchException.cs b/src/RP2040.NanoFramework.TestKit/NanoChecksumMismatchException.cs
new file mode 100644
index 0000000..5d30b2f
--- /dev/null
+++ b/src/RP2040.NanoFramework.TestKit/NanoChecksumMismatchException.cs
@@ -0,0 +1,24 @@
+namespace RP2040.NanoFramework.TestKit;
+
+///
+/// Thrown when a deployed assembly's .pe declares a native-methods checksum that the flashed
+/// firmware does not provide. This is the classic nanoFramework footgun: if a library's InternalCall
+/// surface changes, its .pe checksum changes and the old firmware can no longer bind its
+/// natives — the app then fails to run in a confusing way. The guard turns that into a clear error.
+///
+public sealed class NanoChecksumMismatchException : Exception
+{
+ public string Assembly { get; }
+ public uint DeploymentChecksum { get; }
+ public uint FirmwareChecksum { get; }
+
+ public NanoChecksumMismatchException(string assembly, uint deploymentChecksum, uint firmwareChecksum)
+ : base($"Native checksum mismatch for '{assembly}': the deployment .pe declares " +
+ $"0x{deploymentChecksum:X8} but the firmware provides 0x{firmwareChecksum:X8}. " +
+ $"Rebuild the nanoCLR firmware for this assembly, or use a .pe that matches the firmware.")
+ {
+ Assembly = assembly;
+ DeploymentChecksum = deploymentChecksum;
+ FirmwareChecksum = firmwareChecksum;
+ }
+}
diff --git a/src/RP2040.NanoFramework.TestKit/NanoClrHarness.cs b/src/RP2040.NanoFramework.TestKit/NanoClrHarness.cs
new file mode 100644
index 0000000..8e8c796
--- /dev/null
+++ b/src/RP2040.NanoFramework.TestKit/NanoClrHarness.cs
@@ -0,0 +1,293 @@
+using RP2040.Core.Cpu;
+using RP2040.TestKit.Boards;
+
+namespace RP2040.NanoFramework.TestKit;
+
+///
+/// Boots a deployed nanoFramework app on the RP2040Sharp emulator and drives it to points of
+/// interest inside the running CLR — a generic condition () or a native CLR
+/// function located by symbol () — so a test can assert on what the
+/// firmware is actually doing, not just on wall-clock slices.
+///
+///
+/// Both run methods drive the emulator's hook-aware execution path (the one that services the
+/// bootrom/flash native hooks the firmware needs to boot). watches
+/// every executed instruction's PC through the profiling observer, so the crossing into the native
+/// function is detected exactly — never sampled past.
+///
+public sealed class NanoClrHarness : IDisposable
+{
+ /// The emulated Pico. Add probes (e.g. AddPioProbe) before running.
+ public PicoSimulation Pico { get; }
+
+ public NanoFirmware Firmware { get; }
+ public NanoApp App { get; }
+
+ /// The symbol last reached by (null if none).
+ public string? LastReachedSymbol { get; private set; }
+
+ /// The cycle count when was reached (-1 if none).
+ public long LastReachedCycle { get; private set; } = -1;
+
+ private NanoClrHarness(PicoSimulation pico, NanoFirmware firmware, NanoApp app)
+ {
+ Pico = pico;
+ Firmware = firmware;
+ App = app;
+ }
+
+ ///
+ /// Creates the emulator and flashes booter + nanoCLR + deployment (after the checksum guard), but
+ /// does not run — wire up probes via , then call a RunUntil… method.
+ ///
+ public static NanoClrHarness Boot(NanoFirmware firmware, NanoApp app, bool withUsbCdc = false)
+ {
+ var pico = new PicoSimulation(withUsbCdc: withUsbCdc);
+ firmware.BootInto(pico, app);
+ return new NanoClrHarness(pico, firmware, app);
+ }
+
+ /// The current CPU program counter.
+ public uint Pc => Pico.Cpu.Registers.PC;
+
+ public bool IsLockedUp => Pico.Cpu.IsLockedUp;
+
+ public long InstructionCount => Pico.InstructionCount;
+
+ ///
+ /// Runs the CLR in hook-aware slices until holds (checked at slice
+ /// boundaries — apt for monotonic, observable state such as a probe's captured-word count).
+ /// Returns false if is reached first.
+ ///
+ public bool RunUntil(Func ready, long maxInstructions = 200_000_000, int slice = 100_000)
+ {
+ long ran = 0;
+ while (ran < maxInstructions && !ready())
+ {
+ Pico.RunInstructions(slice);
+ ran += slice;
+ }
+
+ return ready();
+ }
+
+ ///
+ /// Runs the CLR until the CPU executes the native function (resolved
+ /// from the firmware manifest) — i.e. until managed code crosses into that InternalCall. Every
+ /// executed instruction's PC is watched, so the crossing is caught exactly. On success
+ /// / record where and when.
+ ///
+ public bool RunUntilNativeCall(string symbol, long maxInstructions = 200_000_000)
+ {
+ uint target = Firmware.ResolveSymbol(symbol);
+ var watch = new PcWatch(target);
+
+ long remaining = maxInstructions;
+ while (remaining > 0 && !watch.Hit)
+ {
+ int chunk = (int)Math.Min(remaining, 1_000_000);
+ Pico.Rp2040.RunProfiled(chunk, watch);
+ remaining -= chunk;
+ }
+
+ if (watch.Hit)
+ {
+ LastReachedSymbol = symbol;
+ LastReachedCycle = watch.HitCycle;
+ }
+
+ return watch.Hit;
+ }
+
+ ///
+ /// Runs the CLR until a managed method executes — i.e. until CLR_RT_Thread::Execute_IL is
+ /// entered for a stack frame whose method is ("Assembly!Method", e.g.
+ /// a generated AppSymbols.Methods.Main). Needs Execute_IL in the firmware manifest.
+ ///
+ public bool RunUntilManagedMethod(string methodFqn, long maxInstructions = 200_000_000)
+ {
+ uint executeIl = Firmware.ResolveSymbol("Execute_IL");
+ var watch = new MethodWatch(Pico.Cpu, Clr, executeIl, methodFqn);
+
+ long remaining = maxInstructions;
+ while (remaining > 0 && !watch.Hit)
+ {
+ int chunk = (int)Math.Min(remaining, 1_000_000);
+ Pico.Rp2040.RunProfiled(chunk, watch);
+ remaining -= chunk;
+ }
+
+ if (watch.Hit)
+ {
+ LastReachedSymbol = methodFqn;
+ LastReachedCycle = watch.HitCycle;
+ }
+
+ return watch.Hit;
+ }
+
+ /// Element count of a managed static int[] field (a heap-allocated array object).
+ public uint StaticArrayLength(string assembly, string field) => Clr.ReadArrayLength(ResolveStaticArray(assembly, field));
+
+ /// Reads element of a managed static int[] field.
+ public int ReadStaticArrayInt32(string assembly, string field, int index)
+ => Clr.ReadArrayInt32(ResolveStaticArray(assembly, field), index);
+
+ private uint ResolveStaticArray(string assembly, string field)
+ {
+ uint asm = Clr.FindAssembly(assembly);
+ if (asm == 0 || !Clr.TryResolveStaticSlot(asm, field, out int slot))
+ {
+ throw new InvalidOperationException($"Static array '{field}' not found/ready in '{assembly}'.");
+ }
+
+ uint arrayObject = Clr.ReadStatic(asm, slot).Raw; // the static cell holds the array reference
+ if (arrayObject == 0)
+ {
+ throw new InvalidOperationException($"Static array '{field}' is null.");
+ }
+
+ return arrayObject;
+ }
+
+ // Watches every Execute_IL entry; flags when the running frame's method matches the target. The
+ // CLR_RT_StackFrame& is the call argument, so it sits in R0 or R1 — check both.
+ private sealed class MethodWatch(CortexM0Plus cpu, ClrInspector clr, uint executeIl, string fqn) : IProfilingObserver
+ {
+ public bool Hit { get; private set; }
+ public long HitCycle { get; private set; } = -1;
+
+ public void OnInstruction(uint pc, ushort opcode, long cycles)
+ {
+ if (Hit || pc != executeIl)
+ {
+ return;
+ }
+
+ if (clr.MethodAt(cpu.Registers.R1) == fqn || clr.MethodAt(cpu.Registers.R0) == fqn)
+ {
+ Hit = true;
+ HitCycle = cycles;
+ }
+ }
+ }
+
+ private ClrInspector? _inspector;
+
+ /// Reads managed CLR state out of the emulator (needs g_CLR_RT_TypeSystem in the manifest).
+ public ClrInspector Clr => _inspector ??= new ClrInspector(Pico.Rp2040, Firmware.ResolveSymbol("g_CLR_RT_TypeSystem"));
+
+ /// Reads a managed static field's cell (data type + raw value) by name (assembly must be loaded).
+ public ClrInspector.HeapValue ReadStatic(string assembly, string field)
+ {
+ uint asm = Clr.FindAssembly(assembly);
+ if (asm == 0)
+ {
+ throw new InvalidOperationException($"Assembly '{assembly}' is not loaded yet.");
+ }
+
+ if (!Clr.TryResolveStaticSlot(asm, field, out int slot))
+ {
+ throw new InvalidOperationException($"Static field '{field}' not found/ready in '{assembly}'.");
+ }
+
+ return Clr.ReadStatic(asm, slot);
+ }
+
+ /// Reads a managed static int field by name (the assembly must already be loaded).
+ public int ReadStaticInt32(string assembly, string field) => ReadStatic(assembly, field).AsInt32;
+
+ /// Reads a managed static long field by name (its full 64-bit value).
+ public long ReadStaticInt64(string assembly, string field)
+ {
+ uint asm = Clr.FindAssembly(assembly);
+ if (asm == 0 || !Clr.TryResolveStaticSlot(asm, field, out int slot))
+ {
+ throw new InvalidOperationException($"Static field '{field}' not found/ready in '{assembly}'.");
+ }
+
+ return Clr.ReadStaticInt64(asm, slot);
+ }
+
+ ///
+ /// Reads an instance field by name from the object held in a static reference field — e.g. the
+ /// of type on the object in
+ /// .
+ ///
+ public ClrInspector.HeapValue ReadInstance(string assembly, string staticObjectField, string typeName, string instanceField)
+ {
+ uint asm = Clr.FindAssembly(assembly);
+ if (asm == 0 || !Clr.TryResolveStaticSlot(asm, staticObjectField, out int objSlot))
+ {
+ throw new InvalidOperationException($"Static field '{staticObjectField}' not found/ready in '{assembly}'.");
+ }
+
+ uint obj = Clr.ReadStatic(asm, objSlot).Raw; // the static cell holds the object reference
+ if (obj == 0)
+ {
+ throw new InvalidOperationException($"Static object '{staticObjectField}' is null.");
+ }
+
+ if (!Clr.TryResolveInstanceSlot(asm, typeName, instanceField, out int fieldSlot))
+ {
+ throw new InvalidOperationException($"Instance field '{typeName}.{instanceField}' not found in '{assembly}'.");
+ }
+
+ return Clr.ReadInstance(obj, fieldSlot);
+ }
+
+ /// Reads an int instance field by name from the object in a static reference field.
+ public int ReadInstanceInt32(string assembly, string staticObjectField, string typeName, string instanceField)
+ => ReadInstance(assembly, staticObjectField, typeName, instanceField).AsInt32;
+
+ ///
+ /// Runs the CLR until a managed static field satisfies . The assembly
+ /// is loaded and the field resolved lazily (once the CLR has deployed it), then read at each slice
+ /// boundary. Returns false if the budget is exhausted first.
+ ///
+ public bool RunUntilStatic(
+ string assembly,
+ string field,
+ Func predicate,
+ long maxInstructions = 200_000_000,
+ int slice = 100_000)
+ {
+ // Resolve fresh each time: the CLR may relocate the assembly object and (re)allocate its
+ // statics during load, so a cached pointer/slot can go stale.
+ bool Satisfied()
+ {
+ uint asm = Clr.FindAssembly(assembly);
+ return asm != 0
+ && Clr.TryResolveStaticSlot(asm, field, out int slot)
+ && predicate(Clr.ReadStatic(asm, slot));
+ }
+
+ long ran = 0;
+ while (ran < maxInstructions && !Satisfied())
+ {
+ Pico.RunInstructions(slice);
+ ran += slice;
+ }
+
+ return Satisfied();
+ }
+
+ public void Dispose() => Pico.Dispose();
+
+ // Stops at the first instruction whose PC equals the target (the profiling observer sees every
+ // executed instruction's PC with the Thumb bit already stripped).
+ private sealed class PcWatch(uint target) : IProfilingObserver
+ {
+ public bool Hit { get; private set; }
+ public long HitCycle { get; private set; } = -1;
+
+ public void OnInstruction(uint pc, ushort opcode, long cycles)
+ {
+ if (!Hit && pc == target)
+ {
+ Hit = true;
+ HitCycle = cycles;
+ }
+ }
+ }
+}
diff --git a/src/RP2040.NanoFramework.TestKit/NanoFirmware.cs b/src/RP2040.NanoFramework.TestKit/NanoFirmware.cs
new file mode 100644
index 0000000..5cb9f84
--- /dev/null
+++ b/src/RP2040.NanoFramework.TestKit/NanoFirmware.cs
@@ -0,0 +1,177 @@
+using System.Globalization;
+using System.Text.Json;
+using RP2040.TestKit.Boards;
+
+namespace RP2040.NanoFramework.TestKit;
+
+///
+/// A flashed nanoCLR firmware (nanoBooter + nanoCLR) plus the native-methods checksums it provides,
+/// discovered from a directory by convention. Boots a deployment onto a
+/// at the RP2040 flash layout, after verifying the deployment is checksum-compatible.
+///
+public sealed class NanoFirmware
+{
+ // RP2040 flash layout, from the nf-interpreter linker scripts:
+ // nanoBooter @ 0x10000000 (flash base), nanoCLR @ +0x14000, deployment @ +0xFC000.
+ public const int NanoClrOffset = 0x14000;
+ public const int DeploymentOffset = 0xFC000;
+
+ /// The nanoBooter image (loaded at the flash base).
+ public byte[] NanoBooter { get; }
+
+ /// The nanoCLR interpreter image.
+ public byte[] NanoClr { get; }
+
+ /// Native-methods checksums the firmware was built with, keyed by assembly name (from the manifest).
+ public IReadOnlyDictionary ExpectedNativeChecksums { get; }
+
+ /// Selected native symbol addresses (name -> address) from the manifest, for run-to-function.
+ public IReadOnlyDictionary Symbols { get; }
+
+ private NanoFirmware(
+ byte[] booter,
+ byte[] clr,
+ IReadOnlyDictionary expected,
+ IReadOnlyDictionary symbols)
+ {
+ NanoBooter = booter;
+ NanoClr = clr;
+ ExpectedNativeChecksums = expected;
+ Symbols = symbols;
+ }
+
+ /// Resolves a native symbol address by name (Thumb bit cleared). Throws if not in the manifest.
+ public uint ResolveSymbol(string name)
+ {
+ if (!Symbols.TryGetValue(name, out uint address))
+ {
+ throw new KeyNotFoundException(
+ $"Symbol '{name}' is not in the firmware manifest. Known: {string.Join(", ", Symbols.Keys)}.");
+ }
+
+ return address & ~1u;
+ }
+
+ /// The manifest symbol whose address most closely precedes ("func+0xNN"),
+ /// or "0x........" if none. Curated symbols only — good for the points you run to, not full coverage.
+ public string SymbolizePc(uint pc)
+ {
+ pc &= ~1u;
+ string? best = null;
+ uint bestAddr = 0;
+ foreach (var (name, addr) in Symbols)
+ {
+ uint a = addr & ~1u;
+ if (a <= pc && a >= bestAddr)
+ {
+ best = name;
+ bestAddr = a;
+ }
+ }
+
+ if (best is null)
+ {
+ return $"0x{pc:X8}";
+ }
+
+ uint offset = pc - bestAddr;
+ return offset == 0 ? best : $"{best}+0x{offset:X}";
+ }
+
+ ///
+ /// Discovers the firmware in : nanoBooter*.bin, nanoCLR*.bin,
+ /// and an optional firmware.manifest.json declaring the native checksums it provides. No
+ /// hard-coded file names or flash offsets leak into the test.
+ ///
+ public static NanoFirmware FromDirectory(string directory)
+ {
+ if (!Directory.Exists(directory))
+ {
+ throw new DirectoryNotFoundException($"No firmware directory at '{directory}'.");
+ }
+
+ byte[] booter = File.ReadAllBytes(FindOne(directory, "nanoBooter"));
+ byte[] clr = File.ReadAllBytes(FindOne(directory, "nanoCLR"));
+ var (checksums, symbols) = LoadManifest(Path.Combine(directory, "firmware.manifest.json"));
+ return new NanoFirmware(booter, clr, checksums, symbols);
+ }
+
+ ///
+ /// Verifies every assembly the firmware declares a checksum for is present in
+ /// with a matching .pe checksum. Throws on drift.
+ ///
+ public void AssertCompatible(NanoApp app)
+ {
+ foreach (var (assembly, firmwareChecksum) in ExpectedNativeChecksums)
+ {
+ if (app.NativeChecksums.TryGetValue(assembly, out uint peChecksum) && peChecksum != firmwareChecksum)
+ {
+ throw new NanoChecksumMismatchException(assembly, peChecksum, firmwareChecksum);
+ }
+ }
+ }
+
+ /// Boots the deployment: verifies checksums, then loads booter + nanoCLR + deployment into flash.
+ public void BootInto(PicoSimulation pico, NanoApp app)
+ {
+ AssertCompatible(app);
+ pico.Rp2040.LoadFlash(NanoBooter);
+ pico.Rp2040.WriteFlash(NanoClrOffset, NanoClr);
+ pico.Rp2040.WriteFlash(DeploymentOffset, app.DeploymentBytes);
+ }
+
+ private static string FindOne(string directory, string prefix)
+ {
+ var matches = Directory.GetFiles(directory, prefix + "*.bin");
+ return matches.Length switch
+ {
+ 1 => matches[0],
+ 0 => throw new FileNotFoundException($"No '{prefix}*.bin' in '{directory}'."),
+ _ => throw new InvalidOperationException(
+ $"Ambiguous firmware: {matches.Length} files match '{prefix}*.bin' in '{directory}'."),
+ };
+ }
+
+ private static (IReadOnlyDictionary Checksums, IReadOnlyDictionary Symbols)
+ LoadManifest(string manifestPath)
+ {
+ var checksums = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var symbols = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (!File.Exists(manifestPath))
+ {
+ return (checksums, symbols); // optional: no manifest means no guard / no symbols
+ }
+
+ using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath));
+ ReadHexMap(doc.RootElement, "nativeChecksums", checksums);
+ ReadHexMap(doc.RootElement, "symbols", symbols);
+ return (checksums, symbols);
+ }
+
+ private static void ReadHexMap(JsonElement root, string property, Dictionary into)
+ {
+ if (root.TryGetProperty(property, out var obj))
+ {
+ foreach (var entry in obj.EnumerateObject())
+ {
+ into[entry.Name] = ParseHex(entry.Value.GetString());
+ }
+ }
+ }
+
+ private static uint ParseHex(string? value)
+ {
+ if (value is null)
+ {
+ throw new InvalidDataException("Null checksum in firmware manifest.");
+ }
+
+ string s = value.Trim();
+ if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
+ {
+ s = s[2..];
+ }
+
+ return uint.Parse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
+ }
+}
diff --git a/src/RP2040.NanoFramework.TestKit/RP2040.NanoFramework.TestKit.csproj b/src/RP2040.NanoFramework.TestKit/RP2040.NanoFramework.TestKit.csproj
new file mode 100644
index 0000000..39c1a2a
--- /dev/null
+++ b/src/RP2040.NanoFramework.TestKit/RP2040.NanoFramework.TestKit.csproj
@@ -0,0 +1,39 @@
+
+
+
+ RP2040Sharp.TestKit.NanoFramework
+ RP2040Sharp.TestKit.NanoFramework
+ RP2040Sharp.TestKit.NanoFramework — boot & assert deployed .NET nanoFramework apps on the RP2040 emulator
+ Boot a deployed .NET nanoFramework app on the RP2040Sharp emulator: firmware
+ discovery, deployment assembly from .pe metadata, and a native-checksum compatibility guard. Run your
+ real deployed C# on the emulated chip and assert against it from xUnit — managed static and instance
+ fields by name, run-until-managed-method, and a NanoSymbols source generator that emits strongly-typed
+ symbols for your app's methods and static fields.
+ rp2040;nanoframework;testkit;emulator;integration-testing;clr;xunit
+ true
+ net10.0
+ enable
+ enable
+
+
+ BUSL-1.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RP2040.NanoFramework.TestKit/build/RP2040Sharp.TestKit.NanoFramework.targets b/src/RP2040.NanoFramework.TestKit/build/RP2040Sharp.TestKit.NanoFramework.targets
new file mode 100644
index 0000000..f68ce23
--- /dev/null
+++ b/src/RP2040.NanoFramework.TestKit/build/RP2040Sharp.TestKit.NanoFramework.targets
@@ -0,0 +1,55 @@
+
+
+
+
+
+ Release
+ $(RootNamespace)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RP2040.TestKit/Probes/FifoCapture.cs b/src/RP2040.TestKit/Probes/FifoCapture.cs
new file mode 100644
index 0000000..90988fc
--- /dev/null
+++ b/src/RP2040.TestKit/Probes/FifoCapture.cs
@@ -0,0 +1,50 @@
+using System.Text;
+
+namespace RP2040.TestKit.Probes;
+
+///
+/// A timestamped capture of a byte stream flowing through a peripheral FIFO (UART/SPI/I2C TX or RX).
+/// Each byte is recorded with the simulated cycle at which it passed, so a test can inspect both the
+/// data and its timing.
+///
+public sealed class FifoCapture
+{
+ /// One captured byte and the simulated cycle it was observed at.
+ public readonly record struct Sample(long Cycle, byte Value);
+
+ private readonly List _samples = [];
+
+ internal void Record(long cycle, byte value) => _samples.Add(new Sample(cycle, value));
+
+ /// Every captured byte with its timestamp, in order.
+ public IReadOnlyList Samples => _samples;
+
+ /// The captured bytes, without timestamps.
+ public IReadOnlyList Bytes => _samples.Select(s => s.Value).ToList();
+
+ /// Number of bytes captured.
+ public int Count => _samples.Count;
+
+ /// The captured bytes decoded as a string (one char per byte).
+ public string Text => string.Concat(_samples.Select(s => (char)s.Value));
+
+ /// The captured bytes as a space-separated hex string (e.g. "A0 0F 12").
+ public string Hex() => string.Join(' ', _samples.Select(s => s.Value.ToString("X2")));
+
+ /// True if the captured bytes contain as a contiguous run.
+ public bool Contains(params byte[] sequence)
+ {
+ if (sequence.Length == 0) return true;
+ for (int i = 0; i + sequence.Length <= _samples.Count; i++)
+ {
+ bool match = true;
+ for (int j = 0; j < sequence.Length; j++)
+ if (_samples[i + j].Value != sequence[j]) { match = false; break; }
+ if (match) return true;
+ }
+ return false;
+ }
+
+ /// Clear the capture.
+ public void Clear() => _samples.Clear();
+}
diff --git a/src/RP2040.TestKit/Probes/PioProbe.cs b/src/RP2040.TestKit/Probes/PioProbe.cs
new file mode 100644
index 0000000..33d7ce2
--- /dev/null
+++ b/src/RP2040.TestKit/Probes/PioProbe.cs
@@ -0,0 +1,65 @@
+using RP2040.Peripherals.Pio;
+
+namespace RP2040.TestKit.Probes;
+
+///
+/// Captures the FIFO traffic of a PIO block's four state machines: every word the firmware pushes into
+/// a state machine's TX FIFO and every word it pulls out of an RX FIFO, each with a simulated-time
+/// stamp. PIO programs are arbitrary, so this is a per-state-machine FIFO capture rather than a
+/// protocol decoder — but it tells you exactly what data crossed the SM boundary and when.
+///
+public sealed class PioProbe
+{
+ /// One word that crossed a state machine's FIFO.
+ public readonly record struct Word(long Cycle, int StateMachine, uint Value);
+
+ private const int SmCount = 4;
+ private readonly FifoCapture[] _tx = { new(), new(), new(), new() };
+ private readonly FifoCapture[] _rx = { new(), new(), new(), new() };
+ private readonly List _txWords = [];
+ private readonly List _rxWords = [];
+ private readonly Func _clock;
+
+ internal PioProbe(PioPeripheral pio, Func clock)
+ {
+ _clock = clock;
+ pio.OnTxPush += (sm, value) =>
+ {
+ var cycle = _clock();
+ _txWords.Add(new Word(cycle, sm, value));
+ for (int i = 0; i < 4; i++) _tx[sm].Record(cycle, (byte)(value >> (i * 8)));
+ };
+ pio.OnRxPull += (sm, value) =>
+ {
+ var cycle = _clock();
+ _rxWords.Add(new Word(cycle, sm, value));
+ for (int i = 0; i < 4; i++) _rx[sm].Record(cycle, (byte)(value >> (i * 8)));
+ };
+ }
+
+ /// All words the firmware pushed into TX FIFOs (any state machine), in order.
+ public IReadOnlyList TxWords => _txWords;
+
+ /// All words the firmware pulled from RX FIFOs (any state machine), in order.
+ public IReadOnlyList RxWords => _rxWords;
+
+ /// Words pushed into state machine 's TX FIFO.
+ public IReadOnlyList TxOf(int sm) => _txWords.Where(w => w.StateMachine == sm).Select(w => w.Value).ToList();
+
+ /// Words pulled from state machine 's RX FIFO.
+ public IReadOnlyList RxOf(int sm) => _rxWords.Where(w => w.StateMachine == sm).Select(w => w.Value).ToList();
+
+ /// The little-endian byte stream pushed into a state machine's TX FIFO.
+ public FifoCapture TxBytes(int sm) => _tx[sm];
+
+ /// The little-endian byte stream pulled from a state machine's RX FIFO.
+ public FifoCapture RxBytes(int sm) => _rx[sm];
+
+ /// Clear all captured words.
+ public void Clear()
+ {
+ _txWords.Clear();
+ _rxWords.Clear();
+ for (int i = 0; i < SmCount; i++) { _tx[i].Clear(); _rx[i].Clear(); }
+ }
+}
diff --git a/src/RP2040.TestKit/RP2040TestSimulation.cs b/src/RP2040.TestKit/RP2040TestSimulation.cs
index 51cf1d3..6ead98d 100644
--- a/src/RP2040.TestKit/RP2040TestSimulation.cs
+++ b/src/RP2040.TestKit/RP2040TestSimulation.cs
@@ -48,13 +48,24 @@ public class RP2040TestSimulation : IDisposable
public IReadOnlyList BreakpointHits => _breakpointHits;
private readonly List _breakpointHits = new();
+ // A firmware panic (pico-sdk panic() = BKPT #0) spins in `_exit: bkpt; b _exit`, so the capturing
+ // handler below fires on every loop iteration. Over a long run that is millions of identical hits —
+ // enough to exhaust memory (the List backing array hits the 2 GB array-length limit and throws
+ // OutOfMemoryException). Cap the recorded sequence: the panic is fully observable from the first
+ // entries, and no legitimate test records anywhere near this many distinct breakpoints.
+ private const int MaxBreakpointHits = 1 << 16;
+ private void RecordBreakpoint(byte imm8)
+ {
+ if (_breakpointHits.Count < MaxBreakpointHits) _breakpointHits.Add(imm8);
+ }
+
protected RP2040TestSimulation()
{
Machine = new RP2040Machine();
// Install a capturing breakpoint handler so BKPT does not escalate to HardFault.
// This is the correct ARMv6-M behaviour when a debugger/monitor is attached.
- Machine.Cpu.OnBreakpoint = imm8 => _breakpointHits.Add(imm8);
- Machine.Cpu1.OnBreakpoint = imm8 => _breakpointHits.Add(imm8);
+ Machine.Cpu.OnBreakpoint = RecordBreakpoint;
+ Machine.Cpu1.OnBreakpoint = RecordBreakpoint;
}
/// Create a new simulation instance.
@@ -92,6 +103,14 @@ public RP2040TestSimulation AddUart(int index, out UartProbe probe)
return this;
}
+ /// Attach a to a PIO block (0 or 1) to capture its FIFO traffic.
+ public RP2040TestSimulation AddPioProbe(int pioIndex, out PioProbe probe)
+ {
+ var pio = pioIndex == 0 ? Machine.Pio0 : Machine.Pio1;
+ probe = new PioProbe(pio, () => Machine.InstructionCount);
+ return this;
+ }
+
/// Lazily-created CDC-ACM host driver bound to the device USB peripheral.
public UsbCdcHost UsbCdcHost => _usbCdcHost ??= new UsbCdcHost(Machine.Usb);
private UsbCdcHost? _usbCdcHost;
diff --git a/src/RP2040.Wireless/Ble/BleLink.cs b/src/RP2040.Wireless/Ble/BleLink.cs
new file mode 100644
index 0000000..9ee7611
--- /dev/null
+++ b/src/RP2040.Wireless/Ble/BleLink.cs
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: BUSL-1.1
+// Copyright (c) 2024-2026 Iván Montiel Cardona
+
+namespace RP2040.Wireless.Ble;
+
+///
+/// An established LE connection between two emulated controllers. Each side has its own connection
+/// handle; ACL data sent by one is relayed to the other with the peer's handle rewritten in, so the
+/// guests' L2CAP/ATT/GATT layers talk to each other unmodified over the virtual radio.
+///
+public sealed class BleLink
+{
+ private readonly HciController _a, _b;
+ private readonly ushort _handleA, _handleB;
+
+ public BleLink(HciController a, ushort handleA, HciController b, ushort handleB)
+ {
+ _a = a; _handleA = handleA; _b = b; _handleB = handleB;
+ }
+
+ /// Relay an ACL packet from to the peer, rewriting the 12-bit
+ /// connection handle to the peer's and setting the packet-boundary flag for the controller→host
+ /// direction.
+ public void DeliverAclToPeer(HciController from, byte[] acl)
+ {
+ var (peer, peerHandle) = from == _a ? (_b, _handleB) : (_a, _handleA);
+ var copy = (byte[])acl.Clone();
+ // The host sends with PB=0b00 (host→controller, non-flushable). On the wire to the *receiving*
+ // host this is a controller→host packet, which must carry PB=0b10 ("first fragment / start of
+ // an L2CAP PDU") — btstack's receive path drops anything else as "invalid boundary flags".
+ // Our links always relay a complete L2CAP PDU in one ACL, so 0b10 is always correct.
+ var hdr = (ushort)((peerHandle & 0x0FFF) | (0x2 << 12)); // PB=0b10, BC=0b00
+ copy[0] = (byte)hdr; copy[1] = (byte)(hdr >> 8);
+ peer.DeliverAcl(copy);
+ }
+
+ /// One side closed; tear down the peer's handle too.
+ public void NotifyClosed(HciController from)
+ {
+ var (peer, peerHandle) = from == _a ? (_b, _handleB) : (_a, _handleA);
+ peer.CloseLink(peerHandle, 0x16); // connection terminated by local host
+ }
+}
diff --git a/src/RP2040.Wireless/Ble/BtSharedBus.cs b/src/RP2040.Wireless/Ble/BtSharedBus.cs
new file mode 100644
index 0000000..eb7548a
--- /dev/null
+++ b/src/RP2040.Wireless/Ble/BtSharedBus.cs
@@ -0,0 +1,177 @@
+// SPDX-License-Identifier: BUSL-1.1
+// Copyright (c) 2024-2026 Iván Montiel Cardona
+
+using System;
+using RP2040.Wireless.Cyw43;
+
+namespace RP2040.Wireless.Ble;
+
+///
+/// The CYW43439's Bluetooth "shared bus": HCI transported over the same gSPI/SDIO backplane as Wi-Fi,
+/// through a pair of circular buffers in chip RAM plus a few control registers (cybt_shared_bus). The
+/// host downloads BT firmware, raises SW_RDY, waits for the chip's FW_RDY, then reads/writes HCI
+/// packets via the H2B (host→BT) and B2H (BT→host) rings. We model the chip side: parse the host's HCI
+/// commands/ACL out of H2B and hand them to the , and serialise the
+/// controller's HCI events/ACL back into B2H, signalling the host through I_HMB_FC_CHANGE.
+///
+public sealed class BtSharedBus
+{
+ // Backplane registers the cybt driver pokes (chip addresses).
+ private const uint BtCtrlReg = 0x18000C7C; // chip→host: FW_RDY(b24), BT_AWAKE(b8), DATA_VALID(b1)
+ private const uint HostCtrlReg = 0x18000D6C; // host→chip: SW_RDY(b24), WAKE_BT(b17), DATA_VALID(b1)
+ private const uint WlanRamBaseReg = 0x18000D68; // chip tells host where the BT RAM/rings live
+ private const uint SdioIntStatusAddr = 0x18002020;
+
+ private const uint FwRdy = 1u << 24, BtAwake = 1u << 8; // BT_CTRL bits we drive
+ private const uint SwRdy = 1u << 24; // HOST_CTRL bit the host sets
+ private const uint IHmbFcChange = 0x20; // "BT has work" in SDIO_INT_STATUS
+
+ // BT RAM layout (cybt_shared_bus_driver): rings + their in/out indices at fixed offsets from base.
+ private const uint WlanRamBase = 0x19100000;
+ private const uint FwBufSize = 0x1000;
+ private const uint H2bBuf = WlanRamBase + 0x0000; // host→BT data ring (host writes)
+ private const uint B2hBuf = WlanRamBase + 0x1000; // BT→host data ring (we write)
+ private const uint H2bIn = WlanRamBase + 0x2000;
+ private const uint H2bOut = WlanRamBase + 0x2004;
+ private const uint B2hIn = WlanRamBase + 0x2008;
+ private const uint B2hOut = WlanRamBase + 0x200C;
+
+ // HCI H4 packet types (the 4th header byte in each ring entry).
+ public const byte HciCommand = 0x01, HciAcl = 0x02, HciEvent = 0x04;
+
+ private readonly Backplane _chip;
+ private readonly HciController _hci;
+ private uint _hostCtrl;
+ private bool _swReady;
+
+ /// Raised whenever the chip→host work state may have changed (a B2H packet was queued or
+ /// the host drained the ring). The device ORs this into the gSPI host-wake line (GPIO24) so an idle
+ /// guest is woken to run cyw43_poll → cyw43_ll_bt_has_work, exactly as the Wi-Fi F2
+ /// path does — without it a peripheral sitting at the REPL never notices an inbound HCI packet.
+ public Action? OnWorkChanged;
+
+ /// Diagnostics: low-level trace of BT shared-bus register/ring activity.
+ public Action? OnDiag;
+ /// Diagnostics: a host→chip HCI packet (type, payload) was extracted from H2B.
+ public Action? OnHostPacket;
+ /// Diagnostics: an HCI packet (type, payload) was queued chip→host into B2H.
+ public Action? OnChipPacket;
+
+ public BtSharedBus(Backplane chip, HciController hci)
+ {
+ _chip = chip;
+ _hci = hci;
+ _hci.SendToHost = (type, payload) => QueueB2H(type, payload);
+ _chip.Bt = this;
+ }
+
+ /// Whether is one of the BT control registers (4 bytes each).
+ public bool OwnsRegister(uint addr) =>
+ (addr >= BtCtrlReg && addr < BtCtrlReg + 4) ||
+ (addr >= HostCtrlReg && addr < HostCtrlReg + 4) ||
+ (addr >= WlanRamBaseReg && addr < WlanRamBaseReg + 4);
+
+ public byte ReadRegByte(uint addr)
+ {
+ // NB: WLAN_RAM_BASE (0xD68) sits *below* HOST_CTRL (0xD6C), so each register must be matched by
+ // its exact 4-byte range — an ordered "addr < HOST_CTRL+4" chain would wrongly swallow 0xD68.
+ uint reg, baseAddr;
+ if (addr >= BtCtrlReg && addr < BtCtrlReg + 4) { baseAddr = BtCtrlReg; reg = BtCtrlValue(); }
+ else if (addr >= HostCtrlReg && addr < HostCtrlReg + 4) { baseAddr = HostCtrlReg; reg = _hostCtrl; }
+ else { baseAddr = WlanRamBaseReg; reg = WlanRamBase; if (addr == WlanRamBaseReg) OnDiag?.Invoke($"read WLAN_RAM_BASE=0x{reg:X8}"); }
+ return (byte)(reg >> (int)((addr - baseAddr) * 8));
+ }
+
+ public void WriteRegByte(uint addr, byte value)
+ {
+ if (addr >= HostCtrlReg && addr < HostCtrlReg + 4)
+ {
+ var shift = (int)((addr - HostCtrlReg) * 8);
+ _hostCtrl = (_hostCtrl & ~(0xFFu << shift)) | ((uint)value << shift);
+ if ((_hostCtrl & SwRdy) != 0) _swReady = true; // host signalled ready → chip reports FW_RDY
+ if (addr == HostCtrlReg + 3) OnDiag?.Invoke($"HOSTCTRL=0x{_hostCtrl:X8} h2bIn={_chip.MemReadU32(H2bIn)} h2bOut={_chip.MemReadU32(H2bOut)}");
+ // Any host-control write may accompany an H2B push (the driver toggles DATA_VALID after
+ // writing a packet); drain whatever the host has queued.
+ DrainH2B();
+ }
+ // BT_CTRL / WLAN_RAM_BASE are chip-driven (read-only to the host); ignore writes.
+ }
+
+ // The host downloads the BT firmware, then polls BT_CTRL for FW_RDY *before* it sets SW_RDY, so
+ // the emulated controller reports ready as soon as it's queried (the firmware "boots" instantly),
+ // and is always awake. (_swReady is tracked only to gate H2B draining onced the host is up.)
+ private uint BtCtrlValue() => FwRdy | BtAwake | (_b2hPending ? 2u : 0u);
+
+ // I_HMB_FC_CHANGE is a write-1-to-clear interrupt *latch*, NOT the live ring state. The chip raises
+ // it when it pushes a B2H packet; the host clears it in cyw43_ll_bt_has_work and only THEN — at
+ // thread level, via btstack's run loop — drains the ring. If we instead derived "work" from the
+ // live ring (B2H_IN != B2H_OUT), the bit would stay set until the drain, but the drain can't run
+ // because the still-asserted level-high host-wake keeps PendSV/cyw43_poll spinning and starves the
+ // thread: a deadlock. Latching it (set on queue, cleared on the host's write-1-to-clear) lets the
+ // pin drop the moment the host acknowledges, so the thread runs and reads the ring — as on silicon.
+ private bool _b2hPending;
+ /// Whether an inbound B2H interrupt is latched (drives SDIO_INT_STATUS and the host-wake line).
+ public bool HasWork => _b2hPending;
+
+ /// SDIO_INT_STATUS as the host's cyw43_ll_bt_has_work reads it.
+ public uint SdioIntStatus() => _b2hPending ? IHmbFcChange : 0u;
+ public void ClearSdioInt(int mask) { if ((mask & IHmbFcChange) != 0) { _b2hPending = false; OnWorkChanged?.Invoke(); } }
+
+ // ── H2B (host → controller) ────────────────────────────────────────────
+ private void DrainH2B()
+ {
+ // Read complete HCI packets out of the circular H2B buffer (each prefixed by a 4-byte header:
+ // 3-byte little-endian payload length + 1-byte H4 packet type) until in==out.
+ for (var guard = 0; guard < 64; guard++)
+ {
+ var inIdx = _chip.MemReadU32(H2bIn) & (FwBufSize - 1);
+ var outIdx = _chip.MemReadU32(H2bOut) & (FwBufSize - 1);
+ var avail = (inIdx - outIdx) & (FwBufSize - 1);
+ if (avail < 4) return;
+
+ var hdr = ReadRing(H2bBuf, outIdx, 4);
+ var len = (uint)(hdr[0] | hdr[1] << 8 | hdr[2] << 16);
+ var type = hdr[3];
+ // The host pads each ring entry up to a 4-byte boundary, so consume the rounded length or
+ // the next packet's OUT pointer drifts out of alignment.
+ var total = (4 + len + 3u) & ~3u;
+ if (((inIdx - outIdx) & (FwBufSize - 1)) < total) return; // packet not fully written yet
+
+ var payload = ReadRing(H2bBuf, (outIdx + 4) & (FwBufSize - 1), len);
+ _chip.MemWriteU32(H2bOut, (outIdx + total) & (FwBufSize - 1));
+ OnHostPacket?.Invoke(type, payload);
+ _hci.HandleHostPacket(type, payload);
+ }
+ }
+
+ // ── B2H (controller → host) ──────────────────────────────────────────────
+ private void QueueB2H(byte type, byte[] payload)
+ {
+ var inIdx = _chip.MemReadU32(B2hIn) & (FwBufSize - 1);
+ var outIdx = _chip.MemReadU32(B2hOut) & (FwBufSize - 1);
+ var total = (4 + (uint)payload.Length + 3u) & ~3u; // entries are 4-byte aligned, like the host expects
+ // Drop if the ring can't hold the packet (the host hasn't drained yet) rather than wrap over
+ // unread data — a real controller would back-pressure; corrupting the ring crashes the host.
+ var space = (outIdx - (inIdx + 4)) & (FwBufSize - 1);
+ if (total > space) return;
+ var hdr = new byte[] { (byte)payload.Length, (byte)(payload.Length >> 8), (byte)(payload.Length >> 16), type };
+ WriteRing(B2hBuf, inIdx, hdr);
+ WriteRing(B2hBuf, (inIdx + 4) & (FwBufSize - 1), payload);
+ _chip.MemWriteU32(B2hIn, (inIdx + total) & (FwBufSize - 1));
+ _b2hPending = true; // latch I_HMB_FC_CHANGE + assert host-wake so the host polls
+ OnWorkChanged?.Invoke();
+ OnChipPacket?.Invoke(type, payload);
+ }
+
+ private byte[] ReadRing(uint ringBase, uint start, uint len)
+ {
+ var b = new byte[len];
+ for (uint i = 0; i < len; i++) b[i] = _chip.MemReadByte(ringBase + ((start + i) & (FwBufSize - 1)));
+ return b;
+ }
+
+ private void WriteRing(uint ringBase, uint start, byte[] data)
+ {
+ for (uint i = 0; i < data.Length; i++) _chip.MemWriteByte(ringBase + ((start + i) & (FwBufSize - 1)), data[i]);
+ }
+}
diff --git a/src/RP2040.Wireless/Ble/HciController.cs b/src/RP2040.Wireless/Ble/HciController.cs
new file mode 100644
index 0000000..2baf157
--- /dev/null
+++ b/src/RP2040.Wireless/Ble/HciController.cs
@@ -0,0 +1,297 @@
+// SPDX-License-Identifier: BUSL-1.1
+// Copyright (c) 2024-2026 Iván Montiel Cardona
+
+using System;
+using System.Collections.Generic;
+
+namespace RP2040.Wireless.Ble;
+
+///
+/// An emulated Bluetooth LE controller at the HCI boundary — the chip side of the host's HCI stack
+/// (btstack/NimBLE). It answers the bring-up command sequence (Reset, Read BD_ADDR, LE buffer sizes,
+/// …) with the Command Complete/Status events the host expects, and drives the LE link layer:
+/// advertising, scanning, and connections. Everything above the link layer (L2CAP/ATT/GATT) runs
+/// unmodified in the guests; this controller only relays ACL data between connected peers over the
+/// , exactly as the Wi-Fi side relays Ethernet frames.
+///
+public sealed class HciController
+{
+ // HCI command opcodes (OGF << 10 | OCF).
+ private const ushort Reset = 0x0C03, SetEventMask = 0x0C01, SetEventMaskPage2 = 0x0C63;
+ private const ushort ReadLocalVersion = 0x1001, ReadLocalSupportedCommands = 0x1002,
+ ReadLocalSupportedFeatures = 0x1003, ReadBufferSize = 0x1005, ReadBdAddr = 0x1009;
+ private const ushort LeSetEventMask = 0x2001, LeReadBufferSize = 0x2002, LeReadLocalFeatures = 0x2003,
+ LeSetRandomAddress = 0x2005, LeSetAdvParams = 0x2006, LeReadAdvTxPower = 0x2007,
+ LeSetAdvData = 0x2008, LeSetScanRspData = 0x2009, LeSetAdvEnable = 0x200A,
+ LeSetScanParams = 0x200B, LeSetScanEnable = 0x200C, LeCreateConn = 0x200D,
+ LeCreateConnCancel = 0x200E, LeReadWhiteListSize = 0x200F, LeClearWhiteList = 0x2010,
+ LeReadSupportedStates = 0x201C, LeRand = 0x2018, LeReadBufferSizeV2 = 0x2060;
+ private const ushort LeConnUpdate = 0x2013, LeReadRemoteFeatures = 0x2016, LeSetDataLength = 0x2022;
+ private const ushort Disconnect = 0x0406, ReadRssi = 0x1405;
+
+ // HCI event codes.
+ private const byte EvtDisconnComplete = 0x05, EvtCommandComplete = 0x0E, EvtCommandStatus = 0x0F,
+ EvtNumCompletedPackets = 0x13, EvtLeMeta = 0x3E;
+ // LE meta sub-events.
+ private const byte LeConnComplete = 0x01, LeAdvReport = 0x02, LeConnUpdateComplete = 0x03,
+ LeReadRemoteFeaturesComplete = 0x04, LeEnhancedConnComplete = 0x0A;
+
+ /// Send an HCI packet (type, payload) to the host via the shared bus. Wired by BtSharedBus.
+ public Action? SendToHost;
+
+ /// This controller's public BD_ADDR (6 bytes, little-endian on the wire).
+ public byte[] BdAddr { get; set; } = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66];
+
+ /// The shared virtual radio linking this controller to peers (advertising/scan/connect/ACL).
+ public VirtualBleRadio? Radio;
+
+ /// Diagnostics: a command was handled (opcode), an LE event emitted, etc.
+ public Action? OnTrace;
+
+ // Link-layer state.
+ internal bool Advertising;
+ internal bool Scanning;
+ internal byte[] AdvData = [];
+ internal byte AdvType; // 0=ADV_IND, 2=ADV_SCAN_IND, 3=ADV_NONCONN_IND
+ internal byte OwnAddrType;
+ private ushort _nextHandle = 0x0040;
+ private readonly Dictionary _links = new(); // connection handle → link
+ private readonly Dictionary> _externalLinks = new(); // handle → ACL relay to peer
+
+ // ── Cross-assembly bridging seam ──────────────────────────────────────────────────────────────
+ // The in-process VirtualBleRadio links controllers that live in THIS assembly. To link a controller
+ // to a peer in a DIFFERENT assembly (e.g. an RP2040 ↔ an RP2350 in one test harness), an external
+ // coordinator drives the same link layer through this public surface instead of via Radio.
+
+ /// This controller's host has advertising enabled.
+ public bool IsAdvertising => Advertising;
+ /// This controller's host has scanning enabled.
+ public bool IsScanning => Scanning;
+ /// The advertising PDU type (0=ADV_IND, 2=ADV_SCAN_IND, 3=ADV_NONCONN_IND).
+ public byte AdvertisingType => AdvType;
+ /// The advertising payload the host set.
+ public byte[] AdvertisingData => AdvData;
+ /// This controller's own address type (0=public, 1=random).
+ public byte LocalAddrType => OwnAddrType;
+
+ /// Advertising was enabled/disabled by the host.
+ public Action? OnAdvertisingChanged;
+ /// Scanning was enabled by the host (an external radio should deliver current advertisers).
+ public Action? OnScanningChanged;
+ /// The host issued LE Create Connection: (peerAddr, asCentral). An external radio links it.
+ public Action? OnHostCreateConnection;
+
+ /// Deliver an advertising report to this (scanning) controller's host.
+ public void DeliverAdvReport(byte[] peerAddr, byte peerAddrType, byte advType, byte[] advData, sbyte rssi)
+ => EmitAdvReport(peerAddr, peerAddrType, advType, advData, rssi);
+
+ /// Open a connection driven by an external radio: reserve a handle, register the relay that
+ /// forwards this side's ACL to the peer, and emit LE Connection Complete. Returns the local handle.
+ public ushort OpenExternalLink(byte role, byte[] peerAddr, byte peerAddrType, Action aclToPeer)
+ {
+ var h = _nextHandle++;
+ _externalLinks[h] = aclToPeer;
+ EmitConnectionComplete(h, role, peerAddr, peerAddrType);
+ return h;
+ }
+
+ /// Deliver an ACL packet (already handle-rewritten for this side) up to this host.
+ public void DeliverAclToHost(byte[] acl) => DeliverAcl(acl);
+
+ /// Tear down an external-radio link and notify this host.
+ public void CloseExternalLink(ushort handle, byte reason)
+ {
+ _externalLinks.Remove(handle);
+ CloseLink(handle, reason);
+ }
+
+ public void HandleHostPacket(byte type, byte[] p)
+ {
+ switch (type)
+ {
+ case BtSharedBus.HciCommand: HandleCommand(p); break;
+ case BtSharedBus.HciAcl: HandleAcl(p); break;
+ }
+ }
+
+ private void HandleCommand(byte[] p)
+ {
+ if (p.Length < 3) return;
+ var opcode = (ushort)(p[0] | p[1] << 8);
+ var plen = p[2];
+ var args = p.Length > 3 ? p[3..] : [];
+ OnTrace?.Invoke($"CMD 0x{opcode:X4} len{plen}");
+
+ switch (opcode)
+ {
+ // ── bring-up: Command Complete with status 0 + the parameters the host validates ──
+ case ReadBdAddr: CommandComplete(opcode, Concat([0x00], BdAddr)); break;
+ case ReadLocalVersion:
+ // status, HCI version(5.3=12), HCI rev, LMP version(12), manufacturer(0x000F Broadcom), LMP subver
+ CommandComplete(opcode, [0x00, 12, 0x00, 0x00, 12, 0x0F, 0x00, 0x00, 0x00]); break;
+ case ReadLocalSupportedCommands:
+ // The Supported Commands bitmap gates btstack's init: a zero map makes it believe the
+ // controller supports nothing, so it SKIPS Read Buffer Size / LE Read Buffer Size and
+ // ends up with zero ACL credits — connections still form, but no ATT/GATT ACL can ever
+ // be sent. Advertise everything supported so btstack runs full init and learns its
+ // ACL buffer budget. (status + 64-byte all-ones bitmap.)
+ CommandComplete(opcode, Concat([0x00], Filled(64, 0xFF))); break;
+ case ReadLocalSupportedFeatures:
+ CommandComplete(opcode, [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40]); break; // LE supported (byte4 bit6)
+ case ReadBufferSize: CommandComplete(opcode, [0x00, 0xFB, 0x00, 0x01, 0x08, 0x00, 0x08, 0x00]); break;
+ case LeReadBufferSize: CommandComplete(opcode, [0x00, 0xFB, 0x00, 0x08]); break; // ACL len 251, 8 packets
+ case LeReadBufferSizeV2: CommandComplete(opcode, [0x00, 0xFB, 0x00, 0x08, 0xFB, 0x00, 0x08]); break;
+ case LeReadLocalFeatures: CommandComplete(opcode, Concat([0x00], new byte[8])); break;
+ case LeReadSupportedStates: CommandComplete(opcode, Concat([0x00], new byte[8])); break;
+ case LeReadWhiteListSize: CommandComplete(opcode, [0x00, 0x08]); break;
+ case LeReadAdvTxPower: CommandComplete(opcode, [0x00, 0x09]); break;
+ case ReadRssi: CommandComplete(opcode, Concat(Concat([0x00], U16(args.Length >= 2 ? (ushort)(args[0] | args[1] << 8) : (ushort)0)), [unchecked((byte)-50)])); break;
+ case LeRand: CommandComplete(opcode, Concat([0x00], new byte[8])); break;
+
+ // ── LE addresses / data ──
+ case LeSetRandomAddress: if (args.Length >= 6) BdAddr = args[..6]; CommandComplete(opcode, [0x00]); break;
+ case LeSetAdvParams: if (args.Length >= 15) AdvType = args[4]; CommandComplete(opcode, [0x00]); break;
+ case LeSetAdvData:
+ if (args.Length >= 1) { var n = Math.Min(args[0], (byte)31); AdvData = args.Length >= 1 + n ? args[1..(1 + n)] : []; }
+ CommandComplete(opcode, [0x00]); break;
+ case LeSetScanRspData: CommandComplete(opcode, [0x00]); break;
+
+ // ── advertising / scanning enable ──
+ case LeSetAdvEnable:
+ Advertising = args.Length >= 1 && args[0] != 0;
+ OnTrace?.Invoke($"ADV {(Advertising ? "ON" : "OFF")}");
+ CommandComplete(opcode, [0x00]);
+ OnAdvertisingChanged?.Invoke();
+ break;
+ case LeSetScanParams: CommandComplete(opcode, [0x00]); break;
+ case LeSetScanEnable:
+ Scanning = args.Length >= 1 && args[0] != 0;
+ OnTrace?.Invoke($"SCAN {(Scanning ? "ON" : "OFF")}");
+ CommandComplete(opcode, [0x00]);
+ if (Scanning) Radio?.RequestAdvertisements(this); // deliver any current advertisers' reports
+ OnScanningChanged?.Invoke();
+ break;
+
+ // ── connection ──
+ case LeCreateConn:
+ CommandStatus(opcode, 0x00);
+ // args: scan_interval(2) scan_window(2) filter(1) peer_addr_type(1) peer_addr(6) own_addr_type(1) ...
+ if (args.Length >= 12)
+ {
+ Radio?.CreateConnection(this, args[6..12], asCentral: true);
+ OnHostCreateConnection?.Invoke(args[6..12], true);
+ }
+ break;
+ case LeCreateConnCancel: CommandComplete(opcode, [0x00]); break;
+ // Post-connection setup btstack runs before it will start ATT/GATT. Each is a "status now,
+ // result later" command: a Command Status, then the matching LE Meta completion event. The
+ // host blocks waiting for that event, so a bare Command Complete (the default) stalls GATT.
+ case LeReadRemoteFeatures:
+ CommandStatus(opcode, 0x00);
+ if (args.Length >= 2)
+ {
+ var h = (ushort)(args[0] | args[1] << 8);
+ // LE Read Remote Features Complete: subevent, status, handle(2), 8 feature bytes.
+ var ev = new List { LeReadRemoteFeaturesComplete, 0x00, (byte)h, (byte)(h >> 8) };
+ ev.AddRange(new byte[8]);
+ LeMeta(ev.ToArray());
+ }
+ break;
+ case LeConnUpdate:
+ CommandStatus(opcode, 0x00);
+ if (args.Length >= 14)
+ {
+ var h = (ushort)(args[0] | args[1] << 8);
+ // LE Connection Update Complete: subevent, status, handle(2), interval(2), latency(2), timeout(2).
+ LeMeta([LeConnUpdateComplete, 0x00, (byte)h, (byte)(h >> 8),
+ args[2], args[3], args[6], args[7], args[8], args[9]]);
+ }
+ break;
+ case Disconnect:
+ if (args.Length >= 2) { var h = (ushort)(args[0] | args[1] << 8); CommandStatus(opcode, 0x00); CloseLink(h, 0x16); }
+ else CommandStatus(opcode, 0x12);
+ break;
+
+ // ── everything else the bring-up touches: accept with status 0 ──
+ default: CommandComplete(opcode, [0x00]); break;
+ }
+ }
+
+ // ── ACL data: relay between connected peers ──
+ private void HandleAcl(byte[] acl)
+ {
+ if (acl.Length < 4) return;
+ var handle = (ushort)((acl[0] | acl[1] << 8) & 0x0FFF);
+ if (_links.TryGetValue(handle, out var link))
+ {
+ // Acknowledge the host's TX (flow control), then relay the payload to the peer's handle.
+ SendNumberOfCompletedPackets(handle, 1);
+ link.DeliverAclToPeer(this, acl);
+ }
+ else if (_externalLinks.TryGetValue(handle, out var relay))
+ {
+ // Same relay, but to a peer controller in another assembly (cross-assembly bridge).
+ SendNumberOfCompletedPackets(handle, 1);
+ relay(acl);
+ }
+ }
+
+ // ── events the radio/links drive ──
+ internal void EmitConnectionComplete(ushort handle, byte role, byte[] peerAddr, byte peerAddrType)
+ {
+ // LE Connection Complete: subevent, status, handle(2), role, peer_addr_type, peer_addr(6),
+ // conn_interval(2), conn_latency(2), supervision_timeout(2), master_clock_accuracy(1)
+ var ev = new List { LeConnComplete, 0x00, (byte)handle, (byte)(handle >> 8), role, peerAddrType };
+ ev.AddRange(peerAddr);
+ ev.AddRange(new byte[] { 0x18, 0x00, 0x00, 0x00, 0x2A, 0x00, 0x00 }); // 30ms interval, 0 latency, 420ms timeout
+ LeMeta(ev.ToArray());
+ }
+
+ internal void EmitAdvReport(byte[] peerAddr, byte peerAddrType, byte advType, byte[] advData, sbyte rssi)
+ {
+ // LE Advertising Report: subevent, num_reports=1, event_type, addr_type, addr(6), data_len, data, rssi
+ var ev = new List { LeAdvReport, 0x01, advType, peerAddrType };
+ ev.AddRange(peerAddr);
+ ev.Add((byte)advData.Length);
+ ev.AddRange(advData);
+ ev.Add((byte)rssi);
+ LeMeta(ev.ToArray());
+ }
+
+ internal void DeliverAcl(byte[] acl) => SendToHost?.Invoke(BtSharedBus.HciAcl, acl);
+
+ internal ushort RegisterLinkDeferred(BleLinkHolder link) { var h = _nextHandle++; _links[h] = link; return h; }
+
+ internal void CloseLink(ushort handle, byte reason)
+ {
+ if (_links.Remove(handle, out var link))
+ {
+ link.NotifyClosed(this);
+ // Disconnection Complete: status, handle(2), reason
+ SendToHost?.Invoke(BtSharedBus.HciEvent, [EvtDisconnComplete, 0x04, 0x00, (byte)handle, (byte)(handle >> 8), reason]);
+ }
+ }
+
+ // ── HCI event encoders ──
+ private void CommandComplete(ushort opcode, byte[] returnParams)
+ {
+ var ev = new byte[3 + returnParams.Length];
+ ev[0] = 0x01; ev[1] = (byte)opcode; ev[2] = (byte)(opcode >> 8);
+ Array.Copy(returnParams, 0, ev, 3, returnParams.Length);
+ SendToHost?.Invoke(BtSharedBus.HciEvent, Concat([EvtCommandComplete, (byte)ev.Length], ev));
+ }
+
+ private void CommandStatus(ushort opcode, byte status) =>
+ SendToHost?.Invoke(BtSharedBus.HciEvent, [EvtCommandStatus, 0x04, status, 0x01, (byte)opcode, (byte)(opcode >> 8)]);
+
+ private void LeMeta(byte[] sub) =>
+ SendToHost?.Invoke(BtSharedBus.HciEvent, Concat([EvtLeMeta, (byte)sub.Length], sub));
+
+ private void SendNumberOfCompletedPackets(ushort handle, ushort count) =>
+ SendToHost?.Invoke(BtSharedBus.HciEvent,
+ [EvtNumCompletedPackets, 0x05, 0x01, (byte)handle, (byte)(handle >> 8), (byte)count, (byte)(count >> 8)]);
+
+ private static byte[] Filled(int n, byte v) { var b = new byte[n]; Array.Fill(b, v); return b; }
+ private static byte[] U16(ushort v) => [(byte)v, (byte)(v >> 8)];
+ private static byte[] Concat(byte[] a, byte[] b) { var r = new byte[a.Length + b.Length]; Array.Copy(a, r, a.Length); Array.Copy(b, 0, r, a.Length, b.Length); return r; }
+}
diff --git a/src/RP2040.Wireless/Ble/VirtualBleRadio.cs b/src/RP2040.Wireless/Ble/VirtualBleRadio.cs
new file mode 100644
index 0000000..cd99662
--- /dev/null
+++ b/src/RP2040.Wireless/Ble/VirtualBleRadio.cs
@@ -0,0 +1,97 @@
+// SPDX-License-Identifier: BUSL-1.1
+// Copyright (c) 2024-2026 Iván Montiel Cardona
+
+using System.Collections.Generic;
+
+namespace RP2040.Wireless.Ble;
+
+///
+/// The virtual BLE air linking several emulated controllers (each a Pico 2 W). It carries the link
+/// layer the controllers share: advertising packets reach scanners as Advertising Reports, a central's
+/// Create Connection finds the matching advertiser and establishes a (both sides
+/// get LE Connection Complete), and ACL traffic is relayed peer-to-peer. The host BLE stacks
+/// (btstack/NimBLE, GATT/ATT) run unmodified above it.
+///
+public sealed class VirtualBleRadio
+{
+ private readonly List _devices = new();
+ // How many advertising reports a scanner has had from each advertiser in the current scan session.
+ // A real advertiser repeats every interval, but we cap delivery so a fast host poll loop can't be
+ // flooded with thousands of identical reports (which exhausts the guest's heap).
+ private readonly Dictionary<(HciController, HciController), int> _reportCount = new();
+ private const int MaxReportsPerSession = 8;
+
+ /// Diagnostics: a connection was established (central addr, peripheral addr).
+ public System.Action? OnConnected;
+
+ public void AddDevice(HciController c) { _devices.Add(c); c.Radio = this; }
+
+ /// Deliver, to , an Advertising Report for every other device
+ /// that is currently advertising (rate-limited per scan session). Called on scan enable and from
+ /// .
+ public void RequestAdvertisements(HciController scanner)
+ {
+ if (!scanner.Scanning)
+ {
+ // Scan stopped: forget this scanner's deliveries so the next scan rediscovers advertisers.
+ foreach (var key in new List<(HciController, HciController)>(_reportCount.Keys))
+ if (key.Item1 == scanner) _reportCount.Remove(key);
+ return;
+ }
+ foreach (var d in _devices)
+ {
+ if (d == scanner || !d.Advertising) continue;
+ var key = (scanner, d);
+ _reportCount.TryGetValue(key, out var n);
+ if (n >= MaxReportsPerSession) continue;
+ _reportCount[key] = n + 1;
+ scanner.EmitAdvReport(d.BdAddr, d.OwnAddrType, d.AdvType, d.AdvData, rssi: -50);
+ }
+ }
+
+ /// Periodic tick: deliver advertising reports to every active scanner so a scan that
+ /// starts before an advertiser, or runs for a window, still discovers it (bounded per session).
+ public void Poll()
+ {
+ foreach (var s in _devices)
+ RequestAdvertisements(s);
+ }
+
+ /// A central is connecting to . Find the advertiser with that
+ /// address, register a link on both controllers, and emit LE Connection Complete to each.
+ public void CreateConnection(HciController central, byte[] peerAddr, bool asCentral)
+ {
+ HciController? peripheral = null;
+ foreach (var d in _devices)
+ if (d != central && d.Advertising && AddrEquals(d.BdAddr, peerAddr)) { peripheral = d; break; }
+ if (peripheral == null) return; // no advertiser — the host's create-connection simply times out
+
+ peripheral.Advertising = false; // a connectable advertiser stops advertising once connected
+
+ // Reserve handles, wire the link, then notify both ends (central role=0, peripheral role=1).
+ var link = new BleLinkHolder();
+ var hC = central.RegisterLinkDeferred(link);
+ var hP = peripheral.RegisterLinkDeferred(link);
+ link.Bind(central, hC, peripheral, hP);
+
+ central.EmitConnectionComplete(hC, role: 0x00, peripheral.BdAddr, peripheral.OwnAddrType);
+ peripheral.EmitConnectionComplete(hP, role: 0x01, central.BdAddr, central.OwnAddrType);
+ OnConnected?.Invoke(central.BdAddr, peripheral.BdAddr);
+ }
+
+ private static bool AddrEquals(byte[] a, byte[] b)
+ {
+ if (a.Length < 6 || b.Length < 6) return false;
+ for (var i = 0; i < 6; i++) if (a[i] != b[i]) return false;
+ return true;
+ }
+}
+
+/// Late-bound so both handles can be reserved before the link exists.
+public sealed class BleLinkHolder
+{
+ private BleLink? _link;
+ public void Bind(HciController a, ushort ha, HciController b, ushort hb) => _link = new BleLink(a, ha, b, hb);
+ public void DeliverAclToPeer(HciController from, byte[] acl) => _link?.DeliverAclToPeer(from, acl);
+ public void NotifyClosed(HciController from) => _link?.NotifyClosed(from);
+}
diff --git a/src/RP2040.Wireless/Cyw43/Backplane.cs b/src/RP2040.Wireless/Cyw43/Backplane.cs
new file mode 100644
index 0000000..8b09d4c
--- /dev/null
+++ b/src/RP2040.Wireless/Cyw43/Backplane.cs
@@ -0,0 +1,91 @@
+// SPDX-License-Identifier: BUSL-1.1
+// Copyright (c) 2024-2026 Iván Montiel Cardona
+
+using System.Collections.Generic;
+
+namespace RP2040.Wireless.Cyw43;
+
+///
+/// The CYW43439's internal address space as reached through the SDIO/gSPI F1 backplane window:
+/// ChipCommon, the core wrappers (ARM CM3 / SOCSRAM, reachable through AI_IOCTRL/AI_RESETCTRL) and
+/// the SOCRAM the host downloads firmware/NVRAM/CLM into. Modelled storage-backed and sparse — the
+/// firmware blob is accepted and parked (never executed; there is no RF), and the few status
+/// registers the bring-up code polls read back as already-settled so the host advances. This is the
+/// same "intercept the MMIO the init waits on" technique the ESP32Sharp radio model uses.
+///
+public sealed class Backplane
+{
+ // Core/clock register addresses the bring-up code touches (cyw43_ll.c).
+ private const uint SdioBase = 0x18002000;
+ private const uint SdioIntStatus = SdioBase + 0x20; // polled, expects "no error" (0)
+ private const uint ChipCommon = 0x18000000;
+
+ // AI (ARM-interconnect) core wrappers. cyw43_ll.c: get_core_address() = base + WRAPPER_OFFSET,
+ // then it pokes AI_IOCTRL (+0x408) and AI_RESETCTRL (+0x800). At power-on both cores sit in
+ // reset, so RESETCTRL must read back AIRC_RESET (bit 0) until the host clears it — that is the
+ // exact handshake disable_device_core()/reset_device_core()/device_core_is_up() walk.
+ private const uint WlanArmWrapper = 0x18003000 + 0x100000; // CORE_WLAN_ARM
+ private const uint SocsramWrapper = 0x18004000 + 0x100000; // CORE_SOCRAM
+ private const uint AiIoctrlOffset = 0x408;
+ private const uint AiResetctrlOffset = 0x800;
+ private const byte AircReset = 0x01;
+
+ private readonly Dictionary _mem = new(4096);
+
+ /// True once the host has taken the WLAN-ARM core out of reset (RESETCTRL←0). The
+ /// downloaded firmware would now be "running", so the F2 (WLAN data) channel reports ready.
+ public bool WlanCoreUp { get; private set; }
+
+ public Backplane()
+ {
+ // Cores come out of power-on in reset.
+ _mem[WlanArmWrapper + AiResetctrlOffset] = AircReset;
+ _mem[SocsramWrapper + AiResetctrlOffset] = AircReset;
+ }
+
+ /// Total firmware/NVRAM/CLM bytes written into SOCRAM (diagnostics).
+ public long BytesDownloaded { get; private set; }
+
+ /// The Bluetooth shared-bus transport (HCI over the backplane), if BLE is wired. It owns
+ /// the BT control/host-control registers and the H2B/B2H rings; the backplane routes the relevant
+ /// chip addresses to it. Null when only Wi-Fi is in use.
+ public Ble.BtSharedBus? Bt;
+
+ public byte ReadByte(uint addr)
+ {
+ // Bluetooth shared-bus registers + the "BT has work" interrupt bit live in the backplane.
+ if (Bt != null)
+ {
+ if (Bt.OwnsRegister(addr)) return Bt.ReadRegByte(addr);
+ if (addr >= SdioIntStatus && addr < SdioIntStatus + 4)
+ return (byte)(Bt.SdioIntStatus() >> (int)((addr - SdioIntStatus) * 8));
+ }
+ // Known status registers read back settled; everything else is plain (downloaded) storage or 0.
+ if (addr >= SdioIntStatus && addr < SdioIntStatus + 4) return 0; // no pending/error bits
+ return _mem.TryGetValue(addr, out var v) ? v : (byte)0;
+ }
+
+ public void WriteByte(uint addr, byte value)
+ {
+ if (Bt != null)
+ {
+ if (Bt.OwnsRegister(addr)) { Bt.WriteRegByte(addr, value); return; }
+ if (addr >= SdioIntStatus && addr < SdioIntStatus + 4) { Bt.ClearSdioInt(value << (int)((addr - SdioIntStatus) * 8)); return; }
+ }
+ _mem[addr] = value;
+ // Taking WLAN-ARM out of reset (RESETCTRL bit0 cleared) is the moment the firmware starts;
+ // surface it so the bus layer can flip the F2-ready status the bring-up loop waits on.
+ if (addr == WlanArmWrapper + AiResetctrlOffset && (value & AircReset) == 0)
+ WlanCoreUp = true;
+ // SOCRAM downloads land below the I/O cores (< 0x18000000): count them as firmware bytes.
+ if (addr < ChipCommon) BytesDownloaded++;
+ }
+
+ /// Raw byte access to the chip address space (used by the BT shared bus for its rings).
+ public byte MemReadByte(uint addr) => _mem.TryGetValue(addr, out var v) ? v : (byte)0;
+ public void MemWriteByte(uint addr, byte value) => _mem[addr] = value;
+ public uint MemReadU32(uint addr) =>
+ (uint)(MemReadByte(addr) | MemReadByte(addr + 1) << 8 | MemReadByte(addr + 2) << 16 | MemReadByte(addr + 3) << 24);
+ public void MemWriteU32(uint addr, uint v)
+ { MemWriteByte(addr, (byte)v); MemWriteByte(addr + 1, (byte)(v >> 8)); MemWriteByte(addr + 2, (byte)(v >> 16)); MemWriteByte(addr + 3, (byte)(v >> 24)); }
+}
diff --git a/src/RP2040.Wireless/Cyw43/Cyw43439Device.cs b/src/RP2040.Wireless/Cyw43/Cyw43439Device.cs
new file mode 100644
index 0000000..3763f0a
--- /dev/null
+++ b/src/RP2040.Wireless/Cyw43/Cyw43439Device.cs
@@ -0,0 +1,312 @@
+// SPDX-License-Identifier: BUSL-1.1
+// Copyright (c) 2024-2026 Iván Montiel Cardona
+
+using RP2040.Peripherals.Gpio;
+using RP2040.Wireless.Ble;
+
+namespace RP2040.Wireless.Cyw43;
+
+///
+/// Emulated Infineon CYW43439 Wi-Fi chip, modelled at its host-visible gSPI register/protocol
+/// boundary (firmware-version independent). This is the growing root: today it answers the F0 bus
+/// layer — the byte-swapped startup handshake (the SPI_READ_TEST_REGISTER → 0xFEEDBEAD probe and the
+/// SPI_BUS_CONTROL mode switch). F1 backplane, firmware download and the F2/SDPCM/WLC layers attach
+/// on top of the same command decode.
+///
+public sealed class Cyw43439Device
+{
+ // F0 (bus function) registers — cyw43_spi.h.
+ private const uint SPI_BUS_CONTROL = 0x00;
+ private const uint SPI_RESPONSE_DELAY = 0x01;
+ private const uint SPI_STATUS_ENABLE = 0x02;
+ private const uint SPI_INTERRUPT_REGISTER = 0x04; // 16-bit
+ private const uint SPI_STATUS_REGISTER = 0x08;
+ private const uint SPI_READ_TEST_REGISTER = 0x14;
+ private const uint TEST_PATTERN = 0xFEEDBEAD;
+
+ // Interrupt-register bit the SPI poll loop checks for an inbound F2/WLAN packet.
+ private const uint F2_PACKET_AVAILABLE_INT = 0x0020;
+
+ /// SDPCM/WLC transport over the WLAN function (F2). Exposed so the radio/join state
+ /// machine (Fase 5) can push async events and inbound Ethernet frames.
+ public readonly Sdpcm Sdpcm = new();
+
+ // SPI status register bits (cyw43_spi.h). The bring-up loop polls SPI_STATUS_REGISTER for
+ // F2 (WLAN data channel) readiness after the firmware download + WLAN-core reset; the SDPCM
+ // layer later reads F2_PKT_AVAILABLE/LEN here to learn an inbound packet is waiting.
+ private const uint STATUS_F2_RX_READY = 0x00000020;
+ private const uint STATUS_F2_PKT_AVAILABLE = 0x00000100;
+ private const int STATUS_F2_PKT_LEN_SHIFT = 9;
+
+ public readonly GSpiSlave Bus;
+
+ private bool _powered;
+ // The bus boots in 16-bit, byte-swapped word mode; the driver writes SPI_BUS_CONTROL with
+ // WORD_LENGTH_32|ENDIAN_BIG to switch to 32-bit. Both the command and the data are swapped on
+ // the wire until then (cyw43_put_swap32 / cyw43_get_swap32).
+ private bool _word32;
+
+ private readonly byte[] _f0 = new byte[0x20];
+
+ /// Diagnostics: every decoded command (write, fn, addr, sz).
+ public Action? OnCommand;
+
+ /// Diagnostics: a read phase began — (host bytes clocked, response bytes driven).
+ public Action? OnReadDebug;
+
+ public Cyw43439Device(IoBank0Peripheral io)
+ {
+ BtBus = new BtSharedBus(_chip, Ble); // wires the BT shared bus into the backplane
+ Bus = new GSpiSlave(io);
+ Bus.OnPower = on => _powered = on;
+ Bus.OnRead = HandleRead;
+ Bus.OnWrite = HandleWrite;
+ // An SDPCM packet queued for the host drives the F2-packet-available IRQ (DATA-line host-wake)
+ // and the F2 length the status register reports; draining the queue lowers both.
+ Sdpcm.OnPacketReadyChanged = (ready, len) => { _f2PacketLen = ready ? len : 0; UpdateHostWake(); };
+ // BT shares the same host-wake line: a queued HCI/ACL packet (or the host draining the B2H ring)
+ // re-evaluates it, so an idle peripheral is woken to run cyw43_poll exactly like an inbound F2 frame.
+ BtBus.OnWorkChanged = UpdateHostWake;
+ _f0[SPI_READ_TEST_REGISTER + 0] = (byte)(TEST_PATTERN & 0xFF);
+ _f0[SPI_READ_TEST_REGISTER + 1] = (byte)((TEST_PATTERN >> 8) & 0xFF);
+ _f0[SPI_READ_TEST_REGISTER + 2] = (byte)((TEST_PATTERN >> 16) & 0xFF);
+ _f0[SPI_READ_TEST_REGISTER + 3] = (byte)((TEST_PATTERN >> 24) & 0xFF);
+ }
+
+ public bool Powered => _powered;
+ public bool Word32 => _word32;
+
+ /// Bytes the host has downloaded into SOCRAM (firmware/NVRAM/CLM) — accepted and parked.
+ public long FirmwareBytes => _chip.BytesDownloaded;
+
+ /// Diagnostics: raw F1 control register byte (offset & 0x1F).
+ public byte DebugF1Ctrl(uint off) => _f1ctrl[off & 0x1F];
+ /// Diagnostics: which path each command took.
+ public Action? OnPath;
+
+ // The command is the first 4 host bytes. The driver lays them out with cyw43_put_swap32 (16-bit
+ // word swap) until it switches the bus to 32-bit mode, then little-endian. Rather than track the
+ // exact switch point across the half-duplex framing, decode whichever byte order yields a valid
+ // command (fn ≤ 2, plausible size) — self-correcting across the swap→le32 transition.
+ private static (bool write, int fn, uint addr, uint sz) Unpack(uint cmd)
+ {
+ var write = (cmd & 0x8000_0000u) != 0;
+ var fn = (int)((cmd >> 28) & 0x3);
+ var addr = (cmd >> 11) & 0x1FFFF;
+ var sz = cmd & 0x7FF;
+ return (write, fn, addr, sz);
+ }
+
+ private bool _pendingWord32 = false;
+
+ private (bool write, int fn, uint addr, uint sz, bool swapped) DecodeCommand(byte[] b)
+ {
+ // The SPI_BUS_CONTROL write (WORD_LENGTH_32) is the last byte-swapped command; everything after
+ // it is little-endian. Latch the switch on the command that follows it.
+ if (_pendingWord32) { _word32 = true; _pendingWord32 = false; }
+
+ var swapped = !_word32;
+ uint cmd = swapped ? (uint)(b[1] | b[0] << 8 | b[3] << 16 | b[2] << 24) // swap32 (startup)
+ : (uint)(b[0] | b[1] << 8 | b[2] << 16 | b[3] << 24); // le32
+ var c = Unpack(cmd);
+ if (swapped && c.write && c.fn == 0 && c.addr == SPI_BUS_CONTROL) _pendingWord32 = true;
+ OnCommand?.Invoke(c.write, c.fn, c.addr, c.sz);
+ return (c.write, c.fn, c.addr, c.sz, swapped);
+ }
+
+ // ── F1 backplane (SDIO function 1) ──
+ // The F1 address map splits at 0x10000: offsets < 0x10000 are windowed accesses into the chip's
+ // internal address space (chip_addr = window | (off & 0x7FFF); the 0x8000 "SB access" bit is part
+ // of that encoding, NOT a control-register marker), while offsets >= 0x10000 are the SDIO function
+ // control registers (the backplane-window bytes, the chip-clock CSR, the watermark, CCCR…).
+ private const uint SDIO_BACKPLANE_ADDRESS_LOW = 0x1000A, SDIO_BACKPLANE_ADDRESS_MID = 0x1000B,
+ SDIO_BACKPLANE_ADDRESS_HIGH = 0x1000C;
+ private readonly byte[] _f1ctrl = new byte[0x20]; // control regs 0x10000-0x1001F (offset & 0x1F)
+ private uint _window; // backplane window base (addr & ~0x7FFF)
+ private readonly Backplane _chip = new();
+
+ /// The emulated Bluetooth LE controller (HCI). Wire its
+ /// to a to advertise/scan/connect against other devices.
+ public readonly HciController Ble = new();
+ /// The Bluetooth HCI-over-gSPI shared-bus transport (routes BT regs/rings in the backplane).
+ public readonly BtSharedBus BtBus;
+
+ private const int BackplaneReadPad = 16; // CYW43_BACKPLANE_READ_PAD_LEN_BYTES (SPI)
+ private static bool IsF1Control(uint off) => off >= 0x10000;
+ private uint F1ChipAddr(uint off) => _window | (off & 0x7FFF);
+
+ /// Diagnostics: current backplane window base.
+ public uint Window => _window;
+ /// Diagnostics: whether the WLAN-ARM core has been taken out of reset.
+ public bool DebugWlanCoreUp => _chip.WlanCoreUp;
+ /// Diagnostics: resolved (offset, chipAddr, firstByte) of recent F1 windowed reads.
+ public readonly List<(uint off, uint chip, byte val)> ChipReads = new();
+
+ private const uint SDIO_CHIP_CLOCK_CSR = 0x1000E;
+ private const uint SDIO_SLEEP_CSR = 0x1001F;
+ private const byte ALP_AVAIL_REQ = 0x08, HT_AVAIL_REQ = 0x10, ALP_AVAIL = 0x40, HT_AVAIL = 0x80, FORCE_HT = 0x02;
+ private const byte SLPCSR_KEEP_SDIO_ON = 0x01, SLPCSR_DEVICE_ON = 0x02;
+
+ private byte ReadF1(uint off)
+ {
+ if (!IsF1Control(off)) return _chip.ReadByte(F1ChipAddr(off));
+ if (off == SDIO_SLEEP_CSR)
+ {
+ // KSO (keep-SDIO-on) handshake: once the host requests KEEP_SDIO_ON, the chip reports
+ // both KEEP_SDIO_ON and DEVICE_ON so cyw43_kso_set() sees the device awake.
+ var v = _f1ctrl[off & 0x1F];
+ if ((v & SLPCSR_KEEP_SDIO_ON) != 0) v |= SLPCSR_DEVICE_ON;
+ return v;
+ }
+ if (off == SDIO_CHIP_CLOCK_CSR)
+ {
+ // Requested clocks are available instantly: reflect ALP/HT-avail for whatever was requested.
+ var v = _f1ctrl[off & 0x1F];
+ if ((v & (ALP_AVAIL_REQ | FORCE_HT)) != 0) v |= ALP_AVAIL;
+ if ((v & (HT_AVAIL_REQ | FORCE_HT)) != 0) v |= HT_AVAIL;
+ // Once the WLAN core is running its firmware brings up the HT (high-throughput) PLL on
+ // its own — the bring-up loop polls for HT_AVAIL with no further request bit set, so
+ // surface both clocks as settled the moment the core leaves reset.
+ if (_chip.WlanCoreUp) v |= ALP_AVAIL | HT_AVAIL;
+ return v;
+ }
+ return _f1ctrl[off & 0x1F];
+ }
+
+ /// Diagnostics: count + last window of F1 windowed (chip-memory) writes.
+ public long WindowedWrites { get; private set; }
+ public uint LastWindow { get; private set; }
+
+ private void WriteF1(uint off, byte v)
+ {
+ if (!IsF1Control(off))
+ { WindowedWrites++; LastWindow = _window; _chip.WriteByte(F1ChipAddr(off), v); return; }
+ _f1ctrl[off & 0x1F] = v;
+ switch (off)
+ {
+ case SDIO_BACKPLANE_ADDRESS_LOW: _window = (_window & ~0x0000FF00u) | ((uint)v << 8); break; // bits[15:8]
+ case SDIO_BACKPLANE_ADDRESS_MID: _window = (_window & ~0x00FF0000u) | ((uint)v << 16); break; // bits[23:16]
+ case SDIO_BACKPLANE_ADDRESS_HIGH: _window = (_window & ~0xFF000000u) | ((uint)v << 24); break; // bits[31:24]
+ }
+ _window &= ~0x7FFFu;
+ }
+
+ private byte[] HandleRead(byte[] hostBytes)
+ {
+ if (hostBytes.Length < 4) return [];
+ var (write, fn, addr, sz, swapped) = DecodeCommand(hostBytes);
+ // The host releases DATA at the end of every transaction — reads AND writes — so this fires for
+ // both. A write carries its data in hostBytes[4..]; apply it here (HandleWrite/FlushWrite only
+ // runs for the rare transaction the host never turns the line around on).
+ if (write) { ApplyWrite(fn, addr, hostBytes, swapped); UpdateHostWake(); return []; }
+ var n = (int)(sz == 0 ? 4 : sz);
+ // F1/backplane reads carry a fixed response delay: the chip drives BackplaneReadPad dummy
+ // bytes (the host's SPI_RESP_DELAY_F1 turnaround) before the data. F0 reads have no pad.
+ var pad = fn == 1 ? BackplaneReadPad : 0;
+ var resp = new byte[pad + n];
+ if (fn == 0)
+ {
+ if (addr == SPI_STATUS_REGISTER) WriteF0Status();
+ if (addr == SPI_INTERRUPT_REGISTER) WriteF0Interrupt();
+ for (var i = 0; i < n; i++) resp[i] = _f0[(addr + (uint)i) & 0x1F];
+ }
+ else if (fn == 2)
+ {
+ // WLAN function: hand back the next queued SDPCM packet (no backplane pad on F2).
+ var pkt = Sdpcm.HostRead(n);
+ Array.Copy(pkt, resp, Math.Min(pkt.Length, resp.Length));
+ OnReadDebug?.Invoke(hostBytes, resp);
+ return resp;
+ }
+ else if (fn == 1)
+ {
+ var data = new byte[n];
+ for (var i = 0; i < n; i++) data[i] = ReadF1(addr + (uint)i);
+ if (!IsF1Control(addr) && ChipReads.Count < 300)
+ ChipReads.Add((addr, F1ChipAddr(addr), data[0]));
+ if (swapped) data = Swap32Bytes(data);
+ Array.Copy(data, 0, resp, pad, n);
+ OnReadDebug?.Invoke(hostBytes, resp);
+ return resp;
+ }
+ if (swapped) resp = Swap32Bytes(resp); // wire byte order mirrors the command
+ OnReadDebug?.Invoke(hostBytes, resp);
+ return resp;
+ }
+
+ /// Compose the F0 SPI_STATUS_REGISTER word from current chip state, into the F0 file so
+ /// the normal read path returns it. F2 reports ready once the WLAN core is running; a queued
+ /// inbound SDPCM frame adds F2_PKT_AVAILABLE with its length (Fase 4 attaches the queue).
+ private void WriteF0Status()
+ {
+ uint status = 0;
+ if (_chip.WlanCoreUp) status |= STATUS_F2_RX_READY;
+ if (_f2PacketLen > 0) status |= STATUS_F2_PKT_AVAILABLE | ((uint)_f2PacketLen << STATUS_F2_PKT_LEN_SHIFT);
+ _f0[SPI_STATUS_REGISTER + 0] = (byte)status;
+ _f0[SPI_STATUS_REGISTER + 1] = (byte)(status >> 8);
+ _f0[SPI_STATUS_REGISTER + 2] = (byte)(status >> 16);
+ _f0[SPI_STATUS_REGISTER + 3] = (byte)(status >> 24);
+ }
+
+ /// Compose the F0 SPI_INTERRUPT_REGISTER (u16): F2_PACKET_AVAILABLE while an SDPCM
+ /// packet is queued. The host clears it write-1-to-clear, but it re-asserts as long as the
+ /// packet is still pending (level-style), which the poll loop tolerates.
+ private void WriteF0Interrupt()
+ {
+ uint intr = _f2PacketLen > 0 ? F2_PACKET_AVAILABLE_INT : 0u;
+ _f0[SPI_INTERRUPT_REGISTER + 0] = (byte)intr;
+ _f0[SPI_INTERRUPT_REGISTER + 1] = (byte)(intr >> 8);
+ }
+
+ // Length (bytes) of the next inbound F2/SDPCM packet the host can read, 0 = none. Driven by the
+ // SDPCM layer; F2_PKT_AVAILABLE/LEN live in the F0 status register and the int register.
+ private int _f2PacketLen;
+
+ // The gSPI host-wake line (GPIO24) is shared by both transports: hold it asserted while either has
+ // work pending for the host (an inbound F2 frame or an unread B2H/HCI packet), so an idle guest is
+ // woken to poll. Re-evaluated after every transaction (which lowers it once the host drains) and
+ // when a packet is queued — but only the *transitions* touch the line, so re-checking it on every
+ // gSPI transaction stays cheap and never re-pulses a steady level (which would keep the level-high
+ // IRQ firing and spin the guest in cyw43_poll instead of letting it idle).
+ private bool _hostWake;
+ private void UpdateHostWake()
+ {
+ var want = _f2PacketLen > 0 || BtBus.HasWork;
+ if (want == _hostWake) return;
+ _hostWake = want;
+ Bus.SetInterrupt(want);
+ }
+
+ private void ApplyWrite(int fn, uint addr, byte[] hostBytes, bool swapped)
+ {
+ OnWriteDebug?.Invoke(fn, addr, hostBytes);
+ var data = hostBytes.Length > 4 ? hostBytes[4..] : [];
+ if (swapped) data = Swap32Bytes(data);
+ if (fn == 0)
+ for (var i = 0; i < data.Length; i++) _f0[(addr + (uint)i) & 0x1F] = data[i];
+ else if (fn == 1)
+ for (var i = 0; i < data.Length; i++) WriteF1(addr + (uint)i, data[i]);
+ else if (fn == 2)
+ Sdpcm.HostWrite(data); // WLAN function: an SDPCM packet (ioctl request or Ethernet frame)
+ }
+
+ /// Diagnostics: a decoded write — (fn, addr, raw host bytes).
+ public Action? OnWriteDebug;
+
+ private void HandleWrite(byte[] hostBytes)
+ {
+ if (hostBytes.Length < 4) return;
+ var (write, fn, addr, sz, swapped) = DecodeCommand(hostBytes);
+ if (write) { ApplyWrite(fn, addr, hostBytes, swapped); UpdateHostWake(); }
+ }
+
+ private static byte[] Swap32Bytes(byte[] src)
+ {
+ var n = (src.Length + 3) & ~3;
+ var dst = new byte[n];
+ Array.Copy(src, dst, src.Length);
+ for (var i = 0; i + 3 < n; i += 4)
+ (dst[i], dst[i + 1], dst[i + 2], dst[i + 3]) = (dst[i + 1], dst[i], dst[i + 3], dst[i + 2]);
+ return dst;
+ }
+}
diff --git a/src/RP2040.Wireless/Cyw43/GSpiSlave.cs b/src/RP2040.Wireless/Cyw43/GSpiSlave.cs
new file mode 100644
index 0000000..cbaf199
--- /dev/null
+++ b/src/RP2040.Wireless/Cyw43/GSpiSlave.cs
@@ -0,0 +1,232 @@
+// SPDX-License-Identifier: BUSL-1.1
+// Copyright (c) 2024-2026 Iván Montiel Cardona
+
+using RP2040.Peripherals.Gpio;
+
+namespace RP2040.Wireless.Cyw43;
+
+///
+/// Pin-level gSPI slave for the CYW43439 as wired on the Pico 2 W: CLK (GPIO29), DATA (GPIO24,
+/// one half-duplex line), CS (GPIO25, active low), WL_REG_ON (GPIO23, power).
+///
+/// Framing. gSPI uses CS only to start a transaction — the host bit-bangs the
+/// clock through PIO+DMA and typically deasserts CS again only a few bits in (the CPU runs far
+/// ahead of the PIO), so the chip latches on clock edges and counts bits rather than gating on CS.
+/// This slave therefore resets on a CS falling edge and then samples every CLK rising edge
+/// (regardless of CS level): the first 32 bits are the command word (host drives DATA, MSB-first);
+/// when the host flips DATA to an input the slave decodes the command and drives the read response.
+/// The wire byte order equals the driver's buffer order (the pico-sdk DMA byte-swap cancels the
+/// PIO's MSB-first shift).
+///
+public sealed class GSpiSlave
+{
+ public const int PinRegOn = 23;
+ public const int PinData = 24;
+ public const int PinCs = 25;
+ public const int PinClk = 29;
+
+ private readonly IoBank0Peripheral _io;
+
+ /// Decode the read command (the host-driven bytes clocked before DATA went input) and
+ /// return the bytes the slave must drive back, MSB-first. Called the instant the host releases DATA.
+ public Func? OnRead;
+
+ /// A completed write transaction: the host-driven bytes (command + write data). Fired when a
+ /// new transaction starts (CS re-asserts) or the line goes idle.
+ public Action? OnWrite;
+
+ /// WL_REG_ON transitioned — the chip power/reset gate.
+ public Action? OnPower;
+
+ private bool _clk;
+ private bool _regOn;
+ private bool _started;
+ private bool _reading;
+
+ // Host-wake / F2-packet IRQ. On the Pico 2 W gSPI the interrupt line IS the DATA pin (GPIO24 =
+ // WL_HOST_WAKE), active-high: when a packet is pending and the bus is idle the chip drives DATA
+ // high so the host's `read_host_interrupt_pin` poll sees it. We hold this level on DATA after a
+ // read response is exhausted (and after a write's empty read phase), which is exactly when the
+ // host releases DATA to an input and samples the pin between transactions.
+ private bool _irq;
+ public void SetInterrupt(bool on)
+ {
+ _irq = on;
+ // Never disturb the DATA line while a gSPI transaction is in flight. During a READ the host has
+ // released DATA and the chip is driving the response on it; asserting the host-wake here (e.g. a
+ // packet another device enqueues mid-read, common with two boards on one virtual LAN) would
+ // overwrite a response bit and corrupt the read. Leave _irq set; EndTransaction re-applies the
+ // level once the bus is idle. Real hardware likewise only drives WL_HOST_WAKE between transactions.
+ if (_started) return;
+ if (HostDrivesData()) return;
+ // The cyw43 driver arms a RISING-edge IRQ on the host-wake pin to wake from WFI while it waits
+ // for a packet (e.g. a blocking accept()). A previous gSPI read may have left DATA driven high,
+ // so simply re-asserting high produces no edge and never wakes the guest. Force a clean
+ // low→high transition so the edge actually fires; otherwise the guest only notices the packet
+ // on its next unrelated wake-up, which can be many simulated seconds away.
+ if (on)
+ {
+ _io.SetExternalInput(PinData, false);
+ _io.SetExternalInput(PinData, true);
+ }
+ else
+ {
+ _io.SetExternalInput(PinData, false);
+ }
+ }
+
+ private readonly List _host = new(72);
+ private int _hostBits;
+ private byte _hostByte;
+
+ private byte[] _response = [];
+ private int _respIndex;
+ private byte _respByte;
+ private int _respBitsLeft;
+
+ /// Diagnostics: (host bytes before read phase, read bits the host clocked, response length).
+ public Action? OnReadStats;
+ private int _readBitsClocked2;
+ /// Diagnostics: read bits the host has clocked in the in-progress/last read phase.
+ public int LastReadBits => _readBitsClocked2;
+
+ public GSpiSlave(IoBank0Peripheral io)
+ {
+ _io = io;
+ _io.PadChanged += OnPad;
+ _clk = _io.GetPadOutputLevel(PinClk);
+ _regOn = _io.GetPadOutputLevel(PinRegOn);
+ }
+
+ private bool HostDrivesData() => _io.GetPadOutputEnable(PinData);
+
+ private void OnPad(int pin)
+ {
+ switch (pin)
+ {
+ case PinRegOn:
+ var on = _io.GetPadOutputLevel(PinRegOn);
+ if (on != _regOn) { _regOn = on; OnPower?.Invoke(on); }
+ break;
+
+ case PinCs:
+ if (!_io.GetPadOutputLevel(PinCs)) StartTransaction(); // CS low = asserted = new transaction
+ else EndTransaction(); // CS high = deasserted = transaction done
+ break;
+
+ case PinData:
+ // Host released DATA mid-transaction → read phase begins. Decode and present bit 0 now.
+ if (_started && !_reading && !HostDrivesData()) BeginReadPhase();
+ break;
+
+ case PinClk:
+ var clk = _io.GetPadOutputLevel(PinClk);
+ if (clk == _clk) break;
+ var rising = !_clk && clk;
+ _clk = clk;
+ if (!_started) break;
+ if (rising) OnRising(); else OnFalling();
+ break;
+ }
+ }
+
+ private void StartTransaction()
+ {
+ if (_started)
+ {
+ if (_reading) OnReadStats?.Invoke(_lastReadHostBytes, _readBitsClocked2, _response.Length);
+ FlushWrite();
+ }
+ _started = true;
+ _reading = false;
+ _host.Clear(); _hostBits = 0; _hostByte = 0;
+ _response = []; _respIndex = 0; _respBitsLeft = 0;
+ }
+
+ private int _lastReadHostBytes;
+
+ /// The host deasserted CS (transaction over). Flush a trailing write, then — crucially —
+ /// hold the host-wake level on DATA (GPIO24) while the bus is idle, so the firmware's host-wake poll
+ /// (gpio_get(WL_HOST_WAKE), e.g. cyw43_ll's SDPCM do_ioctl loop) sees it. PresentNextBit only
+ /// drives the held level when a read phase is clocked; a write that ends with no trailing CLK edge
+ /// (no read phase) would otherwise leave DATA stale and the F2 SDPCM response would never be read.
+ private void EndTransaction()
+ {
+ if (_started)
+ {
+ if (_reading) OnReadStats?.Invoke(_lastReadHostBytes, _readBitsClocked2, _response.Length);
+ FlushWrite();
+ }
+ _started = false;
+ _reading = false;
+ // Apply the host-wake level now that the bus is idle (the host samples DATA as WL_HOST_WAKE
+ // between transactions). No forced edge here: SetInterrupt drives the wake edge when the bus is
+ // already idle; doing it again per-transaction injects a spurious DATA pulse between the cyw43
+ // KSO retry reads that the host can latch as the register value.
+ if (!HostDrivesData()) _io.SetExternalInput(PinData, _irq);
+ }
+
+ private void FlushWrite()
+ {
+ if (!_reading && _host.Count > 0) OnWrite?.Invoke(_host.ToArray());
+ }
+
+ /// Diagnostics: counts rising edges during a read where the host was (wrongly) still
+ /// driving DATA (OE on) — i.e. the line never turned around for the chip to respond.
+ public int ReadEdgesHostDriving { get; private set; }
+
+ private void OnRising()
+ {
+ if (_reading)
+ {
+ _readBitsClocked2++;
+ if (HostDrivesData()) ReadEdgesHostDriving++;
+ return;
+ }
+ if (!HostDrivesData()) return;
+ _hostByte = (byte)((_hostByte << 1) | (_io.GetPadOutputLevel(PinData) ? 1 : 0));
+ if (++_hostBits == 8) { _host.Add(_hostByte); _hostBits = 0; _hostByte = 0; }
+ }
+
+ private void OnFalling()
+ {
+ if (_reading) PresentNextBit(); // present the next read bit for the host's rising-edge sample
+ }
+
+ private void BeginReadPhase()
+ {
+ _reading = true;
+ _lastReadHostBytes = _host.Count; _readBitsClocked2 = 0; BitsDriven = 0;
+ _response = OnRead?.Invoke(_host.ToArray()) ?? [];
+ _respIndex = 0; _respBitsLeft = 0;
+ // The read phase begins on the same instruction that drives CLK low and flips DATA to input
+ // (`set pindirs,0 side 0`); the CLK and DATA PadChanged events can arrive in either order.
+ // Present bit 0 here only if CLK already fell (DATA event arrived second); otherwise the
+ // upcoming CLK falling edge presents it. This drives each bit exactly once per CLK-low period
+ // regardless of event order — without it the first bit is sampled twice and the response shifts.
+ if (!_clk) PresentNextBit();
+ }
+
+ /// Diagnostics: bits this slave has driven in the current/last read phase.
+ public int BitsDriven { get; private set; }
+
+ private void PresentNextBit()
+ {
+ BitsDriven++;
+ if (_respBitsLeft == 0)
+ {
+ // Real response bytes while they last; once exhausted, pad with 0x00. The host-wake level is
+ // NOT driven here: it belongs to the idle bus (EndTransaction applies it between transactions,
+ // which is when the firmware samples DATA as WL_HOST_WAKE). Driving the host-wake 0xFF as a
+ // read's trailing fill makes an over-clocked register read (e.g. cyw43 KSO under heavy
+ // cross-device packet traffic, when a packet is persistently pending) come back 0xFF — which
+ // the driver explicitly rejects (read_value != 0xff) and then logs "kso_set: failed".
+ _respByte = _respIndex < _response.Length ? _response[_respIndex++] : (byte)0x00;
+ _respBitsLeft = 8;
+ }
+ var bit = (_respByte & 0x80) != 0;
+ _respByte <<= 1;
+ _respBitsLeft--;
+ _io.SetExternalInput(PinData, bit);
+ }
+}
diff --git a/src/RP2040.Wireless/Cyw43/Sdpcm.cs b/src/RP2040.Wireless/Cyw43/Sdpcm.cs
new file mode 100644
index 0000000..5354f40
--- /dev/null
+++ b/src/RP2040.Wireless/Cyw43/Sdpcm.cs
@@ -0,0 +1,440 @@
+// SPDX-License-Identifier: BUSL-1.1
+// Copyright (c) 2024-2026 Iván Montiel Cardona
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using RP2040.Core;
+
+namespace RP2040.Wireless.Cyw43;
+
+///
+/// SDPCM (Synchronous Data Packet Control Mechanism) transport for the CYW43439's WLAN function
+/// (gSPI F2). This is the chip side of the protocol the cyw43-driver speaks once the firmware is
+/// "running": the host wraps every WLC ioctl/iovar and every Ethernet frame in an SDPCM header and
+/// pushes it over F2; the chip answers ioctls with a matching SDPCM control packet and pushes async
+/// WLC events and inbound Ethernet frames the same way. Modelled at the packet boundary so it stays
+/// independent of the firmware build — we synthesise plausible responses rather than execute firmware.
+///
+/// Header layout (little-endian, from cyw43_ll.c):
+///
+/// - sdpcm_header (12B): size, ~size, seq, channel, next_len, header_len, flow_ctrl, bus_credit, rsv[2]
+/// - ioctl_header (16B): cmd, len(low16=out len), flags(bits31:16=id, low=kind|iface<<12), status
+/// - bdc_header (4B): flags, priority, flags2(=itf), data_offset — for DATA/ASYNCEVENT channels
+///
+///
+public sealed class Sdpcm
+{
+ public const int SdpcmHeaderLen = 12;
+ public const int IoctlHeaderLen = 16;
+ public const int BdcHeaderLen = 4;
+
+ private const int ControlHeader = 0;
+ private const int AsyncEventHeader = 1;
+ private const int DataHeader = 2;
+
+ // WLC ioctl ids (subset the bring-up + STA/AP paths touch).
+ private const uint WLC_SET_SSID = 26;
+ private const uint WLC_GET_VAR = 262;
+ private const uint WLC_SET_VAR = 263;
+
+ // Async WLC event types / statuses (cyw43_ll.h + cyw43_ctrl.c state machine).
+ private const uint EV_SET_SSID = 0;
+ private const uint EV_AUTH = 3;
+ private const uint EV_LINK = 16;
+ private const uint EV_ESCAN_RESULT = 69;
+ private const uint STATUS_SUCCESS = 0;
+ private const uint STATUS_NO_NETWORKS = 3; // EV_SET_SSID status: no matching SSID
+ private const uint STATUS_PARTIAL = 8;
+
+ /// Diagnostics/notification: the STA joined a network (matched SSID) or failed (null).
+ public Action? OnStaJoin;
+
+ /// A Wi-Fi network the emulated chip "sees" on the air. Populated by the virtual radio
+ /// (Fase 5/6) so scan() returns real BSSes; an empty list yields an empty scan.
+ public sealed record VirtualAp(string Ssid, byte[] Bssid, int Channel, int Rssi, bool Secured);
+
+ /// Networks visible to this chip; the escan ioctl reports each as an ESCAN_RESULT event.
+ public readonly List VisibleAps = new();
+
+ /// Chip MAC (locally-administered). Per-device so multiple Pico 2 W instances differ.
+ public byte[] MacAddress { get; set; } = [0x02, 0x12, 0x34, 0x00, 0x00, 0x01];
+
+ /// Pending chip→host SDPCM packets, oldest first. The host drains these over F2 reads.
+ private readonly Queue _txq = new();
+
+ private byte _chipSeq; // chip→host SDPCM sequence (informational; host matches by ioctl id)
+ private byte _lastHostSeq; // sequence of the most recent host packet (control or data)
+ // Credit window granted ahead of the host's current sequence. The host stalls when its TX
+ // sequence reaches the granted credit; granting seq+window keeps it flowing. The host only
+ // accepts a credit advance of ≤20 per packet (cyw43_ll.c), so the window stays small.
+ private const int CreditWindow = 4;
+ private byte GrantedCredit => (byte)(_lastHostSeq + CreditWindow);
+
+ /// Raised when a packet is queued for the host (drives the F2-packet-available IRQ);
+ /// the arg is the byte length the host should read. Lowered when the queue drains.
+ public Action? OnPacketReadyChanged;
+
+ /// Diagnostics: (cmd, kind, varName, inLen) of each ioctl the host issued.
+ public Action? OnIoctl;
+
+ /// Diagnostics: outbound Ethernet frame from the host (itf, payload).
+ public Action? OnHostEthernet;
+
+ public bool HasPacket => _txq.Count > 0;
+ public int NextPacketLen => _txq.Count > 0 ? _txq.Peek().Length : 0;
+
+ private static ushort Get16(byte[] b, int o) => (ushort)(b[o] | b[o + 1] << 8);
+ private static uint Get32(byte[] b, int o) => (uint)(b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24);
+ private static void Put16(byte[] b, int o, int v) { b[o] = (byte)v; b[o + 1] = (byte)(v >> 8); }
+ private static void Put32(byte[] b, int o, uint v) { b[o] = (byte)v; b[o + 1] = (byte)(v >> 8); b[o + 2] = (byte)(v >> 16); b[o + 3] = (byte)(v >> 24); }
+ private static int Align4(int n) => (n + 3) & ~3;
+
+ /// Host wrote an SDPCM packet over F2. Parse the channel and, for control packets,
+ /// synthesise the ioctl response the host's cyw43_do_ioctl loop is waiting for.
+ public void HostWrite(byte[] pkt)
+ {
+ if (pkt.Length < SdpcmHeaderLen) return;
+ var size = Get16(pkt, 0);
+ if (size < SdpcmHeaderLen || size > pkt.Length) return;
+ var headerLen = pkt[7];
+ var channel = pkt[5] & 0x0f;
+ _lastHostSeq = pkt[4]; // track host TX sequence so we grant credit ahead of it
+
+ var before = _txq.Count;
+ switch (channel)
+ {
+ case ControlHeader:
+ if (size < headerLen + IoctlHeaderLen) return;
+ HandleIoctl(pkt, headerLen);
+ break;
+ case DataHeader:
+ HandleEthernetOut(pkt, headerLen, size);
+ break;
+ }
+ // The host advances its TX credit only from bus_data_credit in packets it receives. A host
+ // packet we don't answer (e.g. an IPv6 frame we ignore) would otherwise never refresh the
+ // credit and the host eventually stalls. Emit a header-only flow-control packet to top it up
+ // — the driver updates the credit then discards it (sdpcm_process_rx_packet returns -4).
+ if (_txq.Count == before) EnqueueFlowControl();
+ }
+
+ private void EnqueueFlowControl()
+ {
+ var buf = new byte[Align4(SdpcmHeaderLen)];
+ Put16(buf, 0, SdpcmHeaderLen);
+ Put16(buf, 2, ~SdpcmHeaderLen & 0xffff);
+ buf[4] = _chipSeq++;
+ buf[5] = ControlHeader; // channel <3 so the host applies the credit update
+ buf[7] = SdpcmHeaderLen;
+ buf[9] = GrantedCredit;
+ Enqueue(buf);
+ }
+
+ private void HandleIoctl(byte[] pkt, int headerLen)
+ {
+ var cmd = Get32(pkt, headerLen + 0);
+ var outLen = Get16(pkt, headerLen + 4); // host's requested output length
+ var flags = Get32(pkt, headerLen + 8);
+ var kind = flags & 0xf; // SDPCM_GET=0, SDPCM_SET=2
+ var payloadOff = headerLen + IoctlHeaderLen;
+ var inLen = Math.Max(0, Get16(pkt, 0) - payloadOff);
+ var payload = inLen > 0 ? pkt[payloadOff..(payloadOff + inLen)] : [];
+
+ var varName = "";
+ if ((cmd == WLC_GET_VAR || cmd == WLC_SET_VAR) && payload.Length > 0)
+ {
+ var z = Array.IndexOf(payload, (byte)0);
+ varName = Encoding.ASCII.GetString(payload, 0, z < 0 ? payload.Length : z);
+ }
+ OnIoctl?.Invoke(cmd, kind, varName, inLen);
+
+ // Build the response payload. The host copies min(requested, returned) bytes back, so for
+ // GETs we return `outLen` bytes (mostly zero) with known variables filled in; SETs need only
+ // status=0 with an empty payload.
+ var resp = BuildIoctlPayload(cmd, kind, varName, outLen);
+ Enqueue(BuildControlPacket(cmd, flags, resp));
+
+ // A scan request (escan iovar) is acked above; the results then arrive asynchronously as a
+ // stream of ESCAN_RESULT events (status=PARTIAL per BSS) terminated by a SUCCESS event.
+ if (cmd == WLC_SET_VAR && varName == "escan")
+ {
+ foreach (var ap in VisibleAps)
+ EnqueueAsyncEvent(BuildEscanResultEvent(ap, STATUS_PARTIAL));
+ EnqueueAsyncEvent(BuildEscanResultEvent(null, STATUS_SUCCESS));
+ }
+ // STA join: connect() ends with WLC_SET_SSID (or the "join" iovar) carrying le32(ssid_len)+ssid.
+ else if (cmd == WLC_SET_SSID)
+ HandleJoin(payload, 0);
+ else if (cmd == WLC_SET_VAR && varName == "join")
+ HandleJoin(payload, varName.Length + 1);
+ // SoftAP SSID: cyw43_ll_wifi_ap_init sends iovar "bsscfg:ssid" = u32(ap iface) u32(ssid_len) ssid[32].
+ // Capture it so the virtual air can advertise this guest's own AP to other stations' scans.
+ else if (cmd == WLC_SET_VAR && varName == "bsscfg:ssid")
+ {
+ var v = varName.Length + 1;
+ if (payload.Length >= v + 8)
+ {
+ var slen = (int)Get32(payload, v + 4);
+ if (slen is >= 0 and <= 32 && payload.Length >= v + 8 + slen)
+ ApSsid = Encoding.UTF8.GetString(payload, v + 8, slen);
+ }
+ }
+ // AP bring-up: cyw43_ll_wifi_ap_set_up sends iovar "bss" = u32(bsscfg idx) u32(up). When the
+ // AP bsscfg (idx 1) is set up, the AP interface link must come up so lwIP runs the AP netif
+ // (and its DHCP server). Emit EV_LINK(up, interface=AP).
+ else if (cmd == WLC_SET_VAR && varName == "bss" && payload.Length >= 12)
+ {
+ var idx = Get32(payload, 4);
+ var up = Get32(payload, 8);
+ if (idx == 1)
+ {
+ EnqueueAsyncEvent(BuildBcmEvent(EV_LINK, STATUS_SUCCESS, (ushort)(up != 0 ? 1 : 0), 0, 1));
+ OnApUp?.Invoke(up != 0);
+ }
+ }
+ // Any other iovar SET is accepted (ACKed above) but its effect is not modelled — usually benign
+ // radio/config (ampdu, mfp, country, …), but recorded so the unmodelled surface stays visible.
+ else if (cmd == WLC_SET_VAR && varName.Length > 0)
+ EmuStrict.Note("cyw43.setvar.ignored", varName);
+ }
+
+ /// Diagnostics/notification: the AP interface went up (true) or down (false).
+ public Action? OnApUp;
+
+ /// The SSID this chip's guest configured for its own SoftAP (from the "bsscfg:ssid" iovar),
+ /// or null if the guest never brought up an AP. Used by the virtual air to advertise the guest-AP.
+ public string? ApSsid { get; private set; }
+
+ /// Drive the STA association state machine for a join request. For a visible open network
+ /// the chip reports the success chain the host's join state machine needs to reach JOIN_STATE_ALL:
+ /// SET_SSID(0) → AUTH(0) → LINK(up). An unknown SSID reports SET_SSID(status=NO_NETWORKS).
+ private void HandleJoin(byte[] payload, int ssidFieldOffset)
+ {
+ if (payload.Length < ssidFieldOffset + 4) { EmitJoinFail(); return; }
+ var ssidLen = (int)Math.Min(32u, Get32(payload, ssidFieldOffset));
+ if (payload.Length < ssidFieldOffset + 4 + ssidLen) { EmitJoinFail(); return; }
+ var ssid = Encoding.UTF8.GetString(payload, ssidFieldOffset + 4, ssidLen);
+
+ var ap = VisibleAps.Find(a => a.Ssid == ssid);
+ if (ap == null) { EmitJoinFail(ssid); return; }
+
+ // Open-network association chain. (WPA/PSK would add an EV_PSK_SUP(WLC_SUP_KEYED) step.)
+ EnqueueAsyncEvent(BuildBcmEvent(EV_SET_SSID, STATUS_SUCCESS, 0, 0, 0));
+ EnqueueAsyncEvent(BuildBcmEvent(EV_AUTH, STATUS_SUCCESS, 0, 0, 0));
+ EnqueueAsyncEvent(BuildBcmEvent(EV_LINK, STATUS_SUCCESS, 1, 0, 0)); // flags&1 = link up
+ OnStaJoin?.Invoke(ssid);
+ }
+
+ private void EmitJoinFail(string? ssid = null)
+ {
+ EnqueueAsyncEvent(BuildBcmEvent(EV_SET_SSID, STATUS_NO_NETWORKS, 0, 0, 0));
+ OnStaJoin?.Invoke(null);
+ }
+
+ private static void Put16Be(byte[] b, int o, int v) { b[o] = (byte)(v >> 8); b[o + 1] = (byte)v; }
+
+ /// Build a minimal BCM-event Ethernet frame (no scan payload) carrying event_type/status/
+ /// flags/reason/interface at the offsets the cyw43-driver reads after its realign.
+ private byte[] BuildBcmEvent(uint eventType, uint status, ushort flags, uint reason, byte iface)
+ {
+ var frame = new byte[24 + 64];
+ for (var i = 0; i < 6; i++) frame[i] = 0xFF;
+ Array.Copy(MacAddress, 0, frame, 6, 6);
+ frame[12] = 0x88; frame[13] = 0x6c;
+ frame[19] = 0x00; frame[20] = 0x10; frame[21] = 0x18;
+ var ev = 24;
+ Put16Be(frame, ev + 2, flags);
+ Put32Be(frame, ev + 4, eventType);
+ Put32Be(frame, ev + 8, status);
+ Put32Be(frame, ev + 12, reason);
+ frame[ev + 46] = iface;
+ return frame;
+ }
+
+ private static void Put32Be(byte[] b, int o, uint v)
+ { b[o] = (byte)(v >> 24); b[o + 1] = (byte)(v >> 16); b[o + 2] = (byte)(v >> 8); b[o + 3] = (byte)v; }
+
+ /// Build the BCM-event Ethernet frame for one ESCAN_RESULT. The cyw43-driver overlays a
+ /// wl_bss_info at event-message offset 60 and a parallel scan_result view; the field
+ /// offsets here (bssid@68, ssid_len@78, ssid@79, chanspec@132, rssi@138, ie_offset@176,
+ /// ie_length@180, all relative to the 24-byte eth/BCM prefix) match that dual layout exactly.
+ private byte[] BuildEscanResultEvent(VirtualAp? ap, uint status)
+ {
+ const int prefix = 24; // eth dst/src + ethertype(0x886c) + BCM hdr(OUI 00:10:18)
+ var frame = new byte[prefix + 256];
+ // Ethernet header: broadcast dst, chip src, BCM ethertype + Broadcom OUI.
+ for (var i = 0; i < 6; i++) frame[i] = 0xFF;
+ Array.Copy(MacAddress, 0, frame, 6, 6);
+ frame[12] = 0x88; frame[13] = 0x6c;
+ frame[19] = 0x00; frame[20] = 0x10; frame[21] = 0x18;
+
+ var ev = prefix; // event-message base
+ Put32Be(frame, ev + 4, EV_ESCAN_RESULT);
+ Put32Be(frame, ev + 8, status);
+ frame[ev + 46] = 0; // interface = STA
+
+ if (ap != null)
+ {
+ var bss = ev + 60; // wl_bss_info overlay
+ Put16(frame, ev + 58, 1); // bss_count = 1
+ Put32(frame, bss + 4, 128); // bss.length
+ Array.Copy(ap.Bssid, 0, frame, bss + 8, 6);
+ if (ap.Secured) Put16(frame, bss + 16, 0x0010); // capability: PRIVACY → WEP/WPA hint
+ var ssid = Encoding.UTF8.GetBytes(ap.Ssid);
+ var sl = Math.Min(ssid.Length, 32);
+ frame[bss + 18] = (byte)sl;
+ Array.Copy(ssid, 0, frame, bss + 19, sl);
+ Put16(frame, bss + 72, (ushort)ap.Channel); // chanspec (driver masks &0xff)
+ Put16(frame, bss + 78, (ushort)(short)ap.Rssi);
+ Put16(frame, bss + 116, 128); // ie_offset == length, ie_length 0 → no IEs to parse
+ Put32(frame, bss + 120, 0); // ie_length
+ }
+ return frame;
+ }
+
+ /// Synthesise the payload a GET ioctl returns. SETs return empty (status carries success).
+ private byte[] BuildIoctlPayload(uint cmd, uint kind, string varName, int outLen)
+ {
+ const uint SDPCM_GET = 0;
+ if (kind != SDPCM_GET) return [];
+
+ var buf = new byte[Math.Max(outLen, 4)];
+ if (cmd == WLC_GET_VAR)
+ {
+ switch (varName)
+ {
+ case "cur_etheraddr":
+ Array.Copy(MacAddress, buf, Math.Min(6, buf.Length));
+ break;
+ case "clmver":
+ WriteString(buf, "CLM API: 12.2, Data: RP2040Sharp-virtual");
+ break;
+ case "ver":
+ WriteString(buf, "wl0: RP2040Sharp virtual CYW43439 (emulated)");
+ break;
+ // Intentionally zeroed (count 0). clmload_status=0 is DLOAD_STATUS_SUCCESS, which the
+ // firmware needs after streaming the CLM blob; the event/mcast lists are legitimately empty.
+ // Anything else the firmware reads back is an UNMODELLED iovar getting bogus zeros.
+ case "bsscfg:event_msgs":
+ case "mcast_list":
+ case "clmload_status":
+ break;
+ default:
+ EmuStrict.Note("cyw43.getvar.unhandled", varName);
+ break;
+ }
+ }
+ return buf;
+ }
+
+ private static void WriteString(byte[] dst, string s)
+ {
+ var b = Encoding.ASCII.GetBytes(s);
+ var n = Math.Min(b.Length, dst.Length - 1);
+ Array.Copy(b, dst, n);
+ dst[n] = 0;
+ }
+
+ /// Wrap an ioctl response payload in SDPCM + ioctl headers (status=0, echoing the
+ /// request flags so the host's id check matches), granting a fresh bus credit.
+ private byte[] BuildControlPacket(uint cmd, uint flags, byte[] payload)
+ {
+ var size = SdpcmHeaderLen + IoctlHeaderLen + payload.Length;
+ var buf = new byte[Align4(size)];
+ Put16(buf, 0, size);
+ Put16(buf, 2, ~size & 0xffff);
+ buf[4] = _chipSeq++;
+ buf[5] = ControlHeader;
+ buf[6] = 0; // next_length
+ buf[7] = SdpcmHeaderLen; // header_length
+ buf[8] = 0; // wireless flow control
+ buf[9] = GrantedCredit; // grant credit ahead of the host sequence so its next send doesn't stall
+ // ioctl header
+ Put32(buf, SdpcmHeaderLen + 0, cmd);
+ Put32(buf, SdpcmHeaderLen + 4, (uint)payload.Length);
+ Put32(buf, SdpcmHeaderLen + 8, flags); // echo flags (carries the ioctl id the host matches)
+ Put32(buf, SdpcmHeaderLen + 12, 0); // status = success
+ if (payload.Length > 0) Array.Copy(payload, 0, buf, SdpcmHeaderLen + IoctlHeaderLen, payload.Length);
+ return buf;
+ }
+
+ private void HandleEthernetOut(byte[] pkt, int headerLen, int size)
+ {
+ // The driver sets header_length = SDPCM_HEADER_LEN + 2 for DATA frames, with the BDC header
+ // placed AT header_length (the +2 pad sits between the SDPCM header and the BDC). The Ethernet
+ // payload follows at bdc + BDC_HEADER_LEN + (data_offset<<2).
+ var bdcOff = headerLen;
+ if (size <= bdcOff + BdcHeaderLen) return;
+ var itf = pkt[bdcOff + 2];
+ var dataOff = pkt[bdcOff + 3] << 2;
+ var payloadOff = bdcOff + BdcHeaderLen + dataOff;
+ if (payloadOff >= size) return;
+ OnHostEthernet?.Invoke(itf, pkt[payloadOff..size]);
+ }
+
+ /// The host is reading bytes from F2 — hand back the oldest queued
+ /// packet (zero-padded/truncated to the read length). Drops it from the queue and updates the
+ /// packet-ready signal.
+ public byte[] HostRead(int n)
+ {
+ var outp = new byte[n];
+ if (_txq.Count > 0)
+ {
+ var pkt = _txq.Dequeue();
+ Array.Copy(pkt, outp, Math.Min(pkt.Length, n));
+ }
+ OnPacketReadyChanged?.Invoke(_txq.Count > 0, NextPacketLen);
+ return outp;
+ }
+
+ /// Queue an async WLC event (ASYNCEVENT_HEADER) carrying a BCM event Ethernet frame.
+ /// Used by the join/link state machine (Fase 5) to deliver WLC_E_* notifications.
+ public void EnqueueAsyncEvent(byte[] bcmEventEthFrame)
+ {
+ // For a chip→host packet the receiver reads the BDC header at header_length (=12) and the
+ // payload at bdc + BDC_HEADER_LEN + (data_offset<<2). So the BDC sits immediately after the
+ // 12-byte SDPCM header (no send-side +2 pad), data_offset 0, then the BCM-event Ethernet frame.
+ var size = SdpcmHeaderLen + BdcHeaderLen + bcmEventEthFrame.Length;
+ var buf = new byte[Align4(size)];
+ Put16(buf, 0, size);
+ Put16(buf, 2, ~size & 0xffff);
+ buf[4] = _chipSeq++;
+ buf[5] = AsyncEventHeader;
+ buf[6] = 0;
+ buf[7] = SdpcmHeaderLen;
+ buf[9] = GrantedCredit;
+ var bdc = SdpcmHeaderLen;
+ buf[bdc + 0] = 0x20; // flags
+ buf[bdc + 3] = 0; // data_offset (in 4-byte units)
+ Array.Copy(bcmEventEthFrame, 0, buf, bdc + BdcHeaderLen, bcmEventEthFrame.Length);
+ Enqueue(buf);
+ }
+
+ /// Queue an inbound Ethernet frame for the host on the given interface (DATA_HEADER).
+ /// The virtual network (DHCP/ARP/peer traffic) uses this to deliver frames to the STA/AP netif.
+ public void EnqueueEthernet(int itf, byte[] ethFrame)
+ {
+ var size = SdpcmHeaderLen + BdcHeaderLen + ethFrame.Length;
+ var buf = new byte[Align4(size)];
+ Put16(buf, 0, size);
+ Put16(buf, 2, ~size & 0xffff);
+ buf[4] = _chipSeq++;
+ buf[5] = DataHeader;
+ buf[6] = 0;
+ buf[7] = SdpcmHeaderLen;
+ buf[9] = GrantedCredit;
+ var bdc = SdpcmHeaderLen;
+ buf[bdc + 0] = 0x20; // flags
+ buf[bdc + 2] = (byte)itf; // flags2 = interface
+ buf[bdc + 3] = 0; // data_offset (4-byte units)
+ Array.Copy(ethFrame, 0, buf, bdc + BdcHeaderLen, ethFrame.Length);
+ Enqueue(buf);
+ }
+
+ private void Enqueue(byte[] pkt)
+ {
+ _txq.Enqueue(pkt);
+ OnPacketReadyChanged?.Invoke(true, NextPacketLen);
+ }
+}
diff --git a/src/RP2040.Wireless/Cyw43/VirtualNet.cs b/src/RP2040.Wireless/Cyw43/VirtualNet.cs
new file mode 100644
index 0000000..96a82ee
--- /dev/null
+++ b/src/RP2040.Wireless/Cyw43/VirtualNet.cs
@@ -0,0 +1,378 @@
+// SPDX-License-Identifier: BUSL-1.1
+// Copyright (c) 2024-2026 Iván Montiel Cardona
+
+using System;
+using System.Collections.Generic;
+
+namespace RP2040.Wireless.Cyw43;
+
+///
+/// A minimal virtual network behind the emulated radio: an L2/L3 gateway that answers ARP and serves
+/// DHCP so a CYW43439 STA that associates actually gets an IP (link status UP), the prerequisite for
+/// real sockets / Microdot. Ethernet frames the guest transmits arrive via ;
+/// replies go back through . This is the "virtualise behind the
+/// register boundary" layer — the guest's TCP/IP stack runs unmodified; only the wire is synthetic.
+///
+public sealed class VirtualNet
+{
+ private readonly Sdpcm _sdpcm;
+
+ public byte[] GatewayMac { get; }
+ public byte[] GatewayIp { get; }
+ public byte[] ClientIp { get; }
+ public byte[] SubnetMask { get; } = [255, 255, 255, 0];
+ public uint LeaseSeconds { get; set; } = 86400;
+
+ /// Diagnostics: a frame the guest transmitted (itf, ethertype, length).
+ public Action? OnGuestFrame;
+ /// Diagnostics: the DHCP lease has been issued (the client IP).
+ public Action? OnDhcpLeased;
+
+ // ── Minimal active-open TCP client (to exercise a server running on the guest, e.g. Microdot) ──
+ private int _tcpItf;
+ private ushort _tcpClientPort, _tcpServerPort;
+ private uint _tcpSeq, _tcpAck; // our next seq to send; next byte we expect from the server
+ private byte[]? _tcpRequest; // payload to send once the connection is established
+ private bool _tcpEstablished, _tcpRequestSent, _tcpClosed;
+ private readonly List _tcpRx = new();
+ private const byte TCP_FIN = 0x01, TCP_SYN = 0x02, TCP_RST = 0x04, TCP_PSH = 0x08, TCP_ACK = 0x10;
+
+ /// The bytes the guest server sent back on the TCP connection (e.g. the HTTP response).
+ public byte[] TcpResponse => _tcpRx.ToArray();
+ /// Fired when the connection closes (guest FIN), with the full received payload.
+ public Action? OnTcpClosed;
+ /// Diagnostics: a TCP segment arrived from the guest (flags, seq, ack, payloadLen).
+ public Action? OnTcpSegment;
+
+ /// Open a TCP connection from the virtual gateway to a server on the guest (the leased
+ /// client IP) and send once established. Drives a minimal active-open
+ /// handshake; the response accumulates in .
+ public void TcpConnect(int itf, ushort serverPort, byte[] request, ushort clientPort = 50000)
+ {
+ _tcpItf = itf; _tcpServerPort = serverPort; _tcpClientPort = clientPort;
+ _tcpRequest = request; _tcpSeq = 1000; _tcpAck = 0;
+ _tcpEstablished = _tcpRequestSent = _tcpClosed = false;
+ _tcpRx.Clear();
+ SendTcp(TCP_SYN, []); // SYN occupies seq 1000; advanced to 1001 on SYN-ACK
+ }
+
+ /// Re-send the SYN while the connection is still opening. Call periodically: it covers
+ /// the race where the SYN reaches the guest before its server has returned to accept()
+ /// (the segment is dropped and, without this, never retried).
+ public void TcpPoke()
+ {
+ if (_tcpRequest != null && !_tcpEstablished && !_tcpClosed)
+ SendTcp(TCP_SYN, []);
+ }
+
+ // Connected stations. The first device keeps the legacy ClientIp; further devices get sequential
+ // leases. Frames between stations are L2-switched; broadcasts also reach the gateway (ARP/DHCP).
+ private readonly List _devices = new();
+ private readonly Dictionary _leases = new(); // device MAC → leased IP
+ private readonly Dictionary _macToDevice = new(); // learned MAC → owning station
+ private int _nextLease = 2;
+
+ public VirtualNet(Sdpcm sdpcm, byte[]? gatewayIp = null, byte[]? clientIp = null, byte[]? gatewayMac = null)
+ {
+ _sdpcm = sdpcm;
+ GatewayIp = gatewayIp ?? [192, 168, 4, 1];
+ ClientIp = clientIp ?? [192, 168, 4, 2];
+ GatewayMac = gatewayMac ?? [0x02, 0x00, 0x5E, 0x00, 0x04, 0x01];
+ AddDevice(sdpcm);
+ }
+
+ /// Attach another station (a second Pico 2 W) to the same virtual LAN. It gets its own
+ /// DHCP lease and can exchange frames with the others through the L2 switch.
+ public void AddDevice(Sdpcm sdpcm)
+ {
+ _devices.Add(sdpcm);
+ sdpcm.OnHostEthernet += (itf, frame) => HandleGuestFrame(sdpcm, itf, frame);
+ }
+
+ private static ushort Be16(byte[] b, int o) => (ushort)(b[o] << 8 | b[o + 1]);
+ private static string MacKey(byte[] b, int o) => $"{b[o]:x2}{b[o + 1]:x2}{b[o + 2]:x2}{b[o + 3]:x2}{b[o + 4]:x2}{b[o + 5]:x2}";
+ private static bool IsBroadcastOrMulticast(byte[] dst) => (dst[0] & 1) != 0; // multicast/broadcast bit
+
+ private void HandleGuestFrame(Sdpcm from, int itf, byte[] frame)
+ {
+ if (frame.Length < 14) return;
+ _macToDevice[MacKey(frame, 6)] = from; // learn source MAC → station
+ _serverMac ??= frame[6..12]; // first learned MAC = single-device TCP client target
+ var ethertype = Be16(frame, 12);
+ OnGuestFrame?.Invoke(itf, ethertype, frame.Length);
+
+ // L2 switch: deliver unicast frames addressed to another station directly.
+ var dstKey = MacKey(frame, 0);
+ if (!IsBroadcastOrMulticast(frame[0..6]) && MacKey(GatewayMac, 0) != dstKey)
+ {
+ if (_macToDevice.TryGetValue(dstKey, out var dest) && dest != from)
+ dest.EnqueueEthernet(itf, frame);
+ return; // unicast to a peer (or unknown) — gateway doesn't process it
+ }
+ // Broadcast/multicast: flood to the other stations, and let the gateway answer ARP/DHCP.
+ if (IsBroadcastOrMulticast(frame[0..6]))
+ foreach (var d in _devices) if (d != from) d.EnqueueEthernet(itf, frame);
+
+ switch (ethertype)
+ {
+ case 0x0806: HandleArp(itf, frame); break;
+ case 0x0800: HandleIpv4(itf, frame); break;
+ }
+ }
+
+ // ── ARP ──────────────────────────────────────────────────────────────
+ private void HandleArp(int itf, byte[] f)
+ {
+ // ARP packet at offset 14: htype(2) ptype(2) hlen(1) plen(1) op(2) sha(6) spa(4) tha(6) tpa(4)
+ const int a = 14;
+ if (f.Length < a + 28) return;
+ var op = Be16(f, a + 6);
+ if (op != 1) return; // only answer requests
+ var targetIp = f[(a + 24)..(a + 28)];
+ if (!IpEquals(targetIp, GatewayIp)) return; // we only own the gateway IP
+ var senderMac = f[(a + 8)..(a + 14)];
+ var senderIp = f[(a + 14)..(a + 18)];
+
+ var reply = new byte[14 + 28];
+ WriteEthHeader(reply, senderMac, GatewayMac, 0x0806);
+ Array.Copy(f, a, reply, 14, 6); // htype/ptype/hlen/plen copied from request
+ reply[14 + 6] = 0; reply[14 + 7] = 2; // op = reply
+ Array.Copy(GatewayMac, 0, reply, 14 + 8, 6);
+ Array.Copy(GatewayIp, 0, reply, 14 + 14, 4);
+ Array.Copy(senderMac, 0, reply, 14 + 18, 6);
+ Array.Copy(senderIp, 0, reply, 14 + 24, 4);
+ _macToDevice.TryGetValue(MacKey(senderMac, 0), out var dev);
+ (dev ?? _sdpcm).EnqueueEthernet(itf, reply);
+ }
+
+ // ── IPv4 / UDP / DHCP ──────────────────────────────────────────────────
+ private void HandleIpv4(int itf, byte[] f)
+ {
+ const int ip = 14;
+ if (f.Length < ip + 20) return;
+ var ihl = (f[ip] & 0x0f) * 4;
+ var proto = f[ip + 9];
+ var l4 = ip + ihl;
+ if (proto == 17) // UDP
+ {
+ if (f.Length < l4 + 8) return;
+ if (Be16(f, l4 + 2) == 67) HandleDhcp(itf, f, l4 + 8); // DHCP server port
+ }
+ else if (proto == 6) // TCP
+ {
+ HandleTcp(itf, f, ip, l4);
+ }
+ }
+
+ private void HandleDhcp(int itf, byte[] f, int d)
+ {
+ // DHCP: op(1) htype(1) hlen(1) hops(1) xid(4) secs(2) flags(2) ciaddr(4) yiaddr(4) siaddr(4)
+ // giaddr(4) chaddr(16) sname(64) file(128) magic(4) options...
+ if (f.Length < d + 240) return;
+ var xid = f[(d + 4)..(d + 8)];
+ var clientMac = f[(d + 28)..(d + 34)]; // chaddr (first 6 bytes)
+
+ // find option 53 (DHCP message type)
+ var msgType = 0;
+ for (var o = d + 240; o + 1 < f.Length && f[o] != 255;)
+ {
+ if (f[o] == 0) { o++; continue; } // pad
+ var len = f[o + 1];
+ if (f[o] == 53 && len >= 1) msgType = f[o + 2];
+ o += 2 + len;
+ }
+ // DISCOVER(1) → OFFER(2); REQUEST(3) → ACK(5)
+ var replyType = msgType switch { 1 => 2, 3 => 5, _ => 0 };
+ if (replyType == 0) return;
+
+ var lease = LeaseFor(clientMac);
+ var dhcp = BuildDhcpReply(xid, clientMac, (byte)replyType, lease);
+ var udp = BuildUdp(67, 68, dhcp);
+ var ipPkt = BuildIpv4(17, GatewayIp, lease, udp);
+ var frame = new byte[14 + ipPkt.Length];
+ WriteEthHeader(frame, clientMac, GatewayMac, 0x0800);
+ Array.Copy(ipPkt, 0, frame, 14, ipPkt.Length);
+ _macToDevice.TryGetValue(MacKey(clientMac, 0), out var dev);
+ (dev ?? _sdpcm).EnqueueEthernet(itf, frame);
+
+ if (replyType == 5) OnDhcpLeased?.Invoke(lease);
+ }
+
+ /// Stable per-MAC lease: the first station keeps , others get
+ /// sequential addresses in the same /24.
+ private byte[] LeaseFor(byte[] mac)
+ {
+ var key = MacKey(mac, 0);
+ if (_leases.TryGetValue(key, out var ip)) return ip;
+ byte[] lease = _leases.Count == 0
+ ? ClientIp
+ : [GatewayIp[0], GatewayIp[1], GatewayIp[2], (byte)_nextLease++];
+ if (_leases.Count == 0) _nextLease = ClientIp[3] + 1;
+ _leases[key] = lease;
+ return lease;
+ }
+
+ // ── TCP client state machine ───────────────────────────────────────────
+ private void HandleTcp(int itf, byte[] f, int ipOff, int tcpOff)
+ {
+ if (f.Length < tcpOff + 20) return;
+ var srcPort = Be16(f, tcpOff);
+ var dstPort = Be16(f, tcpOff + 2);
+ if (dstPort != _tcpClientPort || srcPort != _tcpServerPort) return; // not our connection
+ var seq = Be32(f, tcpOff + 4);
+ var ackNo = Be32(f, tcpOff + 8);
+ var flags = f[tcpOff + 13];
+ var dataOff = (f[tcpOff + 12] >> 4) * 4;
+ var ipTotal = Be16(f, ipOff + 2);
+ var payloadOff = tcpOff + dataOff;
+ var payloadLen = ipOff + ipTotal - payloadOff;
+ OnTcpSegment?.Invoke(flags, seq, ackNo, payloadLen);
+
+ // A RST before the connection is established just means the server isn't listening yet (we
+ // raced its bind/listen). Ignore it and keep re-sending the SYN (TcpPoke); only treat a RST on
+ // an established connection as a real close.
+ if ((flags & TCP_RST) != 0) { if (_tcpEstablished) _tcpClosed = true; return; }
+
+ if ((flags & TCP_SYN) != 0 && (flags & TCP_ACK) != 0 && !_tcpEstablished)
+ {
+ // SYN-ACK: acknowledge the server's ISN, then push the request. Our SYN occupied seq 1000,
+ // so our data starts at 1001.
+ _tcpAck = seq + 1;
+ _tcpEstablished = true;
+ _tcpSeq = 1001;
+ SendTcp(TCP_ACK, []);
+ if (_tcpRequest is { Length: > 0 } req)
+ {
+ SendTcp(TCP_PSH | TCP_ACK, req);
+ _tcpSeq += (uint)req.Length;
+ _tcpRequestSent = true;
+ }
+ return;
+ }
+
+ if (payloadLen > 0)
+ {
+ // In-order data: buffer it and acknowledge.
+ if (seq == _tcpAck)
+ {
+ for (var i = 0; i < payloadLen; i++) _tcpRx.Add(f[payloadOff + i]);
+ _tcpAck = seq + (uint)payloadLen;
+ }
+ SendTcp(TCP_ACK, []);
+ }
+
+ if ((flags & TCP_FIN) != 0)
+ {
+ _tcpAck = seq + (uint)payloadLen + 1; // FIN consumes one sequence number
+ SendTcp(TCP_ACK | TCP_FIN, []);
+ _tcpSeq += 1;
+ if (!_tcpClosed) { _tcpClosed = true; OnTcpClosed?.Invoke(TcpResponse); }
+ }
+ }
+
+ private void SendTcp(byte flags, byte[] payload)
+ {
+ var tcp = new byte[20 + payload.Length];
+ tcp[0] = (byte)(_tcpClientPort >> 8); tcp[1] = (byte)_tcpClientPort;
+ tcp[2] = (byte)(_tcpServerPort >> 8); tcp[3] = (byte)_tcpServerPort;
+ WriteBe32(tcp, 4, _tcpSeq);
+ WriteBe32(tcp, 8, _tcpAck);
+ tcp[12] = 5 << 4; // data offset = 5 words (no options)
+ tcp[13] = flags;
+ tcp[14] = 0x20; tcp[15] = 0x00; // window = 8192
+ Array.Copy(payload, 0, tcp, 20, payload.Length);
+ // checksum over the TCP pseudo-header + segment (client=gateway IP → server=client/leased IP)
+ var csum = TcpChecksum(GatewayIp, ClientIp, tcp);
+ tcp[16] = (byte)(csum >> 8); tcp[17] = (byte)csum;
+
+ var ipPkt = BuildIpv4(6, GatewayIp, ClientIp, tcp);
+ var frame = new byte[14 + ipPkt.Length];
+ WriteEthHeader(frame, _serverMac ?? new byte[6], GatewayMac, 0x0800);
+ Array.Copy(ipPkt, 0, frame, 14, ipPkt.Length);
+ _sdpcm.EnqueueEthernet(_tcpItf, frame);
+ }
+
+ private byte[]? _serverMac; // learned guest MAC (from any guest frame) for unicast delivery
+
+ private static ushort TcpChecksum(byte[] srcIp, byte[] dstIp, byte[] tcp)
+ {
+ uint sum = 0;
+ sum += (uint)(srcIp[0] << 8 | srcIp[1]); sum += (uint)(srcIp[2] << 8 | srcIp[3]);
+ sum += (uint)(dstIp[0] << 8 | dstIp[1]); sum += (uint)(dstIp[2] << 8 | dstIp[3]);
+ sum += 6; // protocol
+ sum += (uint)tcp.Length; // TCP length
+ for (var i = 0; i + 1 < tcp.Length; i += 2) sum += (uint)(tcp[i] << 8 | tcp[i + 1]);
+ if ((tcp.Length & 1) != 0) sum += (uint)(tcp[^1] << 8);
+ while (sum >> 16 != 0) sum = (sum & 0xffff) + (sum >> 16);
+ return (ushort)~sum;
+ }
+
+ private static uint Be32(byte[] b, int o) => (uint)(b[o] << 24 | b[o + 1] << 16 | b[o + 2] << 8 | b[o + 3]);
+ private static void WriteBe32(byte[] b, int o, uint v)
+ { b[o] = (byte)(v >> 24); b[o + 1] = (byte)(v >> 16); b[o + 2] = (byte)(v >> 8); b[o + 3] = (byte)v; }
+
+ private byte[] BuildDhcpReply(byte[] xid, byte[] clientMac, byte type, byte[] yourIp)
+ {
+ var opts = new List
+ {
+ 53, 1, type, // message type
+ 54, 4, GatewayIp[0], GatewayIp[1], GatewayIp[2], GatewayIp[3], // server id
+ 51, 4, (byte)(LeaseSeconds >> 24), (byte)(LeaseSeconds >> 16), (byte)(LeaseSeconds >> 8), (byte)LeaseSeconds, // lease
+ 1, 4, SubnetMask[0], SubnetMask[1], SubnetMask[2], SubnetMask[3], // subnet mask
+ 3, 4, GatewayIp[0], GatewayIp[1], GatewayIp[2], GatewayIp[3], // router
+ 6, 4, GatewayIp[0], GatewayIp[1], GatewayIp[2], GatewayIp[3], // dns
+ 255, // end
+ };
+ var msg = new byte[240 + opts.Count];
+ msg[0] = 2; msg[1] = 1; msg[2] = 6; msg[3] = 0; // op=reply, htype=eth, hlen=6
+ Array.Copy(xid, 0, msg, 4, 4);
+ Array.Copy(yourIp, 0, msg, 16, 4); // yiaddr
+ Array.Copy(GatewayIp, 0, msg, 20, 4); // siaddr
+ Array.Copy(clientMac, 0, msg, 28, 6); // chaddr
+ msg[236] = 0x63; msg[237] = 0x82; msg[238] = 0x53; msg[239] = 0x63; // magic cookie
+ opts.CopyTo(msg, 240);
+ return msg;
+ }
+
+ private static byte[] BuildUdp(ushort srcPort, ushort dstPort, byte[] payload)
+ {
+ var u = new byte[8 + payload.Length];
+ u[0] = (byte)(srcPort >> 8); u[1] = (byte)srcPort;
+ u[2] = (byte)(dstPort >> 8); u[3] = (byte)dstPort;
+ var len = (ushort)u.Length;
+ u[4] = (byte)(len >> 8); u[5] = (byte)len;
+ // checksum 0 = not computed (valid for IPv4 UDP)
+ Array.Copy(payload, 0, u, 8, payload.Length);
+ return u;
+ }
+
+ private static byte[] BuildIpv4(byte proto, byte[] src, byte[] dst, byte[] payload)
+ {
+ var ip = new byte[20 + payload.Length];
+ ip[0] = 0x45; // version 4, IHL 5
+ var total = (ushort)ip.Length;
+ ip[2] = (byte)(total >> 8); ip[3] = (byte)total;
+ ip[8] = 64; // TTL
+ ip[9] = proto;
+ Array.Copy(src, 0, ip, 12, 4);
+ Array.Copy(dst, 0, ip, 16, 4);
+ // header checksum
+ uint sum = 0;
+ for (var i = 0; i < 20; i += 2) sum += (uint)(ip[i] << 8 | ip[i + 1]);
+ while (sum >> 16 != 0) sum = (sum & 0xffff) + (sum >> 16);
+ var csum = (ushort)~sum;
+ ip[10] = (byte)(csum >> 8); ip[11] = (byte)csum;
+ Array.Copy(payload, 0, ip, 20, payload.Length);
+ return ip;
+ }
+
+ private static void WriteEthHeader(byte[] frame, byte[] dst, byte[] src, ushort ethertype)
+ {
+ Array.Copy(dst, 0, frame, 0, 6);
+ Array.Copy(src, 0, frame, 6, 6);
+ frame[12] = (byte)(ethertype >> 8); frame[13] = (byte)ethertype;
+ }
+
+ private static bool IpEquals(byte[] a, byte[] b) =>
+ a.Length == 4 && b.Length == 4 && a[0] == b[0] && a[1] == b[1] && a[2] == b[2] && a[3] == b[3];
+}
diff --git a/src/RP2040.Wireless/Cyw43/VirtualSwitch.cs b/src/RP2040.Wireless/Cyw43/VirtualSwitch.cs
new file mode 100644
index 0000000..50dfdcc
--- /dev/null
+++ b/src/RP2040.Wireless/Cyw43/VirtualSwitch.cs
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: BUSL-1.1
+// Copyright (c) 2024-2026 Iván Montiel Cardona
+
+using System.Collections.Generic;
+
+namespace RP2040.Wireless.Cyw43;
+
+///
+/// A pure layer-2 switch joining several emulated CYW43439 stations on one virtual air — no gateway
+/// services of its own. Used for the STA+AP topology where one Pico runs in AP mode (and provides its
+/// own DHCP) while another joins as a STA: frames are forwarded between chips by destination MAC, with
+/// each device delivered on the interface it actually uses (STA=0 / AP=1), so the guest TCP/IP stacks
+/// see traffic on the correct netif.
+///
+public sealed class VirtualSwitch
+{
+ private sealed record Port(Sdpcm Sdpcm, int Itf);
+
+ private readonly List _ports = new();
+ private readonly Dictionary _macToPort = new();
+
+ /// Diagnostics: (sourceItf, ethertype, length) of a frame entering the switch.
+ public System.Action? OnFrame;
+
+ /// Attach a station to the switch. is the SDPCM interface the
+ /// guest uses for this network (STA = 0, AP = 1).
+ public void AddDevice(Sdpcm sdpcm, int itf)
+ {
+ var port = new Port(sdpcm, itf);
+ _ports.Add(port);
+ sdpcm.OnHostEthernet += (srcItf, frame) => Forward(port, frame);
+ }
+
+ private static string MacKey(byte[] b, int o) =>
+ $"{b[o]:x2}{b[o + 1]:x2}{b[o + 2]:x2}{b[o + 3]:x2}{b[o + 4]:x2}{b[o + 5]:x2}";
+
+ private void Forward(Port from, byte[] frame)
+ {
+ if (frame.Length < 14) return;
+ _macToPort[MacKey(frame, 6)] = from; // learn source MAC → port
+ OnFrame?.Invoke(from.Itf, (ushort)(frame[12] << 8 | frame[13]), frame.Length);
+
+ var broadcastOrMulticast = (frame[0] & 1) != 0;
+ if (broadcastOrMulticast)
+ {
+ foreach (var p in _ports)
+ if (p != from) p.Sdpcm.EnqueueEthernet(p.Itf, frame); // flood to all other ports
+ return;
+ }
+ if (_macToPort.TryGetValue(MacKey(frame, 0), out var dest) && dest != from)
+ dest.Sdpcm.EnqueueEthernet(dest.Itf, frame); // unicast to the owning port
+ }
+}
diff --git a/src/RP2040.Wireless/RP2040.Wireless.csproj b/src/RP2040.Wireless/RP2040.Wireless.csproj
new file mode 100644
index 0000000..8b4895a
--- /dev/null
+++ b/src/RP2040.Wireless/RP2040.Wireless.csproj
@@ -0,0 +1,32 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+
+ RP2040.Wireless
+ RP2040.Wireless
+ RP2040.Wireless — CYW43439 Wi-Fi + BLE emulation for the Raspberry Pi Pico W
+
+ A firmware-agnostic emulation of the Infineon CYW43439 Wi-Fi + Bluetooth LE combo chip as wired on
+ the Raspberry Pi Pico W: the gSPI bus (bit-banged through PIO), the SDIO backplane and firmware
+ download, the WHD/SDPCM control + Ethernet data plane, and the BT HCI shared-bus transport. The
+ radio link is virtualized behind the chip's register/protocol boundary, so unmodified firmware
+ (pico-sdk, MicroPython, CircuitPython) runs in STA and SoftAP modes against a virtual access point,
+ and BLE advertises/scans/connects and relays GATT between virtual devices.
+
+ rp2040;picow;cyw43439;wifi;ble;bluetooth;emulator;sdpcm;whd;hci;virtualization
+
+
+ BUSL-1.1
+ true
+ $(NoWarn);CS1591
+
+
+
+
+
+
+
diff --git a/src/RP2040Sharp/Core/EmuStrict.cs b/src/RP2040Sharp/Core/EmuStrict.cs
new file mode 100644
index 0000000..b1e1ce8
--- /dev/null
+++ b/src/RP2040Sharp/Core/EmuStrict.cs
@@ -0,0 +1,95 @@
+// SPDX-License-Identifier: BUSL-1.1
+// Copyright (c) 2024-2026 Iván Montiel Cardona
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace RP2040.Core;
+
+///
+/// Strict/pedantic mode — a sink for the emulator's silent gaps: the places it would otherwise
+/// return 0, no-op, or fall back to a benign default for something it does not actually model
+/// (an unmapped peripheral, a reserved PIO instruction encoding, an unhandled register, …).
+///
+/// A passing test proves an observed behaviour over an exercised path; it cannot prove the emulator
+/// is complete. The dangerous failures are the silent ones — the "we never noticed the PIO bug"
+/// class — where an unmodelled path quietly returns a plausible value instead of complaining. This turns
+/// every such spot into a recorded event, so the unverified surface becomes a visible, measurable list.
+///
+/// Off by default (zero cost). Enable with EMU_STRICT=1; EMU_STRICT_THROW=1 throws on the
+/// first gap; an aggregated report is written to EMU_STRICT_OUT (default /tmp/emu_strict_rp2040.txt)
+/// on process exit. Hook new gap sites by calling / at each
+/// "default / return 0 / no-op" arm.
+///
+public static class EmuStrict
+{
+ public static bool Enabled;
+ public static bool ThrowOnGap;
+
+ private static readonly object Lock = new();
+ private static readonly Dictionary Counts = new();
+ private static readonly Dictionary Sample = new();
+ private static long _sinceFlush;
+
+ static EmuStrict()
+ {
+ Enabled = Environment.GetEnvironmentVariable("EMU_STRICT") == "1";
+ ThrowOnGap = Environment.GetEnvironmentVariable("EMU_STRICT_THROW") == "1";
+ if (Enabled)
+ AppDomain.CurrentDomain.ProcessExit += (_, _) => Dump();
+ }
+
+ /// Record that the emulator hit an unmodelled/default path. is the
+ /// gap class (e.g. "pio.wait.src-reserved"); discriminates instances within it
+ /// (e.g. "src=3" or "0x40010000"). No-op unless strict mode is enabled.
+ public static void Note(string category, string what)
+ {
+ if (!Enabled) return;
+ var key = category + " " + what;
+ bool flush;
+ lock (Lock)
+ {
+ bool isNew = !Counts.ContainsKey(key);
+ Counts.TryGetValue(key, out var c);
+ Counts[key] = c + 1;
+ Sample.TryAdd(key, what);
+ // Flush so the report is on disk regardless of how the test host exits (vstest kills it without a
+ // graceful ProcessExit). Always flush on a newly-seen gap so the *list* stays complete; otherwise
+ // throttle to every 256 hits so a hot-loop gap can't thrash the file.
+ flush = isNew || (++_sinceFlush & 0xFF) == 0;
+ }
+ if (flush) Dump();
+ if (ThrowOnGap)
+ throw new NotImplementedException($"[emu-strict] {category}: {what}");
+ }
+
+ /// Convenience for switch-expression default arms: records the gap and returns the fallback,
+ /// so an arm like _ => 0 becomes _ => EmuStrict.NoteRet("...", $"src={s}", 0u).
+ public static T NoteRet(string category, string what, T fallback)
+ {
+ Note(category, what);
+ return fallback;
+ }
+
+ public static void Reset()
+ {
+ lock (Lock) { Counts.Clear(); Sample.Clear(); }
+ }
+
+ /// Write the aggregated gap report (one line per distinct gap, busiest first).
+ public static void Dump(string? path = null)
+ {
+ path ??= Environment.GetEnvironmentVariable("EMU_STRICT_OUT") ?? "/tmp/emu_strict_rp2040.txt";
+ lock (Lock)
+ {
+ using var w = new StreamWriter(path, append: false);
+ w.WriteLine($"# emu-strict gap report ({DateTime.Now:u})");
+ w.WriteLine($"# {Counts.Count} distinct silent-gap sites, {Counts.Values.Sum()} total hits");
+ w.WriteLine($"# {"hits",10} category / example");
+ foreach (var kv in Counts.OrderByDescending(k => k.Value))
+ w.WriteLine($"{kv.Value,12} {kv.Key}");
+ }
+ }
+}
diff --git a/src/RP2040Sharp/Core/Memory/BusInterconnect.cs b/src/RP2040Sharp/Core/Memory/BusInterconnect.cs
index 767e420..12acb92 100644
--- a/src/RP2040Sharp/Core/Memory/BusInterconnect.cs
+++ b/src/RP2040Sharp/Core/Memory/BusInterconnect.cs
@@ -121,15 +121,18 @@ public uint ReadWord(uint address)
[MethodImpl(MethodImplOptions.NoInlining)]
private byte ReadByteDispatch(uint address) =>
- _memoryMap[address >> 28]?.ReadByte(address & 0x0FFFFFFF) ?? 0;
+ _memoryMap[address >> 28] is { } d ? d.ReadByte(address & 0x0FFFFFFF)
+ : EmuStrict.NoteRet("mmio.read.unmapped-region", $"0x{address:X8}", (byte)0);
[MethodImpl(MethodImplOptions.NoInlining)]
private ushort ReadHalfWordDispatch(uint address) =>
- _memoryMap[address >> 28]?.ReadHalfWord(address & 0x0FFFFFFF) ?? 0;
+ _memoryMap[address >> 28] is { } d ? d.ReadHalfWord(address & 0x0FFFFFFF)
+ : EmuStrict.NoteRet("mmio.read.unmapped-region", $"0x{address:X8}", (ushort)0);
[MethodImpl(MethodImplOptions.NoInlining)]
private uint ReadWordDispatch(uint address) =>
- _memoryMap[address >> 28]?.ReadWord(address & 0x0FFFFFFF) ?? 0;
+ _memoryMap[address >> 28] is { } d ? d.ReadWord(address & 0x0FFFFFFF)
+ : EmuStrict.NoteRet("mmio.read.unmapped-region", $"0x{address:X8}", 0u);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteWord(uint address, uint value)
@@ -190,8 +193,11 @@ public void WriteHalfWord(uint address, ushort value)
}
[MethodImpl(MethodImplOptions.NoInlining)]
- private void WriteWordDispatch(uint region, uint address, uint value) =>
- _memoryMap[region]?.WriteWord(address & 0x0FFFFFFF, value);
+ private void WriteWordDispatch(uint region, uint address, uint value)
+ {
+ if (_memoryMap[region] is { } d) d.WriteWord(address & 0x0FFFFFFF, value);
+ else EmuStrict.Note("mmio.write.unmapped-region", $"0x{address:X8}");
+ }
public void Dispose()
{
diff --git a/src/RP2040Sharp/Peripherals/Apb/ApbBridge.cs b/src/RP2040Sharp/Peripherals/Apb/ApbBridge.cs
index a2e6c93..37e4e4f 100644
--- a/src/RP2040Sharp/Peripherals/Apb/ApbBridge.cs
+++ b/src/RP2040Sharp/Peripherals/Apb/ApbBridge.cs
@@ -1,3 +1,4 @@
+using RP2040.Core;
using RP2040.Core.Memory;
namespace RP2040.Peripherals.Apb;
@@ -29,25 +30,28 @@ public void Register(uint baseAddress, IMemoryMappedDevice device)
public uint ReadWord(uint address)
{
var device = _devices[(address >> 14) & 0xFF];
- return device?.ReadWord(address & 0xFFF) ?? 0;
+ if (device == null) { EmuStrict.Note("apb.read.unmapped-peripheral", $"0x{0x40000000u | (address & 0x0FFFFFFF):X8}"); return 0; }
+ return device.ReadWord(address & 0xFFF);
}
public ushort ReadHalfWord(uint address)
{
var device = _devices[(address >> 14) & 0xFF];
- return device?.ReadHalfWord(address & 0xFFF) ?? 0;
+ if (device == null) { EmuStrict.Note("apb.read.unmapped-peripheral", $"0x{0x40000000u | (address & 0x0FFFFFFF):X8}"); return 0; }
+ return device.ReadHalfWord(address & 0xFFF);
}
public byte ReadByte(uint address)
{
var device = _devices[(address >> 14) & 0xFF];
- return device?.ReadByte(address & 0xFFF) ?? 0;
+ if (device == null) { EmuStrict.Note("apb.read.unmapped-peripheral", $"0x{0x40000000u | (address & 0x0FFFFFFF):X8}"); return 0; }
+ return device.ReadByte(address & 0xFFF);
}
public void WriteWord(uint address, uint value)
{
var device = _devices[(address >> 14) & 0xFF];
- if (device == null) return;
+ if (device == null) { EmuStrict.Note("apb.write.unmapped-peripheral", $"0x{0x40000000u | (address & 0x0FFFFFFF):X8}"); return; }
var atomicType = (address >> 12) & 0x3;
var offset = address & 0xFFF;
@@ -71,7 +75,7 @@ public void WriteWord(uint address, uint value)
public void WriteHalfWord(uint address, ushort value)
{
var device = _devices[(address >> 14) & 0xFF];
- if (device == null) return;
+ if (device == null) { EmuStrict.Note("apb.write.unmapped-peripheral", $"0x{0x40000000u | (address & 0x0FFFFFFF):X8}"); return; }
var atomicType = (address >> 12) & 0x3;
var offset = address & 0xFFF;
@@ -95,7 +99,7 @@ public void WriteHalfWord(uint address, ushort value)
public void WriteByte(uint address, byte value)
{
var device = _devices[(address >> 14) & 0xFF];
- if (device == null) return;
+ if (device == null) { EmuStrict.Note("apb.write.unmapped-peripheral", $"0x{0x40000000u | (address & 0x0FFFFFFF):X8}"); return; }
var atomicType = (address >> 12) & 0x3;
var offset = address & 0xFFF;
diff --git a/src/RP2040Sharp/Peripherals/Busctrl/BusctrlPeripheral.cs b/src/RP2040Sharp/Peripherals/Busctrl/BusctrlPeripheral.cs
index 58dbf4b..c31f6dd 100644
--- a/src/RP2040Sharp/Peripherals/Busctrl/BusctrlPeripheral.cs
+++ b/src/RP2040Sharp/Peripherals/Busctrl/BusctrlPeripheral.cs
@@ -21,7 +21,10 @@ public sealed class BusctrlPeripheral : IMemoryMappedDevice
private const uint PERFSEL3 = 0x024;
private uint _priority;
- private readonly uint[] _perfsel = new uint[4];
+ // PERFSELn reset value is 0x1F (BUSCTRL_PERFSEL0_RESET): the "no event selected / counter free"
+ // sentinel. pico-sdk's pico_rand initialise_rand() scans for a counter still at 0x1F to claim; if
+ // none read 0x1F it hard_assert()s — which panicked the Pico W boot when these defaulted to 0.
+ private readonly uint[] _perfsel = { 0x1F, 0x1F, 0x1F, 0x1F };
public uint Size => 0x1000;
diff --git a/src/RP2040Sharp/Peripherals/Dma/DmaPeripheral.cs b/src/RP2040Sharp/Peripherals/Dma/DmaPeripheral.cs
index 5d83784..248d3e7 100644
--- a/src/RP2040Sharp/Peripherals/Dma/DmaPeripheral.cs
+++ b/src/RP2040Sharp/Peripherals/Dma/DmaPeripheral.cs
@@ -95,6 +95,33 @@ public void RegisterDreq(int dreqIndex, Func ready)
_dreqSources[dreqIndex] = ready;
}
+ private bool _inResume;
+
+ ///
+ /// Re-arm any channel that stalled waiting on the given DREQ line, now that the source peripheral
+ /// has signalled readiness for another beat (e.g. a PIO RX push, or a TX slot freed by the SM). A
+ /// stalled channel keeps BUSY set with beats still pending; re-running it moves as many beats as the
+ /// DREQ now allows, then re-stalls if the source drains again. This is what makes a DMA↔FIFO transfer
+ /// self-paced instead of draining an empty FIFO greedily at trigger time, and without it a DREQ-paced
+ /// transfer (e.g. a CYW43 firmware download over the PIO gSPI) stalls forever. Reentrancy-guarded.
+ ///
+ public void ResumeDreq(int dreqIndex)
+ {
+ if (_inResume) return;
+ _inResume = true;
+ try
+ {
+ for (var ch = 0; ch < CHANNEL_COUNT; ch++)
+ {
+ if ((_ctrl[ch] & CTRL_BUSY) == 0 || _transCount[ch] == 0) continue;
+ // TREQ_SEL is CTRL bits [20:15] on the RP2040.
+ if ((int)((_ctrl[ch] >> 15) & 0x3F) == dreqIndex)
+ ExecuteChannel(ch);
+ }
+ }
+ finally { _inResume = false; }
+ }
+
public DmaPeripheral(BusInterconnect bus, CortexM0Plus cpu)
{
_bus = bus;
diff --git a/src/RP2040Sharp/Peripherals/Fs/CircuitPythonFs.cs b/src/RP2040Sharp/Peripherals/Fs/CircuitPythonFs.cs
new file mode 100644
index 0000000..0c4c5b7
--- /dev/null
+++ b/src/RP2040Sharp/Peripherals/Fs/CircuitPythonFs.cs
@@ -0,0 +1,240 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace RP2040.Peripherals.Fs;
+
+///
+/// Locates and reads the CircuitPython internal-flash filesystem (a FAT12 volume CircuitPython
+/// formats on first boot when the reserved flash region is blank). This is general-knowledge
+/// tooling for inspecting what the firmware staged — e.g. reading boot_out.txt (which
+/// records the running version and any safe-mode crash reason) or code.py — and is the
+/// foundation for pre-baking a filesystem image into flash before boot.
+///
+/// Empirically (CircuitPython 10.x on the 2 MB Pico/Pico 2 flash) the volume sits at
+/// flash offset 0x100000, is 1 MB, FAT12, 512 B/sector, 1 sector/cluster,
+/// 1 reserved sector, 1 FAT, 512 root entries, 7 sectors/FAT. Rather than hard-code that, the
+/// region is discovered by scanning for a valid FAT boot sector, so it stays correct across builds
+/// and flash sizes.
+///
+public static class CircuitPythonFs
+{
+ /// The located FAT volume and the BPB geometry parsed from its boot sector.
+ public readonly record struct Region(
+ int Offset, int SizeBytes,
+ ushort BytesPerSector, byte SectorsPerCluster, ushort ReservedSectors,
+ byte NumFats, ushort RootEntries, ushort SectorsPerFat);
+
+ /// Flash offset of the reserved CircuitPython filesystem region on the standard 2 MB
+ /// Pico/Pico 2 build (verified empirically against CircuitPython 10.x). Use
+ /// for builds/flash sizes that may differ.
+ public const int DefaultRegionOffset = 0x100000;
+
+ /// Size of that reserved region (1 MB).
+ public const int DefaultRegionSize = 0x100000;
+
+ ///
+ /// Scans a flash image for the CircuitPython FAT volume. A match is a 512-aligned sector with
+ /// the 0x55AA signature and a plausible BPB (512 B/sector, 1–2 FATs, ≥1 reserved sector). The
+ /// bare string "CIRCUITPY" also appears inside the firmware binary, so the boot-sector shape —
+ /// not a string search — is what identifies the real volume.
+ ///
+ public static bool TryLocate(byte[] flash, out Region region)
+ {
+ region = default;
+ for (int off = 0; off + 512 <= flash.Length; off += 512)
+ {
+ if (flash[off + 510] != 0x55 || flash[off + 511] != 0xAA) continue;
+ ushort bps = U16(flash, off + 11);
+ byte nFats = flash[off + 16];
+ ushort reserved = U16(flash, off + 14);
+ if (bps != 512 || (nFats != 1 && nFats != 2) || reserved < 1) continue;
+
+ ushort rootEnts = U16(flash, off + 17);
+ ushort secPerFat = U16(flash, off + 22);
+ ushort totSec16 = U16(flash, off + 19);
+ uint totSec = totSec16 != 0 ? totSec16 : U32(flash, off + 32);
+ region = new Region(off, (int)(totSec * bps), bps, flash[off + 13], reserved,
+ nFats, rootEnts, secPerFat);
+ return true;
+ }
+ return false;
+ }
+
+ /// Lists the 8.3 names of the files/directories in the volume's root directory.
+ public static IReadOnlyList ListRootEntries(byte[] flash)
+ {
+ var names = new List();
+ if (!TryLocate(flash, out var r)) return names;
+ int rootStart = r.Offset + (r.ReservedSectors + r.NumFats * r.SectorsPerFat) * r.BytesPerSector;
+ for (int e = 0; e < r.RootEntries; e++)
+ {
+ int p = rootStart + e * 32;
+ if (p + 32 > flash.Length || flash[p] == 0x00) break;
+ if (flash[p] == 0xE5) continue; // deleted
+ byte attr = flash[p + 11];
+ if (attr == 0x0F || (attr & 0x08) != 0) continue; // LFN / volume label
+ names.Add(ShortName(flash, p));
+ }
+ return names;
+ }
+
+ ///
+ /// Reads a root-level file by its name (8.3, case-insensitive — e.g. "boot_out.txt" or
+ /// "code.py"), following the FAT12 cluster chain. Returns null if the volume or file is absent.
+ ///
+ public static byte[]? ReadFile(byte[] flash, string name)
+ {
+ if (!TryLocate(flash, out var r)) return null;
+ string want = To83(name);
+ int rootStart = r.Offset + (r.ReservedSectors + r.NumFats * r.SectorsPerFat) * r.BytesPerSector;
+ int rootSectors = (r.RootEntries * 32 + r.BytesPerSector - 1) / r.BytesPerSector;
+ int dataStart = rootStart + rootSectors * r.BytesPerSector;
+
+ for (int e = 0; e < r.RootEntries; e++)
+ {
+ int p = rootStart + e * 32;
+ if (p + 32 > flash.Length || flash[p] == 0x00) break;
+ if (flash[p] == 0xE5) continue;
+ byte attr = flash[p + 11];
+ if (attr == 0x0F || (attr & 0x18) != 0) continue; // skip LFN / dir / volume label
+ if (!string.Equals(RawName(flash, p), want, StringComparison.OrdinalIgnoreCase)) continue;
+
+ uint size = U32(flash, p + 28);
+ int cluster = U16(flash, p + 26);
+ int clusterBytes = r.SectorsPerCluster * r.BytesPerSector;
+ var outBuf = new byte[size];
+ int written = 0;
+ while (cluster >= 2 && cluster < 0xFF8 && written < size)
+ {
+ int src = dataStart + (cluster - 2) * clusterBytes;
+ int n = (int)Math.Min(clusterBytes, size - written);
+ if (src + n > flash.Length) break;
+ Array.Copy(flash, src, outBuf, written, n);
+ written += n;
+ cluster = NextCluster12(flash, r, cluster);
+ }
+ return outBuf;
+ }
+ return null;
+ }
+
+ // ── Writing / baking ───────────────────────────────────────────────────────
+
+ ///
+ /// Builds a fresh FAT12 volume of containing
+ /// (typically code.py + modules), using the exact geometry
+ /// CircuitPython formats itself — so the firmware mounts it on boot and runs it instead of
+ /// reformatting and dropping the files. The volume label is "CIRCUITPY".
+ ///
+ public static byte[] BuildVolume(int sizeBytes, IReadOnlyList<(string Name, byte[] Data)> files)
+ {
+ const int bps = 512, spc = 1, reserved = 1, nFats = 1, rootEnts = 512, secPerFat = 7;
+ int totSec = sizeBytes / bps;
+ var img = new byte[sizeBytes];
+
+ img[0] = 0xEB; img[1] = 0xFE; img[2] = 0x90;
+ Encoding.ASCII.GetBytes("MSDOS5.0").CopyTo(img, 3);
+ img[11] = bps & 0xFF; img[12] = bps >> 8;
+ img[13] = spc;
+ img[14] = reserved;
+ img[16] = nFats;
+ img[17] = rootEnts & 0xFF; img[18] = rootEnts >> 8;
+ img[19] = (byte)(totSec & 0xFF); img[20] = (byte)((totSec >> 8) & 0xFF);
+ img[21] = 0xF8;
+ img[22] = secPerFat & 0xFF; img[23] = secPerFat >> 8;
+ img[24] = 0x3F; img[26] = 0xFF;
+ img[36] = 0x80; img[38] = 0x29;
+ img[39] = 0x12; img[40] = 0x34; img[41] = 0x56; img[42] = 0x78;
+ WriteRaw11(img, 43, "CIRCUITPY ");
+ Encoding.ASCII.GetBytes("FAT12 ").CopyTo(img, 54);
+ img[510] = 0x55; img[511] = 0xAA;
+
+ int fatStart = reserved * bps;
+ int rootStart = (reserved + nFats * secPerFat) * bps;
+ int rootSectors = (rootEnts * 32 + bps - 1) / bps;
+ int dataStart = rootStart + rootSectors * bps;
+ img[fatStart] = 0xF8; img[fatStart + 1] = 0xFF; img[fatStart + 2] = 0xFF;
+
+ void SetFat12(int cl, int val)
+ {
+ int idx = fatStart + cl + cl / 2;
+ if ((cl & 1) == 0) { img[idx] = (byte)(val & 0xFF); img[idx + 1] = (byte)((img[idx + 1] & 0xF0) | ((val >> 8) & 0x0F)); }
+ else { img[idx] = (byte)((img[idx] & 0x0F) | ((val << 4) & 0xF0)); img[idx + 1] = (byte)((val >> 4) & 0xFF); }
+ }
+
+ WriteRaw11(img, rootStart, "CIRCUITPY ");
+ img[rootStart + 11] = 0x08; // volume label
+ int entry = 1, cluster = 2;
+ int clusterBytes = spc * bps;
+ foreach (var (name, data) in files)
+ {
+ int p = rootStart + entry * 32;
+ WriteRaw11(img, p, To83(name));
+ img[p + 11] = 0x20; // archive
+ int first = cluster;
+ int need = Math.Max(1, (data.Length + clusterBytes - 1) / clusterBytes);
+ for (int c = 0; c < need; c++)
+ {
+ int cl = cluster + c;
+ SetFat12(cl, c == need - 1 ? 0xFFF : cl + 1);
+ int src = c * clusterBytes, n = Math.Min(clusterBytes, data.Length - src);
+ if (n > 0) Array.Copy(data, src, img, dataStart + (cl - 2) * clusterBytes, n);
+ }
+ img[p + 26] = (byte)(first & 0xFF); img[p + 27] = (byte)(first >> 8);
+ img[p + 28] = (byte)data.Length; img[p + 29] = (byte)(data.Length >> 8);
+ img[p + 30] = (byte)(data.Length >> 16); img[p + 31] = (byte)(data.Length >> 24);
+ cluster += need; entry++;
+ }
+ return img;
+ }
+
+ ///
+ /// Splices a freshly-built CircuitPython volume (containing ) into a
+ /// firmware flash image at the reserved FS region, so the next boot mounts it and runs
+ /// code.py without the firmware formatting first. Defaults to the standard 2 MB layout.
+ ///
+ public static void Bake(byte[] flash, IReadOnlyList<(string Name, byte[] Data)> files,
+ int regionOffset = DefaultRegionOffset, int regionSize = DefaultRegionSize)
+ {
+ var vol = BuildVolume(regionSize, files);
+ Array.Copy(vol, 0, flash, regionOffset, Math.Min(vol.Length, flash.Length - regionOffset));
+ }
+
+ static void WriteRaw11(byte[] img, int p, string raw11)
+ {
+ for (int k = 0; k < 11; k++) img[p + k] = (byte)(k < raw11.Length ? raw11[k] : ' ');
+ }
+
+ // ── FAT12 / BPB helpers ───────────────────────────────────────────────────
+ static int NextCluster12(byte[] flash, Region r, int cluster)
+ {
+ int fatStart = r.Offset + r.ReservedSectors * r.BytesPerSector;
+ int idx = fatStart + cluster + cluster / 2; // 12 bits per entry → c*3/2
+ if (idx + 1 >= flash.Length) return 0xFFF;
+ int val = flash[idx] | (flash[idx + 1] << 8);
+ return (cluster & 1) != 0 ? val >> 4 : val & 0x0FFF;
+ }
+
+ static ushort U16(byte[] b, int p) => (ushort)(b[p] | (b[p + 1] << 8));
+ static uint U32(byte[] b, int p) => (uint)(b[p] | (b[p + 1] << 8) | (b[p + 2] << 16) | (b[p + 3] << 24));
+
+ static string ShortName(byte[] b, int p)
+ {
+ string nm = Encoding.ASCII.GetString(b, p, 8).TrimEnd();
+ string ext = Encoding.ASCII.GetString(b, p + 8, 3).TrimEnd();
+ return ext.Length == 0 ? nm : nm + "." + ext;
+ }
+
+ // The raw 11-byte directory name with no dot (e.g. "CODE PY ") for exact comparison.
+ static string RawName(byte[] b, int p) => Encoding.ASCII.GetString(b, p, 11);
+
+ // Convert "code.py" → the padded 11-byte form "CODE PY ".
+ static string To83(string name)
+ {
+ int dot = name.LastIndexOf('.');
+ string nm = (dot < 0 ? name : name[..dot]).ToUpperInvariant();
+ string ext = (dot < 0 ? "" : name[(dot + 1)..]).ToUpperInvariant();
+ return (nm.Length > 8 ? nm[..8] : nm.PadRight(8)) + (ext.Length > 3 ? ext[..3] : ext.PadRight(3));
+ }
+}
diff --git a/src/RP2040Sharp/Peripherals/Gpio/IoBank0Peripheral.cs b/src/RP2040Sharp/Peripherals/Gpio/IoBank0Peripheral.cs
index a133fa5..3d52ef1 100644
--- a/src/RP2040Sharp/Peripherals/Gpio/IoBank0Peripheral.cs
+++ b/src/RP2040Sharp/Peripherals/Gpio/IoBank0Peripheral.cs
@@ -33,6 +33,8 @@ public sealed class IoBank0Peripheral : IMemoryMappedDevice
// CTRL field masks
private const uint FUNCSEL_MASK = 0x1F;
private const uint FUNCSEL_SIO = 5;
+ private const uint FUNCSEL_PIO0 = 6;
+ private const uint FUNCSEL_PIO1 = 7; // RP2040 has two PIO blocks: PIO0=6, PIO1=7
// IO_IRQ_BANK0 = hardware IRQ 13
private const int IO_IRQ_BANK0 = 13;
@@ -49,6 +51,81 @@ public sealed class IoBank0Peripheral : IMemoryMappedDevice
private readonly uint[] _proc1Inte = new uint[4];
private readonly uint[] _proc1Intf = new uint[4];
+ // Per-PIO-block pad output (level) and direction (output-enable), fed by the machine
+ // from each PioPeripheral's WriteGpioPins/WriteGpioDirs callbacks via SetPioOut/SetPioDirs.
+ // Lets a GPIO muxed to PIO0/1 reflect the state machine's driven level on the pad —
+ // the function-mux level a circuit host reads via GetPadOutputLevel.
+ private readonly uint[] _pioOut = new uint[2];
+ private readonly uint[] _pioOe = new uint[2];
+
+ /// Updates the pad output level driven by PIO (0-1).
+ /// carries the intended pin levels;
+ /// the pins this block drives.
+ public void SetPioOut (int block, uint value, uint mask)
+ {
+ _pioOut[block] = (_pioOut[block] & ~mask) | (value & mask);
+ NotifyPads(mask);
+ }
+
+ /// Updates the output-enable (pin direction) driven by PIO .
+ public void SetPioDirs (int block, uint value, uint mask)
+ {
+ _pioOe[block] = (_pioOe[block] & ~mask) | (value & mask);
+ NotifyPads(mask);
+ }
+
+ // ── External-device pin seam (used by board add-ons such as the Pico W's CYW43439) ──
+ // An off-chip device wired to GPIO pads observes the effective pad output through PadChanged + the
+ // public GetPadOutputLevel/Enable, and drives a pad's input back through SetExternalInput. Generic
+ // (any external peripheral can use it) and costs nothing unless something subscribes.
+
+ /// Raised with a pin index whose effective pad output (level or output-enable) changed.
+ /// Lets an attached external device sample edges (e.g. a bit-banged SPI clock) without polling.
+ public event Action? PadChanged;
+
+ private readonly bool[] _lastPadLevel = new bool[GPIO_COUNT];
+ private readonly bool[] _lastPadOe = new bool[GPIO_COUNT];
+
+ /// Re-evaluate the effective pad output for the pins in and raise
+ /// for any that changed. A board calls this to forward SIO-driven pad
+ /// changes (which originate outside this peripheral) to an attached external device. Cheap no-op
+ /// when nothing subscribes to .
+ public void NotifyPads(uint mask)
+ {
+ if (PadChanged is null) return;
+ while (mask != 0)
+ {
+ var pin = System.Numerics.BitOperations.TrailingZeroCount(mask);
+ mask &= mask - 1;
+ if (pin >= GPIO_COUNT) continue;
+ var lvl = GetPadOutputLevel(pin);
+ var oe = GetPadOutputEnable(pin);
+ if (lvl == _lastPadLevel[pin] && oe == _lastPadOe[pin]) continue;
+ _lastPadLevel[pin] = lvl;
+ _lastPadOe[pin] = oe;
+ PadChanged(pin);
+ }
+ }
+
+ /// The 32-bit word of effective pad input levels (GPIO 0-31): an output-enabled pad reads
+ /// back its driven level; a non-driven pad reads the externally injected input. A board wires this
+ /// into each PioPeripheral.ReadGpioIn so a half-duplex bus (PIO drives, then samples an
+ /// off-chip device's reply on the same pin) reads the device, not the SM's stale output.
+ public uint GetInputWord()
+ {
+ uint w = 0;
+ for (var p = 0; p < GPIO_COUNT; p++) // RP2040 has 30 GPIOs; bits 30/31 don't exist
+ {
+ var level = GetPadOutputEnable(p) ? GetPadOutputLevel(p) : _gpioInput[p];
+ if (level) w |= 1u << p;
+ }
+ return w;
+ }
+
+ /// Drive a pad's input from an off-chip device (the pad's external connection). Identical to
+ /// but named for the external-device direction.
+ public void SetExternalInput(int pin, bool level) => UpdatePinInput(pin, level);
+
///
/// Returns the FUNCSEL value [4:0] for .
/// Key values: 5 = SIO, 6 = PIO0, 7 = PIO1, 31 = NULL (hi-Z / default).
@@ -59,6 +136,33 @@ public uint GetFuncSel(int pin)
return _ctrl[pin] & FUNCSEL_MASK;
}
+ /// The level the pad is driven to after the GPIO function mux. SIO and the
+ /// two PIO functions are modelled; other peripheral functions report false
+ /// (PWM is reproduced element-side via the PWM registers). Pairs with
+ /// .
+ public bool GetPadOutputLevel(int pin)
+ {
+ if ((uint)pin >= GPIO_COUNT) return false;
+ var funcsel = _ctrl[pin] & FUNCSEL_MASK;
+ if (funcsel == FUNCSEL_SIO)
+ return (_sio.GpioOut & (1u << pin)) != 0;
+ if (funcsel == FUNCSEL_PIO0 || funcsel == FUNCSEL_PIO1)
+ return (_pioOut[funcsel - FUNCSEL_PIO0] & (1u << pin)) != 0;
+ return false;
+ }
+
+ /// True if the pad is actively driven (output-enabled) through the function mux.
+ public bool GetPadOutputEnable(int pin)
+ {
+ if ((uint)pin >= GPIO_COUNT) return false;
+ var funcsel = _ctrl[pin] & FUNCSEL_MASK;
+ if (funcsel == FUNCSEL_SIO)
+ return (_sio.GpioOe & (1u << pin)) != 0;
+ if (funcsel == FUNCSEL_PIO0 || funcsel == FUNCSEL_PIO1)
+ return (_pioOe[funcsel - FUNCSEL_PIO0] & (1u << pin)) != 0;
+ return false;
+ }
+
public uint Size => 0x160;
public IoBank0Peripheral(SioPeripheral sio, CortexM0Plus? cpu = null)
@@ -82,10 +186,23 @@ public void UpdatePinInput(int pin, bool value)
var old = _gpioInput[pin];
_gpioInput[pin] = value;
- if (!old && value) _intrEdge[pin] |= IRQ_EDGE_HIGH;
- if (old && !value) _intrEdge[pin] |= IRQ_EDGE_LOW;
-
- CheckInterrupts();
+ // The pad input buffer feeds SIO GPIO_IN regardless of the pin's function select, so a value an
+ // off-chip device drives onto a pad (e.g. the CYW43439 raising its DATA-line host-wake on GPIO24,
+ // which is muxed to PIO) is readable by the CPU through gpio_get. Mirror it into SIO.
+ _sio.SetGpioExternalIn(pin, value);
+
+ if (old == value) return; // no edge → no IRQ to (re-)evaluate
+ if (value) _intrEdge[pin] |= IRQ_EDGE_HIGH;
+ else _intrEdge[pin] |= IRQ_EDGE_LOW;
+
+ // Only run the (whole-bank) interrupt scan when this pin actually has an interrupt enabled or
+ // forced. A bit-banged input with no GPIO IRQ — e.g. the CYW43 gSPI DATA line toggling per bit —
+ // would otherwise pay a full bank scan on every edge; skipping it is the single biggest saving
+ // on the WiFi/BLE data path. (The edge is still latched above for INTR.)
+ var reg = pin >> 3;
+ var nibble = 0xFu << ((pin & 7) * 4);
+ if (((_proc0Inte[reg] | _proc0Intf[reg] | _proc1Inte[reg] | _proc1Intf[reg]) & nibble) != 0)
+ CheckInterrupts();
}
// ── IMemoryMappedDevice ──────────────────────────────────────────
diff --git a/src/RP2040Sharp/Peripherals/Pio/PioPeripheral.cs b/src/RP2040Sharp/Peripherals/Pio/PioPeripheral.cs
index 1214602..e1c95a9 100644
--- a/src/RP2040Sharp/Peripherals/Pio/PioPeripheral.cs
+++ b/src/RP2040Sharp/Peripherals/Pio/PioPeripheral.cs
@@ -1,3 +1,4 @@
+using RP2040.Core;
using RP2040.Core.Cpu;
using RP2040.Core.Memory;
@@ -68,6 +69,33 @@ public sealed class PioPeripheral : IMemoryMappedDevice, ITickable
private uint _irq1Inte;
private uint _irq1Intf;
+ // IRQ-state caching: only re-poke the NVIC when the computed IRQ lines actually change, or while
+ // the target core sleeps (so the wake heartbeat survives). Removes the per-tick NVIC churn that
+ // otherwise dominates fine-grained stepping.
+ private bool _lastIrq0, _lastIrq1, _irqStateKnown;
+ /// Returns whether the core that fields PIO interrupts is currently sleeping (WFE/WFI). A
+ /// busy core re-checks interrupts on its own, so the per-tick recompute is only needed while it sleeps.
+ public Func? CoreWaiting;
+
+ /// Raised when a state machine frees a TX FIFO slot (explicit PULL or autopull), so a
+ /// DREQ-paced DMA feeding that SM can re-arm. Mirrors for the RX direction.
+ public Action? OnTxConsumed { get; set; }
+ /// Raised when a state machine pushes a word into the RX FIFO (PUSH or autopush), so a
+ /// DREQ-paced DMA draining that SM can re-arm. (smIndex, value).
+ public Action? OnRxPush { get; set; }
+
+ /// Raised when the firmware pushes a word into a TX FIFO (a write to TXFx). (smIndex, value).
+ /// Used by test probes to observe the data crossing the SM boundary.
+ public Action? OnTxPush { get; set; }
+ /// Raised when the firmware pulls a word out of an RX FIFO (a read from RXFx). (smIndex, value).
+ /// Used by test probes to observe the data crossing the SM boundary.
+ public Action? OnRxPull { get; set; }
+
+ // FIFO helpers that notify the DREQ-resume hooks. Over-notifying is safe: ResumeDreq is
+ // reentrancy-guarded and a no-op unless a channel is stalled on that exact DREQ with the source ready.
+ private void RxEnqueue(PioStateMachine sm, uint value) { sm.RxFifo.Enqueue(value); OnRxPush?.Invoke(sm.SmIndex, value); }
+ private uint TxDequeue(PioStateMachine sm) { var v = sm.TxFifo.Dequeue(); OnTxConsumed?.Invoke(sm.SmIndex); return v; }
+
public uint Size => 0x100000; // up to 1 MB address space per block
/// Read current physical GPIO input levels (used by WAIT GPIO, IN PINS).
@@ -77,6 +105,13 @@ public sealed class PioPeripheral : IMemoryMappedDevice, ITickable
/// Write physical GPIO pin directions: (dirValue, pinMask).
public Action? WriteGpioDirs { get; set; }
+ ///
+ /// Whether the block is held in reset. On real RP2040 a PIO powers up clock-gated: register reads
+ /// return 0 and writes are no-ops until firmware clears its RESETS bit. The full machine starts the
+ /// PIO in reset and clears this when RESETS releases the block; isolated unit tests leave it false.
+ ///
+ public bool InReset;
+
public PioPeripheral(CortexM0Plus cpu, uint blockIndex)
{
_cpu = cpu;
@@ -120,6 +155,10 @@ public void Tick(long deltaCycles)
public uint ReadWord(uint address)
{
+ // Held in reset: the block is clock-gated, every register reads back 0.
+ if (InReset)
+ return 0;
+
// Strip the base (top 20 bits may vary between PIO0/1)
var off = address & 0xFFFFF;
@@ -148,6 +187,7 @@ public uint ReadWord(uint address)
// rp2040js: readFIFO() → checkWait().
if (sm.Stalled)
CheckSmWait(sm, txEvent: false);
+ OnRxPull?.Invoke(smIdx, v);
return v;
}
@@ -179,6 +219,10 @@ public byte ReadByte(uint address) =>
public void WriteWord(uint address, uint value)
{
+ // Held in reset: the block is clock-gated, register writes are silently dropped.
+ if (InReset)
+ return;
+
var off = address & 0xFFFFF;
if (off >= REG_INSTR_MEM_BASE && off < REG_INSTR_MEM_BASE + INSTR_COUNT * 4)
@@ -200,6 +244,7 @@ public void WriteWord(uint address, uint value)
if (sm.TxFifo.Count < sm.TxDepth)
{
sm.TxFifo.Enqueue(value);
+ OnTxPush?.Invoke(smIdx, value);
// Clear TXSTALL for this SM now that TX FIFO has data
_fdebug &= ~(1u << (24 + smIdx));
// Wake a SM that was stalled waiting for data in the TX FIFO (PULL block / autopull).
@@ -279,7 +324,7 @@ private void CheckSmWait(PioStateMachine sm, bool txEvent)
if (((instr >> 13) & 7) == OP_PUSH_PULL)
{
// Blocking PULL: its sole effect is to load the OSR — do it and step past it.
- sm.OSR = sm.TxFifo.Dequeue();
+ sm.OSR = TxDequeue(sm);
sm.OsrCount = 32;
sm.Stalled = false;
AdvanceSmPc(sm);
@@ -299,7 +344,7 @@ private void CheckSmWait(PioStateMachine sm, bool txEvent)
{
// Deferred autopush: the ISR already holds the captured data; flush it and
// step past the IN that stalled.
- sm.RxFifo.Enqueue(sm.ISR);
+ RxEnqueue(sm, sm.ISR);
sm.ISR = 0; sm.IsrCount = 0;
sm.AutopushPending = false;
sm.Stalled = false;
@@ -310,7 +355,7 @@ private void CheckSmWait(PioStateMachine sm, bool txEvent)
if (((instr >> 13) & 7) == OP_PUSH_PULL && (instr & 0x80) == 0)
{
// Blocking PUSH: flush the ISR and step past it.
- sm.RxFifo.Enqueue(sm.ISR);
+ RxEnqueue(sm, sm.ISR);
sm.ISR = 0; sm.IsrCount = 0;
sm.Stalled = false;
AdvanceSmPc(sm);
@@ -445,6 +490,16 @@ private void CheckInterrupts()
var intr = BuildIntr();
var irq0Active = ((intr | _irq0Intf) & _irq0Inte) != 0;
var irq1Active = ((intr | _irq1Intf) & _irq1Inte) != 0;
+
+ // Re-poke the NVIC only when the IRQ state actually changed, or — to preserve the wake
+ // heartbeat — while the target core sleeps. A busy core re-checks interrupts on its own, so
+ // skipping the unchanged-state recompute removes the per-tick NVIC churn that otherwise
+ // dominated fine-grained stepping, with no change in observed interrupt behaviour.
+ var changed = !_irqStateKnown || irq0Active != _lastIrq0 || irq1Active != _lastIrq1;
+ if (!changed && CoreWaiting != null && !CoreWaiting()) return;
+ _lastIrq0 = irq0Active; _lastIrq1 = irq1Active; _irqStateKnown = true;
+
+ // PIO0_IRQ0=7, PIO0_IRQ1=8, PIO1_IRQ0=9, PIO1_IRQ1=10
_cpu.SetInterrupt((int)(7 + _blockIndex * 2), irq0Active);
_cpu.SetInterrupt((int)(7 + _blockIndex * 2 + 1), irq1Active);
}
@@ -515,7 +570,7 @@ private void ExecuteStep(PioStateMachine sm, int smIdx)
{
if (sm.RxFifo.Count < sm.RxDepth)
{
- sm.RxFifo.Enqueue(sm.ISR);
+ RxEnqueue(sm, sm.ISR);
sm.ISR = 0; sm.IsrCount = 0;
sm.AutopushPending = false;
sm.Stalled = false;
@@ -680,7 +735,7 @@ private void ExecWait(PioStateMachine sm, ushort instr)
0 => (((ReadGpioIn?.Invoke() ?? sm.GpioPins) >> (int)index) & 1) == polarity, // GPIO (absolute)
1 => (((ReadGpioIn?.Invoke() ?? sm.GpioPins) >> (int)((index + sm.InBase) & 0x1F)) & 1) == polarity, // PIN relative to IN_BASE
2 => ((_irq >> irqFlagIdx) & 1) == polarity, // IRQ flag
- _ => true,
+ _ => EmuStrict.NoteRet ("pio.wait.src-reserved", $"src={source}", true), // 3 = reserved
};
sm.Stalled = !condition;
@@ -703,7 +758,7 @@ private void ExecIn(PioStateMachine sm, ushort instr)
3 => 0, // NULL
6 => sm.ISR,
7 => sm.OSR,
- _ => 0,
+ _ => EmuStrict.NoteRet ("pio.in.src-reserved", $"src={source}", 0u), // 4,5 = reserved
};
if (sm.IsrShiftRight)
@@ -723,7 +778,7 @@ private void ExecIn(PioStateMachine sm, ushort instr)
{
if (sm.RxFifo.Count < sm.RxDepth)
{
- sm.RxFifo.Enqueue(sm.ISR);
+ RxEnqueue(sm, sm.ISR);
sm.ISR = 0; sm.IsrCount = 0;
}
else
@@ -757,7 +812,7 @@ private void ExecOut(PioStateMachine sm, ushort instr)
_fdebug |= 1u << (24 + sm.SmIndex); // TXSTALL
return;
}
- sm.OSR = sm.TxFifo.Dequeue();
+ sm.OSR = TxDequeue(sm);
sm.OsrCount = 32;
}
@@ -831,7 +886,7 @@ private void DoPush(PioStateMachine sm, bool block)
if (!block) { sm.ISR = 0; sm.IsrCount = 0; }
return;
}
- sm.RxFifo.Enqueue(sm.ISR);
+ RxEnqueue(sm, sm.ISR);
sm.ISR = 0;
sm.IsrCount = 0;
sm.Stalled = false;
@@ -861,7 +916,7 @@ private void DoPull(PioStateMachine sm, bool block)
}
else
{
- sm.OSR = sm.TxFifo.Dequeue();
+ sm.OSR = TxDequeue(sm);
}
sm.OsrCount = 32;
sm.Stalled = false;
@@ -883,13 +938,14 @@ private void ExecMov(PioStateMachine sm, ushort instr)
5 => ComputeStatus(sm),
6 => sm.ISR,
7 => sm.OSR,
- _ => 0,
+ _ => EmuStrict.NoteRet ("pio.mov.src-reserved", $"src={source}", 0u), // 4 = reserved
};
data = op switch
{
1 => ~data,
2 => BitReverse(data),
+ 3 => EmuStrict.NoteRet ("pio.mov.op-reserved", "op=3", data), // 3 = reserved
_ => data,
};
@@ -916,6 +972,7 @@ private void ExecMov(PioStateMachine sm, ushort instr)
// MOV OSR: rp2040js §setMovDestination / TRM §3.4.3 —
// "The OSR shift count is set to 0 (full, i.e. 32 bits remain)."
sm.OSR = data; sm.OsrCount = 32; break;
+ default: EmuStrict.Note ("pio.mov.dest-reserved", $"dest={dest}"); break; // 3 = reserved
}
sm.Stalled = false;
}
diff --git a/src/RP2040Sharp/Peripherals/RP2040Machine.cs b/src/RP2040Sharp/Peripherals/RP2040Machine.cs
index 0dfa055..fa7790e 100644
--- a/src/RP2040Sharp/Peripherals/RP2040Machine.cs
+++ b/src/RP2040Sharp/Peripherals/RP2040Machine.cs
@@ -42,6 +42,14 @@ namespace RP2040.Peripherals;
/// machine.Run(1_000_000);
///
///
+public enum RP2040BootromRevision
+{
+ /// Bootrom version 2 (RP2040 B0/B1 silicon). Default.
+ B1,
+ /// Bootrom version 3 (RP2040 B2 silicon). Verified by HIL against a real B2 Pico.
+ B2,
+}
+
public sealed class RP2040Machine : IDisposable
{
public const uint CLK_HZ = 125_000_000;
@@ -99,8 +107,12 @@ public sealed class RP2040Machine : IDisposable
private bool _core1Launched;
private int _activeCoreId; // 0 = Core0, 1 = Core1 (set before each Run slice)
- public RP2040Machine(uint flashSize = 2 * 1024 * 1024)
+ private readonly RP2040BootromRevision _bootromRevision;
+
+ public RP2040Machine(uint flashSize = 2 * 1024 * 1024,
+ RP2040BootromRevision bootrom = RP2040BootromRevision.B1)
{
+ _bootromRevision = bootrom;
Bus = new BusInterconnect(flashSize);
Cpu = new CortexM0Plus(Bus) { CoreId = 0 };
Cpu1 = new CortexM0Plus(Bus) { CoreId = 1 };
@@ -262,6 +274,21 @@ public RP2040Machine(uint flashSize = 2 * 1024 * 1024)
ahb.Register(0x50200000, Pio0);
ahb.Register(0x50300000, Pio1);
+ // PIO blocks power up held in reset (RESETS bits 10/11). Start them gated and release on the
+ // RESETS transition, so firmware that drives PIO registers without first clearing the reset bit
+ // gets silent no-ops exactly like silicon (this is what masked the real Pico bug).
+ Pio0.InReset = Pio1.InReset = true;
+ Resets.OnUnreset += released =>
+ {
+ if ((released & (1u << 10)) != 0) Pio0.InReset = false;
+ if ((released & (1u << 11)) != 0) Pio1.InReset = false;
+ };
+ Resets.OnReset += asserted =>
+ {
+ if ((asserted & (1u << 10)) != 0) Pio0.InReset = true;
+ if ((asserted & (1u << 11)) != 0) Pio1.InReset = true;
+ };
+
// ── GPIO pins ─────────────────────────────────────────────────────
var pins = new GpioPin[30];
for (var i = 0; i < 30; i++)
@@ -284,6 +311,15 @@ public RP2040Machine(uint flashSize = 2 * 1024 * 1024)
Dma.RegisterDreq( 8 + sm, () => Pio1.TxFifoNotFull(sm));
Dma.RegisterDreq(12 + sm, () => !Pio1.RxFifoEmpty(sm));
}
+ // Re-arm a DREQ-paced DMA the instant the SM frees a TX slot or produces an RX word — otherwise
+ // a paced transfer (e.g. a CYW43 firmware download over the PIO gSPI) stalls BUSY forever.
+ Pio0.OnTxConsumed += sm => Dma.ResumeDreq(0 + sm);
+ Pio0.OnRxPush += (sm, _) => Dma.ResumeDreq(4 + sm);
+ Pio1.OnTxConsumed += sm => Dma.ResumeDreq(8 + sm);
+ Pio1.OnRxPush += (sm, _) => Dma.ResumeDreq(12 + sm);
+ // Gate the PIO's per-tick NVIC recompute to the window where core 0 sleeps (a busy core
+ // re-checks interrupts itself), removing the churn that dominated fine-grained stepping.
+ Pio0.CoreWaiting = Pio1.CoreWaiting = () => Core0Waiting;
// SPI0 TX(16), RX(17), SPI1 TX(18), RX(19)
Dma.RegisterDreq(16, () => true); // SPI0 TX always ready
Dma.RegisterDreq(17, () => Spi0.RxDataAvailable);
@@ -301,10 +337,13 @@ public RP2040Machine(uint flashSize = 2 * 1024 * 1024)
// Shared helpers: read physical GPIO levels; update SIO output and notify IoBank0
uint ReadGpio() => Sio.GpioIn | Sio.GpioOut;
- void ApplyPins(uint value, uint mask)
+ void ApplyPins(int block, uint value, uint mask)
{
// PIO output: update SIO GpioIn so physical level is visible to CPU reads
Sio.GpioIn = (Sio.GpioIn & ~mask) | (value & mask);
+ // Route to the GPIO function mux so a pad muxed to PIO reflects the SM output
+ // (the level a circuit host reads via IoBank0.GetPadOutputLevel).
+ IoBank0.SetPioOut(block, value, mask);
// Notify IoBank0 for edge/level interrupt detection on each changed pin
for (var pin = 0; pin < 30; pin++)
if ((mask & (1u << pin)) != 0)
@@ -312,12 +351,25 @@ void ApplyPins(uint value, uint mask)
}
Pio0.ReadGpioIn = ReadGpio;
- Pio0.WriteGpioPins = ApplyPins;
- Pio0.WriteGpioDirs = (value, mask) => { /* dir changes tracked in SM only */ };
+ Pio0.WriteGpioPins = (value, mask) => ApplyPins(0, value, mask);
+ Pio0.WriteGpioDirs = (value, mask) => IoBank0.SetPioDirs(0, value, mask);
Pio1.ReadGpioIn = ReadGpio;
- Pio1.WriteGpioPins = ApplyPins;
- Pio1.WriteGpioDirs = (value, mask) => { };
+ Pio1.WriteGpioPins = (value, mask) => ApplyPins(1, value, mask);
+ Pio1.WriteGpioDirs = (value, mask) => IoBank0.SetPioDirs(1, value, mask);
+ }
+
+ ///
+ /// Writes bytes directly into the existing Flash backing store at
+ /// (relative to 0x10000000), without re-loading the whole image. Lets a host edit a filesystem
+ /// region in place — e.g. stage a new code.py — then call to re-run, instead
+ /// of allocating and re-loading a fresh multi-MB flash image on every change.
+ ///
+ public unsafe void WriteFlash(int offset, ReadOnlySpan data)
+ {
+ if (offset < 0 || offset + data.Length > Bus.FlashSize)
+ throw new ArgumentOutOfRangeException(nameof(offset), "Write would fall outside the flash region");
+ data.CopyTo(new Span(Bus.PtrFlash + offset, data.Length));
}
/// Load a binary image into Flash starting at 0x10000000.
@@ -337,7 +389,7 @@ public unsafe void LoadFlash(ReadOnlySpan image)
// flash_range_erase and flash_range_program are intercepted by C# native hooks.
if (*(uint*)Bus.PtrBootRom == 0 && *(uint*)(Bus.PtrBootRom + 4) == 0)
{
- LoadRealBootRom(Bus.PtrBootRom);
+ LoadRealBootRom(Bus.PtrBootRom, _bootromRevision);
if (TryFindVectorTable(Bus.PtrFlash, (int)image.Length, out var sp, out var resetPc,
out var vectorTableOffset))
@@ -613,30 +665,42 @@ private static void Ctz32Hook(Core.Cpu.CortexM0Plus cpu)
/// memory, then patches flash hardware-accessing functions to BX LR so they return
/// without touching SSI/QSPI registers that are not fully emulated.
///
- private static unsafe void LoadRealBootRom(byte* rom)
+ private static unsafe void LoadRealBootRom(byte* rom, RP2040BootromRevision revision)
{
- // Load binary from embedded resource
+ // Load the BootROM for the requested silicon revision. B1 (version 2) is the default; B2
+ // (version 3) is the bootrom on later RP2040 silicon (verified by HIL against a real B2 chip).
+ var resource = revision switch
+ {
+ RP2040BootromRevision.B2 => "RP2040Sharp.bootrom_b2.bin",
+ _ => "RP2040Sharp.bootrom_b1.bin",
+ };
var asm = System.Reflection.Assembly.GetExecutingAssembly();
- using var stream = asm.GetManifestResourceStream("RP2040Sharp.bootrom_b1.bin")
+ using var stream = asm.GetManifestResourceStream(resource)
?? throw new InvalidOperationException(
- "Embedded resource 'RP2040Sharp.bootrom_b1.bin' not found. " +
- "Ensure bootrom_b1.bin is included as an EmbeddedResource in the project.");
+ $"Embedded resource '{resource}' not found. Ensure the matching bootrom .bin is included " +
+ "as an EmbeddedResource in the project.");
stream.ReadExactly(new Span(rom, 16384));
- // Patch flash hardware-accessing bootrom functions to 'BX LR' (0x4770).
- // These functions talk directly to the SSI/QSPI peripheral, which is not
- // fully emulated. They are called by MicroPython's LittleFS flash trampoline
- // (which runs from SRAM) to set up/tear down XIP mode around erase/program ops.
- // Making them no-ops is safe: our C# hooks handle the actual flash data.
- // 0x24A0 = connect_internal_flash
- // 0x23F4 = flash_exit_xip
- // 0x2360 = flash_flush_cache
- // 0x2330 = flash_enter_cmd_xip
+ // Patch the flash hardware-accessing bootrom functions to 'BX LR' (0x4770): they talk directly to
+ // the SSI/QSPI peripheral (not fully emulated) and are called by MicroPython's LittleFS flash
+ // trampoline to set up/tear down XIP around erase/program. Making them no-ops is safe — our C#
+ // hooks handle the actual flash data. The function ADDRESSES differ between bootrom revisions, so
+ // resolve them from the ROM function table instead of hardcoding (revision-agnostic; reproduces
+ // the historical B1 addresses 0x24A0/0x23F4/0x2360/0x2330 exactly).
static void PatchBxLr(byte* p, int addr) { p[addr] = 0x70; p[addr + 1] = 0x47; }
- PatchBxLr(rom, 0x24A0);
- PatchBxLr(rom, 0x23F4);
- PatchBxLr(rom, 0x2360);
- PatchBxLr(rom, 0x2330);
+ static ushort U16(byte* p, int o) => (ushort)(p[o] | (p[o + 1] << 8));
+
+ // rom_func_table pointer lives at offset 0x14; the table is a list of (code:u16, addr:u16) pairs
+ // terminated by code 0. Codes are two ASCII chars: 'IF','EX','FC','CX' (see RP2040 datasheet §2.8.3).
+ foreach (var code in new ushort[] { 0x4649 /*IF*/, 0x5845 /*EX*/, 0x4346 /*FC*/, 0x5843 /*CX*/ })
+ {
+ for (int p = U16(rom, 0x14); p < 0x4000 && U16(rom, p) != 0; p += 4)
+ {
+ if (U16(rom, p) != code) continue;
+ PatchBxLr(rom, U16(rom, p + 2) & ~1); // table stores the Thumb (odd) address; patch the instruction
+ break;
+ }
+ }
}
///
@@ -792,6 +856,10 @@ public unsafe void LoadBootRom(ReadOnlySpan image)
/// Total instructions executed by Core 0 since reset.
public long InstructionCount => Cpu.Cycles;
+ /// True while core 0 is parked in WFI/WFE waiting for an event. Lets a test harness coarsen
+ /// the run quantum while idle, and gates the PIO's per-tick NVIC recompute to the sleeping window.
+ public bool Core0Waiting => Cpu.Registers.Waiting;
+
/// True once Core 1 has been launched via the SIO FIFO multicore handshake.
public bool Core1Launched => _core1Launched;
@@ -834,6 +902,28 @@ public void Run(int instructions)
t.Tick(delta);
}
+ /// Diagnostic sibling of that drives core 0 through the per-instruction
+ /// profiling path (), then ticks peripherals. Core 1, if
+ /// launched, runs unobserved. For boot/fault tracing only — not throughput-sensitive.
+ public void RunProfiled(int instructions, Core.Cpu.IProfilingObserver observer)
+ {
+ _activeCoreId = 0;
+ var before0 = Cpu.Cycles;
+ Cpu.RunProfiled(instructions, observer);
+ var delta = Cpu.Cycles - before0;
+ if (_core1Launched)
+ {
+ _activeCoreId = 1;
+ var before1 = Cpu1.Cycles;
+ Cpu1.Run(instructions);
+ _activeCoreId = 0;
+ delta = Math.Max(delta, (int)(Cpu1.Cycles - before1));
+ }
+ LastElapsedCycles = delta;
+ foreach (var t in _tickables)
+ t.Tick(delta);
+ }
+
/// Reset Core 0. Core 1 is also reset and its launched state is cleared.
public void Reset()
{
diff --git a/src/RP2040Sharp/Peripherals/Resets/ResetsPeripheral.cs b/src/RP2040Sharp/Peripherals/Resets/ResetsPeripheral.cs
index c28c3b9..783c993 100644
--- a/src/RP2040Sharp/Peripherals/Resets/ResetsPeripheral.cs
+++ b/src/RP2040Sharp/Peripherals/Resets/ResetsPeripheral.cs
@@ -18,15 +18,21 @@ public sealed class ResetsPeripheral : IMemoryMappedDevice
private const uint ALL_BITS = 0x01FFFFFF;
private const uint USBCTRL_BIT = 1u << 24;
- // Start with nothing in reset so RESET_DONE = ALL_BITS from power-on.
- // Firmware reset/unreset sequences will still work correctly because after
- // firmware writes RESET then clears it, RESET_DONE returns that bit set.
- private uint _reset = 0;
+ // PIO blocks (bits 10=PIO0, 11=PIO1) power up HELD IN RESET on real silicon — firmware must clear
+ // their bit (and poll RESET_DONE) before the block responds. Modelling this lets the emulator catch
+ // firmware that drives PIO registers without first un-resetting it (a silent no-op on hardware).
+ // Other subsystems stay out of reset from power-on so unrelated firmware isn't forced to un-reset
+ // them (lenient, matching prior behaviour).
+ private const uint PIO_RESET_BITS = (1u << 10) | (1u << 11);
+ private uint _reset = PIO_RESET_BITS;
private uint _wdsel;
- /// Fired when a peripheral is released from reset (bit was set, now cleared).
+ /// Fired when subsystems are released from reset (their bit went set → clear).
public Action? OnUnreset;
+ /// Fired when subsystems are put into reset (their bit went clear → set).
+ public Action? OnReset;
+
public uint Size => 0x1000;
public uint ReadWord(uint address) => address switch
@@ -50,9 +56,11 @@ public void WriteWord(uint address, uint value)
case RESET:
var prev = _reset;
_reset = value & ALL_BITS;
- // Fire OnUnreset for any bits that transitioned from set to clear
+ // Fire OnUnreset for any bits that transitioned from set to clear (and OnReset for the reverse).
var released = prev & ~_reset;
if (released != 0) OnUnreset?.Invoke(released);
+ var asserted = ~prev & _reset;
+ if (asserted != 0) OnReset?.Invoke(asserted);
break;
case WDSEL: _wdsel = value & ALL_BITS; break;
}
diff --git a/src/RP2040Sharp/Peripherals/Sio/SioPeripheral.cs b/src/RP2040Sharp/Peripherals/Sio/SioPeripheral.cs
index 4e6bc26..8c8012b 100644
--- a/src/RP2040Sharp/Peripherals/Sio/SioPeripheral.cs
+++ b/src/RP2040Sharp/Peripherals/Sio/SioPeripheral.cs
@@ -136,6 +136,10 @@ public sealed class SioPeripheral : IMemoryMappedDevice
///
public Action? OnLaunchCore1;
+ /// Raised whenever a SIO GPIO output level or output-enable register changes, so a board can
+ /// forward SIO-driven pad changes to an attached external device (the CYW43439 wires REG_ON/CS to SIO).
+ public Action? OnGpioChanged { get; set; }
+
// Interpolators
private InterpState _interp0;
private InterpState _interp1;
@@ -245,14 +249,14 @@ public void WriteWord(uint address, uint value)
switch (address)
{
- case GPIO_OUT: _gpioOut = value; break;
- case GPIO_OUT_SET: _gpioOut |= value; break;
- case GPIO_OUT_CLR: _gpioOut &= ~value; break;
- case GPIO_OUT_XOR: _gpioOut ^= value; break;
- case GPIO_OE: _gpioOe = value; break;
- case GPIO_OE_SET: _gpioOe |= value; break;
- case GPIO_OE_CLR: _gpioOe &= ~value; break;
- case GPIO_OE_XOR: _gpioOe ^= value; break;
+ case GPIO_OUT: _gpioOut = value; OnGpioChanged?.Invoke(); break;
+ case GPIO_OUT_SET: _gpioOut |= value; OnGpioChanged?.Invoke(); break;
+ case GPIO_OUT_CLR: _gpioOut &= ~value; OnGpioChanged?.Invoke(); break;
+ case GPIO_OUT_XOR: _gpioOut ^= value; OnGpioChanged?.Invoke(); break;
+ case GPIO_OE: _gpioOe = value; OnGpioChanged?.Invoke(); break;
+ case GPIO_OE_SET: _gpioOe |= value; OnGpioChanged?.Invoke(); break;
+ case GPIO_OE_CLR: _gpioOe &= ~value; OnGpioChanged?.Invoke(); break;
+ case GPIO_OE_XOR: _gpioOe ^= value; OnGpioChanged?.Invoke(); break;
case GPIO_HI_OUT: _gpioHiOut = value; break;
case GPIO_HI_OUT_SET: _gpioHiOut |= value; break;
case GPIO_HI_OUT_CLR: _gpioHiOut &= ~value; break;
diff --git a/src/RP2040Sharp/Peripherals/Ssi/SsiPeripheral.cs b/src/RP2040Sharp/Peripherals/Ssi/SsiPeripheral.cs
index 046bef1..0a1f643 100644
--- a/src/RP2040Sharp/Peripherals/Ssi/SsiPeripheral.cs
+++ b/src/RP2040Sharp/Peripherals/Ssi/SsiPeripheral.cs
@@ -74,6 +74,7 @@ public sealed unsafe class SsiPeripheral : IMemoryMappedDevice
private const byte CMD_PAGE_PROGRAM = 0x02;
private const byte CMD_READ_DATA = 0x03;
private const byte CMD_FAST_READ = 0x0B;
+ private const byte CMD_QUAD_IO_READ = 0xEB;
// ── Registers ─────────────────────────────────────────────────────────────
private uint _ctrlr0;
@@ -93,9 +94,17 @@ public sealed unsafe class SsiPeripheral : IMemoryMappedDevice
// ── Transaction state ─────────────────────────────────────────────────────
private bool _csAsserted;
private bool _writeEnabled;
- private readonly List _txBuf = new(260);
+ private readonly List _txBuf = new(260); // CS-delimited; drives write/erase (ProcessTransaction)
private readonly Queue _rxQueue = new(260);
+ // RX framing, independent of the CS model: every byte the firmware clocks out while the SSI is
+ // enabled produces an RX byte. A frame (for choosing the RX value) begins after a CS-deassert or
+ // when a write follows a read (the command/response boundary of bootrom-style auto-CS flash access),
+ // so the command byte that selects the RX response is tracked even when it is clocked before the
+ // IO_QSPI/SER chip-select edge that RP2040Sharp observes.
+ private readonly List _rxFrame = new(260);
+ private bool _lastOpWasRead;
+
public uint Size => 0x1000;
// ── Wiring API ────────────────────────────────────────────────────────────
@@ -131,27 +140,37 @@ public void OnCsDeassert()
_csAsserted = false;
ProcessTransaction();
_txBuf.Clear();
+ // End of transaction: flush the RX FIFO and start a fresh RX frame (the SSI's RX FIFO is
+ // drained/reset at CS deassert; leaving stale bytes desynchronises the next read).
+ _rxQueue.Clear();
+ _rxFrame.Clear();
+ _lastOpWasRead = false;
}
// ── IMemoryMappedDevice ───────────────────────────────────────────────────
- public uint ReadWord(uint address) => address switch
+ public uint ReadWord(uint address)
{
- SSI_CTRLR0 => _ctrlr0,
- SSI_CTRLR1 => _ctrlr1,
- SSI_SSIENR => _ssienr,
- SSI_SER => _ser,
- SSI_BAUDR => _baudr,
- SSI_SR => SR_TFE | SR_RFNE | SR_TFNF, // always ready
- SSI_RXFLR => (uint)_rxQueue.Count,
- SSI_IDR => 0x51535049u, // "QSPI" identifier
- SSI_VERSION_ID => 0x3430312Au,
- SSI_DR0 => _rxQueue.Count > 0 ? _rxQueue.Dequeue() : 0u,
- SSI_SPI_CTRL_R0 => _spiCtrlr0,
- SSI_TXD_DRIVE_EDGE => _txDriveEdge,
- SSI_RX_SAMPLE_DLY => _rxSampleDly,
- _ => 0u,
- };
+ switch (address)
+ {
+ case SSI_CTRLR0: return _ctrlr0;
+ case SSI_CTRLR1: return _ctrlr1;
+ case SSI_SSIENR: return _ssienr;
+ case SSI_SER: return _ser;
+ case SSI_BAUDR: return _baudr;
+ case SSI_SR: return SR_TFE | SR_RFNE | SR_TFNF; // always ready
+ case SSI_RXFLR: return (uint)_rxQueue.Count;
+ case SSI_IDR: return 0x51535049u; // "QSPI" identifier
+ case SSI_VERSION_ID: return 0x3430312Au;
+ case SSI_DR0:
+ _lastOpWasRead = true;
+ return _rxQueue.Count > 0 ? _rxQueue.Dequeue() : 0u;
+ case SSI_SPI_CTRL_R0: return _spiCtrlr0;
+ case SSI_TXD_DRIVE_EDGE: return _txDriveEdge;
+ case SSI_RX_SAMPLE_DLY: return _rxSampleDly;
+ default: return 0u;
+ }
+ }
public ushort ReadHalfWord(uint address) =>
(ushort)(ReadWord(address & ~3u) >> (int)((address & 2) << 3));
@@ -187,11 +206,26 @@ public void WriteWord(uint address, uint value)
}
case SSI_DR0:
- // Only accumulate bytes when a transaction is active (CS asserted).
- if (_csAsserted)
+ // The SSI clocks one byte per DR0 write while enabled, so the RX FIFO always fills —
+ // independently of the chip-select model. This lets bootrom flash routines that clock
+ // dummy bytes with the flash deselected (e.g. rp_flash_exit_xip) progress past their
+ // RXFLR-polling loops, instead of spinning forever on a FIFO that never advances.
+ if ((_ssienr & 1) != 0)
{
- _txBuf.Add((byte)value);
+ // A write following a read begins a new command/response frame.
+ if (_lastOpWasRead)
+ {
+ _rxFrame.Clear();
+ _lastOpWasRead = false;
+ }
+ _rxFrame.Add((byte)value);
_rxQueue.Enqueue(ComputeRxByte());
+
+ // Write/erase application still uses the CS-delimited buffer (unchanged path).
+ if (_csAsserted)
+ {
+ _txBuf.Add((byte)value);
+ }
}
break;
}
@@ -221,10 +255,10 @@ public void WriteByte(uint address, byte value)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private byte ComputeRxByte()
{
- if (_txBuf.Count == 0) return 0;
+ if (_rxFrame.Count == 0) return 0;
- var pos = _txBuf.Count - 1; // 0-based index of the byte just added
- var cmd = _txBuf[0];
+ var pos = _rxFrame.Count - 1; // 0-based index of the byte just clocked
+ var cmd = _rxFrame[0];
switch (cmd)
{
@@ -234,24 +268,28 @@ private byte ComputeRxByte()
return 0x00;
case CMD_READ_DATA when pos >= 4 && _flashPtr != null:
- {
// Layout: [0x03][A2][A1][A0][D0][D1]…
- var flashAddr = GetAddress24() + (uint)(pos - 4);
- return flashAddr < _flashSize ? _flashPtr[flashAddr] : (byte)0xFF;
- }
+ return ReadFlashByte(RxAddress24() + (uint)(pos - 4));
case CMD_FAST_READ when pos >= 5 && _flashPtr != null:
- {
// Layout: [0x0B][A2][A1][A0][dummy][D0][D1]…
- var flashAddr = GetAddress24() + (uint)(pos - 5);
- return flashAddr < _flashSize ? _flashPtr[flashAddr] : (byte)0xFF;
- }
+ return ReadFlashByte(RxAddress24() + (uint)(pos - 5));
+
+ case CMD_QUAD_IO_READ when pos >= 6 && _flashPtr != null:
+ // Layout: [0xEB][A2][A1][A0][M][dummy][D0][D1]… (single-lane model of the quad read)
+ return ReadFlashByte(RxAddress24() + (uint)(pos - 6));
default:
return 0x00;
}
}
+ private uint RxAddress24() =>
+ ((uint)_rxFrame[1] << 16) | ((uint)_rxFrame[2] << 8) | _rxFrame[3];
+
+ private byte ReadFlashByte(uint addr) =>
+ addr < _flashSize ? _flashPtr[addr] : (byte)0xFF;
+
///
/// Apply write/erase operations accumulated in .
/// Called when CS is deasserted (end of transaction).
diff --git a/src/RP2040Sharp/Peripherals/Timer/TimerPeripheral.cs b/src/RP2040Sharp/Peripherals/Timer/TimerPeripheral.cs
index 93a1d89..e921b55 100644
--- a/src/RP2040Sharp/Peripherals/Timer/TimerPeripheral.cs
+++ b/src/RP2040Sharp/Peripherals/Timer/TimerPeripheral.cs
@@ -137,6 +137,13 @@ public void WriteWord(uint address, uint value)
break;
case INTR:
_intr &= ~value; // write 1 to clear raw IRQ
+ // De-assert any timer NVIC line whose effective interrupt (INTR|INTF)&INTE is now clear.
+ // Without this the level-high IRQ re-fires every instruction after the handler clears it,
+ // and a firmware that arms a periodic alarm (e.g. the Pico W cyw43/lwIP boot) spins forever
+ // in alarm_pool_irq_handler. Only touched on the clear path — no per-tick NVIC churn.
+ for (var i = 0; i < 4; i++)
+ if ((((_intr | _intf) & _inte) & (1u << i)) == 0)
+ _cpu.SetInterrupt(i, false);
break;
case INTE:
_inte = value & 0xF;
diff --git a/src/RP2040Sharp/Peripherals/Usb/UsbCdcHost.cs b/src/RP2040Sharp/Peripherals/Usb/UsbCdcHost.cs
index d937bb6..c3feeea 100644
--- a/src/RP2040Sharp/Peripherals/Usb/UsbCdcHost.cs
+++ b/src/RP2040Sharp/Peripherals/Usb/UsbCdcHost.cs
@@ -17,6 +17,7 @@ public sealed class UsbCdcHost
private const byte CDC_DTR = 1 << 0;
private const byte CDC_RTS = 1 << 1;
private const byte CDC_DATA_CLASS = 10;
+ private const byte CDC_COMM_CLASS = 2; // CDC communications (control) interface
private const byte ENDPOINT_BULK = 2;
private const int ENDPOINT_ZERO = 0;
@@ -75,12 +76,38 @@ public UsbCdcHost(UsbPeripheral usb)
_usb.OnSof += HandleSof;
}
+ // The device's bulk-OUT endpoint stays armed (active) while it waits for host data. We remember
+ // that pending arm here and fulfil it the instant bytes become available, mirroring real USB:
+ // a host with nothing to send simply doesn't complete the OUT transfer. The previous behaviour —
+ // fabricating a zero-length completion on every empty arm — created a BUFF_STATUS re-arm busy-loop
+ // that, when the device was busy (e.g. during Wi-Fi association), raced TinyUSB into a
+ // "Can't continue xfer on inactive ep" panic (hw_endpoint_xfer_continue with ep->active == 0).
+ private int _armedOutEp = -1;
+ private int _armedOutSize;
+
/// Queue a byte to be delivered to the device on the next bulk-OUT poll.
- public void SendSerialByte(byte data) => _txFifo.Enqueue(data);
+ public void SendSerialByte(byte data) { _txFifo.Enqueue(data); FlushPendingOut(); }
public void SendSerialBytes(ReadOnlySpan data)
{
foreach (var b in data) _txFifo.Enqueue(b);
+ FlushPendingOut();
+ }
+
+ private void FlushPendingOut()
+ {
+ if (_armedOutEp < 0 || _txFifo.Count == 0) return;
+ var ep = _armedOutEp; var size = _armedOutSize;
+ _armedOutEp = -1;
+ DeliverOut(ep, size);
+ }
+
+ private void DeliverOut(int endpoint, int size)
+ {
+ var n = Math.Min(size, _txFifo.Count);
+ var buffer = new byte[n];
+ for (var i = 0; i < n; i++) buffer[i] = _txFifo.Dequeue();
+ _usb.EndpointReadDone(endpoint, buffer);
}
private void HandleUsbEnabled() => _usb.SignalBusReset();
@@ -101,7 +128,16 @@ private void HandleEndpointWrite(int endpoint, byte[] buffer)
}
else if (!_initialized)
{
- CdcSetControlLineState();
+ // Assert DTR/RTS on every CDC control interface so the firmware sees a terminal as
+ // "connected". MicroPython has one CDC (interface 0); CircuitPython is a composite with
+ // its console CDC potentially at a different interface — without DTR there, CircuitPython
+ // gates code.py's stdout and nothing reaches the Serial Monitor.
+ var controlIfaces = ExtractCdcControlInterfaces(_descriptors);
+ if (controlIfaces.Count == 0)
+ CdcSetControlLineState();
+ else
+ foreach (var ifn in controlIfaces)
+ CdcSetControlLineState(interfaceNumber: (ushort)ifn);
OnDeviceConnected?.Invoke();
OnConfigurationComplete?.Invoke();
// Trigger MicroPython REPL prompt (mirrors rp2040js micropython-run.ts onDeviceConnected)
@@ -154,18 +190,16 @@ private void HandleSof(uint frameNumber)
private void HandleEndpointRead(int endpoint, int size)
{
if (endpoint != _outEndpoint) return;
- var n = Math.Min(size, _txFifo.Count);
- if (n == 0)
+ if (_txFifo.Count == 0)
{
- // No data ready — leave the buffer armed; we'll fulfil it next time the
- // firmware re-arms or whenever bytes become available via SendSerialByte.
- // Submit a zero-length completion so TinyUSB doesn't stall.
- _usb.EndpointReadDone(endpoint, ReadOnlySpan.Empty);
+ // No data ready — leave the OUT endpoint armed (the device keeps ep->active = 1 and waits,
+ // exactly as hardware waits for the host to send an OUT token). Deliver the moment bytes
+ // arrive (FlushPendingOut). Do NOT fabricate a zero-length completion: that re-arm loop
+ // panics TinyUSB on a busy device (see _armedOutEp note above).
+ _armedOutEp = endpoint; _armedOutSize = size;
return;
}
- var buffer = new byte[n];
- for (var i = 0; i < n; i++) buffer[i] = _txFifo.Dequeue();
- _usb.EndpointReadDone(endpoint, buffer);
+ DeliverOut(endpoint, size);
}
private void CdcSetControlLineState(ushort value = CDC_DTR | CDC_RTS, ushort interfaceNumber = 0)
@@ -183,6 +217,28 @@ private void CdcSetControlLineState(ushort value = CDC_DTR | CDC_RTS, ushort int
/// Scans the configuration descriptor blob for the CDC data interface and returns its
/// bulk IN/OUT endpoint numbers. Each output is -1 when no CDC data endpoint is found.
///
+ /// Returns the bInterfaceNumber of every CDC communications (control) interface in the
+ /// configuration descriptor, so DTR/RTS can be asserted on each — needed for composite devices
+ /// (CircuitPython) whose console CDC is not interface 0.
+ public static List ExtractCdcControlInterfaces(IReadOnlyList descriptors)
+ {
+ var result = new List();
+ var index = 0;
+ while (index < descriptors.Count)
+ {
+ var len = descriptors[index];
+ if (len < 2 || index + len > descriptors.Count) break;
+ if (descriptors[index + 1] == (byte)DescriptorType.Interface && len >= 9
+ && descriptors[index + 5] == CDC_COMM_CLASS)
+ {
+ var ifn = descriptors[index + 2]; // bInterfaceNumber
+ if (!result.Contains (ifn)) result.Add (ifn);
+ }
+ index += len;
+ }
+ return result;
+ }
+
public static void ExtractEndpointNumbers(IReadOnlyList descriptors, out int inEp, out int outEp)
{
inEp = outEp = -1;
diff --git a/src/RP2040Sharp/Peripherals/Usb/UsbPeripheral.cs b/src/RP2040Sharp/Peripherals/Usb/UsbPeripheral.cs
index 46c07c3..7bd4ecc 100644
--- a/src/RP2040Sharp/Peripherals/Usb/UsbPeripheral.cs
+++ b/src/RP2040Sharp/Peripherals/Usb/UsbPeripheral.cs
@@ -166,6 +166,7 @@ public void Reset()
_prevSieConnected = false;
_pendingWrites.Clear();
_pendingReads.Clear();
+ _setupPending = false;
}
// ── IMemoryMappedDevice ───────────────────────────────────────────────
@@ -286,12 +287,26 @@ public void WriteWord(uint address, uint value)
case R_INT_EP_CTRL: _intEpCtrl = ApplyAtomic(_intEpCtrl, value, atomicType); break;
// W1C registers — value is always the bitmask of bits to clear.
- case R_BUFF_STATUS: _buffStatus &= ~value; BuffStatusUpdated(); break;
+ case R_BUFF_STATUS: OnUsbEvent?.Invoke($"BS-CLR clr=0x{value:X} bs=0x{_buffStatus:X}->0x{_buffStatus & ~value:X}"); _buffStatus &= ~value; BuffStatusUpdated(); break;
case R_BUFF_CPU_SHOULD_HANDLE: _buffCpuShouldHandle &= ~value; break;
case R_EP_ABORT:
{
var v = ApplyAtomic(_epAbort, value, atomicType);
_epAbort = v; _epAbortDone |= v;
+ OnUsbEvent?.Invoke($"EP_ABORT v=0x{v:X}");
+ // Aborting an endpoint cancels its in-flight transfer AND any pending buffer-status
+ // completion (EP_ABORT and BUFF_STATUS share the same per-buffer bit layout). Without
+ // dropping the BUFF_STATUS bit here, a completion the firmware already aborted (e.g.
+ // EP0-IN aborted by reset_ep0 when a new SETUP arrives) is still delivered to TinyUSB,
+ // which then panics "Can't continue xfer on inactive ep" — the Pico W boot blocker.
+ _buffStatus &= ~v;
+ // Also drop any deferred completion still queued for the aborted buffer(s), so a
+ // completion the firmware already cancelled can't fire later and re-raise
+ // BUFF_STATUS on an inactive endpoint. _pendingReads/_pendingWrites are keyed by the
+ // same per-buffer bit (OUT = endpoint*2+1, IN = endpoint*2).
+ for (var b = 0; b < 32; b++)
+ if ((v & (1u << b)) != 0) { _pendingReads.Remove(b); _pendingWrites.Remove(b); }
+ BuffStatusUpdated();
}
break;
case R_EP_ABORT_DONE: _epAbortDone &= ~value; break; // W1C
@@ -413,11 +428,19 @@ public void SignalSetupPacket()
public void SendSetupPacket(ReadOnlySpan setup)
{
if (setup.Length != 8) throw new ArgumentException("SETUP packet must be 8 bytes", nameof(setup));
+ OnUsbEvent?.Invoke($"SETUP [{Convert.ToHexString(setup)}]");
setup.CopyTo(_dpram.AsSpan(0, 8));
- _sieStatus |= SIE_SETUP_REC;
- SieStatusUpdated();
+ // Raise SETUP_REC on the NEXT Tick, not now. A new SETUP triggers reset_ep0 in the device, which
+ // aborts EP0 transfers (ep->active=0). If it lands while the device is still completing the prior
+ // control transfer's status stage (mid hw_handle_buff_status, between the BUFF_STATUS clear and
+ // hw_endpoint_xfer_continue), the in-flight EP0-IN completion then runs with active=0 and panics
+ // "Can't continue xfer on inactive ep". On real USB the host wouldn't start a new control transfer
+ // until the device ACKs the status; deferring one quantum gives the device that window.
+ _setupPending = true;
}
+ private bool _setupPending;
+
/// Provide data for an OUT endpoint that the firmware previously armed.
public void EndpointReadDone(int endpoint, ReadOnlySpan data)
{
@@ -474,6 +497,7 @@ private void DpramUpdated(uint offset, uint value)
var isOut = (offset & 4) != 0;
var bufLen = (int)(value & USB_BUF_CTRL_LEN_MASK);
var bufferOffset = GetEndpointBufferOffset(endpoint, isOut);
+ OnUsbEvent?.Invoke($"ARM ep{endpoint}{(isOut ? "O" : "I")} len{bufLen} full{(value & USB_BUF_CTRL_FULL) != 0}");
// Consume AVAILABLE flag
value &= ~USB_BUF_CTRL_AVAILABLE;
@@ -507,8 +531,15 @@ private uint GetEndpointBufferOffset(int endpoint, bool out_)
return ReadDpramWord(ctrlOffset) & 0xFFC0u;
}
+ /// Diagnostic: a BUFF_STATUS bit was set for (endpoint, isOut).
+ public Action? OnBuffStatusDiag;
+ /// Diagnostic: a textual USB event (SETUP/ARM/BS) for boot tracing.
+ public Action? OnUsbEvent;
+
private void IndicateBufferReady(int endpoint, bool isOut)
{
+ OnBuffStatusDiag?.Invoke(endpoint, isOut);
+ OnUsbEvent?.Invoke($"BS ep{endpoint}{(isOut ? "O" : "I")}");
_buffStatus |= 1u << (endpoint * 2 + (isOut ? 1 : 0));
BuffStatusUpdated();
}
@@ -578,6 +609,13 @@ public void Tick(long deltaCycles)
{
_totalCycles += deltaCycles;
+ if (_setupPending) // deferred SETUP_REC: see SendSetupPacket
+ {
+ _setupPending = false;
+ _sieStatus |= SIE_SETUP_REC;
+ SieStatusUpdated();
+ }
+
// Auto-clear SOF bit after one tick so it doesn't re-trigger indefinitely.
_intr &= ~INTR_DEV_SOF;
diff --git a/src/RP2040Sharp/RP2040Sharp.csproj b/src/RP2040Sharp/RP2040Sharp.csproj
index 59fee5a..a9453cf 100644
--- a/src/RP2040Sharp/RP2040Sharp.csproj
+++ b/src/RP2040Sharp/RP2040Sharp.csproj
@@ -13,5 +13,6 @@
+
diff --git a/src/RP2040Sharp/bootrom_b2.bin b/src/RP2040Sharp/bootrom_b2.bin
new file mode 100644
index 0000000..cdff0e0
Binary files /dev/null and b/src/RP2040Sharp/bootrom_b2.bin differ
diff --git a/tests/RP2040Sharp.IntegrationTests/RP2040Sharp.IntegrationTests.csproj b/tests/RP2040Sharp.IntegrationTests/RP2040Sharp.IntegrationTests.csproj
index 2ca91d4..0698c1f 100644
--- a/tests/RP2040Sharp.IntegrationTests/RP2040Sharp.IntegrationTests.csproj
+++ b/tests/RP2040Sharp.IntegrationTests/RP2040Sharp.IntegrationTests.csproj
@@ -24,6 +24,7 @@
+
diff --git a/tests/RP2040Sharp.IntegrationTests/Tests/Cyw43BleTests.cs b/tests/RP2040Sharp.IntegrationTests/Tests/Cyw43BleTests.cs
new file mode 100644
index 0000000..8d9a5f4
--- /dev/null
+++ b/tests/RP2040Sharp.IntegrationTests/Tests/Cyw43BleTests.cs
@@ -0,0 +1,126 @@
+using System.Text;
+using FluentAssertions;
+using RP2040.Core.Cpu;
+using RP2040.Peripherals.Usb;
+using RP2040.TestKit;
+using RP2040.Wireless.Cyw43;
+using RP2040Sharp.IntegrationTests.Infrastructure;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace RP2040Sharp.IntegrationTests.Tests;
+
+file sealed class PanicObserver(RP2040TestSimulation sim, System.Collections.Generic.Queue tl) : IProfilingObserver
+{
+ public bool Hit;
+ public uint MsgPtr, Arg1;
+ private uint _epIn;
+ private int _lastActive = -1;
+ private uint _lastPc;
+ public void OnInstruction(uint pc, ushort opcode, long cycles)
+ {
+ if (Hit) return;
+ if (pc == 0x20000C30) // hw_endpoint_xfer_continue(ep): R0 = ep*
+ {
+ var ep = sim.Cpu.Registers.R0;
+ byte B(uint o) => sim.Rp2040.Bus.ReadByte(ep + o);
+ ushort H(uint o) => (ushort)(B(o) | B(o + 1) << 8);
+ if (B(2) == 0x80) _epIn = ep; // latch EP0-IN struct address
+ tl.Enqueue($" xfer_continue ep=0x{B(2):X2} active={B(26)} rem={H(20)} xfd={H(22)}");
+ if (tl.Count > 80) tl.Dequeue();
+ }
+ if (_epIn != 0) // watch EP0-IN ep->active (offset 26): log the PC that drives it 1 -> 0
+ {
+ int a = sim.Rp2040.Bus.ReadByte(_epIn + 26);
+ if (_lastActive == 1 && a == 0)
+ {
+ tl.Enqueue($" >>> active 1->0 by PC=0x{_lastPc:X8}");
+ if (tl.Count > 80) tl.Dequeue();
+ }
+ _lastActive = a;
+ }
+ _lastPc = pc;
+ if (pc == 0x10055650) // pico-sdk panic() entry
+ {
+ MsgPtr = sim.Cpu.Registers.R0; Arg1 = sim.Cpu.Registers.R1; Hit = true;
+ }
+ }
+}
+
+///
+/// BLE bring-up on the Pico W: real MicroPython (btstack) on the emulated CYW43439 brings up the
+/// Bluetooth LE controller. The BT firmware downloads over the shared gSPI backplane and the HCI bring-up
+/// sequence (Reset, Read BD_ADDR, buffer sizes, …) completes against the emulated HciController, so
+/// bluetooth.BLE().active(True) returns True and real HCI flows. Mirrors the validated RP2350.Wireless.
+///
+public class Cyw43BleTests(ITestOutputHelper output)
+{
+ private const string PicoW = "/Users/begeistert/Repos/micropython/ports/rp2/build-RPI_PICO_W/firmware.uf2";
+
+ [Fact(Skip = "Diagnostic-only USB-DCD trace.")]
+ public void Diag_usb_timeline()
+ {
+ if (!File.Exists(PicoW)) { output.WriteLine("skip"); return; }
+ using var sim = RP2040TestSimulation.Create().WithBinary(Uf2Reader.ToFlashImage(File.ReadAllBytes(PicoW)));
+ sim.Rp2040.Pio0.ReadGpioIn = () => sim.Rp2040.IoBank0.GetInputWord();
+ sim.Rp2040.Pio1.ReadGpioIn = () => sim.Rp2040.IoBank0.GetInputWord();
+ sim.Rp2040.Sio.OnGpioChanged += () => sim.Rp2040.IoBank0.NotifyPads(0xFFFFFFFFu);
+ _ = new Cyw43439Device(sim.Rp2040.IoBank0);
+ var cdc = new UsbCdcHost(sim.Rp2040.Usb);
+ var rx = new StringBuilder();
+ cdc.OnSerialData += d => rx.Append(Encoding.Latin1.GetString(d));
+ var tl = new System.Collections.Generic.Queue();
+ sim.Rp2040.Usb.OnUsbEvent += s => { tl.Enqueue(s); if (tl.Count > 80) tl.Dequeue(); };
+ var obs = new PanicObserver(sim, tl);
+ for (long i = 0; i < 600_000 && !rx.ToString().Contains(">>>") && !obs.Hit; i++)
+ sim.Rp2040.RunProfiled(sim.Rp2040.Core0Waiting ? 1600 : 512, obs);
+ var msg = "";
+ if (obs.MsgPtr is >= 0x10000000 and < 0x20000000)
+ for (uint k = 0; k < 80; k++) { var b = sim.Rp2040.Bus.ReadByte(obs.MsgPtr + k); if (b == 0) break; msg += (char)b; }
+ File.WriteAllText("/tmp/rp2040_usbtl.txt",
+ $"hitPanic={obs.Hit} msg='{msg}' ep=0x{obs.Arg1:X2} reached>>>={rx.ToString().Contains(">>>")}\n" +
+ "--- unified timeline (last 80: SETUP/ARM/BS + xfer_continue) ---\n" + string.Join("\n", tl));
+ output.WriteLine(File.ReadAllText("/tmp/rp2040_usbtl.txt"));
+ }
+
+ [Fact]
+ public void Ble_controller_brings_up()
+ {
+ if (!File.Exists(PicoW)) { output.WriteLine("skip"); return; }
+ using var sim = RP2040TestSimulation.Create().WithBinary(Uf2Reader.ToFlashImage(File.ReadAllBytes(PicoW)));
+ sim.Rp2040.Pio0.ReadGpioIn = () => sim.Rp2040.IoBank0.GetInputWord();
+ sim.Rp2040.Pio1.ReadGpioIn = () => sim.Rp2040.IoBank0.GetInputWord();
+ sim.Rp2040.Sio.OnGpioChanged += () => sim.Rp2040.IoBank0.NotifyPads(0xFFFFFFFFu);
+
+ var dev = new Cyw43439Device(sim.Rp2040.IoBank0);
+ var hci = new List();
+ dev.BtBus.OnHostPacket += (t, p) => { if (hci.Count < 200) hci.Add($"H2B t{t} [{Convert.ToHexString(p)}]"); };
+ dev.BtBus.OnChipPacket += (t, p) => { if (hci.Count < 200) hci.Add($"B2H t{t} [{Convert.ToHexString(p)}]"); };
+ var cdc = new UsbCdcHost(sim.Rp2040.Usb);
+ var rx = new StringBuilder();
+ cdc.OnSerialData += d => rx.Append(Encoding.Latin1.GetString(d));
+
+ void Step(long max, Func done)
+ { for (long i = 0; i < max && !done(); i++) sim.Rp2040.Run(sim.Rp2040.Core0Waiting ? 1600 : 512); }
+
+ Step(120_000_000, () => rx.ToString().Contains(">>>"));
+
+ cdc.SendSerialBytes("\x01"u8);
+ cdc.SendSerialBytes(Encoding.ASCII.GetBytes(
+ "import bluetooth\nble=bluetooth.BLE()\nble.active(True)\nprint('BLE', ble.active())\n"));
+ cdc.SendSerialBytes("\x04"u8);
+
+ int at = rx.Length;
+ Step(2_000_000_000, () => rx.ToString(at, rx.Length - at).Contains("BLE ") || rx.ToString(at, rx.Length - at).Contains("Error"));
+
+ output.WriteLine(rx.ToString()[at..]);
+ output.WriteLine("HCI:\n " + string.Join("\n ", hci));
+ rx.ToString().Should().Contain("BLE True", "bluetooth.BLE().active(True) must bring the controller up");
+ // The controller really came up — the HCI Reset was answered and the bring-up sequence flowed
+ // over the emulated BT shared bus (not just an optimistic active() return).
+ hci.Should().Contain(h => h.StartsWith("H2B t1 [030C00]"), "the host must issue HCI Reset");
+ hci.Should().Contain(h => h.StartsWith("B2H t4 [0E0401030C00]"), "the controller must answer HCI Reset Command Complete");
+ hci.Should().Contain(h => h.Contains("0E0A01091000"), "Read BD_ADDR must return the controller address");
+ sim.Cpu.IsLockedUp.Should().BeFalse();
+ }
+}
diff --git a/tests/RP2040Sharp.IntegrationTests/Tests/Cyw43WifiTests.cs b/tests/RP2040Sharp.IntegrationTests/Tests/Cyw43WifiTests.cs
new file mode 100644
index 0000000..b653a57
--- /dev/null
+++ b/tests/RP2040Sharp.IntegrationTests/Tests/Cyw43WifiTests.cs
@@ -0,0 +1,70 @@
+using System.Text;
+using FluentAssertions;
+using RP2040.Peripherals.Usb;
+using RP2040.TestKit;
+using RP2040.Wireless.Cyw43;
+using RP2040Sharp.IntegrationTests.Infrastructure;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace RP2040Sharp.IntegrationTests.Tests;
+
+///
+/// WiFi bring-up on the Pico W: real MicroPython on the emulated CYW43439 brings up the WLAN interface
+/// (firmware download over the gSPI backplane, then SDPCM/WLC ioctls) and scans, seeing a virtual AP.
+///
+/// SKIPPED (in-progress WiFi gap; BLE on the same chip already works — see Cyw43BleTests): the first WLAN
+/// SDPCM ioctl ('clmload') is received by the emulated chip and a 28-byte response is queued
+/// (OnPacketReadyChanged), but the firmware's cyw43_ll do_ioctl poll never issues the F2 read — it gates
+/// on the WL_HOST_WAKE pin (GPIO24) reading high at the moment it polls, and the gSPI F2 host-wake/poll
+/// timing isn't satisfied (the BT shared bus works because it reads SDIO_INT_STATUS over F1, never GPIO24).
+/// Result: "[CYW43] do_ioctl/STALL timeout, CLM load failed". Next: fix the GPIO24 host-wake level held
+/// between gSPI transactions for the F2 poll path (GSpiSlave). Unskip once WLAN.active(True) + scan work.
+///
+public class Cyw43WifiTests(ITestOutputHelper output)
+{
+ private const string PicoW = "/Users/begeistert/Repos/micropython/ports/rp2/build-RPI_PICO_W/firmware.uf2";
+
+ [Fact]
+ public void Wlan_brings_up_and_scans()
+ {
+ if (!File.Exists(PicoW)) { output.WriteLine("skip"); return; }
+ using var sim = RP2040TestSimulation.Create().WithBinary(Uf2Reader.ToFlashImage(File.ReadAllBytes(PicoW)));
+ sim.Rp2040.Pio0.ReadGpioIn = () => sim.Rp2040.IoBank0.GetInputWord();
+ sim.Rp2040.Pio1.ReadGpioIn = () => sim.Rp2040.IoBank0.GetInputWord();
+ sim.Rp2040.Sio.OnGpioChanged += () => sim.Rp2040.IoBank0.NotifyPads(0xFFFFFFFFu);
+
+ var dev = new Cyw43439Device(sim.Rp2040.IoBank0);
+ dev.Sdpcm.VisibleAps.Add(new Sdpcm.VirtualAp("RP2040Sharp-AP", [0x02, 0, 0x5E, 0, 4, 1], 6, -50, false));
+ var ioctls = new List();
+ dev.Sdpcm.OnIoctl += (cmd, kind, name, len) => { if (ioctls.Count < 200) ioctls.Add($"ioctl cmd={cmd} kind={kind} '{name}' in={len}"); };
+ dev.Sdpcm.OnPacketReadyChanged += (ready, len) => { if (ioctls.Count < 200) ioctls.Add($" pktReady={ready} len={len}"); };
+ dev.OnCommand += (w, fn, addr, sz) => { if (fn == 2 && ioctls.Count < 200) ioctls.Add($" F2 {(w ? "WR" : "RD")} sz{sz}"); };
+
+ var cdc = new UsbCdcHost(sim.Rp2040.Usb);
+ var rx = new StringBuilder();
+ cdc.OnSerialData += d => rx.Append(Encoding.Latin1.GetString(d));
+
+ void Step(long max, Func done)
+ { for (long i = 0; i < max && !done(); i++) sim.Rp2040.Run(sim.Rp2040.Core0Waiting ? 1600 : 512); }
+
+ Step(120_000_000, () => rx.ToString().Contains(">>>"));
+
+ cdc.SendSerialBytes("\x01"u8);
+ cdc.SendSerialBytes(Encoding.ASCII.GetBytes(
+ "import network\nw=network.WLAN(network.STA_IF)\nw.active(True)\nprint('WLAN', w.active())\n" +
+ "print('SCAN', [s[0] for s in w.scan()])\n"));
+ cdc.SendSerialBytes("\x04"u8);
+
+ int at = rx.Length;
+ Step(3_000_000_000, () => rx.ToString(at, rx.Length - at).Contains("SCAN ") || rx.ToString(at, rx.Length - at).Contains("Error"));
+
+ File.WriteAllText("/tmp/rp2040_wifi.txt", "REPL:\n" + rx + "\n\nFirmwareBytes=" + dev.FirmwareBytes +
+ " WlanCoreUp=" + dev.DebugWlanCoreUp + "\nIOCTLS:\n " + string.Join("\n ", ioctls));
+ output.WriteLine(rx.ToString()[at..]);
+
+ rx.ToString().Should().Contain("WLAN True", "WLAN.active(True) must bring the interface up");
+ rx.ToString().Should().Contain("RP2040Sharp-AP", "scan must surface the virtual AP over SDPCM");
+ sim.Cpu.IsLockedUp.Should().BeFalse();
+ }
+}
diff --git a/tests/RP2040Sharp.Tests/Peripherals/BootromRevisionTests.cs b/tests/RP2040Sharp.Tests/Peripherals/BootromRevisionTests.cs
new file mode 100644
index 0000000..fda8aa7
--- /dev/null
+++ b/tests/RP2040Sharp.Tests/Peripherals/BootromRevisionTests.cs
@@ -0,0 +1,49 @@
+using RP2040.Peripherals;
+using Xunit;
+
+namespace RP2040Sharp.Tests.Peripherals;
+
+///
+/// Verifies BootROM revision selection (RP2040 B1 = bootrom v2, B2 = bootrom v3). The version byte lives
+/// at offset 0x13 of the ROM; the flash hardware helper functions (connect_internal_flash etc.) must be
+/// patched to BX LR (0x4770) at the addresses resolved from each revision's ROM function table. B2 was
+/// dumped from real B2 silicon (RP2040 rev B2) via HIL.
+///
+public sealed class BootromRevisionTests
+{
+ // Minimal flash image (valid-looking vector table) just to trigger BootROM installation in LoadFlash.
+ private static byte[] MinimalImage()
+ {
+ var img = new byte[512];
+ // initial SP at [0], reset vector (thumb) at [4]
+ BitConverter.GetBytes(0x20040000u).CopyTo(img, 0);
+ BitConverter.GetBytes(0x10000101u).CopyTo(img, 4);
+ return img;
+ }
+
+ private static bool IsBxLr(RP2040Machine m, uint addr) =>
+ m.Bus.ReadByte(addr) == 0x70 && m.Bus.ReadByte(addr + 1) == 0x47;
+
+ [Fact]
+ public void B1_loads_bootrom_v2_and_patches_b1_flash_functions()
+ {
+ using var m = new RP2040Machine(); // default = B1
+ m.LoadFlash(MinimalImage());
+
+ m.Bus.ReadByte(0x13).Should().Be(2, "B1 silicon ships bootrom version 2");
+ IsBxLr(m, 0x24A0).Should().BeTrue("connect_internal_flash patched (B1 address)");
+ IsBxLr(m, 0x2330).Should().BeTrue("flash_enter_cmd_xip patched (B1 address)");
+ }
+
+ [Fact]
+ public void B2_loads_bootrom_v3_and_patches_b2_flash_functions()
+ {
+ using var m = new RP2040Machine(bootrom: RP2040BootromRevision.B2);
+ m.LoadFlash(MinimalImage());
+
+ m.Bus.ReadByte(0x13).Should().Be(3, "B2 silicon ships bootrom version 3");
+ // B2 flash helpers sit at different addresses than B1 (resolved from the ROM table).
+ IsBxLr(m, 0x2490).Should().BeTrue("connect_internal_flash patched (B2 address)");
+ IsBxLr(m, 0x2320).Should().BeTrue("flash_enter_cmd_xip patched (B2 address)");
+ }
+}