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_pollcyw43_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)"); + } +}