Build pipeline

An application goes through three steps to become a package: compile the C# to an object, link the object into an ELF module, and pack the module. The first two are driven by MSBuild files in build/; the third is the packager. src/SharpProspero.Sample/build.ps1 runs all three.

Inputs

Nothing outside the .NET SDK is set up. build.ps1 gathers the ahead-of-time runtime from the .NET SDK’s own NativeAOT runtime pack (restored by the compile step) and links through the SDK’s own linker, which supplies its own start object, a compat object for the C-library names the runtime needs that the device does not publish, and stubs for the service modules. So there is no runtime pack to assemble, no PROSPERO_RUNTIME_PACK to set, and no separate linker, start file, or stub library.

The one host requirement is that the compile step runs on Linux (see step 1). On Windows build.ps1 runs that step through WSL automatically, so a Windows user builds in place.

Step 1: compile

An application imports build/Prospero.App.props, which configures the ahead-of-time compile:

  • Trimming is set to full and the size preference is on, so unused framework surface does not survive.
  • Framework features an on-device module never uses are switched off: globalization data, resource strings, event sources, the metadata updater, activity propagation and the debugger hooks.
  • The garbage collector is the workstation, non-concurrent collector, with a hard ceiling baked into the image through System.GC.HeapHardLimit. Set the ceiling per project with ProsperoHeapHardLimitBytes.
  • Every service module is declared a direct import (DirectPInvoke), so each binding call becomes a direct reference the linker resolves against a stub it generates for that module.

Publish with an x86_64 runtime identifier:

dotnet publish -c Release -r linux-x64

The compiler writes an object under obj/Release/net10.0/linux-x64/native. It targets the x86_64 instruction set; the native runtime it links against (below) matches the device ABI.

One constraint on this step: the ahead-of-time compiler emits an object only for the operating system it runs on, so it does not cross-compile to Linux from a Windows host. build.ps1 therefore runs the publish through WSL on Windows — the object still lands in the project’s obj folder, which both sides share — and runs it directly on Linux. The link and pack steps below are the toolchain itself and run wherever the script is started.

build/Prospero.App.targets defines the ProsperoLink target. It is not part of a normal build, so dotnet build never touches it; run it directly or through build.ps1. The target:

  1. Checks the runtime archives and the compiled object exist, stopping with a specific message if one is missing.
  2. Runs the SDK’s linker over the application object and the runtime archives. The linker supplies its own start object (which carries the _start entry point) and its own stubs for the modules the SDK imports from. It reads each object and archive, resolves the symbol graph, lays the sections into segments, applies the relocations, and writes eboot.bin — the exception-frame index and the thread-local template included.

A project can add stubs for its own modules through ProsperoUserStubLibrary (generated from a .prx by the stub tool); those let an application link against libraries it supplies. Run the target directly like this:

dotnet msbuild src/SharpProspero.Sample/SharpProspero.Sample.csproj /t:ProsperoLink ^
  /p:ProsperoObjectFile=<path-to-object> /p:OutputPath=<module-folder>/

Override the runtime archive list and its order with ProsperoRuntimeLibraries (semicolon-separated) when the default folder scan is not the order you want.

Step 3: output

The build gathers eboot.bin next to the sce_sys metadata and any sce_module libraries, then writes one of two outputs. build-app.ps1 picks with -Output:

pwsh build/build-app.ps1 -ProjectPath MyApp.csproj                    # Package (the default)
pwsh build/build-app.ps1 -ProjectPath MyApp.csproj -Output Folder     # every file in one folder

Package hands the folder to the packager and writes an installable *.pkg:

dotnet run --project tools/SharpProspero.Packager -- --in <module-folder> --out <output-folder>

Folder stops after gathering, leaving eboot.bin, sce_sys and sce_module together in one folder ready to copy or inspect. Nothing is packed, so there is no content id or passcode to set.

Where the runtime comes from

The linked module needs the ahead-of-time runtime — the garbage collector, exception handling, and the bootstrap that runs before the managed entry. These are the standard NativeAOT runtime archives, and the dotnet publish compile step restores them into the .NET SDK’s package cache as its own runtime pack. build.ps1 gathers those archives from the cache and hands them to the linker, so nothing is assembled or downloaded separately.

The runtime archives call a set of C-library and operating-system functions. The device’s own C and kernel modules already publish most of them (the whole pthread family, the memory and file calls, timing, and the C library), so the linker resolves those as ordinary imports against the module stubs. The rest — a small set the runtime asks for by a name the device does not publish, such as the large-file variants of the file calls — are provided by a compat object the linker emits itself: each is a thin forwarder to the name the device does publish, or a fixed result an application module can accept. The upshot is that the runtime’s operating-system surface is satisfied entirely by the device modules and the toolchain, with no platform layer to build.

The linker also defines the section-boundary symbols the runtime reads to walk its own managed-code and module tables (__start_<section> / __stop_<section>), the way the system linker does.

A plain dotnet build of the solution and the tests need none of this; it applies only to the link step, which runs after the compile step has restored the runtime pack.

Keeping the heap in bounds

Memory maps are limited, so the heap ceiling matters. Set ProsperoHeapHardLimitBytes to the largest managed heap the module should use; the value is written into the image through System.GC.HeapHardLimit. Read usage at runtime with SharpProspero.Memory.HeapMonitor, and prefer drawing into pre-allocated framebuffers and reusing buffers over allocating each frame.


Copyright © SvenGDK 2026. Distributed under the GPL-3.0-or-later license.

This site uses Just the Docs, a documentation theme for Jekyll.