Build & Platforms
Purpose
Section titled “Purpose”How the solution is built, what it depends on, how content and shaders reach the output folder, and what works on each platform.
Building
Section titled “Building”just build # dotnet build MainframeEngine.slnx (warnings are errors)just test # unit tests just test-render # render testsjust demo # dotnet run --project Examples/Demo/Demo.Desktopjust (1.58+) wraps every local command; run just for the list. Engine code resolves every content
path with ContentPaths.Resolve against AppContext.BaseDirectory (rooted paths unchanged,
"Content/…" relative to the app folder, anything else relative to Content/), so it works from any
working directory. The render-test host still pins the working directory for
SpineFolder, which enumerates its folder relative to it.
Solution
Section titled “Solution”MainframeEngine.slnx (the XML solution format, SDK 9.0.200+; migrated from
MainframeEngine.sln with dotnet sln migrate) contains MainframeEngine,
MainframeEngine.Generators (the source generator, netstandard2.0, referenced as an analyzer by the
engine and the unit tests; its only package, Microsoft.CodeAnalysis.CSharp, is build-time
only — see Scene serialization),
MainframeEngine.Editor, and the solution folders Plugins (spine-csharp), Tools (MainframeEngine.L10n, the mf-l10n CLI) and Tests
(MainframeEngine.Tests, MainframeEngine.Editor.Tests, MainframeEngine.RenderTests,
MainframeEngine.RenderTests.Host and MainframeEngine.Benchmarks; see Testing). The template package
Templates/MainframeEngine.Templates is not in the solution (it is packed on its own: just template-pack), nor is
its mfgame content, which CI’s template job instantiates and builds (just template-smoke). Configurations: Debug|Any CPU and Release|Any CPU only.
Shared build settings
Section titled “Shared build settings”| File | Role |
|---|---|
global.json | Pins the .NET SDK to 10.0.401 (rollForward: latestFeature) |
Directory.Build.props | LangVersion latest, nullable, implicit usings, TreatWarningsAsErrors, AnalysisLevel latest-recommended, deterministic builds (ContinuousIntegrationBuild on CI), Version 0.0.0-dev (publish overrides with -p:Version), authors/company “Mainframe Games”. Analyzer suppressions are listed here with their reason. |
Directory.Build.targets | Exempts the vendored spine-csharp (submodule) from warnings-as-errors, analyzers and nullable warnings |
Directory.Packages.props | Central package management: every version lives here; project files have no Version |
.editorconfig | Formatting (dotnet format) and analyzer options; keeps column-aligned declarations |
Tests/Directory.Build.props | Test-project defaults (chains to the root props); DeterministicSourcePaths=false, so CI builds keep real [CallerFilePath] paths that tests check against files on disk |
Every project targets net10.0 (the vendored Spine runtime keeps net8.0). AllowUnsafeBlocks is set
in each project that needs it and must stay. The build has 0 warnings in Debug and Release.
Packages
Section titled “Packages”All versions are in Directory.Packages.props. Silk.NET is unified on 2.23.0.
| Package | Version | Used by / for |
|---|---|---|
| Silk.NET.Windowing.Sdl, .Input.Sdl | 2.22.0 | Window, keyboard/mouse/gamepad on SDL2 (Silk.NET.SDL, Ultz.Native.SDL 2.30.8 transitive). GLFW is not referenced (ADR 0003). |
| Silk.NET.Vulkan (+ Extensions.EXT/KHR) | 2.22.0 | Vulkan bindings |
| Silk.NET.MoltenVK.Native | 2.22.0 | Bundled MoltenVK for macOS |
| Silk.NET.Assimp | 2.22.0 | Referenced but unused (kept for M3) |
| StbImageSharp | 2.30.16 | Image decoding (sky, Spine atlas, icon) |
| ENet-CSharp | 2.4.8 | UDP networking |
| Steamworks.NET | 2024.8.0 | Steam wrappers (inert, see Steamworks) |
| SoundFlow | 1.4.1 (exact) | Audio device, mixer graph, MP3/FLAC decoding (Audio, ADR 0030). Ships its miniaudio natives in runtimes/<rid>/native/ (win-x64/x86/arm64, linux-x64/arm/arm64, osx-x64/arm64, …); NuGet copies them to the app’s runtimes/ and SoundFlow resolves them. |
| NVorbis | 0.10.5 (exact) | OGG Vorbis decoding, managed (ADR 0032) |
| xunit.v3, xunit.runner.visualstudio, Microsoft.NET.Test.Sdk, coverlet.collector | see props | Tests only |
| BenchmarkDotNet | see props | Benchmarks only |
New NuGet dependencies must be discussed before they are added (CLAUDE.md).
Content
Section titled “Content”MainframeEngine.csproj copies Content\** with CopyToOutputDirectory=Always, except
Content/Shaders/** (sources, includes, lock, committed .spv), whose compiled .spv files are added by
build/Shaders.targets; this flows to the test outputs and to games (the Demo) through
project references. A game copies its own Content\** as well.
Shaders
Section titled “Shaders”Shaders are Slang in MainframeEngine/Content/Shaders/** (*.vk.<stage>.slang, shared includes in include/;
ADR 0144, rules in Shaders).
dotnet build compiles them (build/Shaders.targets,
ADR 0007):
| Target | What it does |
|---|---|
GenerateShaderLimits | Content/Shaders/limits.json → Src/Rendering/Generated/ShaderLimits.g.cs + include/limits.slang (rewritten only when the content changes; both committed) |
CompileShaders | Incremental (sources, includes, limits.json): slangc -target spirv -capability spirv_1_5 -matrix-layout-row-major -entry main -stage <stage> -I Content/Shaders/include → obj/<config>/Shaders/**.spv; slangc errors are build errors. slangc = $(Slangc), $(VULKAN_SDK)/bin/slangc or PATH. |
IncludeShadersInOutput | Copies the compiled .spv (or, without slangc, the committed ones after warning MFSHADER001) to Content/Shaders/ in every output |
-p:CompileShaders=false uses the committed .spv files (CI does: no slangc on the build runners, and the
warning would fail -warnaserror). The committed .spv files are the fallback for machines without
the Vulkan SDK; build/shaders.sh (POSIX sh, used by just and CI) keeps
them current:
| Command | What it does |
|---|---|
just shaders | slangc with the build’s flags on every engine *.vk.{vert,frag,comp}.slang (same flags as the build), spirv-val each result, then rewrite MainframeEngine/Content/Shaders/shaders.lock |
just shaders-check | Fails if a source, an include or a .spv no longer matches the lock (source edited without recompiling, or .spv not committed) |
The lock stores the sha256 of each source and of its .spv, and of each include/*.slang. After
editing a shader or include, run just shaders and commit the .spv files with the lock. See
Shaders.
.github/workflows/ci.yml runs on every pull request and on demand
for any branch (workflow_dispatch: gh workflow run ci.yml --ref <branch>). It does not run on pushes to
main: the main-protection ruleset only lets changes in through pull requests that already passed it, so a
second run on main would only cost Actions minutes. publish.yml re-runs it before a release.
Docs-only pull requests skip the heavy jobs. The changes job diffs the PR’s merge commit against its first parent
(the base branch) and runs build/ci-changes.sh. When every changed file is documentation,
build-test, format, shaders, render-tests and template are skipped and ci-success still passes.
Documentation means:
docs/**, exceptdocs/images/brand/**(the editor icon, the NuGet package icons and release packaging read it);docs-site/**;memory/**;- a top-level
*.mdsuch asREADME.mdorCLAUDE.md.
docs.yml still builds the docs site for those PRs. The rule fails toward running
everything:
- any other path, including workflows,
build/and.mdfiles inside projects (PACKAGE.mdis packed), counts as code; - a renamed file counts at both its old and new path (
--no-renames); - an empty or unreadable diff counts as code;
workflow_dispatchandpublish.yml’sworkflow_callalways run the full suite. There is one run per ref; newer runs cancel older ones. Checkouts include the Spine submodule. To save LFS bandwidth, jobs check out without LFS and fetch only the LFS files they need (git lfs pull --exclude=…; never the README screenshots orExamples/images), with.git/lfscached under a key hashed from the needed object IDs (.lfs-assets-id), so a run whose LFS files did not change downloads none.formatandshadersfetch no LFS files;publish.yml’s editor builds follow the same pattern, andnatives.yml’s verify job fetches onlyMainframeEngine/runtimes/**.
| Job | Runs |
|---|---|
changes | Classifies the PR (above): code=true runs the jobs below, code=false (docs only) skips them |
build-test (ubuntu-24.04, windows-latest, macos-14) | dotnet build -c Release -warnaserror -p:CompileShaders=false (committed .spv), unit tests with coverage; uploads .trx results and (Linux) Cobertura coverage |
format | dotnet format --verify-no-changes --exclude Plugins/Spine |
shaders | Slang v2026.1 from its GitHub release (SHA-256 pinned) + apt spirv-tools, compiles every shader to a temp dir, spirv-val, build/shaders.sh check |
render-tests | Ubuntu with lavapipe (mesa-vulkan-drivers, VK_DRIVER_FILES = lvp_icd.json), vulkan-validationlayers, Xvfb; three parallel shards by test class (the matrix’s --filters; the last runs every class the others do not name); compares against Goldens/lavapipe/; each shard uploads artifacts/render-tests (frames, diffs) as render-tests-<shard>. Timing tests measure a short --perf-on-cpu window there. Re-recording: Testing |
template | Ubuntu with lavapipe + Xvfb: build/template-smoke.sh installs the mfgame template from source, builds a game against the checkout (Release, warnings as errors) and runs its desktop project (GameHost) for 30 hidden frames, failing on logged errors; then the same against both NuGet packages packed into a local feed, plus a linux-x64 publish (Distribution via NuGet). See Projects & GameHost |
ci-success | Runs always; fails unless every job above succeeded (a skipped job counts only when changes said docs-only). The required status check in the main ruleset — do not rename. |
Windowing: SDL2
Section titled “Windowing: SDL2”Window, Vulkan surface and input run on SDL2 through Silk.NET’s SDL backend
(Silk.NET.Windowing.Sdl, Silk.NET.Input.Sdl; decision in
ADR 0003). SDL brings the community controller
database, rumble/gyro, IME text input, clipboard and DPI queries that later milestones need, and is
Silk.NET 3’s long-term backend. Games see no difference: Engine.Window, InputContext, the events
and the frame loop are unchanged.
flowchart TD
A["Engine ctor"] --> A1["SilkNativeResolver.Install()<br/>portable runtimes/<rid>/native probing"]
A1 --> B["SdlWindowing / SdlInput.RegisterPlatform()<br/>Window.PrioritizeSdl()"]
B --> C{"macOS?"}
C -- yes --> D["VulkanLoaderBootstrap.Probe()<br/>resolve libvulkan / MoltenVK path"]
D --> E["HandOffToSdl(): SDL_Vulkan_LoadLibrary(path)"]
C -- no --> F
E --> F["Window.Create(WindowOptions.DefaultVulkan)<br/>SDL window with SDL_WINDOW_VULKAN"]
F --> G["VulkanRenderer: instance extensions from<br/>IWindow.VkSurface (SDL_Vulkan_GetInstanceExtensions)"]
G --> H["VkSurface.Create: SDL_Vulkan_CreateSurface"]
H --> I["Window.CreateInput(): SDL input backend"]
- The platforms are registered explicitly in the
Engineconstructor (no reflection discovery, so trimming/AOT keep them). GLFW is not in the dependency graph and is never loaded. - Event pump without allocation (ADR 0139).
Silk 2.23’s SDL view walks each frame’s events through
IEnumerable<Event>, boxing the list enumerator (88 B on every frame with an event, so every frame of mouse look).SdlEventBatchre-subscribes Silk’s own handler behind a reusable enumerable inEngine.OnLoad; if Silk’s internals move it logs a warning and leaves Silk alone. - HiDPI. SDL reports
IWindow.Sizein points, and Silk’sIWindow.FramebufferSizereturns the GL drawable, which for a Vulkan window is also in points.Engine.FramebufferSize(WindowPixels) returnsSDL_Vulkan_GetDrawableSizein pixels (3024×1692 for a 1512×846 pt Retina window); the renderer’s extent fallback, minimise detection and game aspect ratios use it. Prefer it overWindow.FramebufferSize. - Fixed content scale.
EngineOptions.ContentScale> 0 makes the framebuffer exactlyWindowSize× that scale in pixels on any display: beforeOnLoadcreates the swapchain, the engine measures the display’s pixels per point and sizes the OS window to the request ÷ that ratio (WindowPixels.ResizeToPixels; again after centring, in case it moved displays), and throws if the framebuffer still differs (a fractional scale that does not divide it). The UI’s dp ratio (UiServer.ContentScale) and the gizmo scale (RenderServer.GizmoScale) use the fixed scale; mouse and IME positions keep converting with the display’s real pixels per point.Engine.ResizeWindowresizes in the same layout points. Render tests and the Demo’s screenshots use it so images do not depend on the monitor; 0 (default) follows the display. - Finding libSDL2 (and other Silk.NET package natives). A RID-agnostic build (
dotnet build/run/test) keeps package natives underruntimes/<rid>/native/. Silk.NET’sDefaultPathResolverpicks that folder from the distro-specific RID ofMicrosoft.DotNet.PlatformAbstractions(ubuntu.24.04-x64). It maps that RID back to a portable one with a hard-coded distro list, and Ubuntu is not on it. A .NET 8+ deps.json has no RID graph to fall back on, so on Ubuntu Silk never probesruntimes/linux-x64/native.Window.Createthen fails with “SdlPlatform - not applicable”, and theFileNotFoundExceptionsays SDL could not be loaded.SilkNativeResolverfixes this. TheEngineconstructor calls it before anything loads SDL, and it appends a resolver to Silk’s process-widePathResolver.Default. That resolver probesruntimes/<RuntimeInformation.RuntimeIdentifier>, thenruntimes/<os>-<arch>, thenruntimes/<os>. It runs after Silk’s own resolvers. macOS and Windows RIDs already map correctly. RID-specific builds and publishes (dotnet publish -r linux-x64) copy the natives next to the app and never need it. Engine code that can load a Silk.NET native before anEngineexists (e.g. a future Assimp import path used by tools) must call the idempotentSilkNativeResolver.Install()first. - On Linux, SDL dlopens X11 (or Wayland) at runtime; CI installs
libx11-6 libxext6 libxfixes3 libxrandr2 libxinerama1 libxcursor1 libxi6 libxss1 libxkbcommon0for the Xvfb render tests.
macOS: Vulkan loader bootstrap
Section titled “macOS: Vulkan loader bootstrap”Vulkan on macOS runs on MoltenVK. SDL (which creates the surface) and Silk.NET’s Vk must bind the
same Vulkan library: instances from two different loaders are not interchangeable, and modern
dyld no longer searches /usr/local/lib for leaf-name dlopen.
VulkanLoaderBootstrap runs in
the Engine constructor after SDL is selected and before the window exists, in two steps:
Probe() (find and load a library, set ActiveLibraryPath) and HandOffToSdl()
(SDL_Vulkan_LoadLibrary(ActiveLibraryPath) on the shared SdlProvider instance). On other
platforms both are no-ops and SDL and Silk load the system loader. MAINFRAME_VULKAN_LIBRARY=<path>
forces one library, which is how each source below is QA’d.
flowchart TD
A["Engine ctor (after PrioritizeSdl)"] --> B{"OS is macOS?"}
B -- no --> Z["no-op"]
B -- yes --> C["Probe candidates in order"]
C --> C1["libvulkan.1.dylib in app dir or<br/>runtimes/osx-*/native"]
C1 -->|miss| C2["$VULKAN_SDK/lib/libvulkan.1.dylib"]
C2 -->|miss| C3["/usr/local/lib/libvulkan.1.dylib"]
C3 -->|miss| C4["~/VulkanSDK/(highest version)/macOS/lib"]
C4 -->|miss| C5["bundled libMoltenVK.dylib"]
C1 & C2 & C3 & C4 & C5 -->|hit| D["NativeLibrary.Load → ActiveLibraryPath"]
D --> E["SDL_Vulkan_LoadLibrary(ActiveLibraryPath)"]
E --> F["VulkanRenderer: TryCreateVk()<br/>new Vk(DefaultNativeContext(ActiveLibraryPath))"]
The renderer then enables VK_KHR_portability_enumeration (instance) and VK_KHR_portability_subset
(device) only when they are advertised, so Windows/Linux are unaffected.
Rule: never call bare Vk.GetApi() in engine code; take Vk from IVulkanContext.
Verified on macOS arm64 against all three sources: SDK loader (/usr/local/lib), ~/VulkanSDK/<ver>
and the bundled libMoltenVK.dylib (no validation layers through that one).
Platform matrix
Section titled “Platform matrix”| Feature | Windows x64 | Linux x64 | macOS x64 | macOS arm64 |
|---|---|---|---|---|
| Vulkan renderer | ✅ | ✅ | ✅ MoltenVK | ✅ MoltenVK (verified, the Demo at 120 fps) |
| Validation layers | Vulkan SDK | Vulkan SDK | Vulkan SDK | Vulkan SDK |
| ENet networking | ✅ | ✅ | ✅ | ❌ native is x86_64-only |
| Audio (SoundFlow/miniaudio) | ✅ WASAPI | ✅ ALSA/PulseAudio | ✅ Core Audio | ✅ Core Audio (verified with the Demo’s Audio scenes); null device on machines without one (CI) |
| Steamworks | ⚠ no steam_api shipped | ⚠ same | ⚠ same | ❌ no osx-arm64 assets |
MoltenVK limits that shaped the design: mutableComparisonSamplers = false (shadow samplers are
immutable), 16 samplers per shader stage (the shadow set uses 6 since M4; before, MaxShadowSpot was 7), and a mutable-format swapchain whose
UNORM view intermittently resolves to a stale drawable (why the default swapchain is UNORM; see
Color pipeline). See Shadow system.
Known issues
Section titled “Known issues”SpineFolder(node code) still lists its folder relative to the working directory.- Node-side shape shaders are still loaded by path through
File.ReadAllBytes(CWD-relative), not the shader-module cache; the materials rewrite moves them. - The window layer may report no monitor while a Mac’s display sleeps; the engine then skips window centering.
Related docs
Section titled “Related docs”Architecture overview · Testing · Shaders · GPU resources · Color pipeline