openvibers/source.openvibe.games Commits

client JS: split into C ABI core (clang-cl) + C++ glue (no quickjs.h)

CO Copilot committed 2 Jul 2026 · f2dafbc0ee · 5 files, +508 −224

Fixes the Windows client build wall (cl.exe can't parse quickjs.h in C++).
Separate the client runtime into:
- ovjs_core.c: host-agnostic QuickJS core, compiled AS C (quickjs.h's C99
  constructs are valid in C). Exposes a plain C ABI (ovjs_core.h): create/
  destroy/eval/fire_hook + an OVJSHost callback table for log/file/net.
  Loads core JS + <mode>/client.js and registers OV.* over the callbacks.
  On Windows it's compiled by clang-cl into libquickjs_openvibe.lib (same
  proven toolchain as QuickJS itself); on Linux it's a plain $File (gcc).
- openvibe_js_client.cpp: C++ Source-SDK glue that includes ONLY ovjs_core.h
  (never quickjs.h), so cl.exe never sees QuickJS. Implements the host
  callbacks (Msg/filesystem/engine), OVNet usermessage -> OVNetReceive, and
  the per-frame lifecycle.

Verified: Linux client.so + server.so compile and link with the split.

Changes

ddiff --git a/sdk/openvibe/client/hl2mp/openvibe_js_client.cpp b/sdk/openvibe/client/hl2mp/openvibe_js_client.cpp
iindex b6ee16f..50bcaa3 100644
---- a/sdk/openvibe/client/hl2mp/openvibe_js_client.cpp
++++ b/sdk/openvibe/client/hl2mp/openvibe_js_client.cpp
@@@ -1,260 +1,142 @@
#include "cbase.h"
#include "openvibe_js_client.h"
--// The client always uses the real QuickJS runtime (the Windows *server* stub
--// guard keys on GAME_DLL, which is not defined in the client build).
++// Host-side glue for the client JavaScript runtime. Deliberately includes only
++// the pure C-ABI core header (never quickjs.h), so MSVC cl.exe never parses
++// QuickJS's C headers in C++ mode. All QuickJS work lives in ovjs_core.c
++// (compiled as C / clang-cl on Windows).
++#include "openvibe/ovjs_core.h"
#include "filesystem.h"
--#include "openvibe/ov_js_runtime.h"
--#include "openvibe/ov_js_bindings.h"
#include "usermessages.h"
--#include <ctype.h>
++#include <stdlib.h>
++#include <string.h>
#include "tier0/memdbgon.h"
--// ---------------------------------------------------------------------------
--// Client JS runtime instance + mode.
--// ---------------------------------------------------------------------------
--static COpenVibeJSRuntime g_OVClientJS;
--static bool g_OVClientJSStarted = false;
++static OVJSCore *g_pClientCore = NULL;
static ConVar ov_client_js_enabled(
"ov_client_js_enabled", "1", FCVAR_CLIENTDLL,
"Enable the OpenVibe client-side JavaScript runtime." );
--
--// Which gamemode's client.js to load. The server pushes its mode to the client
--// (ov_client_mode <mode>; then the runtime reloads), defaulting to hub.
static ConVar ov_client_mode(
"ov_client_mode", "hub", FCVAR_CLIENTDLL,
"OpenVibe client gamemode realm: hub, prophunt, deathrun, fortwars, traitortown, sandbox." );
--static bool OpenVibeClientJS_IsRunning()
--{
-- return ov_client_js_enabled.GetBool() && g_OVClientJS.Context() != nullptr;
--}
--
// ---------------------------------------------------------------------------
--// Client OV.* native bindings. Client-safe subset (no server player list /
--// ServerCommand). File I/O + logging + net + realm info, matching the server
--// bridge so the shared core JS (require/net/addon/timer/hook) runs unchanged.
++// Host callbacks (C ABI). Source SDK lives entirely on this side.
// ---------------------------------------------------------------------------
--static bool OVCJS_IsSafeModPath( const char *pszPath )
++static bool OVC_IsSafeModPath( const char *p )
{
-- if ( !pszPath || !pszPath[0] ) return false;
-- if ( pszPath[0] == '/' || pszPath[0] == '\\' ) return false;
-- if ( Q_strstr( pszPath, ".." ) ) return false;
-- if ( Q_strstr( pszPath, ":" ) ) return false;
++ if ( !p || !p[0] ) return false;
++ if ( p[0] == '/' || p[0] == '\\' ) return false;
++ if ( strstr( p, ".." ) ) return false;
++ if ( strstr( p, ":" ) ) return false;
return true;
}
--static JSValue OVCJS_log( JSContext *ctx, JSValueConst, int argc, JSValueConst *argv )
--{
-- if ( argc < 1 ) return JS_UNDEFINED;
-- const char *msg = JS_ToCString( ctx, argv[0] );
-- if ( msg ) { Msg( "[OV JS/client] %s\n", msg ); JS_FreeCString( ctx, msg ); }
-- return JS_UNDEFINED;
--}
--
--static JSValue OVCJS_warn( JSContext *ctx, JSValueConst, int argc, JSValueConst *argv )
--{
-- if ( argc < 1 ) return JS_UNDEFINED;
-- const char *msg = JS_ToCString( ctx, argv[0] );
-- if ( msg ) { Warning( "[OV JS/client] %s\n", msg ); JS_FreeCString( ctx, msg ); }
-- return JS_UNDEFINED;
--}
--
--static JSValue OVCJS_error( JSContext *ctx, JSValueConst, int argc, JSValueConst *argv )
--{
-- if ( argc < 1 ) return JS_UNDEFINED;
-- const char *msg = JS_ToCString( ctx, argv[0] );
-- if ( msg ) { Warning( "[OV JS/client ERROR] %s\n", msg ); JS_FreeCString( ctx, msg ); }
-- return JS_UNDEFINED;
--}
--
--static JSValue OVCJS_isServer( JSContext *ctx, JSValueConst, int, JSValueConst * )
--{
-- return JS_FALSE; // client realm
--}
--
--static JSValue OVCJS_getMode( JSContext *ctx, JSValueConst, int, JSValueConst * )
--{
-- return JS_NewString( ctx, g_OVClientJS.GetMode() );
--}
--
--static JSValue OVCJS_getMapName( JSContext *ctx, JSValueConst, int, JSValueConst * )
--{
-- return JS_NewString( ctx, engine ? engine->GetLevelName() : "" );
--}
++static void OVC_log( const char *m ) { Msg( "[OV JS/client] %s\n", m ); }
++static void OVC_warn( const char *m ) { Warning( "[OV JS/client] %s\n", m ); }
++static void OVC_error( const char *m ){ Warning( "[OV JS/client ERROR] %s\n", m ); }
--static JSValue OVCJS_time( JSContext *ctx, JSValueConst, int, JSValueConst * )
++static char *OVC_readFile( const char *path )
{
-- return JS_NewFloat64( ctx, gpGlobals ? gpGlobals->curtime : 0.0 );
++ if ( !OVC_IsSafeModPath( path ) ) return NULL;
++ FileHandle_t f = filesystem->Open( path, "rb", "MOD" );
++ if ( !f ) return NULL;
++ int size = filesystem->Size( f );
++ if ( size < 0 || size > 8 * 1024 * 1024 ) { filesystem->Close( f ); return NULL; }
++ char *buf = (char *)malloc( size + 1 );
++ if ( !buf ) { filesystem->Close( f ); return NULL; }
++ filesystem->Read( buf, size, f );
++ filesystem->Close( f );
++ buf[size] = '\0';
++ return buf;
}
--static JSValue OVCJS_readFile( JSContext *ctx, JSValueConst, int argc, JSValueConst *argv )
++static int OVC_fileExists( const char *path )
{
-- if ( argc < 1 ) return JS_NULL;
-- const char *path = JS_ToCString( ctx, argv[0] );
-- if ( !path ) return JS_NULL;
--
-- if ( !OVCJS_IsSafeModPath( path ) )
-- {
-- Warning( "[OV JS/client] readFile refused unsafe path: %s\n", path );
-- JS_FreeCString( ctx, path );
-- return JS_NULL;
-- }
--
-- FileHandle_t file = filesystem->Open( path, "rb", "MOD" );
-- if ( !file ) { JS_FreeCString( ctx, path ); return JS_NULL; }
--
-- int size = filesystem->Size( file );
-- if ( size < 0 || size > 8 * 1024 * 1024 )
-- {
-- filesystem->Close( file );
-- JS_FreeCString( ctx, path );
-- return JS_NULL;
-- }
--
-- char *buffer = new char[size + 1];
-- filesystem->Read( buffer, size, file );
-- filesystem->Close( file );
-- buffer[size] = '\0';
--
-- JSValue out = JS_NewStringLen( ctx, buffer, size );
-- delete[] buffer;
-- JS_FreeCString( ctx, path );
-- return out;
--}
--
--static JSValue OVCJS_fileExists( JSContext *ctx, JSValueConst, int argc, JSValueConst *argv )
--{
-- if ( argc < 1 ) return JS_FALSE;
-- const char *path = JS_ToCString( ctx, argv[0] );
-- if ( !path ) return JS_FALSE;
-- bool exists = OVCJS_IsSafeModPath( path )
++ return OVC_IsSafeModPath( path )
&& filesystem->FileExists( path, "MOD" )
-- && !filesystem->IsDirectory( path, "MOD" );
-- JS_FreeCString( ctx, path );
-- return exists ? JS_TRUE : JS_FALSE;
++ && !filesystem->IsDirectory( path, "MOD" ) ? 1 : 0;
}
--static JSValue OVCJS_listDir( JSContext *ctx, JSValueConst, int argc, JSValueConst *argv )
++static char *OVC_listDir( const char *dir, const char *wildcard )
{
-- JSValue arr = JS_NewArray( ctx );
-- if ( argc < 1 ) return arr;
--
-- const char *dir = JS_ToCString( ctx, argv[0] );
-- if ( !dir ) return arr;
--
-- const char *wildcardArg = ( argc >= 2 && JS_IsString( argv[1] ) ) ? JS_ToCString( ctx, argv[1] ) : nullptr;
-- const char *wildcard = ( wildcardArg && wildcardArg[0] ) ? wildcardArg : "*";
--
-- if ( !OVCJS_IsSafeModPath( dir ) )
-- {
-- if ( wildcardArg ) JS_FreeCString( ctx, wildcardArg );
-- JS_FreeCString( ctx, dir );
-- return arr;
-- }
--
++ if ( !OVC_IsSafeModPath( dir ) ) return NULL;
char search[512];
-- Q_snprintf( search, sizeof( search ), "%s/%s", dir, wildcard );
++ Q_snprintf( search, sizeof( search ), "%s/%s", dir, ( wildcard && wildcard[0] ) ? wildcard : "*" );
-- FileFindHandle_t findHandle;
-- const char *name = filesystem->FindFirstEx( search, "MOD", &findHandle );
-- uint32 index = 0;
++ CUtlString out;
++ FileFindHandle_t h;
++ const char *name = filesystem->FindFirstEx( search, "MOD", &h );
++ bool first = true;
while ( name )
{
if ( Q_strcmp( name, "." ) != 0 && Q_strcmp( name, ".." ) != 0 )
-- JS_SetPropertyUint32( ctx, arr, index++, JS_NewString( ctx, name ) );
-- name = filesystem->FindNext( findHandle );
++ {
++ if ( !first ) out += "\n";
++ out += name;
++ first = false;
++ }
++ name = filesystem->FindNext( h );
}
-- filesystem->FindClose( findHandle );
++ filesystem->FindClose( h );
-- if ( wildcardArg ) JS_FreeCString( ctx, wildcardArg );
-- JS_FreeCString( ctx, dir );
-- return arr;
++ const char *s = out.Get();
++ if ( !s || !s[0] ) return NULL;
++ size_t len = strlen( s );
++ char *buf = (char *)malloc( len + 1 );
++ if ( !buf ) return NULL;
++ memcpy( buf, s, len + 1 );
++ return buf;
}
--// client -> server: forward as the ov_net command the server JS dispatches.
--static JSValue OVCJS_netSendToServer( JSContext *ctx, JSValueConst, int argc, JSValueConst *argv )
++static int OVC_isServer( void ) { return 0; }
++static const char *OVC_getMode( void ) { return ov_client_mode.GetString(); }
++
++static const char *OVC_getMapName( void )
{
-- if ( argc < 2 ) return JS_UNDEFINED;
-- const char *name = JS_ToCString( ctx, argv[0] );
-- const char *payload = JS_ToCString( ctx, argv[1] );
-- if ( name && payload && engine )
-- {
-- char cmd[16384];
-- Q_snprintf( cmd, sizeof( cmd ), "ov_net %s %s\n", name, payload );
-- engine->ClientCmd_Unrestricted( cmd );
-- }
-- if ( name ) JS_FreeCString( ctx, name );
-- if ( payload ) JS_FreeCString( ctx, payload );
-- return JS_UNDEFINED;
++ static char s_map[128];
++ const char *lvl = engine ? engine->GetLevelName() : "";
++ Q_strncpy( s_map, lvl ? lvl : "", sizeof( s_map ) );
++ return s_map;
}
--// broadcast/serverCommand are server-only; provide inert client stubs so shared
--// JS that references them doesn't throw on the client.
--static JSValue OVCJS_noop( JSContext *ctx, JSValueConst, int, JSValueConst * )
++static double OVC_getTime( void ) { return gpGlobals ? gpGlobals->curtime : 0.0; }
++
++static void OVC_netSendToServer( const char *name, const char *payloadB64 )
{
-- return JS_UNDEFINED;
++ if ( !name || !payloadB64 || !engine ) return;
++ char cmd[16384];
++ Q_snprintf( cmd, sizeof( cmd ), "ov_net %s %s\n", name, payloadB64 );
++ engine->ClientCmd_Unrestricted( cmd );
}
--// Register imperatively with JS_NewCFunction instead of a JS_CFUNC_DEF table.
--// JS_CFUNC_DEF expands to C designated initializers (.u = { .func = ... }),
--// which MSVC rejects before /std:c++20 (C7555/C7556); JS_NewCFunction is plain
--// function calls that compile cleanly on both gcc and MSVC.
--static void OVCJS_Bind( JSContext *ctx, JSValue ov, const char *name, JSCFunction *fn, int length )
++static void OVC_netEmit( const char *, const char *, const char * )
{
-- JS_SetPropertyStr( ctx, ov, name, JS_NewCFunction( ctx, fn, name, length ) );
++ // server->client emit is server-only; inert on the client.
}
--// This is the symbol ov_js_runtime.cpp calls; the client project compiles this
--// file instead of the server ov_js_bindings.cpp.
--void OVJS_RegisterNativeBindings( JSContext *ctx, COpenVibeJSRuntime * )
++static OVJSHost g_ClientHost =
{
-- JSValue global = JS_GetGlobalObject( ctx );
-- JSValue ov = JS_NewObject( ctx );
--
-- OVCJS_Bind( ctx, ov, "log", OVCJS_log, 1 );
-- OVCJS_Bind( ctx, ov, "warn", OVCJS_warn, 1 );
-- OVCJS_Bind( ctx, ov, "error", OVCJS_error, 1 );
-- OVCJS_Bind( ctx, ov, "isServer", OVCJS_isServer, 0 );
-- OVCJS_Bind( ctx, ov, "getMode", OVCJS_getMode, 0 );
-- OVCJS_Bind( ctx, ov, "getMapName", OVCJS_getMapName, 0 );
-- OVCJS_Bind( ctx, ov, "time", OVCJS_time, 0 );
-- OVCJS_Bind( ctx, ov, "readFile", OVCJS_readFile, 1 );
-- OVCJS_Bind( ctx, ov, "fileExists", OVCJS_fileExists, 1 );
-- OVCJS_Bind( ctx, ov, "listDir", OVCJS_listDir, 2 );
-- OVCJS_Bind( ctx, ov, "netSendToServer", OVCJS_netSendToServer, 2 );
-- OVCJS_Bind( ctx, ov, "broadcast", OVCJS_noop, 1 );
-- OVCJS_Bind( ctx, ov, "serverCommand", OVCJS_noop, 1 );
--
-- JS_SetPropertyStr( ctx, global, "OV", ov );
-- JS_FreeValue( ctx, global );
--}
++ OVC_log, OVC_warn, OVC_error,
++ OVC_readFile, OVC_fileExists, OVC_listDir,
++ OVC_isServer, OVC_getMode, OVC_getMapName, OVC_getTime,
++ OVC_netSendToServer, OVC_netEmit
++};
// ---------------------------------------------------------------------------
// server -> client: OVNet usermessage -> OVNetReceive hook into client JS.
// ---------------------------------------------------------------------------
void __MsgFunc_OVNet( bf_read &msg )
{
-- if ( !OpenVibeClientJS_IsRunning() )
-- return;
--
++ if ( !g_pClientCore ) return;
char name[128];
char payload[16384];
msg.ReadString( name, sizeof( name ) );
msg.ReadString( payload, sizeof( payload ) );
--
-- JSContext *ctx = g_OVClientJS.Context();
-- JSValue jsName = JS_NewString( ctx, name );
-- JSValue jsPayload = JS_NewString( ctx, payload );
-- JSValue jsPly = JS_NULL; // server->client has no "sending player"
--
-- JSValueConst argv[] = { jsName, jsPayload, jsPly };
-- g_OVClientJS.CallHookVoid( "OVNetReceive", 3, argv );
--
-- JS_FreeValue( ctx, jsName );
-- JS_FreeValue( ctx, jsPayload );
++ // ply arg is null on the client (server->client has no sending player).
++ ovjs_fire_hook_s( g_pClientCore, "OVNetReceive", name, payload, NULL );
}
// ---------------------------------------------------------------------------
@@@ -262,20 +144,17 @@ void __MsgFunc_OVNet( bf_read &msg )
// ---------------------------------------------------------------------------
void OpenVibeJS_Client_Init()
{
-- if ( g_OVClientJSStarted )
-- return;
-- g_OVClientJSStarted = true;
--
++ if ( g_pClientCore ) return;
if ( !ov_client_js_enabled.GetBool() )
{
Msg( "[OV JS/client] disabled by ov_client_js_enabled=0\n" );
return;
}
--
-- if ( g_OVClientJS.Init( false /*client realm*/, ov_client_mode.GetString() ) )
++ g_pClientCore = ovjs_create( &g_ClientHost, 0 /*client*/, ov_client_mode.GetString() );
++ if ( g_pClientCore )
{
Msg( "[OV JS/client] client runtime initialized for mode '%s'\n", ov_client_mode.GetString() );
-- g_OVClientJS.CallHookVoid( "Initialize" );
++ ovjs_fire_hook( g_pClientCore, "Initialize" );
}
else
{
@@@ -285,27 +164,28 @@ void OpenVibeJS_Client_Init()
void OpenVibeJS_Client_Shutdown()
{
-- if ( OpenVibeClientJS_IsRunning() )
-- g_OVClientJS.CallHookVoid( "Shutdown" );
-- g_OVClientJS.Shutdown();
-- g_OVClientJSStarted = false;
++ if ( g_pClientCore )
++ {
++ ovjs_fire_hook( g_pClientCore, "Shutdown" );
++ ovjs_destroy( g_pClientCore );
++ g_pClientCore = NULL;
++ }
}
void OpenVibeJS_Client_Think()
{
-- if ( OpenVibeClientJS_IsRunning() )
-- g_OVClientJS.CallHookVoid( "Think" );
++ if ( g_pClientCore )
++ ovjs_fire_hook( g_pClientCore, "Think" );
}
// ---------------------------------------------------------------------------
--// Client concommands (mirror the server ones for parity/debugging).
++// Client concommands.
// ---------------------------------------------------------------------------
static void OV_ClientJSStatus_f()
{
-- Msg( "[OV JS/client] enabled=%d started=%d running=%d mode=%s\n",
++ Msg( "[OV JS/client] enabled=%d running=%d mode=%s\n",
ov_client_js_enabled.GetBool() ? 1 : 0,
-- g_OVClientJSStarted ? 1 : 0,
-- OpenVibeClientJS_IsRunning() ? 1 : 0,
++ g_pClientCore ? 1 : 0,
ov_client_mode.GetString() );
}
static ConCommand ov_client_js_status( "ov_client_js_status", OV_ClientJSStatus_f,
@@@ -320,33 +200,25 @@ static void OV_ClientJSReload_f()
static ConCommand ov_client_js_reload( "ov_client_js_reload", OV_ClientJSReload_f,
"Reload the OpenVibe client JavaScript runtime.", FCVAR_CLIENTDLL );
--// Drives the client JS runtime: hooks the OVNet usermessage at load, starts the
--// runtime once the level/systems are up, and pumps Think() each frame (for JS
--// timers). Self-contained so no external init-call patching is needed.
++// Drives the runtime: hook OVNet at load, start on level enter, Think per frame.
class COpenVibeClientJSSystem : public CAutoGameSystemPerFrame
{
public:
COpenVibeClientJSSystem() : CAutoGameSystemPerFrame( "COpenVibeClientJSSystem" ) {}
--
bool Init() OVERRIDE
{
usermessages->HookMessage( "OVNet", __MsgFunc_OVNet );
return true;
}
--
void LevelInitPostEntity() OVERRIDE
{
-- // (Re)start the runtime when a level is entered so <mode>/client.js and
-- // client addons load fresh for the connected server.
OpenVibeJS_Client_Shutdown();
OpenVibeJS_Client_Init();
}
--
void LevelShutdownPostEntity() OVERRIDE
{
OpenVibeJS_Client_Shutdown();
}
--
void Update( float frametime ) OVERRIDE
{
OpenVibeJS_Client_Think();
ddiff --git a/sdk/openvibe/shared/ovjs_core.c b/sdk/openvibe/shared/ovjs_core.c
nnew file mode 100644
iindex 0000000..f7cbee0
---- /dev/null
++++ b/sdk/openvibe/shared/ovjs_core.c
@@@ -0,0 +1,324 @@
++/* ovjs_core.c — host-agnostic OpenVibe JavaScript core. Compiled AS C so it can
++ * include QuickJS's C headers cleanly (see ovjs_core.h for the why). */
++#include "ovjs_core.h"
++#include "third_party/quickjs/quickjs.h"
++
++#include <stdlib.h>
++#include <string.h>
++
++struct OVJSCore
++{
++ JSRuntime *rt;
++ JSContext *ctx;
++ const OVJSHost *host;
++ int isServer;
++ char mode[64];
++};
++
++/* The active core is stored as the context opaque so bindings can reach host. */
++static OVJSCore *OVJS_FromCtx(JSContext *ctx)
++{
++ return (OVJSCore *)JS_GetContextOpaque(ctx);
++}
++
++/* ------------------------------------------------------------------ */
++/* OV.* native bindings — thin shims over the host callbacks. */
++/* ------------------------------------------------------------------ */
++static JSValue ov_log(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ OVJSCore *c = OVJS_FromCtx(ctx);
++ if (argc >= 1 && c && c->host && c->host->log) {
++ const char *s = JS_ToCString(ctx, argv[0]);
++ if (s) { c->host->log(s); JS_FreeCString(ctx, s); }
++ }
++ return JS_UNDEFINED;
++}
++static JSValue ov_warn(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ OVJSCore *c = OVJS_FromCtx(ctx);
++ if (argc >= 1 && c && c->host && c->host->warn) {
++ const char *s = JS_ToCString(ctx, argv[0]);
++ if (s) { c->host->warn(s); JS_FreeCString(ctx, s); }
++ }
++ return JS_UNDEFINED;
++}
++static JSValue ov_error(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ OVJSCore *c = OVJS_FromCtx(ctx);
++ if (argc >= 1 && c && c->host && c->host->error) {
++ const char *s = JS_ToCString(ctx, argv[0]);
++ if (s) { c->host->error(s); JS_FreeCString(ctx, s); }
++ }
++ return JS_UNDEFINED;
++}
++static JSValue ov_isServer(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ OVJSCore *c = OVJS_FromCtx(ctx);
++ return JS_NewBool(ctx, c ? c->isServer : 0);
++}
++static JSValue ov_getMode(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ OVJSCore *c = OVJS_FromCtx(ctx);
++ return JS_NewString(ctx, c ? c->mode : "");
++}
++static JSValue ov_getMapName(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ OVJSCore *c = OVJS_FromCtx(ctx);
++ const char *m = (c && c->host && c->host->getMapName) ? c->host->getMapName() : "";
++ return JS_NewString(ctx, m ? m : "");
++}
++static JSValue ov_time(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ OVJSCore *c = OVJS_FromCtx(ctx);
++ double v = (c && c->host && c->host->getTime) ? c->host->getTime() : 0.0;
++ return JS_NewFloat64(ctx, v);
++}
++static JSValue ov_readFile(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ OVJSCore *c = OVJS_FromCtx(ctx);
++ if (argc < 1 || !c || !c->host || !c->host->readFile) return JS_NULL;
++ const char *path = JS_ToCString(ctx, argv[0]);
++ if (!path) return JS_NULL;
++ char *buf = c->host->readFile(path);
++ JS_FreeCString(ctx, path);
++ if (!buf) return JS_NULL;
++ JSValue out = JS_NewString(ctx, buf);
++ free(buf);
++ return out;
++}
++static JSValue ov_fileExists(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ OVJSCore *c = OVJS_FromCtx(ctx);
++ if (argc < 1 || !c || !c->host || !c->host->fileExists) return JS_FALSE;
++ const char *path = JS_ToCString(ctx, argv[0]);
++ if (!path) return JS_FALSE;
++ int ok = c->host->fileExists(path);
++ JS_FreeCString(ctx, path);
++ return JS_NewBool(ctx, ok);
++}
++static JSValue ov_listDir(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ OVJSCore *c = OVJS_FromCtx(ctx);
++ JSValue arr = JS_NewArray(ctx);
++ if (argc < 1 || !c || !c->host || !c->host->listDir) return arr;
++ const char *dir = JS_ToCString(ctx, argv[0]);
++ const char *wc = (argc >= 2) ? JS_ToCString(ctx, argv[1]) : NULL;
++ if (dir) {
++ char *joined = c->host->listDir(dir, (wc && wc[0]) ? wc : "*");
++ if (joined) {
++ uint32_t idx = 0;
++ char *p = joined, *start = joined;
++ for (;; ++p) {
++ if (*p == '\n' || *p == '\0') {
++ if (p > start) {
++ JS_SetPropertyUint32(ctx, arr, idx++,
++ JS_NewStringLen(ctx, start, (size_t)(p - start)));
++ }
++ if (*p == '\0') break;
++ start = p + 1;
++ }
++ }
++ free(joined);
++ }
++ }
++ if (dir) JS_FreeCString(ctx, dir);
++ if (wc) JS_FreeCString(ctx, wc);
++ return arr;
++}
++static JSValue ov_broadcast(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ /* Server-only chat broadcast is handled by the server bindings; on the
++ * client this is an inert stub so shared JS doesn't throw. */
++ return JS_UNDEFINED;
++}
++static JSValue ov_serverCommand(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ return JS_UNDEFINED;
++}
++static JSValue ov_netSendToServer(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ OVJSCore *c = OVJS_FromCtx(ctx);
++ if (argc < 2 || !c || !c->host || !c->host->netSendToServer) return JS_UNDEFINED;
++ const char *name = JS_ToCString(ctx, argv[0]);
++ const char *pl = JS_ToCString(ctx, argv[1]);
++ if (name && pl) c->host->netSendToServer(name, pl);
++ if (name) JS_FreeCString(ctx, name);
++ if (pl) JS_FreeCString(ctx, pl);
++ return JS_UNDEFINED;
++}
++static JSValue ov_netEmit(JSContext *ctx, JSValueConst t, int argc, JSValueConst *argv)
++{
++ OVJSCore *c = OVJS_FromCtx(ctx);
++ if (argc < 3 || !c || !c->host || !c->host->netEmit) return JS_UNDEFINED;
++ const char *ids = JS_ToCString(ctx, argv[0]);
++ const char *name = JS_ToCString(ctx, argv[1]);
++ const char *pl = JS_ToCString(ctx, argv[2]);
++ if (ids && name && pl) c->host->netEmit(ids, name, pl);
++ if (ids) JS_FreeCString(ctx, ids);
++ if (name) JS_FreeCString(ctx, name);
++ if (pl) JS_FreeCString(ctx, pl);
++ return JS_UNDEFINED;
++}
++
++static const JSCFunctionListEntry ov_funcs[] = {
++ JS_CFUNC_DEF("log", 1, ov_log),
++ JS_CFUNC_DEF("warn", 1, ov_warn),
++ JS_CFUNC_DEF("error", 1, ov_error),
++ JS_CFUNC_DEF("isServer", 0, ov_isServer),
++ JS_CFUNC_DEF("getMode", 0, ov_getMode),
++ JS_CFUNC_DEF("getMapName", 0, ov_getMapName),
++ JS_CFUNC_DEF("time", 0, ov_time),
++ JS_CFUNC_DEF("readFile", 1, ov_readFile),
++ JS_CFUNC_DEF("fileExists", 1, ov_fileExists),
++ JS_CFUNC_DEF("listDir", 2, ov_listDir),
++ JS_CFUNC_DEF("broadcast", 1, ov_broadcast),
++ JS_CFUNC_DEF("serverCommand", 1, ov_serverCommand),
++ JS_CFUNC_DEF("netSendToServer", 2, ov_netSendToServer),
++ JS_CFUNC_DEF("netEmit", 3, ov_netEmit),
++};
++
++static void OVJS_ReportException(OVJSCore *c, const char *where)
++{
++ JSValue exc = JS_GetException(c->ctx);
++ const char *msg = JS_ToCString(c->ctx, exc);
++ if (c->host && c->host->error) {
++ char buf[1024];
++ snprintf(buf, sizeof(buf), "[%s] %s", where ? where : "eval", msg ? msg : "unknown");
++ c->host->error(buf);
++ }
++ if (msg) JS_FreeCString(c->ctx, msg);
++ JS_FreeValue(c->ctx, exc);
++}
++
++int ovjs_eval(OVJSCore *c, const char *code, const char *filename)
++{
++ if (!c || !c->ctx || !code) return 0;
++ JSValue r = JS_Eval(c->ctx, code, strlen(code),
++ filename ? filename : "<openvibe>", JS_EVAL_TYPE_GLOBAL);
++ if (JS_IsException(r)) {
++ OVJS_ReportException(c, filename);
++ JS_FreeValue(c->ctx, r);
++ return 0;
++ }
++ JS_FreeValue(c->ctx, r);
++ return 1;
++}
++
++static int OVJS_LoadFile(OVJSCore *c, const char *path)
++{
++ if (!c->host || !c->host->readFile) return 0;
++ char *code = c->host->readFile(path);
++ if (!code) {
++ if (c->host->warn) {
++ char buf[512];
++ snprintf(buf, sizeof(buf), "core: could not read %s", path);
++ c->host->warn(buf);
++ }
++ return 0;
++ }
++ int ok = ovjs_eval(c, code, path);
++ free(code);
++ return ok;
++}
++
++static int OVJS_LoadCoreFiles(OVJSCore *c)
++{
++ if (!OVJS_LoadFile(c, "js/core/hook.js")) return 0;
++ if (!OVJS_LoadFile(c, "js/core/gamemode.js")) return 0;
++ OVJS_LoadFile(c, "js/bridge.js"); /* bootstraps module/net/addon */
++ OVJS_LoadFile(c, "js/core/command.js");
++ OVJS_LoadFile(c, "js/core/timer.js");
++ return 1;
++}
++
++static void OVJS_LoadGamemode(OVJSCore *c)
++{
++ char path[256];
++ OVJS_LoadFile(c, "js/gamemodes/base/server.js"); /* base defines shared GM */
++ snprintf(path, sizeof(path), "js/gamemodes/%s/%s.js",
++ c->mode, c->isServer ? "server" : "client");
++ OVJS_LoadFile(c, path);
++}
++
++static void OVJS_RegisterBindings(OVJSCore *c)
++{
++ JSValue global = JS_GetGlobalObject(c->ctx);
++ JSValue ov = JS_NewObject(c->ctx);
++ JS_SetPropertyFunctionList(c->ctx, ov, ov_funcs,
++ (int)(sizeof(ov_funcs) / sizeof(ov_funcs[0])));
++ JS_SetPropertyStr(c->ctx, global, "OV", ov);
++ JS_FreeValue(c->ctx, global);
++}
++
++OVJSCore *ovjs_create(const OVJSHost *host, int isServerRealm, const char *mode)
++{
++ OVJSCore *c = (OVJSCore *)calloc(1, sizeof(OVJSCore));
++ if (!c) return NULL;
++ c->host = host;
++ c->isServer = isServerRealm ? 1 : 0;
++ if (mode && mode[0]) { strncpy(c->mode, mode, sizeof(c->mode) - 1); }
++ else { strncpy(c->mode, "hub", sizeof(c->mode) - 1); }
++
++ c->rt = JS_NewRuntime();
++ if (!c->rt) { free(c); return NULL; }
++ JS_SetMemoryLimit(c->rt, 32 * 1024 * 1024);
++ JS_SetMaxStackSize(c->rt, 1024 * 1024);
++
++ c->ctx = JS_NewContext(c->rt);
++ if (!c->ctx) { JS_FreeRuntime(c->rt); free(c); return NULL; }
++ JS_SetContextOpaque(c->ctx, c);
++
++ OVJS_RegisterBindings(c);
++ if (!OVJS_LoadCoreFiles(c)) { ovjs_destroy(c); return NULL; }
++ OVJS_LoadGamemode(c);
++ return c;
++}
++
++void ovjs_destroy(OVJSCore *c)
++{
++ if (!c) return;
++ if (c->ctx) JS_FreeContext(c->ctx);
++ if (c->rt) JS_FreeRuntime(c->rt);
++ free(c);
++}
++
++/* Fire gamemode.call(hook, args...) — mirrors the server bridge's hook dispatch. */
++static void OVJS_FireHookArgv(OVJSCore *c, const char *hook, int argc, JSValueConst *argv)
++{
++ JSValue global = JS_GetGlobalObject(c->ctx);
++ JSValue gamemode = JS_GetPropertyStr(c->ctx, global, "gamemode");
++ JSValue call = JS_GetPropertyStr(c->ctx, gamemode, "call");
++ if (JS_IsFunction(c->ctx, call)) {
++ int n = argc + 1;
++ JSValue *args = (JSValue *)malloc(sizeof(JSValue) * (n > 0 ? n : 1));
++ args[0] = JS_NewString(c->ctx, hook);
++ for (int i = 0; i < argc; ++i) args[i + 1] = argv[i];
++ JSValue r = JS_Call(c->ctx, call, gamemode, n, args);
++ if (JS_IsException(r)) OVJS_ReportException(c, hook);
++ JS_FreeValue(c->ctx, r);
++ JS_FreeValue(c->ctx, args[0]);
++ free(args);
++ }
++ JS_FreeValue(c->ctx, call);
++ JS_FreeValue(c->ctx, gamemode);
++ JS_FreeValue(c->ctx, global);
++}
++
++void ovjs_fire_hook(OVJSCore *c, const char *hook)
++{
++ if (!c || !c->ctx || !hook) return;
++ OVJS_FireHookArgv(c, hook, 0, NULL);
++}
++
++void ovjs_fire_hook_s(OVJSCore *c, const char *hook,
++ const char *a, const char *b, const char *d)
++{
++ if (!c || !c->ctx || !hook) return;
++ JSValue argv[3];
++ int argc = 0;
++ if (a) { argv[argc++] = JS_NewString(c->ctx, a); }
++ if (a && b) { argv[argc++] = JS_NewString(c->ctx, b); }
++ if (a && b && d) { argv[argc++] = JS_NewString(c->ctx, d); }
++ OVJS_FireHookArgv(c, hook, argc, argv);
++ for (int i = 0; i < argc; ++i) JS_FreeValue(c->ctx, argv[i]);
++}
ddiff --git a/sdk/openvibe/shared/ovjs_core.h b/sdk/openvibe/shared/ovjs_core.h
nnew file mode 100644
iindex 0000000..228c3da
---- /dev/null
++++ b/sdk/openvibe/shared/ovjs_core.h
@@@ -0,0 +1,64 @@
++/* ovjs_core.h — host-agnostic OpenVibe JavaScript core (C ABI).
++ *
++ * This is the boundary that lets the client DLL embed QuickJS on Windows.
++ * The core (ovjs_core.c) is compiled AS C, so it may include quickjs.h — whose
++ * C99 compound literals / designated initializers are valid in C but rejected by
++ * MSVC cl.exe in C++ mode. The Source-SDK glue (openvibe_js_client.cpp) is C++
++ * and includes ONLY this pure-C-ABI header, never quickjs.h, so cl.exe never
++ * parses QuickJS. The core reaches the engine (logging, files, networking)
++ * exclusively through the host callbacks below.
++ */
++#ifndef OVJS_CORE_H
++#define OVJS_CORE_H
++
++#ifdef __cplusplus
++extern "C" {
++#endif
++
++typedef struct OVJSCore OVJSCore;
++
++/* Callbacks the JS core invokes; implemented by the host (Source SDK glue).
++ * String returns marked "(host-owned static)" must remain valid until the next
++ * call. readFile/listDir return malloc()'d buffers the CORE frees with free(). */
++typedef struct OVJSHost
++{
++ void (*log)(const char *msg);
++ void (*warn)(const char *msg);
++ void (*error)(const char *msg);
++
++ /* Returns malloc'd NUL-terminated file contents (core frees), or NULL. */
++ char *(*readFile)(const char *path);
++ int (*fileExists)(const char *path);
++ /* Returns malloc'd newline-joined entry names (core frees), or NULL. */
++ char *(*listDir)(const char *dir, const char *wildcard);
++
++ int (*isServer)(void);
++ const char *(*getMode)(void); /* host-owned static */
++ const char *(*getMapName)(void); /* host-owned static */
++ double (*getTime)(void);
++
++ /* client -> server */
++ void (*netSendToServer)(const char *name, const char *payloadB64);
++ /* server -> client (idsCsv: comma-separated userIds, "-1" = broadcast) */
++ void (*netEmit)(const char *idsCsv, const char *name, const char *payloadB64);
++} OVJSHost;
++
++/* Create a runtime, register the OV.* bindings, and load the core JS files plus
++ * js/gamemodes/<mode>/(server|client).js. Returns NULL on failure. */
++OVJSCore *ovjs_create(const OVJSHost *host, int isServerRealm, const char *mode);
++void ovjs_destroy(OVJSCore *core);
++
++/* Evaluate a script. Returns 1 on success, 0 on error (reported via host->error). */
++int ovjs_eval(OVJSCore *core, const char *code, const char *filename);
++
++/* Fire a gamemode/hook by name. The _s variant passes up to three string args
++ * (pass NULL to stop early); used for events like OVNetReceive(name,payload). */
++void ovjs_fire_hook(OVJSCore *core, const char *hook);
++void ovjs_fire_hook_s(OVJSCore *core, const char *hook,
++ const char *a, const char *b, const char *c);
++
++#ifdef __cplusplus
++}
++#endif
++
++#endif /* OVJS_CORE_H */
ddiff --git a/tools/apply-openvibe-sdk.sh b/tools/apply-openvibe-sdk.sh
iindex d824117..618791e 100755
---- a/tools/apply-openvibe-sdk.sh
++++ b/tools/apply-openvibe-sdk.sh
@@@ -54,6 +54,10 @@ copy_file "$ROOT/sdk/openvibe/shared/ov_js_player.h" \
"$SDK/src/game/shared/openvibe/ov_js_player.h"
copy_file "$ROOT/sdk/openvibe/shared/ov_js_player.cpp" \
"$SDK/src/game/shared/openvibe/ov_js_player.cpp"
++copy_file "$ROOT/sdk/openvibe/shared/ovjs_core.h" \
++ "$SDK/src/game/shared/openvibe/ovjs_core.h"
++copy_file "$ROOT/sdk/openvibe/shared/ovjs_core.c" \
++ "$SDK/src/game/shared/openvibe/ovjs_core.c"
copy_file "$ROOT/sdk/openvibe/server/hl2mp/openvibe_js_server.h" \
"$SDK/src/game/server/hl2mp/openvibe_js_server.h"
@@@ -79,8 +83,9 @@ perl -0pi -e '
s/^.*hl2mp\\vgui_openvibe_menu\.cpp.*\n//mg;
s/^.*hl2mp\\openvibe_js_client\.cpp.*\n//mg;
s/^.*openvibe\\ov_js_runtime\.cpp.*\n//mg;
++ s/^.*openvibe\\ovjs_core\.c".*\n//mg;
s/^.*libquickjs_openvibe(?:\.a)?".*\n//mg;
-- s/(\$File\s+"hl2mp\\clientmode_hl2mpnormal\.h"\n)/$1\t\t\t\$File\t"hl2mp\\openvibe_client.cpp"\n\t\t\t\$File\t"hl2mp\\vgui_openvibe_menu.cpp"\n\t\t\t\$File\t"hl2mp\\openvibe_js_client.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_runtime.cpp"\n\t\t\t\$Lib\t"..\\shared\\openvibe\\third_party\\quickjs\\build\\libquickjs_openvibe"\n/s;
++ s/(\$File\s+"hl2mp\\clientmode_hl2mpnormal\.h"\n)/$1\t\t\t\$File\t"hl2mp\\openvibe_client.cpp"\n\t\t\t\$File\t"hl2mp\\vgui_openvibe_menu.cpp"\n\t\t\t\$File\t"hl2mp\\openvibe_js_client.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ovjs_core.c" [\$LINUXALL]\n\t\t\t\$Lib\t"..\\shared\\openvibe\\third_party\\quickjs\\build\\libquickjs_openvibe"\n/s;
' "$CLIENT_VPC"
echo "[openvibe-sdk] patched client_hl2mp.vpc"
ddiff --git a/tools/build-quickjs-lib-windows.ps1 b/tools/build-quickjs-lib-windows.ps1
iindex 0f40df7..522a31f 100644
---- a/tools/build-quickjs-lib-windows.ps1
++++ b/tools/build-quickjs-lib-windows.ps1
@@@ -282,6 +282,25 @@ foreach ($src in $sources) {
$objects += $obj
}
++# Also compile the OpenVibe host-agnostic JS core (ovjs_core.c) with the SAME
++# clang-cl toolchain and pack it into libquickjs_openvibe.lib. ovjs_core.c is C
++# and includes quickjs.h, so it must NOT be compiled by cl.exe in C++ mode — this
++# is exactly why the client glue talks to it through the plain C ABI in
++# ovjs_core.h. Compiling it here means the client project just links this lib.
++$SdkOpenvibe = Join-Path $Sdk 'src/game/shared/openvibe'
++$OvjsCoreSrc = Join-Path $SdkOpenvibe 'ovjs_core.c'
++if (Test-Path $OvjsCoreSrc) {
++ $ovjsObj = Join-Path $Out 'ovjs_core.obj'
++ $CoreArgs = $Common + @("/I$SdkOpenvibe")
++ Say "$([IO.Path]::GetFileName($Compiler)) ovjs_core.c -> $ovjsObj"
++ & $Compiler @CoreArgs /c "$OvjsCoreSrc" "/Fo$ovjsObj"
++ if ($LASTEXITCODE -ne 0) { throw "$Compiler failed for ovjs_core.c" }
++ if (!(Test-Path $ovjsObj)) { throw "$Compiler reported success but did not produce $ovjsObj" }
++ $objects += $ovjsObj
++} else {
++ Say "ovjs_core.c not present at $OvjsCoreSrc; skipping (client JS core will be unresolved)"
++}
++
$outLib = Join-Path $Out 'libquickjs_openvibe.lib'
Say "lib -> $outLib"
& lib.exe /nologo "/OUT:$outLib" $objects