Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **C# scripting (Mono):** optional `USE_CSHARP=ON` embeds Mono for runtime `.cs` scripts (`cs_reload`, `cs_list`, `cs_dump`); shared events with JavaScript via `Com_ScriptEmitEvent`; API in `src/qcommon/csharp/IdTech3.Engine.cs`; `IdTech3.Engine.Exec`; demo mod `demo_csharp.cfg`; see **`docs/CSHARP.md`**.
- **C# scripting (Mono):** optional `USE_CSHARP=ON` embeds Mono for runtime `.cs` scripts (`cs_reload`, `cs_exec`, `cs_list`, `cs_dump`); shared events with JavaScript via `Com_ScriptEmitEvent`; API in `src/qcommon/csharp/IdTech3.Engine.cs` (`Exec`, `ReadFile`, events); `cs_frameCallbackBudgetMs` soft frame cap; demo mod `demo_csharp.cfg`; see **`docs/CSHARP.md`**.
- **Lua `Engine.*` on client:** `LuaBindings_RegisterAll` runs when the Lua VM opens (`LuaDebug_SetEngineRegisterCallback` from `CL_Init`).
- Optional Git submodule **`tools/tiled`**: [Tiled Map Editor](https://www.mapeditor.org/) (GPL-2.0, upstream `mapeditor/tiled`, pinned tag **v1.9.91**); not linked into the engine — see **`docs/TILED.md`**. Init via **`scripts/init_optional_submodules.sh`** (`--tiled`, `--svo`, `--all`, `--dry-run`). Designer sample: **`examples/tiled/minimal_demo.tmx`**; CTest **`test_init_optional_submodules`**.
- Vulkan VDB: console commands **`vdb_load`**, **`vdb_upload`**, **`vdb_bind_fog`**, **`vdb_list`** for NanoVDB → volumetric fog workflow (refreshes volumetric descriptors after upload).
Expand Down
12 changes: 12 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ When `fs_restrict` is **0** (default), `VM_Create` always tries a **native** sha

The **client** links **libcurl** when `USE_CURL` is enabled at build time. It powers **HTTPS/FTP** fetches of **`.pk3`** archives: server redirect downloads (`sv_dlURL` + `CL_cURL_*`) and manual or auto map downloads (`cl_dlURL` + `Com_DL_*`, commands `download` / `dlmap`). Protocols are restricted to **http, https, ftp, ftps**; there is no generic HTTP API exposed to game VMs without additional code. Full tutorial: [CURL_NETWORKING.md](CURL_NETWORKING.md).

## Scripting (Lua / JavaScript / C#)

Three optional runtimes share the same **event bridge** (`Com_ScriptEmitEvent` in `src/qcommon/script_emit.c`):

| Runtime | Build flag | Entry / API | Console |
|---------|------------|-------------|---------|
| **Lua** | `USE_LUA` (default on client) | `g_lua_bindings.c` — `Engine.*` on client via `LuaBindings_RegisterAll` | `lua_reload`, `lua_exec`, … |
| **JavaScript** | `USE_DUKTAPE` | Duktape HUD + `js_*` bindings | `js_reload`, `js_exec`, `js_list`, … |
| **C#** | `USE_CSHARP` (default OFF) | Mono + `IdTech3.Engine.cs` + `Game.Script` | `cs_reload`, `cs_exec`, `cs_list`, … |

**Lua** is the richest gameplay API (Director, nav, physics hooks). **JavaScript** targets UI/HUD scripting. **C#** is a lighter Mono host for mods that prefer C# syntax; see [CSHARP.md](CSHARP.md). All three can receive the same engine events (`frame`, `map_load`, `entity_spawn`, …) when their policy cvars allow it.

## JavaScript / UI Debug (Duktape)

When `USE_DUKTAPE` is enabled, the engine provides a JavaScript runtime (`idtech3` namespace) with event callbacks and HUD bindings. **Game events** (emitted from snapshot parsing): `entity_spawn`, `entity_death`, `weapon_fire` - payloads include `entityNum`, `eType`, `attacker`, `weapon`. See [JS_HUD_DRAWING.md](JS_HUD_DRAWING.md#game-events). Other events: `frame`, `menu_changed`, `ui_open`, `ui_close`, `map_load`, `input_key`, `mouse_move`, etc. For debugging UI and script issues:
Expand Down
4 changes: 3 additions & 1 deletion docs/CSHARP.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Startup log when enabled: `C# scripting: USE_CSHARP enabled (cs_reload, scripts/
| Command | Description |
|---------|-------------|
| `cs_reload [path.cs ...]` | Compile `.cs` with `mcs` and load assembly (see `cs_compiler` cvar) |
| `cs_exec <statements>` | One-shot: compile statements inside `Game.Script.Init` (does not replace tracked assembly) |
| `cs_list` | Runtime status and tracked scripts |
| `cs_dump` | Same as `cs_list` |

Expand All @@ -31,6 +32,7 @@ Startup log when enabled: `C# scripting: USE_CSHARP enabled (cs_reload, scripts/
- Entry: `namespace Game { public static class Script { public static void Init(); public static void Frame(int msec, int realMsec); } }`
- Events: `IdTech3.Engine.On("event", (s0,s1,i0,i1) => { ... });`
- Console: `IdTech3.Engine.Exec("set r_fullscreen 0");` (when `cs_allowExec` 1)
- VFS read: `IdTech3.Engine.ReadFile("scripts/csharp/foo.txt")` (relative paths only; no `..`)
- Allowed paths: `scripts/csharp/`, `gameplay/`, `client/`, `ui/`

Compiled DLL cache: `<fs_home>/vm/csharp_cache/`
Expand All @@ -41,7 +43,7 @@ Compiled DLL cache: `<fs_home>/vm/csharp_cache/`
|------|---------|---------|
| `cs_autoInit` | `0` | Open Mono at startup (no scripts until `cs_reload`) |
| `cs_allowEvents` | `1` | `Engine.On` / `DispatchEvent` |
| `cs_frameCallbackBudgetMs` | `2` | Reserved for future budget enforcement |
| `cs_frameCallbackBudgetMs` | `2` | Soft cap for `Game.Script.Frame` + `frame` event per tick (`0` = unlimited) |
| `cs_compiler` | `mcs` | Compiler executable |
| `cs_allowExec` | `1` | `IdTech3.Engine.Exec` appends to the command buffer |
| `cs_compatTarget` | `mono-4.7-api` | Read-only API profile label |
Expand Down
3 changes: 3 additions & 0 deletions examples/demo_game/mod/scripts/csharp/IdTech3.Engine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public static class Engine
[MethodImpl( MethodImplOptions.InternalCall )]
public static extern void Exec( string command );

[MethodImpl( MethodImplOptions.InternalCall )]
public static extern string ReadFile( string path );

static readonly Dictionary<string, List<Action<string, string, int, int>>> s_handlers =
new Dictionary<string, List<Action<string, string, int, int>>>( StringComparer.OrdinalIgnoreCase );

Expand Down
2 changes: 2 additions & 0 deletions src/qcommon/cmd.c
Original file line number Diff line number Diff line change
Expand Up @@ -1203,9 +1203,11 @@ void Cmd_Init( void ) {
extern void Cmd_CsReload_f(void);
extern void Cmd_CsList_f(void);
extern void Cmd_CsDump_f(void);
extern void Cmd_CsExec_f(void);
extern void CsDebug_InitCvars(void);
Cmd_AddCommand("cs_reload", Cmd_CsReload_f);
Cmd_AddCommand("cs_list", Cmd_CsList_f);
Cmd_AddCommand("cs_dump", Cmd_CsDump_f);
Cmd_AddCommand("cs_exec", Cmd_CsExec_f);
CsDebug_InitCvars();
}
3 changes: 3 additions & 0 deletions src/qcommon/csharp/IdTech3.Engine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public static class Engine
[MethodImpl( MethodImplOptions.InternalCall )]
public static extern void Exec( string command );

[MethodImpl( MethodImplOptions.InternalCall )]
public static extern string ReadFile( string path );

static readonly Dictionary<string, List<Action<string, string, int, int>>> s_handlers =
new Dictionary<string, List<Action<string, string, int, int>>>( StringComparer.OrdinalIgnoreCase );

Expand Down
207 changes: 192 additions & 15 deletions src/qcommon/csharp_debug.c
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,68 @@ static void id3_cs_exec( MonoString *command ) {
}
}

static qboolean CsDebug_IsSafeReadPath( const char *path ) {
int i;

if ( !path || !path[0] ) {
return qfalse;
}
if ( path[0] == '/' || path[0] == '\\' ) {
return qfalse;
}
if ( strstr( path, ".." ) ) {
return qfalse;
}
if ( strchr( path, ':' ) ) {
return qfalse;
}
for ( i = 0; path[i]; i++ ) {
if ( path[i] == '\\' ) {
return qfalse;
}
}
return qtrue;
}

static MonoString *id3_cs_read_file( MonoString *pathMono ) {
char *pathUtf8;
void *buf;
int len;
MonoString *result;

if ( !pathMono ) {
return mono_string_new( mono_domain_get(), "" );
}
pathUtf8 = mono_string_to_utf8( pathMono );
if ( !pathUtf8 ) {
return mono_string_new( mono_domain_get(), "" );
}
if ( !CsDebug_IsSafeReadPath( pathUtf8 ) ) {
Com_Printf( S_COLOR_YELLOW "C#: ReadFile denied unsafe path '%s'\n", pathUtf8 );
mono_free( pathUtf8 );
return mono_string_new( mono_domain_get(), "" );
}

len = FS_ReadFile( pathUtf8, &buf );
mono_free( pathUtf8 );

if ( len < 0 || !buf ) {
return mono_string_new( mono_domain_get(), "" );
}

result = mono_string_new( mono_domain_get(), (const char *)buf );
FS_FreeFile( buf );
return result;
}

static void CsDebug_RegisterInternalCalls( void ) {
mono_add_internal_call( "IdTech3.Engine::Print", id3_cs_print );
mono_add_internal_call( "IdTech3.Engine::CvarGet", id3_cs_cvar_get );
mono_add_internal_call( "IdTech3.Engine::CvarSet", id3_cs_cvar_set );
mono_add_internal_call( "IdTech3.Engine::GetMilliseconds", id3_cs_get_milliseconds );
mono_add_internal_call( "IdTech3.Engine::GetEngineInfo", id3_cs_get_engine_info );
mono_add_internal_call( "IdTech3.Engine::Exec", id3_cs_exec );
mono_add_internal_call( "IdTech3.Engine::ReadFile", id3_cs_read_file );
}

static qboolean CsDebug_StageVfsFileToHome( const char *vfsPath, char *diskPath, int diskPathSize );
Expand Down Expand Up @@ -308,39 +363,87 @@ static qboolean CsDebug_LoadAssembly( const char *dllPath ) {
return qtrue;
}

static qboolean CsDebug_LoadScript( const char *scriptPath ) {
char dllPath[MAX_OSPATH];
static qboolean CsDebug_DllPathForScript( const char *scriptPath, char *dllPath, int dllPathSize ) {
char cacheDir[MAX_OSPATH];
char baseName[MAX_QPATH];
const char *base;
int len;

if ( !CsDebug_EnsureMono() ) {
return qfalse;
}
if ( !CsDebug_IsAllowedPath( scriptPath ) ) {
Com_Printf( S_COLOR_RED "C#: denied path '%s' (use scripts/csharp/, gameplay/, client/, ui/)\n", scriptPath );
if ( !scriptPath || !dllPath || dllPathSize <= 0 ) {
return qfalse;
}

len = (int)strlen( scriptPath );
if ( len < 4 || Q_stricmp( scriptPath + len - 3, ".cs" ) ) {
Com_Printf( S_COLOR_RED "C#: only .cs sources supported (got '%s')\n", scriptPath );
return qfalse;
}

Com_sprintf( cacheDir, sizeof( cacheDir ), "%s/vm/csharp_cache", FS_GetHomePath() );
Sys_Mkdir( cacheDir );

{
char baseName[MAX_QPATH];
Q_strncpyz( baseName, scriptPath, sizeof( baseName ) );
base = COM_SkipPath( baseName );
COM_StripExtension( base, dllPath, dllPathSize );
Com_sprintf( dllPath, dllPathSize, "%s/%s.dll", cacheDir, dllPath );
return qtrue;
}

static qboolean CsDebug_CompileToDll( const char *scriptPath, char *dllPath, int dllPathSize ) {
if ( !CsDebug_DllPathForScript( scriptPath, dllPath, dllPathSize ) ) {
Com_Printf( S_COLOR_RED "C#: only .cs sources supported (got '%s')\n", scriptPath );
return qfalse;
}
return CsDebug_CompileScript( scriptPath, dllPath, dllPathSize );
}

static qboolean CsDebug_RunInitFromDll( const char *dllPath ) {
MonoAssembly *assembly;
MonoImage *image;
MonoClass *gameScriptClass;
MonoMethod *initMethod;
MonoObject *exc;
MonoImageOpenStatus status;

assembly = mono_assembly_open( dllPath, &status );
if ( !assembly || status != MONO_IMAGE_OK ) {
Com_Printf( S_COLOR_RED "C#: cs_exec failed to open '%s' (status %d)\n", dllPath, (int)status );
return qfalse;
}

image = mono_assembly_get_image( assembly );
gameScriptClass = mono_class_from_name( image, "Game", "Script" );
if ( !gameScriptClass ) {
Com_Printf( S_COLOR_RED "C#: cs_exec missing Game.Script in %s\n", dllPath );
return qfalse;
}

initMethod = mono_class_get_method_from_name( gameScriptClass, "Init", 0 );
if ( !initMethod ) {
Com_Printf( S_COLOR_RED "C#: cs_exec missing Game.Script.Init in %s\n", dllPath );
return qfalse;
}

mono_runtime_invoke( initMethod, NULL, NULL, &exc );
if ( exc ) {
Com_Printf( S_COLOR_RED "C#: cs_exec Game.Script.Init threw an exception\n" );
return qfalse;
}

return qtrue;
}

Q_strncpyz( baseName, scriptPath, sizeof( baseName ) );
base = COM_SkipPath( baseName );
COM_StripExtension( base, dllPath, sizeof( dllPath ) );
Com_sprintf( dllPath, sizeof( dllPath ), "%s/%s.dll", cacheDir, dllPath );
static qboolean CsDebug_LoadScript( const char *scriptPath ) {
char dllPath[MAX_OSPATH];

if ( !CsDebug_EnsureMono() ) {
return qfalse;
}
if ( !CsDebug_IsAllowedPath( scriptPath ) ) {
Com_Printf( S_COLOR_RED "C#: denied path '%s' (use scripts/csharp/, gameplay/, client/, ui/)\n", scriptPath );
return qfalse;
}

if ( !CsDebug_CompileScript( scriptPath, dllPath, sizeof( dllPath ) ) ) {
if ( !CsDebug_CompileToDll( scriptPath, dllPath, sizeof( dllPath ) ) ) {
return qfalse;
}

Expand Down Expand Up @@ -375,12 +478,19 @@ void CsDebug_Frame( int msec, int realMsec ) {
void *evArgs[5];
int i0;
int i1;
int startMs;
int budgetMs;
int elapsedMs;
MonoObject *exc;

if ( !s_csMonoReady || !s_csAssembly ) {
return;
}

CsDebug_InitPolicyCvars();
startMs = Sys_Milliseconds();
budgetMs = cs_frameCallbackBudgetMs ? cs_frameCallbackBudgetMs->integer : 0;

if ( s_csFrameMethod ) {
args[0] = &msec;
args[1] = &realMsec;
Expand All @@ -390,6 +500,11 @@ void CsDebug_Frame( int msec, int realMsec ) {
}
}

elapsedMs = Sys_Milliseconds() - startMs;
if ( budgetMs > 0 && elapsedMs >= budgetMs ) {
return;
}

if ( cs_allowEvents && cs_allowEvents->integer && s_csDispatchEventMethod ) {
i0 = msec;
i1 = realMsec;
Expand Down Expand Up @@ -508,6 +623,64 @@ void Cmd_CsDump_f( void ) {
Cmd_CsList_f();
}

#define CS_EXEC_SCRATCH_PATH "scripts/csharp/cs_exec_scratch.cs"
#define CS_EXEC_MAX_SOURCE 8192

void Cmd_CsExec_f( void ) {
const char *source;
char wrapped[CS_EXEC_MAX_SOURCE + 512];
char dllPath[MAX_OSPATH];
int wrappedLen;

CsDebug_InitPolicyCvars();

if ( Cmd_Argc() <= 1 ) {
Com_Printf( "Usage: cs_exec <csharp-statements>\n" );
Com_Printf( " Wraps statements in Game.Script.Init (one-shot; does not replace cs_reload assembly).\n" );
return;
}

if ( !CsDebug_EnsureMono() ) {
return;
}

source = Cmd_ArgsFrom( 1 );
if ( !source || !source[0] ) {
Com_Printf( "C#: empty source\n" );
return;
}

if ( (int)strlen( source ) > CS_EXEC_MAX_SOURCE ) {
Com_Printf( S_COLOR_RED "C#: cs_exec source exceeds %d bytes\n", CS_EXEC_MAX_SOURCE );
return;
}

wrappedLen = Com_sprintf( wrapped, sizeof( wrapped ),
"namespace Game {\n"
"public static class Script {\n"
"public static void Init() {\n"
"%s\n"
"}\n"
"public static void Frame(int msec,int realMsec) {}\n"
"}\n"
"}\n",
source );
if ( wrappedLen < 0 || wrappedLen >= (int)sizeof( wrapped ) ) {
Com_Printf( S_COLOR_RED "C#: cs_exec wrapped source too large\n" );
return;
}

FS_WriteFile( CS_EXEC_SCRATCH_PATH, wrapped, wrappedLen );

if ( !CsDebug_CompileToDll( CS_EXEC_SCRATCH_PATH, dllPath, sizeof( dllPath ) ) ) {
return;
}

if ( CsDebug_RunInitFromDll( dllPath ) ) {
Com_Printf( "C#: cs_exec completed\n" );
}
}

#else /* !USE_CSHARP */

void CsDebug_InitCvars( void ) {
Expand All @@ -525,6 +698,10 @@ void Cmd_CsDump_f( void ) {
Cmd_CsReload_f();
}

void Cmd_CsExec_f( void ) {
Cmd_CsReload_f();
}

void CsDebug_Frame( int msec, int realMsec ) {
(void)msec;
(void)realMsec;
Expand Down
1 change: 1 addition & 0 deletions src/qcommon/csharp_debug.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
void Cmd_CsReload_f( void );
void Cmd_CsList_f( void );
void Cmd_CsDump_f( void );
void Cmd_CsExec_f( void );
void CsDebug_InitCvars( void );
void CsDebug_Frame( int msec, int realMsec );
void CsDebug_EmitEvent( const char *eventName, const char *s0, const char *s1, int i0, int i1 );
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
glslang_validator_path=/usr/bin/glslangValidator
glslang_validator_version=Glslang Version: 11:15.1.0
generated_at=2026-05-21T18:34:34Z
generated_at=2026-05-21T18:57:31Z
shader_data_sha256=02fcd2a91cb59e4ca9bea412eb9626b45a0a34432e93306ffc52d084a7b5c441
shader_binding_sha256=b5b9eea3699f249a8bcc862fef2304af888d895599abf4e825827b40ebfcc584
5 changes: 5 additions & 0 deletions tests/scripts/test_csharp_scripting.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ grep -q 'option(USE_CSHARP' "$PROJECT_ROOT/CMakeLists.txt" || fail "USE_CSHARP C
grep -q 'csharp_debug.c' "$PROJECT_ROOT/src/qcommon/csharp_debug.c" 2>/dev/null || \
[ -f "$PROJECT_ROOT/src/qcommon/csharp_debug.c" ] || fail "csharp_debug.c missing"
grep -q 'Cmd_CsReload_f' "$PROJECT_ROOT/src/qcommon/cmd.c" || fail "cs_reload not registered in cmd.c"
grep -q 'Cmd_CsExec_f' "$PROJECT_ROOT/src/qcommon/cmd.c" || fail "cs_exec not registered in cmd.c"
grep -q 'Com_ScriptEmitEvent' "$PROJECT_ROOT/src/qcommon/script_emit.c" || fail "script_emit bridge missing"
grep -q 'CsDebug_Frame' "$PROJECT_ROOT/src/qcommon/common.c" || fail "CsDebug_Frame not called from Com_Frame"
grep -q 'cs_allowExec' "$PROJECT_ROOT/src/qcommon/csharp_debug.c" || fail "cs_allowExec cvar missing"
grep -q 'id3_cs_read_file' "$PROJECT_ROOT/src/qcommon/csharp_debug.c" || fail "ReadFile internal call missing"
grep -q 'Cmd_CsExec_f' "$PROJECT_ROOT/src/qcommon/csharp_debug.c" || fail "Cmd_CsExec_f missing"
grep -q 'cs_frameCallbackBudgetMs' "$PROJECT_ROOT/src/qcommon/csharp_debug.c" || fail "frame budget cvar missing"
grep -q 'ReadFile' "$PROJECT_ROOT/src/qcommon/csharp/IdTech3.Engine.cs" || fail "Engine.ReadFile missing from API"
grep -q 'LuaDebug_SetEngineRegisterCallback' "$PROJECT_ROOT/src/client/cl_main.c" || fail "client must register Lua Engine.* callback"
[ -f "$PROJECT_ROOT/src/qcommon/csharp/IdTech3.Engine.cs" ] || fail "IdTech3.Engine.cs missing"
[ -f "$PROJECT_ROOT/docs/CSHARP.md" ] || fail "docs/CSHARP.md missing"
Expand Down
Loading