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 withProsperoHeapHardLimitBytes. - 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.
Step 2: link
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:
- Checks the runtime archives and the compiled object exist, stopping with a specific message if one is missing.
- Runs the SDK’s linker over the application object and the runtime archives. The
linker supplies its own start object (which carries the
_startentry 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 writeseboot.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.