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 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:

  1. Detecting obsolete files — if a previous compile owned OldName.razor but 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.
  2. 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 existDirectory.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 .sui files — the asset writer is separate (lives in SuiAsset).

See also


SUI Designer · MIT license · Built for the s&box ecosystem.

This site uses Just the Docs, a documentation theme for Jekyll.