Shoddy Documentation
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.
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:
| Stage | What it does |
|---|---|
| verify | checks mill.manifest against the tree — a stale machines section is a build error naming the drift |
| gate | default-deny over the manifest's declared capabilities — anything not granted is refused; a refusal prints the exact Grant="…" line to add |
| weave | compiles the mill — incremental, so an untouched mill launches nothing |
| reference | the woven DLL and its transitive machine closure become references, all copy-local |
| assets | declared assets are copied beside the output under the mill's own layout |
| embed | mill.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.
bin/debug/ already means the
instrumented weave rather than a configuration, so a bare
$(Configuration) would collide with it.SHODDY_BIN_SCOPE, passed
to every mill process the targets start, so the plan that predicts output
paths and the weave that writes them cannot disagree.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:
Mode N (native): the mill's pure core is
woven as a machine DLL — Shoddy.Machines.<Core-stem> —
and its words become ordinary .NET calls. The host owns the loop; §2.
Mode T (terminal): the mill's shell is woven as a complete console program — an assembly named after the shell file's stem — and runs whole through pipes the host supplies. A pipe is a channel that carries text between two programs. The mill owns the loop; §3.
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.
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:
Output/Input/Args for the
engine's I/O — its text in and out;AllowNet to arm the gated TCP builtins for this
engine and no other;FileRoot — the directory a
file-granted mill's relative paths live under, validated to
exist at load. The grant said the mill may; the option says
where. §5 has the whole story.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:
Peek() answers -1 when nothing is pending —
that is how InKey stays non-blocking through a pipe, answering
"" between keystrokes so a game loop can poll between
frames.
Read/ReadLine block until input arrives —
that is Input waiting at its prompt.
Feed special keys as their VT100 application-mode sequences, whole.
(VT100 is the classic terminal whose control codes Shoddy speaks.) An
ESC with O or [ straight behind it
comes back to InKey as the whole three-character sequence
EvalKey classifies, exactly as at a live terminal.
Cancellation is cooperative: the mill unblocks when the host's pipe does, so cancel by ending the input stream.
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.
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:
| Seam | The mill's side | The host's side |
|---|---|---|
| terminal | Print, Input, InKey, VT100 escapes | the TextWriter/TextReader handed to RunWovenAsync — the Reckoner backs them with a screen-grid control and a key queue |
| scribbler | ScribblerOpen, drawing words, the event queue | ScribblerRegistry.CreateScribbler: answer a handle, present the blits it pushes, feed pointer/key/tick events into its queue, send Quit when your window closes |
| buzzer | Sound, NoteOn, queues, gain | the 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.
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:
file — the grant is permission; the
root is a run-time argument. A project that granted
file compiles in a fail-fast: every ShoddyHost.Load
must supply FileRoot, or it dies at startup naming the mill —
never by silently writing beside the executable.
The root is a boundary, not merely a base. Every file word in
the engine resolves its path against that directory, and is refused if the
result would land outside it. A path climbing out with .., an
absolute path elsewhere, and a symbolic link — a file that is secretly a
pointer to another place — anywhere along the way are all
turned away before anything is touched. The aborting words stop with
{WORD}: '{path}' is outside the file root. The guarded twins
report it in their own shape, and FileExists answers
False so nothing outside can be probed for. Your host does not
need to change its working directory to get this, and one that does gets
the same answer.
It is a boundary against the mill, not against a hostile process
sharing the root. A component swapped for a link between the check and the
open that follows it would need the operating system to prevent, not a path
comparison. mill run sets no root and is unaffected: it is a
general-purpose tool run by someone who could open the file in an editor
anyway.
net — Mode N engines are armed
per-engine through ShoddyHostOptions.AllowNet. A Mode T mill
constructs its own engine inside the woven program, so granting it
net arms the whole process for its lifetime. That is
all-or-nothing, exactly as mill run --allow-net behaves at the
desktop. Two concurrent Mode T mills in one process share the switch; a host
that needs them isolated runs them in separate processes.
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.
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:
| Page | Mill | What it demonstrates |
|---|---|---|
| Calculator | halifax | Mode N — core words called natively, one line at a time, no console anywhere |
| Pac-Man | pac-vt100 | Mode T, raw keys — a fixed 80×26 grid, VT100 escapes drawn by the terminal control, InKey polled through the pipe |
| Oregon Trail | oregon | Mode T, line input — a scrolling transcript with the platform's own entry beneath it, keyboard, IME and clipboard kept |
| Mungo Caverns | mungo-caverns | Mode T with file — SAVE and LOAD land in the app's own storage |
| Invaders | invaders | the scribbler seam — the mill opens its window, the host presents it as a page and feeds it events |
| Devil's Dust | devils-dust | scribbler + 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.
Two dotnet new templates in templates/ scaffold
the shapes above:
shoddy-console — a console host calling a
mill's words, with ShoddyWeave wired, grants parameterized, and F5 debugging
both sides;shoddy-lib — a mill wrapped as a class library.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: