Printing a mission: sbs docs
AMD is where a mission's design lives - the quests, the dialogue, the factions, the lore, the help text. Until now the only thing that could read it was the game, so a writer reviewing dialogue, a modder looking up faction keys and a playtester reading a mission bible all had to open a code editor and read markup.
sbs docs renders a mission's .amd files into one self-contained HTML file
laid out for paper.
sbs docs . # the prose edition, into ./__docs__/
sbs docs . --lens all # all four editions
sbs docs . --lens catalog --open # build one and open it
Getting a PDF
sbs docs . --pdf # HTML and PDF, side by side
sbs docs . --lens all --pdf # four editions, plus one bound book
No Python library is involved, and that is deliberate: sbs runs on the
embedded CPython in PyRuntime, whose python311._pth has import site
commented out, so site-packages is never on sys.path and PYTHONPATH is
ignored. A pip-installed PDF library would be invisible to it. So --pdf shells
out to a program instead.
Two engines, and they are not interchangeable
| Faces | Contents page numbers | Install | |
|---|---|---|---|
| Headless Chrome / Edge | composited | no | none - Edge ships with Windows |
weasyprint CLI |
blank - it runs no JavaScript | yes, plus exact @page margins |
needs GTK/Pango natives |
auto picks the browser when the document has faces, because weasyprint
would print every one of them as a blank box, and weasyprint otherwise,
because it is the better typesetter and turns on the target-counter page
numbers the stylesheet already emits. It says which it chose, and why, in one
line. Override with --pdf-engine chrome|weasyprint.
When weasyprint is used on a document that does have faces, they are rendered as honest placeholders rather than blank boxes - the same rule the on-screen renderer follows when the compositor is unavailable.
Two tables of contents
The Contents page is part of the document and is always there.
The PDF outline - the bookmark tree a reader navigates by in the sidebar - has no HTML equivalent, and is built two different ways:
- weasyprint builds it from the
bookmark-levelCSS the stylesheet emits. - Chrome has no bookmark facility at all. But it does write a named
destination for every anchor, using the page's own ids, so the page each
record landed on is already recorded in the file. With
pypdfinstalled the tree is added from those:
sbs deps install pypdf
That also binds --lens all --pdf into a single book with each edition as a
chapter. Without pypdf you get the four PDFs and no outline, which is a
document, not a failure - sbs docs says so once and moves on.
Other PDF options
| Option | Effect |
|---|---|
--pdf-engine auto\|chrome\|weasyprint |
Force an engine |
--browser <path> |
Point at a specific chrome.exe / msedge.exe (also SBS_BROWSER) |
--pdf-timeout 90 |
Seconds to allow the engine |
--pdf also upgrades --assets from none to link, unless you passed
--assets yourself - a PDF is where the art matters, and linking costs nothing
because the paths are relative to __docs__, where the HTML lands.
Four lenses, because AMD is three documents wearing one syntax
The number of renderings is the number of audiences, not the number of record kinds.
| Lens | Reads | What it is for |
|---|---|---|
prose |
every record, in document order | A manual or a story book. The lens the fence-less files were written for - help_docs.amd, library_docs.amd, lore.amd, where the body already is markdown. |
catalog |
every record that has a --- fence, grouped by archetype |
A sourcebook. Reference cards you look things up in. |
screenplay |
dialogue, cutscenes, and anything with spoken lines | A script someone could read aloud, in Fountain geometry. |
bible |
the whole mission, structured by the story timeline | A design document: the quest spine, its triggers, and the causal graph. |
A file with no fence at all is prose and converts almost directly. A file whose
content is the fence is a catalog - printing Color: #ffcc44 as a line of
text would be worthless, so the catalog renders each field as its type: a
color becomes a swatch, a reference becomes a working link, a coordinate becomes
a cell chip. That typing comes from amd_schema, the same registry the editor
and the linter use.
Two profiles
sbs docs . --profile author # default
sbs docs . --profile player
player is a hard filter, not a stylesheet class. What it withholds is
absent from the file, because "print to PDF" and "view source" have to agree
about what a player was told.
| Content | player |
author |
|---|---|---|
= synopsis (author-only note) |
dropped | shown as a margin note |
/* ... */ cut text |
never parsed at all | never parsed at all |
| Choice guard, outcomes, target key | label only | in full |
Speech gate (%{standing < -20}) |
variants print flat | condition chip |
Trigger fields (Then:, Starts when:, Action:, Scope:, Show:) |
dropped | in full |
Player-facing fields (Objective:, Reward:, Scan says:) |
shown | shown |
The bible has no player profile and refuses one. It exists to show the machine - every trigger, every hidden beat, every branch. The bible is the spoiler.
Art
sbs docs . --assets embed # inline the bytes (self-contained)
sbs docs . --assets link # relative paths (smaller, breaks if moved)
sbs docs . --assets none # placeholders everywhere (default)
Three schemes, three different answers - and only one of them is a real limitation.
image:// resolves to a file. The search covers the mission's own media/,
each pack story.json pins, and the engine's data/graphics. That last one
matters: keys like ball and test are engine built-ins that live in the
install, not in any mission, so a search stopping at the mission folder reports
art as missing that is sitting right there.
face:// has no file, but composites. The value is a face-builder string
naming cells of a race atlas, and those atlases are real 4096x4096 PNGs in the
engine graphics folder. cosmos_dev/mockgui/face.js is the canonical compositor
and carries a setSheetResolver hook precisely so a host other than the mock
server can say where a sheet comes from - the printed page is simply a third
host. Faces render onto a <canvas>, drawn on load and again on beforeprint.
Only the atlases a document actually references are pulled in, so a cast that is all Zimni costs 0.44 MB rather than the 6.8 MB of all six sheets.
ship:// genuinely cannot resolve, and stays a placeholder. The tag names a
3D hull. The .png beside each mesh is its diffuse texture, not a picture of
the ship - ships/tsn_light_cruiser.png is a near-white sheet that would print
as a blank box. The browser mock does not draw one either.
Watch the page size
--assets embed inlines every atlas, which for a mission spanning all six
races is around 9 MB. --assets link references the art by relative path
instead: the same document drops to about 110 KB. It stops working if the
file is moved, which is the right trade while iterating and the wrong one for
something you hand to someone else. sbs docs prints the page size, and
suggests link when a page gets large.
Other options
| Option | Effect |
|---|---|
-o, --out PATH |
Where to write (single lens only). Default <folder>/__docs__/<name>-<lens>.html |
--format json |
The block model as data instead of a page - for tools, and the artifact the golden test pins |
--title TEXT |
Document title. Default: the folder name |
--include / --exclude |
Glob filters over the .amd files, repeatable |
--show-internal |
Show fields the schema marks internal (schema debugging) |
--open |
Open the result in a browser |
How it fits together
.amd -> amd_core.parse -> AmdDocument (records, fences, spans)
amd_blocks -> typed blocks (cue, speech, choice, table, ...)
amd_schema -> field types (color, ref, coord2, trigger, ...)
amd_timeline -> the spine (beats, tracks, causal edges)
amd_render -> one HTML page
Three things are worth knowing if you extend it.
The renderer owns no grammar. Every body mark is recognized by the function
in procedural/amd.py that already owned it, and amd_blocks decides only the
order those recognizers are tried in. That matters because the in-game text
area reads the same marks: the game and the printed page must not come to read
the same bytes differently, and tests/test_amd_blocks_one_grammar.py pins that
they cannot.
Anchors are path-based, and unique per file. Bare keys are not unique - 40 of
the corpus's 374 repeat, one file holds three recover records, and five
.amd basenames repeat across the missions (OpenUniverse alone has two
ashfang.amd).
The contents list and every cross-reference are built from what the body actually emitted. Each lens renders a different subset, so a link to a record that lens skipped is demoted to plain text rather than offered as a jump that lands nowhere. That makes a dangling anchor structurally impossible instead of a promise each lens has to keep.