Compile writer
How SuiCompileWriter takes the generator output and safely writes it to disk — without ever clobbering user-edited files.
Table of contents
- Why a separate writer?
- The contract
- File ownership
- The .User.scss sidecar
- The manifest
- Backups — outside Code/
- Hash-based no-op skip
- Output folder lifecycle
- Failure modes the writer handles
- What the writer does NOT do
- See also
Why a separate writer?
The generator is pure — it produces in-memory Razor + SCSS strings. The writer is the side-effect half. Splitting them gives:
- Generator is trivially testable — pass a
SuiDocument, assert on the output strings. No disk mocks. - One writer, multiple consumers — Final compile uses it; Test in Play uses a different writer (
SuiPreviewCacheWriter) for the same generator output. - Safety — every disk operation in one file, easier to audit.
Source: Editor/SuiCompileWriter.cs.
The contract
public static SuiCompileResult Run(
SuiGenerationResult generation, // from SuiGenerationPipeline.Run
SuiDocument document,
string outputFolderAbs ); // absolute path, must exist or be creatable
Returns a SuiCompileResult with classified rows: Generated, Skipped, Preserved, Conflict, Obsolete, plus errors/warnings/infos surfaced from the generator.
File ownership
The writer’s central invariant: only files SUI owns may be touched. Ownership is asserted via a SUI:GENERATED header at the top of every emitted file:
@* SUI:GENERATED:BEGIN ==========================================================
Generated by Sbox UI Designer.
Source: my_hud.sui
DocumentId: sui_my_hud_a3b2c1d4
GeneratorVersion: 0.1.0
Do not edit this file manually. Changes will be overwritten.
SUI:GENERATED:END ============================================================ *@
On every compile, for each target file the writer reads what’s currently on disk and classifies:
| Disk state | Classification | Action |
|---|---|---|
| File doesn’t exist | Generated | Write fresh |
| Has our header, DocumentId matches, same hash | Skipped | No-op (saves engine hot-reload) |
| Has our header, DocumentId matches, different hash | Preserved | Backup the old, write new |
| Has our header, different DocumentId | Conflict | Don’t touch. Report to user |
| No header at all | Conflict | Don’t touch. User edited or hand-wrote |
The Conflict case is the safety net. If a user opens the generated MyHud.razor and edits it without realizing it’s generated, the next compile sees the missing header → refuses to overwrite. They get told via Compile Results: “MyHud.razor exists but has no SUI header — move or delete it manually”.
The .User.scss sidecar
For every .razor.scss the writer emits, it also writes a .User.scss sidecar once:
var baseName = file.Path.Substring( 0, file.Path.Length - ".razor.scss".Length );
var userRelative = baseName + ".User.scss";
if ( File.Exists( userAbs ) )
continue; // never overwrite — user-owned
File.WriteAllText( userAbs, boilerplate );
result.UserOwned.Add( userRelative );
The sidecar is user-owned forever. The boilerplate contains a comment block + an empty outer selector block:
// MyHud.User.scss — your custom styles for MyHud.
// This file is created once and never overwritten by the SUI compiler.
// Edit freely — your rules win the cascade because the generated
// .razor.scss imports this file last.
MyHud {
// Example:
// .my-class { color: red; }
}
The generated .razor.scss ends with @import "MyHud.User.scss"; (Final mode only), so the sidecar’s rules cascade last and win at equal specificity.
Read more: User SCSS customization.
The manifest
Per-document, the writer maintains <outputFolder>/.sui-manifest/<DocumentId>.json:
{
"GeneratedFiles": [
{
"Kind": "Razor",
"Path": "MyHud.razor",
"LastHash": "abc123...",
"OwnedByDocumentId": "sui_my_hud_a3b2c1d4",
"GeneratorVersion": "0.1.0"
},
{
"Kind": "Scss",
"Path": "MyHud.razor.scss",
"LastHash": "def456...",
"OwnedByDocumentId": "sui_my_hud_a3b2c1d4",
"GeneratorVersion": "0.1.0"
}
]
}
Per-doc isolation lets multiple .sui files share one output folder (e.g. several HUDs under Code/UI/) without their manifests colliding.
The manifest is used for:
- Detecting obsolete files — if a previous compile owned
OldName.razorbut this compile didn’t emit one, the manifest still references it. The writer classifies it as Obsolete (likely from a class rename) and surfaces it in Compile Results so the user can delete it. - Tracking who owns what across compiles — supports the Conflict classification.
The manifest is deleted when the user invokes Tools → Clean SUI Caches.
Backups — outside Code/
When a Generated file becomes a Preserved one (hash differed), the prior content is backed up to a timestamped folder:
<projectRoot>/.sui-backups/<DocumentName>/<UTC-timestamp>/<file>
Critical: backups live OUTSIDE the Code/ folder. If they lived next to the generated files, the s&box Razor compiler would pick them up as additional partial class MyHud declarations → CS0111: duplicate render-tree members. Resolved by always rooting backups at projectRoot/.sui-backups/.
Backups are never auto-cleaned. The user empties them via Tools → Clean All SUI Caches when comfortable.
Hash-based no-op skip
Every generated file carries a SHA-256 of its content from the generator. The writer compares this hash against the existing on-disk file’s content:
var currentHash = SuiHashUtility.Sha256( File.ReadAllText( abs ) );
if ( currentHash == file.Hash )
{
// Same content. Skip the write entirely.
result.Skipped.Add( file.Path );
continue;
}
Why this matters: every file write in Code/ triggers an engine hot-reload. Spurious hot-reloads add a few hundred ms each and reset gameplay state. The no-op skip means “Compile” on an unchanged document costs zero hot-reload time.
Output folder lifecycle
The output folder is chosen by the user on first compile. It’s persisted in Document.Output.RootFolder:
public sealed class SuiOutputSettings
{
public string ClassName { get; set; }
public string Namespace { get; set; } = "Game.UI";
public string RootFolder { get; set; } // user-picked, project-relative
}
Subsequent compiles skip the folder picker. Users can change the folder later via File → Change Output Folder.
The writer does NOT enforce that the folder is under Code/ — but doing so is required for the engine Razor compiler to pick up the generated .razor files. The folder picker UI defaults to Code/UI/.
Failure modes the writer handles
- Output folder doesn’t exist →
Directory.CreateDirectory(folder)is called first. - File locked by another process → caught, surfaces as an error row, other files still attempt.
- Permission denied on backup folder → caught, falls back to compile but warns.
- Manifest JSON corrupt → treated as missing (caught + warned), rebuilt fresh.
- Disk full → caught, error row, no partial state — already-written files stay.
The writer is best-effort per file, not transactional. If 3 of 4 files write successfully, the 3 are valid output and the 4th is reported.
What the writer does NOT do
- Doesn’t generate — that’s the generator.
- Doesn’t trigger engine hot-reload — the engine watches
Code/itself. - Doesn’t run the s&box compiler — that happens after the file lands on disk.
- Doesn’t touch
.suifiles — the asset writer is separate (lives inSuiAsset).
See also
- Compile + output management — user-facing version
- Generator pipeline — what produces the input
- Compile Results panel — where the result is surfaced