Shoddy Documentation

Hosting

Shoddy inside your .NET app — ShoddyWeave in the csproj, ShoddyHost at runtime, and the Reckoner as the worked example.

A mill is not only a console program. The same woven assembly — the compiled .dll — that mill run executes at a desktop can run inside any .NET application — a WinUI window, a web service, a phone — whole and unmodified. The host, the .NET app carrying the mill, supplies only the edges: where the text goes, where keystrokes come from, what a window or a speaker means. Everything between the edges is the mill's, exactly as it was tested.

This page is for the C# side of the seam. Writing the mill itself is the rest of this documentation — the guide, the toolchain, the mills. Read on when you have a mill (or want one) and a .NET app that should carry it.

1. The weave in your csproj

Your csproj is the project file that tells .NET what to build. ShoddyWeave (src/Shoddy.Build) plugs into it and turns a declared mill folder into ordinary build inputs. One item per mill, per mode:

<Import Project="...\Shoddy.Build\build\Shoddy.Build.props" />

<ItemGroup>
  <ShoddyMill Include="..\mills\pac-vt100" Mode="T"
              Grant="terminal;clock;random;vt100" />
</ItemGroup>

<Import Project="...\Shoddy.Build\build\Shoddy.Build.targets" />

Every dotnet build then runs the mill's half of the pipeline, in order:

StageWhat it does
verifychecks mill.manifest against the tree — a stale machines section is a build error naming the drift
gatedefault-deny over the manifest's declared capabilities — anything not granted is refused; a refusal prints the exact Grant="…" line to add
weavecompiles the mill — incremental, so an untouched mill launches nothing
referencethe woven DLL and its transitive machine closure become references, all copy-local
assetsdeclared assets are copied beside the output under the mill's own layout
embedmill.manifest is embedded as a resource, for hosts that read their mills at runtime

The toolchain computes every name and path itself; the targets only orchestrate, so there is no second derivation to drift. The mill is found three ways, in order: the ShoddyToolPath property, a local .NET tool manifest carrying mill, then PATH.

Two configurations building one mill want ShoddyBinScope. Woven output lands in bin/ beside the mill, at the same path in every configuration. That is fine until a lane builds Debug and Release back to back. A test host from the first still holds its woven DLLs loaded, and Windows refuses to rename a loaded assembly until its holder exits — so the second build dies moving a Shoddy.Machines.*.dll into place. No lock helps: the holder is not competing for the file, it is using it. Set <ShoddyBinScope>cfg-$(Configuration)</ShoddyBinScope> and each configuration weaves into its own subdirectory of that bin/ instead.

The manifest is the contract. Each mill folder carries a mill.manifest (schema: docs/schemas/mill.manifest.json) declaring its name, core, shell, modes, capabilities, and machine closure. The identity on top is hand-authored; the machines section is generated, and the verify stage keeps it honest. The two modes are the two things a mill can be to a host:

One folder may be declared twice, once per mode: the Reckoner does exactly that with its calculator mill, calling core words natively while also able to run the same mill as a terminal program.

2. Mode N — words as calls

ShoddyHost.Load reads the woven machine assemblies' manifests (attribute reading only — nothing from the toolchain ships in your app) and owns the one engine the words run against:

using System.Reflection;
using Shoddy.Hosting;

var host = ShoddyHost.Load(Assembly.Load("Shoddy.Machines.Widget-core"));
double n = host.Word("Total").Call(ShoddyValue.Num(21)).AsNum();

Word finds a word case-insensitively — every Shoddy name is — and an unknown word throws by name rather than answering null. Call is the marshalling layer, the code that carries values between the two worlds: it pushes the arguments, runs the word, and pops the answer, so the caller never sees the engine's stack. ShoddyValue carries the values across: Num, Str, Bool, ListOf in; AsNum, AsStr, AsBool, AsList, and Field("Name") for records, out. A word's own abort — Error(msg) — reaches the caller as the same exception it would be at the console, line number and all.

Two rules of the road:

ShoddyHostOptions is where run-time resources meet build-time permission:

3. Mode T — a program, whole

A Mode T mill is the program you already tested at the console, run through pipes:

Task<int> run = ShoddyHost.RunWovenAsync(
    Assembly.Load("pac"),      // the shell's stem names the assembly
    outputWriter,              // where Print and VT100 escapes land
    inputReader,               // where Input and InKey read from
    Array.Empty<string>());    // the program's own arguments

The woven entry type is found per-assembly by name, never referenced in code. That is what lets several woven games coexist in one host, as the Reckoner's shelf does. The task completes with the program's exit code — the number a program returns as it ends — when its Main returns.

The piped-reader contract is the host's half of the bargain, and it is small:

Reading the console while a scribbler window is open is refused — at a live console. There the two input channels are routed by OS focus, and the read would silently hang. Redirected or host-piped input has no focus to lose, so a host may keep a canvas open beside a prompt and both work. The errors page carries the full statement.

4. The three seams a host supplies

Everything a mill can do beyond pure computation reaches the world through a seam the host fills in — a seam is a boundary where the host plugs in its own implementation. Three of them carry all the games:

SeamThe mill's sideThe host's side
terminalPrint, Input, InKey, VT100 escapesthe TextWriter/TextReader handed to RunWovenAsync — the Reckoner backs them with a screen-grid control and a key queue
scribblerScribblerOpen, drawing words, the event queueScribblerRegistry.CreateScribbler: answer a handle, present the blits it pushes, feed pointer/key/tick events into its queue, send Quit when your window closes
buzzerSound, NoteOn, queues, gainthe BuzzerRegistry delegates, behind the shared engine that owns channels, pooling and ramps — the host supplies only a platform audio sink

The deliberate shape: the registries are null by default, and a mill run against an empty seam computes exactly as it would headless — with no display at all. That is how the whole surface is tested without a window on CI, and how your host can adopt one seam at a time. A sink or backend installed before the run is simply what the mill finds when it asks.

5. Capabilities and grants

The Grant attribute is your project's capability statement. A capability is a thing the mill is allowed to do — touch files, open the network — and a grant is your written permission for one. The gate is default-deny: anything not granted is refused. A capability the manifest declares and the project has not granted fails the build, and the refusal prints the exact line to add. Granting is deliberate, so refusal is self-documenting.

Two capabilities have run-time teeth beyond the gate:

What each capability needs from each platform the host targets — permissions, entitlements, manifest lines — is tabulated in the repo at hosts/maui/CAPABILITIES.md. The short version: only net costs anything anywhere, and only a manifest line.

6. The worked example — the Shoddy Reckoner

The repo's own host is hosts/maui: Shoddy.Maui holds the reusable pieces (the session loop, the terminal control, the canvas view, the audio sinks), shoddy-reckoner is the app, and Shoddy.Maui.Tests proves both without opening a window. Build the mill first, then run it:

./build.ps1                # repo root: publishes bin/mill  (./build.sh elsewhere)
dotnet run -c Release --project hosts/maui/shoddy-reckoner

The app is a calculator with a games shelf, and the shelf is the point: five mills from the catalog, each running unmodified, each exercising a different part of the seam:

PageMillWhat it demonstrates
CalculatorhalifaxMode N — core words called natively, one line at a time, no console anywhere
Pac-Manpac-vt100Mode T, raw keys — a fixed 80×26 grid, VT100 escapes drawn by the terminal control, InKey polled through the pipe
Oregon TrailoregonMode T, line input — a scrolling transcript with the platform's own entry beneath it, keyboard, IME and clipboard kept
Mungo Cavernsmungo-cavernsMode T with file — SAVE and LOAD land in the app's own storage
Invadersinvadersthe scribbler seam — the mill opens its window, the host presents it as a page and feeds it events
Devil's Dustdevils-dustscribbler + buzzer together — mouse, tabs, and the platform audio sink behind the shared engine

The test project is as much of the example as the app: the same woven mills, driven through the same pipes, asserted headless. A game boots to its first prompt, answers travel the pipe, the frame rate holds, and the audio graph provably pulls samples. If you are writing a host, read hosts/maui/Shoddy.Maui.Tests before anything else: it is the seam contract, executable.

7. Consuming from your own repo

Two dotnet new templates in templates/ scaffold the shapes above:

Until Shoddy's packages publish, consumption is checkout-relative: the templates reference Shoddy.Hosting and import Shoddy.Build straight from a Shoddy checkout beside your repo. The packaged import becomes a one-line swap when the NuGet graduation lands.

Debugging crosses the seam too. Under Debug, ShoddyWeave weaves the instrumented core and compiles in the attach service, so one F5 runs the .NET debugger on your host and the Shoddy debugger on the mill at once — release builds carry neither. Three constraints every host inherits: