Technical Documentation: Facepunch/sbox-public
โน๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference
Overview (`README.md`)
s&box
s&box is a modern game engine, built on Valve's Source 2 and the latest .NET technology, it provides a modern intuitive editor for creating games.
If your goal is to create games using s&box, please start with getting started guide.
This repository is for building the engine from source for those who want to contribute to the development of the engine.
Getting the engine
Steam
You can download and install the s&box editor directly from Steam.
Compiling from source
This repository contains the C# engine, editor, tooling and game content. The native Source 2 core is
distributed as prebuilt binaries that setup downloads for your platform, so no C++ toolchain is needed.
| Platform | Setup | Notes |
|---|---|---|
| Windows 10 / 11 (x64) | Setup.bat |
|
| Linux (x64) | ./Setup.sh |
Binaries target the Steam Linux Runtime, most distros should work. |
| macOS (Apple Silicon) | ./Setup.sh |
Intel Macs are not supported. |
Prerequisites
- Git
- .NET 10 SDK
- An IDE for the C# code is recommended: Visual Studio 2026 or
Rider on Windows, Rider or VS Code on Linux and macOS.
Setup
# Clone the repo
git clone https://github.com/Facepunch/sbox-public.git
cd sbox-public
# Windows
Setup.bat
# Linux / macOS
./Setup.shOnce setup completes, the game (sbox) and editor (sbox-dev.exe) run from the game folder.
Staying up to date
Pulling on Git will run a hook that fetches any new native binaries or content that changed and regenerates the interop bindings. Build the C# code from your IDE as usual, or rerun Setup.bat for a full incremental rebuild including shaders and content.
Contributing
If you would like to contribute to the engine, please see the contributing guide.
If you want to report bugs or request new features, see sbox-issues.
Documentation
Full documentation, tutorials, and API references are available at sbox.game/dev/.
License
The s&box engine source code is licensed under the MIT License.
Certain native binaries in game/bin are not covered by the MIT license. These binaries are distributed under the s&box EULA. You must agree to the terms of the EULA to use them.
This project includes third-party components that are separately licensed.
Those components are not covered by the MIT license above and remain subject
to their original licenses as indicated in game/thirdpartylegalnotices.
Overview (`engine/Sandbox.CodeUpgrader/Readme.md`)
Analyzer
Analyzers create diagostrics. The analyzers look at the code for stuff and if it finds
it, it creates a diagnostic.
Diagnostic
A diagnostic is one of those CS234 things you get in the output in your visual
studio console. We want them to all have unique numbers so we define them in
a big static class called Diagnostics, and reference them from there.
Fixer
The fixers respond to the diagnostics, and use roslyn to convert one peice of
code to a different peice of code.
Tests
Each Analyzer and Fixer has its own tests. To make things simpler the tests
are in the class itself.
The tests aren't automatically collected, you need to add them to the test
project ( in UpgraderTests.cs )
Test markup
In the test code you're going to see this weird [| and |] markup.
This is telling the tester that we expect a diagnostic between these two tokens.
For example if we have this code:
class [|Type1|] { }We're telling the test that we're expecting a diagnostic to trigger for the
name Type1 for some reason. If it does create a single diagnostic for that span
then the test will pass.
If you do this
class Type1 { }and no diagnostics are created then it will pass.
more: https://www.meziantou.net/how-to-test-a-roslyn-analyzer.htm#marker-syntax
Overview (`engine/Sandbox.SceneRenderer/README.md`)
Sandbox.SceneRenderer
A C# scene renderer for s&box, intended to replace the native scenesystem's world rendering.
It manages render objects, visibility, lighting and frame execution while using the engine's existing
materials, shaders and graphics backend.
It also holds the layers of native's rendering pipeline (Native): native sets each view up and
this adds its layers, for every camera that isn't rendered here. See the
render pipeline doc.
The renderer is experimental. To use it for GameObject scenes, enable r_managed_scene 1.
It is disabled by default. SceneLab runs the managed and native renderers
against the same scenes for visual comparison and performance testing.
This README covers the project structure and development workflow. The
renderer reference contains the implementation details,
native equivalents, performance investigations and debugging history.
What it renders
- Static and skinned meshes, with LOD selection, instancing, morphs and model deformers.
- Opaque, translucent and faded geometry, decals, glass, viewmodels and screen overlays.
- Clustered point and spot lights; directional, spot and point shadows; contact shadows.
- Environment maps, baked lighting, light probe volumes, 2D and 3D skies, and gradient, cubemap and volumetric fog.
- Custom objects such as particles, sprites, terrain, clutter, debug geometry and world panels.
- Camera command lists, post processing, auto exposure and UI through the existing camera stages.
- Render-to-texture targets, MSAA, tools materials and debug views.
The GameObject bridge mirrors scene changes into the renderer, including material overrides,
body groups, tags, per-object attributes and camera settings.
Architecture
RenderWorld owns the scene data. RenderView describes a camera, and RenderSystem turns the visible
part of a world into a frame. Features prepare and draw different kinds of objects; layers specify the
steps of the frame. FrameRecorder decides which layers are needed and records their work for submission.
Rendering uses a dedicated native IRenderContext, rather than native ISceneSystem, ISceneView orISceneLayer objects. The renderer still relies on shared engine services, including the material
system, shader code, shadow-map policy and skinning vertex cache.
Source layout
| Folder | Responsibility |
|---|---|
| World | Scene foundations: RenderWorld, RenderObject and the CustomObject extension point. |
| World/Objects | Meshes, lights, environment-map objects and decals, plus their RenderMesh data. |
| World/Environment | Scene lighting, light probe volumes, fog settings and the 3D skybox. |
| Culling | The dynamic spatial tree, frustum tests and screen-size culling. |
| Pipeline | Cameras, frames, targets, collection, preparation and command recording. |
| Pipeline/Features | RenderFeature, mesh preparation and drawing, and clustered light/probe/decal binning. |
| Pipeline/Layers | Render passes and their resource dependencies. |
| Pipeline/Shadows | Integration with the engine's shared ShadowMapper. |
| Pipeline/Fog | Volumetric fog lighting, temporal accumulation and integration. |
| Gpu | Native rendering access, GPU uploads, transform buffers and shader constants. |
| Bridge | Synchronization with GameObject scenes and execution of existing camera stages. |
| Diagnostics | Frame comparison, rendering statistics and profiler instrumentation. |
| Native | Native's rendering pipeline: the layers of its standard and 3D skybox views (NativeRenderPipeline). |
Folders describe responsibilities; they do not all introduce namespaces. Public types useSandbox.SceneRenderer. Internal subsystems also use .Features, .Culling, .Shadows, .Gpu,.Bridge and .Native; the first four are imported within the project by Assembly.cs.
Shared math belongs in Sandbox.System. In particular,
Matrix3x4 lives alongside Matrix and represents the native affine
layout used for bones and transform buffers.
How a frame runs
RenderSystem.Render( world, view, target ) renders into a ViewTarget. The internal swap-chain
overload is used by the engine and SceneLab.
- Collect: query the spatial tree, test exact bounds and screen size, then group visible objects by feature.
- Prepare: choose LODs, sort draw runs, pack lights and write transform data. The shadow system requests
additional views, which are culled and prepared in the same way. - Setup: create or resolve GPU resources, upload constants and buffers, and dispatch feature setup work.
- Draw: record the required layers and submit them in order, resolving or copying the final output as needed.
Collect and Prepare are CPU-only. This keeps them testable without booting the native engine.
The layer order is defined in RenderSystem.Layers.cs.
Layers declare the resources they read and write through FrameResources. Planning walks those
dependencies backwards and drops producers whose output is unused. IsNeeded handles whether a layer
can do useful work for the current frame.
Large frames record work in parallel. Each recording segment has its own native context, created on
the thread that records it. Shadow culling is also parallelized. Camera stages and custom-object work
that requires the main thread stay there. Small frames avoid the overhead of parallel recording.
With r_managed_async_compute (enabled by default), depth-chain, contact-shadow and AO work can overlap
shadow-map rendering. SSR automatically starts on graphics once Hi-Z is ready, alongside AO on compute.
Declare each pass's inputs, outputs and compute capability. The pipeline itself stays a plain list:
// On the AO pass:
public override FrameResources Reads( RenderFrame frame ) => FrameResources.DepthChain | FrameResources.Normals;
public override FrameResources Writes => FrameResources.AmbientOcclusion;
public override bool SupportsAsyncCompute => true;
// In the pipeline, in serial execution order:
new DepthChainLayer(),
new EarlyStageLayer( Stage.AfterDepthPrepass, PostProcessQueue.AsyncCompute ),
contactShadows,
new EarlyStageLayer( Stage.AfterDepthPrepass, PostProcessQueue.Graphics ),
new ShadowLayer(),
new ShadowsReadyLayer(),FrameRecorder finds consecutive compute-capable passes and the following independent graphics work. It
splits compute submissions at the results graphics needs and inserts waits for read/write, write/read and
write/write conflicts. Read-only sharing needs no wait. Graphics producers submit before the next compute
batch's handoff; outstanding compute joins before that handoff, another frame or untracked work. No groups,
named pass dependencies or manual semaphore wiring are required in the pipeline.
Reusing an earlier compute pass's input or output starts another batch, so graphics preparation cannot clear it too early.
Reads defaults to FrameResources.Untracked: an undeclared pass is a conservative scheduling boundary.
To allow overlap, override it with all resources the pass reads (None for no inputs), and declare its
outputs in Writes. Include Untracked alongside known inputs for arbitrary camera hooks or custom drawing.
These declarations also drive output culling; HasSideEffects keeps work such as the depth prepass even
when no later pass explicitly reads its output. This does not inspect shader bindings or allocate resources.
Each queue preserves list order. Disabled/unsupported async compute and windows with no independent graphics
work stay serial. CPU worker thresholds do not control GPU overlap. Graphics-only preparation and result
transitions remain in HandOff and TakeBack; preparation must only initialize outputs and transition inputs
that already exist before the batch. Early camera effects stay at their normal stage on the serial path.
Connections to the rest of the engine
- ManagedScene owns the convars, scene-change notifications
and shutdown integration. The app system registers this assembly with the engine at startup
(EngineHooks.cs). - ShadowMapper supplies shared shadow allocation, caching
and time-slicing policy for both renderers. - Graphics.IFrameView lets camera effects and
custom drawing operate on either renderer. Use theGraphicsabstraction rather than assuming a
native scene view or layer exists. - RenderTools.def exposes native helpers implemented in
rendertools.cpp. - Gpu/SharedPasses.cs holds the depth chain, quarter depth and bloom and refraction
targets, which this renderer and native's pipeline both use.
Building and testing
Run these commands from the repository root. Close SceneLab before building because it locks the
assemblies in game/bin/managed. See the build guide for
initial setup and native build options.
# Build managed code and regenerate bindings, using the existing native build.
dotnet run --project engine/Tools/SboxBuild/SboxBuild.csproj -- build --skip-native
# Run the renderer's unit tests after building.
dotnet test -c Release --no-build engine/Tests/Sandbox.Test.Unit --filter "FullyQualifiedName~SceneRendererTests"The unit tests cover culling, object storage, transforms,
GPU layouts, light packing, layer selection and allocation behavior. Tests that prepare frames use[DoNotParallelize] because ShadowMapper and Application.FrameCount contain shared static state.
Visual changes require a parity sweep and inspection of the captured images. A passing comparison
alone cannot show that a feature rendered correctly: both renderers might have drawn nothing or used
the same missing asset. A new feature needs a SceneLab preset and a matching native reference.
Disable the feature once to confirm the comparison detects its absence.
Visual comparison
With a game loaded, run these commands in the console:
r_managed_scene 1
r_managed_scene_compare 120The comparison waits 120 camera renders, then captures the next camera through both renderers.
Results go to the console and images to game/screenshots/managed_scene/. Allow the scene to finish
loading before comparing.
In SceneLab, use View > Compare With Native for the current scene. To run a preset sweep, launch
the built-in runner directly from game/:
bin/managed/scenelab.exe -parity
bin/managed/scenelab.exe -parity "Sun Shadows;Reflections" -zoom 0.3The sweep writes its results and captures to game/screenshots/scenelab/parity/, and exits with code 1
if any comparison fails. Use -parity-out <folder> to choose another output location.
<scene>.png: the managed frame.<scene>.native.png: the native frame.<scene>.diff.png: the managed frame dimmed, with differences highlighted in red.
Effects with shared or temporal state need isolated comparisons. Use -parity -isolated for those checks;
AO and SSR presets already request isolation. Test scene changes as well as initial rendering, and
check representative games when changing the bridge.
Changes to the shared shadow mapper also require a native-to-native comparison. Runbin/managed/scenelab.exe -parity -save-native before from game/, make the change, then runbin/managed/scenelab.exe -parity -compare-native before.
For deterministic presets, those frames should be byte-identical.
Performance and frame inspection
From game/, run:
bin/managed/scenelab.exe -scene "All Shadows"
bin/managed/scenelab.exe -benchmark "10k Boxes;50k Mixed" -novsyncBenchmark results go to game/screenshots/scenelab/benchmark.md. Warm up maps before measuring so asset
loading, JIT compilation and shader compilation do not dominate the result. Include scene switching
when testing resource lifetime.
Use overlay_scene_plan 1 in a game, or View > Frame Plan in SceneLab, to see active layers,
resource dependencies, recording segments and timings. Level 2 also shows skipped layers.
Tracy zones are defined in Diagnostics/Zones.cs; they cover collection,
preparation, setup, recording, worker waits and submission. SceneLab's -serialrecording switch helps
compare serial and parallel execution.
Development conventions
Read the relevant section of the renderer reference before
changing a subsystem. When porting behavior, cite the native function or file in the code comment so
the implementation can be checked against its source.
Allocation and threading
- Steady-state per-frame work must not allocate. Reuse buffers and pool per-view state by stable
identity, such as a shadow map and face.PipelineTests.CollectAndPrepareDoNotAllocatechecks the
collection and preparation path, including shadows. - Avoid LINQ, interface-based enumeration, iterators, allocating
paramscalls, string formatting,
capturing lambdas and repeatedly created delegates in hot paths. - Resolve textures, bindless indices and other native resources during Setup, not Collect or Prepare.
- Worker jobs read prepared state and write only their own context, attributes and view-local lists.
Warm mutable caches before dispatching jobs; do not lazily create materials on a recording thread. - Mark work that needs
Graphicsor other main-thread-only engine code throughRenderFeature.CanRecordOffMainThread. - Each pass binds its own targets, viewport, depth range and view constants through
BeginPassorBindFrame. UseSetPassCombofor pass-local shader choices. A pass must not depend on state left
by the previous pass.
GPU resources and interoperability
Keep native rendering calls in Gpu/RenderContext. Add new native helpers through rendertools and
the .def bindings, then regenerate with tools/BuildBindings.bat. Never hand-edit generated interop.
Native changes require a native build; shader changes require shader compilation before parity testing.
Dispose owned GPU resources before native shutdown. Cached materials returned by Material.FromShader
are shared; only destroy materials created and owned by the caller. Context state changes must go
through RenderContext helpers so cached material bindings are invalidated correctly.
GPU structs must match the native and shader layouts, including packing and matrix conventions.
Check those layouts when updating native dependencies. Skinning uses the engine's shared vertex cache;
morph and vertex-cache preparation needed by this renderer must complete before its draw submission.
C# style
Use the engine's Matrix, Vector2, Vector3, Vector4 and Rotation types. They already wrapSystem.Numerics; direct Numerics usage needs a concrete reason, such as an interface that requires it.
Avoid shorthand aliases such as V3, V4 and M4. Remember that Vector3 == is approximate; useEquals when cache invalidation or validation requires exact equality.
Follow the repository's .editorconfig: tabs, spaces inside parentheses, _camelCase instance fields
and s_camelCase mutable static fields. Prefer collection expressions and target-typed new where
appropriate, while retaining preallocated capacity for scratch arrays. Document public types and
members (CS1591 is an error here), and give internal types a summary explaining their role.
Extending the renderer
Add an object type
- Derive from
RenderObjectinWorld/Objects/. OverrideSizeCulledfor objects that should stay out
of the size-culling tree, as lights and probes do. - Add its preparation and drawing in
Pipeline/Features/, using theRenderFeaturecontract.
Include shadow support and the main-thread recording requirement where applicable. - Register the feature in
RenderSystem. Registration order matters; light binning runs first. - Add bridge synchronization where needed, a SceneLab preset, and its native equivalent in
NativeScene.
Add a layer
Add the layer in Pipeline/Layers/ and register it in RenderSystem.Layers.cs at the appropriate point.
Declare resource Reads and Writes; let dependency planning decide whether a producer's output is
needed rather than duplicating all reader conditions in IsNeeded.
Mesh layers select their run lists and shader mode. Additional run lists belong in MeshRuns and the
classifier in MeshRenderFeature.Lists.cs, with a corresponding ClassifierTests case.
Add shader inputs or bridge state
Use native view setup and light-binning code as the reference for shader inputs. Missing constants
often produce incorrect pixels without an error. ViewEnvironment supplies shared environment inputs;
each pass must still supply constants for its own target and viewport.
Bridge changes must preserve native object flags, layer membership, material overrides and camera
attributes. Read native draw state rather than deriving it again. New state setters must notify the
mirror, including changes made indirectly by native code. Verify the scene both before and after a change.
Debugging and remaining work
For incorrect frames, start with pass constants, resource dependencies, object flags and shader modes.
Use debug views to isolate shading terms and inspect the depth chain for depth-dependent effects.
Check that the assets exist and that both renderers have comparable temporal history.
The renderer reference records detailed investigations into
color space, shadow behavior, skinning, material binding, bridge synchronization and parallel recording.
Remaining work includes moving more camera effects into this renderer, threading main-view collection
and preparation, and reducing native per-draw overhead. Known gaps include morphs on meshes without a
skeleton, glass shadow dithering differences, layer-specific attribute overrides and DDGI parity with
baked test data. An intermittent native shutdown crash has also been observed in 256-light scenes;
it still needs a native stack trace.
Overview (`engine/Mounting/Sandbox.Mounting.Source1/README.md`)
Source 1 mount
The engine's gmod mount reads supported models, materials, textures and sounds
from GMA/VPK archives and loose files inside the Garry's Mod installation.
Registration and archives
The installation is inventoried in one filesystem pass, including nested addon,
gamemode content, download and cache folders. Directory links/reparse points are
skipped to avoid cycles or leaving the installation. Unreadable directories are
skipped with a warning while other directories continue mounting. Files directly
in the installation root are included; top-level materials, models, sound andscripts directories retain their canonical prefixes.
Archives precede loose files. Archives inside garrysmod take priority over
other installation folders, with GMAs before VPKs in each priority group and
case-insensitive path sorting for stable first-match resolution. Loose content
keeps its indexed paths without unprefixed addon or gamemode aliases.
Shadowed loose files are logged at trace verbosity; the first indexed source
continues to win.
Model registration uses only companion filenames; models without a VVD/DX90 VTX
pair or a PHY companion remain indexed for dependencies but are not advertised.
Texture headers register the supported individual frames and animation arrays.
GMA versions 1-3 share the VPK reader's leased random-access stream implementation.
Indexing reads directories, not whole payloads; assets load lazily without
extracting archives. Invalid paths, duplicate GMA entries and out-of-bounds data
are rejected. Invalid or unreadable archives are logged as warnings without
marking otherwise valid mounted content as erroneous. Split VPK chunks with a
matching directory archive are skipped, and alternate names for the same VPK
directory are deduplicated.
Only supported model, material, texture and sound resources are registered;
Lua is not executed and BSP/map import is not included. Local Workshop GMA files
inside the installation are supported, but external Steam Workshop folders and
automatic Workshop downloads are not discovered. Remount after downloading or
changing content.
Material dependency resolution
VMT parameters target Windows PC DX90/PS2.0b. Shader fallback blocks such as>=dx90_20b and the $DX90_20B condition are supported. Both combine soldier
skins keep their base texture and self-illumination mask from those blocks.
Loose models resolve their MDL material paths within their enclosing content root
before falling back to ordinary mounted content. VMT patch includes, relative
KeyValues directives, textures and HDR environment maps keep that same root.
For example, addons/example/models/ resolves againstaddons/example/materials/ without adding global aliases. Full indexed resource
paths keep separate addons' materials and texture caches distinct. VMTs retain
their actual indexed entry paths rather than receiving an extra materials/
prefix. Reimport existing models to replace old error-material bindings.
There is no arbitrary basename matching or cross-installation dependency
fallback. Genuinely missing assets remain reported rather than receiving
unrelated replacement materials.
Overview (`engine/Sandbox.Tools/Mcp/README.md`)
MCP tools and sbox-cli
For agent-facing usage, see the sbox-cli skill,
which ships beside the executable and is printed by sbox-cli --skill sbox-cli. Agents find it
through a small bootstrap skill (bootstrap/sbox,
also published as Facepunch/sbox-skills), installed for the user. The editor offers it once when
it finds Claude Code or Codex; Editor Preferences โบ AI Agents and sbox-cli --skill-install manage it.
game/bin/managed/sbox-cli.exe is a .NET 10 console application that keeps one MCP connection
open over stdin/stdout, or makes a single call with --call. It has no engine dependencies of its own. Editors expose a local
named pipe by default, controlled by Project Settings > AI > Enable MCP Communication;
games expose one when launched with -mcp. The pipe is restricted to the current
OS user and named using the process ID and start time. On Windows the CLI also verifies the
server's actual PID. Multiple editors and games can therefore run independently without TCP ports.
The preference is stored in the .sbproj and shared with collaborators. Saving MCP as disabled
immediately closes connected transports and cancels pending work; already running scripts must
cooperate with cancellation.
# Discover running instances that support the new transport.
game/bin/managed/sbox-cli.exe --list
# One call against the editor for the project in the current directory, then exit.
game/bin/managed/sbox-cli.exe --call editor_status
game/bin/managed/sbox-cli.exe --call search_tools '{"query":"scene"}'
# Attach; closing the CLI leaves the process running. Takes a PID, a project, or nothing for
# the project containing the current directory (or the only instance, outside any project).
game/bin/managed/sbox-cli.exe --connect
game/bin/managed/sbox-cli.exe --connect 12345
# Attach to the project's editor if it's open, otherwise launch and own one.
game/bin/managed/sbox-cli.exe --project C:/projects/my-game
# Create a project from a template, like the launcher does. Prints the new .sbproj path.
game/bin/managed/sbox-cli.exe --templates
game/bin/managed/sbox-cli.exe --new "My Game" --template game.minimal --in C:/projects
# Launch and own a game.
game/bin/managed/sbox-cli.exe --game C:/projects/my-game/my-game.sbproj
game/bin/managed/sbox-cli.exe --game facepunch.sandboxA project is a .sbproj or the directory containing it. Inside a project, the CLI only ever
attaches to that project's instance. Launches open normal visible windows. --engine <directory> overrides the game installation
inferred from the CLI's location. Owned launches currently require Windows. An existing process
must be running an engine build with the pipe support; restart older editors after rebuilding.
Stdio protocol and session files
Configure an MCP client to run the CLI with one connection mode and keep that process open.
Do not start a new CLI for every request. Each request/response is one UTF-8 JSON-RPC line:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"my-agent","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"instance_status","arguments":{}}}
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"search_tools","arguments":{"query":"scene"}}}Wait for the initialization response before sending subsequent messages. Its instructions
explain tool discovery, conventions and the session exchange directory. _meta contains pid,kind, project and exchangeDirectory for programs. The unique directory is created under%TEMP%/sbox-cli/; use it for scripts, screenshots and other files shared with the instance.
It is retained after disconnect so outputs remain usable. Copy wanted outputs to the project,
then remove the session directory when finished. Discovery alone does not create directories.
Stdout contains only protocol messages in connection modes. Startup messages and diagnostics
go to stderr. Requests are limited to 8 MiB, responses to 64 MiB, with at most 32 outstanding
requests per CLI. Each connection executes requests in order; separate connections share the
engine main thread. Tools, discovery and binding run on that thread in game scope.
C# scripts
Use search_tools with csharp to find run_csharp, then call it directly or through call_tool:
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"run_csharp","arguments":{"code":"return Game.ActiveScene?.Name;"}}}Supply a C# async method body in code, or an absolute UTF-8 file path (not both).System, System.Linq, System.Threading, System.Threading.Tasks, Sandbox, and the game
global namespace are imported. await, return, exchangeDirectory and cancellationToken
are available. References come from loaded assemblies, including the active addon assemblies;
editor types can be fully qualified in an editor. Compile diagnostics use script line numbers.
This is trusted local automation with full process privileges, and mutations have no automatic
undo. The protocol, transport, registry, session state and built-in tool classes are internal.
Built-in tool methods are also internal and described only in a private MCP registry; never add[Expose], [Library] or [ActionGraphInclude] to them. Game code must not reach them through
its TypeLibrary.
Only the tool declaration attributes, hints and McpResult builders are public for addon authors.
Lifetime
Closing stdin, losing the pipe, or Ctrl+C shuts down the bridge. An attached instance
(--connect, --call, or --project on an editor that was already open) is never terminated.
For an instance it launched, the CLI first requests a graceful quit,
waits up to two seconds, then closes its Windows job to terminate the owned process tree.
The instance is assigned to the job atomically during creation, before running; the job also closes if the
CLI crashes or is forcibly killed. Engine processes do not inherit the CLI's stdio handles.
The defaults are --startup-timeout 180, --idle-timeout 600, and --request-timeout 300
(seconds, configurable from 1 to 86400). Idle timeouts apply only without outstanding requests;
logs and other unsolicited output do not renew them. A running request has its own deadline.
On timeout the connection closes. Queued calls are cancelled on disconnect or after a 30-second
main-thread pickup timeout. Running code cancels cooperatively; use cancellationToken in
long scripts. A running mutation may have completed before disconnection, so inspect state
before retrying. An attached instance is never killed to stop a stuck script.
Tool discovery
A tool is a static method. There is no registration - discovery goes through the editor or game TypeLibrary,
so tools in addons and hotloaded assemblies appear and disappear as code compiles. Write the
method, save, and an agent can call it.
The registry and protocol live in engine/Sandbox.Engine/Systems/Mcp/.
The established Editor.Mcp attribute/result namespace remains available for source and binary
compatibility, including game tools. Editor-specific tools stay in Sandbox.Tools and editor
addons.
Adding a tool
namespace Editor.Mcp;
[McpToolset( "car", "Everything about the cars in the scene" )]
public static class CarTools
{
/// <summary>
/// Find cars by name. Returns each car's id - pass one to get_car for details.
/// </summary>
/// <param name="query">Case insensitive substring of the car's name. Empty matches everything.</param>
/// <param name="limit">How many results to return. Default 20, max 100.</param>
[McpTool( "find_cars" )]
public static object FindCars( string query = "", int limit = 20 )
{
...
return new { Total = total, Cars = cars };
}
}That's everything. In addon code the XML summary becomes the tool description and `` docs
become the parameter descriptions (codegen turns them into attributes). In engine assemblies there
is no codegen - put [Description( ... )] on the method and each parameter instead.
The description is the API. Agents pick tools by reading it, so say what the tool returns, what to
call next, and anything surprising. Cross reference other tools by name - "as returned by
find_cars" tells an agent exactly where a value comes from.
[McpTool]- marks the method. Pass a name ("find_cars") or one gets derived from the method
name in snake_case (FindCarsโfind_cars). Names are public API - agents' workflows break
when they change.[McpToolset]on the class groups its tools under a name and description, browsable throughlist_toolsets. Without it the name derives from the class name (CarToolsโcar).
Tools don't go in the client's static tool list - agents find them live through search_tools and
invoke them through call_tool, which is what keeps the registry fresh across hotloads. (The
handful of built in entry points are listed via the internal [McpListed], and that's deliberate -
a list a client fetched once must never go stale.)
Parameters
Parameters become the tool's json schema. A parameter with a default value is optional; without
one it's required.
| C# type | What the agent sends |
|---|---|
string |
A string. Anything else gets stringified, so this is the most forgiving type. |
bool |
true / false |
int, long, byte... |
An integer |
float, double, decimal |
A number |
| enums | The name as a string, case insensitive. The schema lists every value. |
Vector2/3/4, Vector2Int/3Int, Angles, Rotation |
A comma string - "100,0,50", "pitch,yaw,roll" |
Guid, DateTime, TimeSpan, char |
A string |
T[], List<T> |
An array of T |
Dictionary<string, T> |
An object with T values |
JsonObject, JsonArray, JsonNode |
Raw json, taken as sent |
| A class or struct you wrote | An object - see below |
Binding is deliberately tolerant, because agents make predictable mistakes: argument names match
case insensitively, values that arrive json-encoded inside a string get unwrapped, numbers arrive
as strings, enum names in the wrong case still count. Unknown argument names fail loudly - silently
ignoring a typo'd argument does the wrong thing invisibly.
Custom classes and structs
A plain data type works as a parameter (or inside one - arrays, lists, nested properties). The
schema is generated from its public settable properties:
/// <summary>
/// One waypoint on a patrol route.
/// </summary>
public class Waypoint
{
/// <summary>Where to stand, as 'x,y,z'.</summary>
public required Vector3 Position { get; set; }
/// <summary>Seconds to wait before moving on.</summary>
public float Wait { get; set; }
}
[McpTool( "set_patrol" )]
public static object SetPatrol( string id, Waypoint[] waypoints ) { ... }- Every public property with a public setter becomes a schema property, advertised in camelCase
(Positionโposition). Binding is case insensitive so both spellings work. - The C#
requiredkeyword makes the property required in the schema, and the serializer enforces
it - a call missingpositionfails with an error saying so. - Describe properties the same way as everything else - XML summaries in addon code,
[Description]in engine code. [JsonPropertyName]and[JsonIgnore]are respected.
Engine and framework types don't get their properties reflected - most serialize through custom
converters (Vector3 is the string "x,y,z", not {x, y, z}), so a guessed shape would be wrong.
They keep an open schema; prefer the types in the table above.
Return values
Return whatever is most honest, it gets shaped into an MCP result:
| Return | What the agent gets |
|---|---|
string |
The text as is |
| Any object | Serialized to json - anonymous objects are the usual shape |
GameObject / Component, at any depth |
A compact reference - { "type": "PointLight", "id": ..., "gameObject": ... } - never a serialized scene subtree. The id chains into get_game_object. |
Resource (Model, Material, any GameResource) |
Its resource path - "models/chair.vmdl" - which chains into asset_info and friends. That's the engine's own serialization; no special handling. |
Bitmap |
A png image |
McpResult |
Full control - compose text and image blocks, e.g. McpResult.Image( thumb ).WithText( path ) |
null / void |
"ok" |
Task<T> / async |
Awaited, then shaped as above |
Shape returns for a reader, not a serializer: return counts alongside truncated lists
(new { Total, Showing, Assets }), format dates as strings, and leave out fields nobody asked for.
Declare a data class as the return type instead of object and the tool advertises anoutputSchema generated from its properties, and every result carries structuredContent the
client can bind to it. Anonymous returns stay plain text - use a class when the shape is stable
and worth promising.
Errors
Throw. The exception's message reaches the agent as an error result it will read and adapt to, so
write the message for that reader: say what was wrong and what to do instead.
throw new Exception( $"No car named '{name}'. Find cars with find_cars." );Everything from tool lookup onward goes back in-band - unknown tools, arguments that don't bind,
failures thrown mid-tool - written with the same care: suggestions for near-miss names, the tool's
real parameter list. The agent reads the error and self corrects. You get all of that for free.
Threading and lifetime
Tools run on the editor main thread - touch scenes, assets and widgets freely, no marshalling. If
the main thread doesn't pick the call up within 30 seconds (modal dialog, long operation) the agent
is told the editor is blocked; the queued call is cancelled and will not run later.
Tools can be async. The main thread invokes the method, the result is awaited off it - so do slow
work (network, compiles) after your last touch of engine state, or hop back with MainThread.
Conventions
Registry-wide rules live in one place - the Instructions string in McpProtocol.cs, which every
client injects into its agent's context at connect. Add new rules there instead of repeating them
per tool. The current ones:
- Paging is
limitandoffset. Say the default and max in the parameter description. - Vectors and angles are comma strings -
'x,y,z','pitch,yaw,roll'. - Coordinates are Source engine convention: one unit is one inch, +x forward, +y left, +z up,
angles in degrees. - Game objects and components are identified by guid, assets by the relative path
asset_search
returns.
Trying it out
From the project directory, sbox-cli --call search_tools '{"query":"car"}' finds your tool andsbox-cli --call find_cars calls it. For an MCP client, configure a stdio server with the full
path to sbox-cli.exe and ["--connect"] (or ["--project"]) as arguments; the client's working
directory selects the project.
Then ask it to search_tools for yours and call it. read_console shows what the editor logged,
which is also where your tool's errors and prints end up.
Overview (`engine/Tools/InteropGen/docs/README.md`)
InteropGen
InteropGen generates the C++โC# bindings for the engine. It reads .def files (a small DSL
describing the classes and structs that cross the native/managed boundary) and writes, for each def:
- a managed file (
csdirective) - typed C# wrappers,[UnmanagedCallersOnly]exports and theNativeInteropbootstrap - a native header (
hppdirective) - declarations of the managed classes native can call - a native source file (
cppdirective) - exported thunks, import function pointers and theigen_*initializer
The generated output is not committed (see .gitignore - Interop.*.cs / interop.*); the
build regenerates it, and files whose content didn't change are left untouched.
Documentation
| File | Contents |
|---|---|
| def-files.md | The .def file format: top-level directives, includes, inherit/skipall |
| classes.md | Declaring classes, structs, enums; functions, variables, attributes |
| types.md | Argument types, marshalling, flags (out, ref, CastTo[...], ...) |
How it runs
The build (Tools/SboxBuild) calls Facepunch.InteropGen.Program.ProcessManifest( "engine" ) from
the repo root. That reads engine/manifest.def - a plain list of .def files - and processes each
listed def in parallel. Files whose content didn't change are not rewritten.
Project layout
Program.cs Entry point - reads the manifest, processes each def
Logger.cs Per-thread buffered console output so parallel defs don't interleave
Parsers/ Line-oriented parsers for the .def DSL (global โ class โ inline body)
Definition/ The model: Definition, Class, Function, Variable, Struct
Arguments/ One Arg type per marshallable type - each knows its C#/C++ types and conversions
Pipeline/ InteropPipeline (parse โ resolve โ sort โ mangle โ hash), TypeResolver, Mangler
Writer/ The three emitters (ManagedWriter, NativeWriter, NativeHeaderWriter) and helpersThe pipeline (Pipeline/InteropPipeline.cs) turns a def file into a resolved Definition:
- Parse -
GlobalParserreads the file (and its includes) line by line - Resolve -
TypeResolverlinks base classes, pulls inherited functions down, and converts
every unknown type name into a concreteArg - Sort - structs and classes are ordered by name so output is deterministic
- Mangle -
Manglergives every function/variable a unique C-safe export name - Hash - the def text is hashed; both sides verify it at runtime so a stale binary fails fast
Invariants - read before changing anything
The native and managed sides are built from the same generated files, and the runtime verifies the
def hash when the tables are exchanged - so a stale native/managed pairing fails fast. When
changing the generator:
- Never change the hash computation, the name mangling or the sort order unless both sides will
be regenerated and rebuilt together (they normally are - the build runs the generator). - A pure refactor should be verified by snapshot-diffing the output, since the generated files
aren't in git: run the generator before your change, hash every output, run it again after, and
compare.
# from the repo root, with a tiny console app calling Program.ProcessManifest("engine"):
dotnet run # before the change
<hash engine/Sandbox.*/Interop.*.cs and src/**/interop.*> # snapshot A
dotnet run # after the change
<hash again> # snapshot B - must equal A for a pure refactorTwo ordering details that are easy to break:
- The native export table (casts, functions, variable get/set) is index-matched between C++ and C#.
Both emitters drive it fromWriter/NativeExportTable.cs- the single source of truth for slot
order. Don't enumerate slots anywhere else. - Mangled names de-duplicate collisions with numeric suffixes in encounter order, so reordering
classes or members in a def can rename exports. That's fine - both sides regenerate together -
but it's why output must always be regenerated as a pair.
Overview (`game/unittest/addons/dmmod/readme.md`)
This addon references online package "facepunch.dm98"
To prove:
- Local uncompiled projects can reference remote packages
- Local projects can compile using remote packages
- Local projects can use remote package's base.dll instead of the local
Overview (`engine/Tests/Sandbox.Test.Unit/Layout/Tools/README.md`)
Layout conformance fixture tools
Both fixture sets come from upstream repositories and are regenerated with these scripts (Python 3):
convert_flex_fixtures.py /tests/generated ../FlexConformance- converts Yoga's Chrome-generated gtest fixtures
(facebook/yoga, tag v3.2.1, MIT) into the[TestClass]files in../FlexConformance/.pack_layout_fixtures.py /tests/xml ../../Data/Layout block grid blockgrid gridflex- packs Taffy's
Chrome-generated XML fixtures (DioxusLabs/taffy revisionac2b86929d35b7e0f1d24919595b89b4ce89baa4, MIT) into onelayout-<group>.xmlper group forBlockGridFixtureTests. The checked-in files match that revision byte-for-byte apart from their wrapper.update_layout_signatures.py <results.trx> ../../Data/Layout/layout-known-failures.txt- prints an updated
known-failure baseline after a deliberate layout change. Run the block/grid conformance tests with a TRX logger, review every
changed mismatch, and use the output to update the baseline; never refresh signatures blindly.
Local suite and file names are neutral; generated source comments and XML headers retain upstream attribution.
Flex fixture factories explicitly assign omitted source styles; production nodes always use CSS defaults.FixtureGeometry compares raw results in the source fixture's rounded coordinate convention without mutating
nodes. This is test observation only, not an alternate production layout mode. Block/grid fixtures retain
rectangle and child-count assertions; resolved-track diagnostics are no longer collected in production.
Full MIT notices are comments in Sandbox.Layout/Layout/Flex/FlexLayout.cs and Sandbox.Layout/Layout/Grid/TrackSizing.cs;
the layout assembly embeds these sources so binary distributions also retain the notices.
Signatures hash only the comparison lines (LF-normalized, with a trailing blank line), not diagnostic paths,
suite names, or input/actual tree dumps. Renaming the suites and data files does not require refreshing them.
Engine Tools InteropGen Docs Classes (`engine/Tools/InteropGen/docs/classes.md`)
Classes, structs and members
Declaring classes
native class IPhysicsBody as NativeEngine.IPhysicsBody
{
void SetMass( float f );
float GetMass();
}Every class declaration starts with native or managed - which side implements it:
native class- implemented in C++, imported into managed. The generator emits a C# wrapper
struct holding the native pointer, with a method per function.managed class- implemented in C#, exported to native. The generator emits a C++ class
(in the native header) whose methods call into managed, and[UnmanagedCallersOnly]thunks on
the managed side. Instances are addressed by an object id resolved viaSandbox.InteropSystem.
The forms:
| Form | Meaning |
|---|---|
native class X |
Instance class - wrapper carries a self pointer. |
native static class X |
No instances; members are called as X::Member(). A static class whose native name starts with global binds free functions at global scope (::Member()). |
native accessor g_pThing |
Like a static class, but members are reached through a global pointer: g_pThing->Member(). The generated thunk asserts the pointer isn't null. |
managed class X / managed static class X |
Same split, implemented in C#. |
Names and aliasing
as gives the type a different name on the other side:
native class CVideoPlayer as NativeEngine.CVideoPlayer
managed static class Sandbox.Engine.BootstrapThe first name is the implementing side's name, the as name is the other side's. Namespaces use. (or :: for native) and are split off automatically. Without as, both sides use the same
name.
Base classes
native class CSceneObject as Sandbox.SceneObject
native class CSceneModel as Sandbox.SceneModel : Sandbox.SceneObject: Base references another class in the same def. The derived wrapper inherits all of the base's
non-static functions and gets implicit/explicit conversion operators to and from every base. The
conversions go through native dynamic_cast, because with multiple inheritance base pointers can
differ from derived pointers.
Functions
bool Play( string pUrl, string pExt );
static CVideoPlayer Create( Sandbox.VideoPlayer managedObject ); [new]
void Destroy(); [delete]
int GetMemRequired( int width, int height ) const;staticmembers don't take aself/instance argument.- A trailing
constis accepted (and required to match a const native method on managed classes). - Overloads are fine - mangled export names are de-duplicated automatically.
- A function named
GetTypeis emitted asGetType_Nativein C# (to avoidobject.GetType).
Specials after the semicolon:
| Special | Meaning |
|---|---|
[new] |
The thunk runs return new NativeType( args ); instead of calling a method. |
[delete] |
The thunk runs delete (NativeType*)self;. On the managed side the wrapper also nulls its own pointer afterwards. |
Inline functions
A function starting with inline carries its native body in the def file, instead of binding to an
existing native method:
inline void SetBoneName( string boneName )
{
self->m_boneName = boneName;
self->m_pBoneName = self->m_boneName.String();
}The body is emitted into the generated .cpp. Parameters arrive already converted to their native
types under their declared names (self is the typed instance pointer; this-> is rewritten toself->). return expressions are marshalled like a normal return value.
Variables
A field declaration generates a get/set thunk pair and a C# property:
native class SheetSequence_t
{
uint m_nId;
bool m_bClamp;
float m_flTotalTime;
}Structs, enums and pointer handles
Value types that cross the boundary are declared at file level:
native struct MeshTraceInput
native enum ButtonCode_t is NativeEngine.ButtonCode;
native pointer SwapChainHandle_t;struct- both sides must define the type with an identical layout. Sizes are verified at
startup (structSizesexchange); a mismatch is a fatal error. Structs 8 bytes or larger are
passed by pointer when used as parameters; smaller structs must be marked[small]and are
passed by value.enum- passed as a 64-bit integer and cast on both sides.pointer- a native type that pretends not to be a pointer (DECLARE_POINTER_HANDLE).
Managed treats it as an opaqueIntPtr-sized struct.
as / is (synonyms) alias the managed name, same as for classes.
Attributes
Attributes are written on the line(s) before a declaration and apply to the next class, struct or
function parsed.
On functions
| Attribute | Meaning |
|---|---|
[nogc] |
Emits [SuppressGCTransition] - the call skips the GC transition, so it must be short, non-blocking and never call back into managed. On a class, applies to all of its functions. |
[callback] |
Marks a function that can re-enter managed. Cancels [nogc] inherited from the class. |
On classes
| Attribute | Meaning |
|---|---|
[Handle:Managed.Type] |
Instances cross the boundary as handle ids instead of raw pointers. Managed resolves them with Sandbox.HandleIndex.Get<T>, native with GetManagedHandle. |
[ResourceHandle:HModel] |
The class is accessed through a strong resource handle (HModelStrong). Thunks dereference the handle (returning default if unloaded), and the class automatically gains handle-management functions: DestroyStrongHandle, IsStrongHandleValid, IsError, IsStrongHandleLoaded, CopyStrongHandle, GetBindingPtr. |
[SharedDataPointer] |
For Qt-style classes that are just a smart pointer to shared data. The wrapper is a public class (not a struct) that copies by value across the boundary and queues a Dispose from its finalizer. |
[WindowsOnly] |
When generating on a non-Windows platform, the native thunks are emitted as stubs returning default values. |
On structs
| Attribute | Meaning |
|---|---|
[small] |
The struct is smaller than a pointer (8 bytes) and is passed by value. Required - the generated code static_asserts that unmarked structs are at least 8 bytes. |
Engine Tools InteropGen Docs Def Files (`engine/Tools/InteropGen/docs/def-files.md`)
.def file format
A .def file is parsed line by line. Blank lines are skipped, and lines starting with // are
comments. Every other line is one of:
- a directive - the first word is the keyword, the rest of the line is its argument
- a type declaration -
native class ...,managed struct ..., etc. (see classes.md) - an attribute -
[nogc],[Handle:...], etc., applying to the next declaration
Directive arguments may be wrapped in double quotes; the quotes are stripped. The keyword doesn't
have to start the line - #include "foo.h" works because the parser matches the first word-run it
finds, which is handy for keeping C++ syntax highlighting happy in def files.
Top-level directives
| Directive | Example | Meaning |
|---|---|---|
ident |
ident "engine" |
Name of this binding set. Becomes the native init export igen_engine and namespaces handle types. |
nativedll |
nativedll engine2.dll |
The native dll managed loads at startup (extension is stripped; resolved per-platform). |
cs |
cs "../Sandbox.Engine/Interop.Engine.cs" |
Where to write the managed output, relative to the def file. |
cpp |
cpp "../../src/engine2/interop.engine.cpp" |
Where to write the native source. |
hpp |
hpp "../../src/engine2/interop.engine.h" |
Where to write the native header. Per-class sub-headers (interop.engine.<class>.h) are written next to it. |
namespace |
namespace "Managed.SandboxEngine" |
The namespace for the generated NativeInterop/Exports classes - C# namespace on the managed side, C++ namespace on the native side. |
exceptions |
exceptions "Sandbox.Interop.BindingException" |
Managed method called when an exported managed function throws: (string className, string functionName, Exception e). |
pch |
pch "cbase.h" |
Precompiled header #included at the top of the generated .cpp. |
include |
include "engine/*" |
Includes a header, def file or folder - see below. |
inherit |
inherit "engine.def" |
This binding set builds on another - see below. |
skipall |
skipall "tools.def" |
Skip everything another def already covers - see below. |
delegate |
delegate DebugDrawDelegate_t; |
Declares a native delegate type name so it can be used as a parameter type (passed as IntPtr, converted with FunctionPointerToDelegate<T> on the native side). |
include
include does three different things depending on its argument:
- ends in
.hโ emitted as#include "..."in the generated native header. The conventional
spelling is#include "vphysics2/iphysicsbody.h", which parses identically. - ends in
.defโ that file is parsed inline, as if its contents were pasted here. Paths are
relative to the including file. - otherwise โ treated as a folder: every
*.defin it is included in alphabetical order.
A trailing/*(e.g.include "common/*") recurses into subfolders.
Included def text counts toward the definition hash, so a change in any included file correctly
invalidates the binary pairing.
inherit and skipall
Several defs can cover overlapping native code. tools.def builds on engine.def:
ident "tools"
inherit "engine.def"
include "common/*" // same folders engine.def includesinherit loads the other def and remembers what it declared. Types declared there are still
parsed here (the includes overlap on purpose) so they can be referenced as parameter and return
types - but they are not re-emitted: their wrappers already exist in the inherited def's
assembly, and they're excluded from this def's export tables and struct size checks.
skipall is the stronger form used when a def chain gets deep (e.g. tools.hammer.def inheritsengine.def, tools.def and tools.assetsystem.def):
inherit "tools.def"
skipall "tools.def"Everything that appears in the named def - classes (native and managed), structs, and its .h
includes - is skipped during emission here.
manifest.def
engine/manifest.def is just the list of defs to build, one per line:
Definitions/tools.def
Definitions/engine.def
...Lines not ending in .def are ignored.
Engine Tools InteropGen Docs Types (`engine/Tools/InteropGen/docs/types.md`)
Argument types and flags
Every parameter, return value and variable in a def has a type. A type is either one of the
built-ins below, or a name resolved against the def's declared classes, structs, enums, pointers
and delegates.
Built-in types
Built-ins are Arg subclasses in Arguments/, registered by [TypeName] attribute (matching is
case-insensitive).
| .def type | C# | C++ | Notes |
|---|---|---|---|
void |
void |
void |
Return type only. |
bool |
bool |
bool |
Crosses the boundary as int (0/1). |
byte |
byte |
unsigned char |
|
ushort |
ushort |
unsigned short |
|
int |
int |
int |
|
uint |
uint |
unsigned int |
|
long |
long |
int64 |
|
ulong |
ulong |
uint64 |
|
float |
float |
float |
|
double |
double |
double |
|
intptr, void* |
IntPtr |
void* |
Raw pointer. |
string |
string |
const char* |
UTF-8. Pinned for the call going in; copied to a thread-local on the way out (SafeReturnString). |
CUtlString |
string |
CUtlString |
Like string but the native side holds a CUtlString. |
stringtoken |
Sandbox.StringToken |
uint32 |
Built native-side with StringTokenFromHashCode. |
Qt types, used by the tools defs:
| .def type | C# | C++ |
|---|---|---|
qstring |
string |
QString (crosses as const QChar*) |
qdir |
string |
QDir |
qbytearray |
string |
QByteArray (crosses base64-encoded) |
qicon |
string |
resolved with FindOrCreateQIcon |
qreal |
float |
qreal |
qpoint, qpointf |
Vector3 |
QPoint / QPointF |
qsize, qsizef |
Vector3 |
QSize / QSizeF |
qcolor |
Color32 |
QColor |
qrect, qrectf |
QRectF |
QRectF |
Resolving everything else
Any other type name is looked up, in order:
- a declared class by managed name with namespace, then by short managed name
- a declared struct/enum/pointer by managed name (with or without namespace), then native name
- a declared delegate
An unknown type fails the build with Unknown Type <name>.
How a resolved type crosses the boundary depends on what it is:
- native class - the raw native pointer (
IntPtrin C#).[Handle:...]classes cross as a
handle id,[ResourceHandle:...]classes as a strong-handle pointer. - managed class - a uint object id, resolved through
Sandbox.InteropSystem. - struct - by pointer if 8+ bytes, by value if marked
[small]. - enum - as a 64-bit integer, cast on both sides.
- pointer - as an opaque
IntPtr. - delegate - as an
IntPtr, converted native-side withFunctionPointerToDelegate<T>.
Parameter flags
Flags are written before the type:
void GetEngineSwapChainSize( out int w, out int h );
void SetTonemapParameters( ref SceneTonemapParameters_t pParams );
bool ConvertImageFormat( CastTo[uint8*] void* src, ... );| Flag | Sides | Meaning |
|---|---|---|
out |
C# out param |
Passed to native as a pointer to write through. out string works too - the native side writes a pointer, managed converts it after the call. |
ref |
C# ref param |
Passed as a pointer, readable and writable. |
asref |
return values | The wrapper returns ref T - a managed ref into native memory (Unsafe.AsRef). |
cref |
native side | The native method wants a reference (T&): the thunk dereferences the incoming pointer, and outgoing calls pass &value. |
cast |
native side | Cast the value to the parameter's native type when passing it. |
CastTo[X] |
native side | Cast the incoming value to the exact native type X before use - for types managed doesn't model (e.g. CastTo[CKV3MemberName] string key). |
stable |
string returns |
Promises the returned const char* outlives the call (an interned name, a member that isn't going away), so the thunk skips the defensive copy into a thread-local that string returns normally get. Only valid on string - don't use it for pointers into temporaries. |
Arrays and literals
type[] name- passes a raw pointer to the first element (type*on both sides). The caller
guarantees the length by convention (usually a separate count parameter).[expression]as a parameter is a literal: it isn't part of the managed signature at all, and the
native call site passes the expression verbatim.
--- METRICS ---
- Files Extracted: 11
- Estimated Token Budget: ~16019 tokens
- Canonical Reference: https://codewiki.google/github.com/Facepunch/sbox-public