the symptom, in your words

"Build an s&box game assembly offline without stomping the live editor"

✓ verified on 26.08.05
lane Tooling & environmentposted

▸ SYMPTOM

You check out a git worktree of an s&box project to build its game assembly offline, in CI, on a headless agent, or as a second opinion on a wedged in-editor compile. The build fails one of two ways.

If the worktree is missing the game csproj, dotnet build reports MSB1009, "Project file does not exist". If you copy the csproj in from the main tree, the build then reports MSB3245, unresolved Sandbox.*.dll references.

A separate and worse symptom appears later, on a machine that already has an editor open. Every game component in an open scene reads as MissingComponent, the console logs TypeLibrary could not find <ComponentTypeName> once per affected type, and editor_status can report a null active scene while the scene is genuinely open. Through all of it, compile_status keeps reporting Success: true.

▸ CAUSE

The editor generates Code/<project>.csproj and the project gitignores it, so a fresh worktree does not have the file. That is the MSB1009.

The generated csproj references the Steam engine DLLs by relative paths, computed from the main tree's directory depth. A worktree usually nests one level deeper than the main checkout, so a straight copy resolves every ../ one directory short and no engine DLL is found. That is the MSB3245.

The MissingComponent wave has a different cause: a shared output directory. The generated csproj points OutputPath at the engine's .vs/output/ folder, with no project-name segment in the path. A freshly generated csproj reads <OutputPath>../../../../../../Program Files (x86)/Steam/steamapps/common/sbox/.vs/output/</OutputPath>. Every project and every worktree on the machine writes its game DLL into that one folder by default.

A sibling build into the shared folder harms a running editor through two measured mechanisms. A headless build on the same tree cannot redirect obj/, so the intermediates land with fresh timestamps, the editor sees obj/ newer than its sources, and it reports NeedsBuild: false without producing its own DLL. Separately, a second project's editor compiling on the same machine writes its assemblies into the shared folder and deletes this project's game DLL outright. The perpetrator sees nothing. The failure is one-sided, so in the victim's project it reads like a scene bug.

▸ FIX

  1. Copy Code/<project>.csproj from the main tree into the worktree. Never commit the copy: it stays gitignored.
  2. Rewrite the engine references as absolute paths, such as C:/Program Files (x86)/Steam/steamapps/common/sbox/bin/managed/Sandbox.*.dll. Absolute paths are depth-independent, so the same file builds from any worktree nesting.
  3. Give the build a distinct OutputPath inside the worktree, for example .../.vs/output-wt-<branch>/. This keeps the build out of the shared .vs/output/.
  4. Replace every ProjectReference that points outside the worktree, most often addons/base/code/Base Library.csproj, with a <Reference> HintPath'd at its prebuilt DLL. A verify build has no reason to recompile code it is not changing, and recompiling it is what collides with the editor's shared obj/.
  5. For a single throwaway verify build, skip the copy and patch entirely. Run dotnet build <target> -p:OutputPath=<temp-dir>/. The command-line property overrides the csproj without editing it, so nothing lands in the shared directory.

Two verification steps prove the build did what you think:

  1. sha256 the shared .vs/output/ DLLs before and after the build and require byte-identity. That is what proves a live editor in the main tree was untouched.
  2. A green build proves the assembly links, not that your new code is in it. Search the built assembly for every new type and convar name the build was supposed to add.

Watch a stale prebuilt library DLL. A <Reference> to a prebuilt DLL is only as current as the build that produced it. If the library's source moved on since then, the worktree build compiles against an outdated library surface and can pass clean while testing behavior the library no longer has. Re-check the DLL's timestamp against the library source's last change before you trust the result. When the library source has moved, build the vendored library from a gitignored *.offline.csproj with absolute paths instead of referencing the stale DLL.

To prevent the MissingComponent wave, never run a bare dotnet build while any editor on any project is running on the box. Gate and build headlessly with the editor closed and output redirected. Treat a sudden MissingComponent or TypeLibrary could not find wave as a shared-output collision before you suspect a real regression, and check DLL timestamps in .vs/output against your own build time first.

Recovery after a stomp: delete the affected project's own obj/ directories and its own DLLs out of the shared output, never the engine's, then restart the editor. Clearing obj/ is the load-bearing half. Without it the editor decides it has nothing to rebuild. Trust only the restarted session's reads; discard whatever the stomped session reported.

▸ WHY IT WORKS

Absolute engine paths remove the one variable a worktree changes. Relative references encode the main tree's depth, so any checkout at a different depth resolves them wrong. An absolute path resolves to the same DLL from every location, which is why depth stops mattering.

The redirect works because harm to the editor comes from a shared write target, not from the build itself. Two builds that never write the same directory cannot clobber each other's DLL, and an editor whose .vs/output/ is byte-identical before and after a sibling build never loses its assembly. A green compile_status describes the compiler's opinion, not whether the assembly on disk is the one you built, so the timestamp and byte checks are what close that gap.

Verified on engine 26.08.05: seen in a real project.
s&box moves fast; an undated fix is a liability. Spot a stale detail?
changelog
  • Published

Want to know when new guides or fixes drop? Join the community to help build this out. Report gotchas, flag outdated fixes, or just lurk.

Join the Discord