openvibers/source.openvibe.games Commits
Fix local server connectivity + full console overhaul (engine everywhere, autocomplete, menu override)
Changes
@@@ -601,6 +601,19 @@font-size: var(--console-font);}.console-input:focus { outline: none; border-color: var(--accent); }++ .console-input-wrap { position: relative; }++ .console-suggest {++ position: absolute; bottom: 100%; left: 24px; right: 70px; margin-bottom: 4px;++ background: var(--bg-elev, #101826); border: 1px solid var(--border, #24324a);++ border-radius: 6px; max-height: 220px; overflow-y: auto; z-index: 40;++ font-family: var(--mono, monospace); font-size: var(--console-font, 13px);++ box-shadow: 0 -4px 18px rgba(0,0,0,0.45);++ }++ .console-suggest .sugg {++ padding: 4px 10px; cursor: pointer; display: flex; justify-content: space-between; gap: 12px;++ }++ .console-suggest .sugg .hint { opacity: 0.55; font-size: 0.85em; }++ .console-suggest .sugg.sel, .console-suggest .sugg:hover { background: rgba(80,140,255,0.18); }.console-select {background: var(--bg-card);border: 1px solid var(--border);@@@ -1041,11 +1054,14 @@<button class="btn-console" onclick="OVApp.consoleClear()">Clear</button></div><div id="console-log" class="console-log" aria-live="polite"></div>-- <div class="console-input-row">-- <span class="console-prompt">›</span>-- <input id="console-input" class="console-input" type="text" spellcheck="false" autocomplete="off"-- placeholder="JS code — or ]engine_command — or npm install <pkg>" />-- <button class="btn-console" onclick="OVApp.consoleSubmit()">Run</button>++ <div class="console-input-wrap">++ <div id="console-suggest" class="console-suggest" hidden></div>++ <div class="console-input-row">++ <span class="console-prompt">›</span>++ <input id="console-input" class="console-input" type="text" spellcheck="false" autocomplete="off"++ placeholder="JS code — or ]engine_command — or npm install <pkg> — Tab completes" />++ <button class="btn-console" onclick="OVApp.consoleSubmit()">Run</button>++ </div></div></div></div>@@@ -1482,24 +1498,177 @@input.addEventListener('keydown', function (ev) {if (ev.key === 'Enter') {ev.preventDefault();++ if (_sugg.open && _sugg.sel >= 0) { suggApply(); return; }++ suggHide();consoleSubmit();++ } else if (ev.key === 'Tab') {++ ev.preventDefault();++ if (_sugg.open) suggApply();++ else suggUpdate(true);++ } else if (ev.key === 'Escape') {++ if (_sugg.open) { ev.preventDefault(); suggHide(); }} else if (ev.key === 'ArrowUp') {ev.preventDefault();++ if (_sugg.open) { suggMove(-1); return; }if (_con.hIdx > 0) {_con.hIdx -= 1;input.value = _con.history[_con.hIdx] || '';}} else if (ev.key === 'ArrowDown') {ev.preventDefault();++ if (_sugg.open) { suggMove(1); return; }if (_con.hIdx < _con.history.length) {_con.hIdx += 1;input.value = _con.history[_con.hIdx] || '';}}});++ input.addEventListener('input', function () { suggUpdate(false); });++ input.addEventListener('blur', function () { setTimeout(suggHide, 150); });++ suggPrefetch();}}++ // ── Console autocomplete ────────────────────────────────────────────────++ // Engine commands + convars (curated), live map list (/maps) and script++ // list (/scripts) from the server runtime, ov_join modes, JS globals.++ var ENGINE_COMMANDS = [++ ['map', 'load a map (dedicated-style)'], ['changelevel', 'switch map, keep players'],++ ['connect', 'join server ip:port'], ['disconnect', 'leave server'], ['retry', 'reconnect last server'],++ ['status', 'server/player status'], ['ping', 'show latency'], ['kill', 'suicide'], ['explode', 'explode'],++ ['noclip', 'toggle fly/clip (sv_cheats 1)'], ['sv_cheats', '0/1 enable cheats'], ['god', 'godmode (cheat)'],++ ['echo', 'print text'], ['say', 'chat message'], ['name', 'set player name'],++ ['bind', 'bind <key> <cmd>'], ['unbind', 'unbind <key>'], ['fov_desired', 'field of view'],++ ['sensitivity', 'mouse sensitivity'], ['volume', 'master volume 0-1'], ['snd_musicvolume', 'music volume'],++ ['net_graph', '0-3 net debug HUD'], ['cl_showfps', '0-2 fps meter'], ['quit', 'exit game'],++ ['ov_join', 'join an OpenVibe mode'], ['ov_menu', 'open the OpenVibe menu'], ['ov_console', 'toggle this console'],++ ['ov_menu_console', 'console route'], ['ov_menu_options', 'options route'], ['ov_menu_close', 'close menu'],++ ['ov_js_status', 'server JS runtime status'], ['ov_js_reload', 'reload server JS'],++ ['ov_js_fire', 'fire a JS hook'], ['ov_js_cmd', 'run a JS chat/console command'],++ ['ov_npm', 'npm install/ls in js/'], ['js_run', 'eval JS on the server'], ['js_run_cl', 'eval JS on this client'],++ ['js_openscript', 'run js/<path> on the server'], ['js_openscript_cl', 'run js/<path> on this client'],++ ['ov_client_js_status', 'client JS bridge status'], ['ov_client_js_reconnect', 'reconnect client JS bridge'],++ ['ov_prophunt_disguise', 'disguise as prop'], ['ov_fortwars_spawn', 'spawn build prop']++ ];++ var JS_GLOBALS = ['hook.Add(', 'hook.Run(', 'hook.Remove(', 'net.Start(', 'net.Receive(', 'net.Broadcast()',++ 'net.SendToServer()', 'ents.Create(', 'ents.GetAll()', 'ents.FindByClass(', 'player.GetAll()',++ 'player.GetByUserID(', 'team.GetAllTeams()', 'timer.Simple(', 'timer.Create(', 'GAMEMODE', 'LocalPlayer()',++ 'util.AddNetworkString(', 'scripted_ents.Register(', 'concommand.Add(', 'include(', 'AddCSJSFile(',++ 'require(', 'OV.log(', 'OV.getMode()', 'OVLoader.openScript(', 'RunConsoleCommand('];++ var OV_MODES = ['hub', 'prophunt', 'deathrun', 'fortwars', 'traitortown', 'sandbox'];++ var _sugg = { open: false, sel: -1, items: [], maps: null, scripts: null };++++ function suggPrefetch() {++ fetch(RT.server + '/maps').then(function (r) { return r.json(); })++ .then(function (res) { _sugg.maps = res.maps || []; }).catch(function () { _sugg.maps = []; });++ fetch(RT.server + '/scripts').then(function (r) { return r.json(); })++ .then(function (res) { _sugg.scripts = res.scripts || []; }).catch(function () { _sugg.scripts = []; });++ }++++ function suggCandidates(line, realm) {++ var isEngine = realm === 'engine' || line.charAt(0) === ']';++ var text = line.charAt(0) === ']' ? line.slice(1) : line;++ var parts = text.split(/\s+/);++ var word = parts[parts.length - 1];++ var head = parts[0] ? parts[0].toLowerCase() : '';++ var out = [];++++ if (isEngine && parts.length >= 2) {++ // Argument completion for known commands.++ var pool = null, hint = '';++ if ((head === 'map' || head === 'changelevel') && _sugg.maps) { pool = _sugg.maps; hint = 'map'; }++ else if (head === 'ov_join') { pool = OV_MODES; hint = 'mode'; }++ else if ((head === 'js_openscript' || head === 'js_openscript_cl') && _sugg.scripts) { pool = _sugg.scripts; hint = 'js/'; }++ if (pool) {++ pool.forEach(function (m) {++ if (m.toLowerCase().indexOf(word.toLowerCase()) === 0 && out.length < 10) {++ out.push({ text: m, hint: hint, replaceWord: true });++ }++ });++ }++ return out;++ }++++ if (isEngine) {++ ENGINE_COMMANDS.forEach(function (c) {++ if (c[0].indexOf(word.toLowerCase()) === 0 && word.length > 0 && out.length < 10) {++ out.push({ text: c[0], hint: c[1], replaceWord: true });++ }++ });++ return out;++ }++++ // JS realms: complete known globals; npm lines: subcommands.++ if (/^npm\s+\S*$/.test(text)) {++ ['install', 'uninstall', 'update', 'ls'].forEach(function (s) {++ if (s.indexOf(word.toLowerCase()) === 0) out.push({ text: s, hint: 'npm', replaceWord: true });++ });++ return out;++ }++ if (word.length > 1) {++ JS_GLOBALS.forEach(function (g) {++ if (g.toLowerCase().indexOf(word.toLowerCase()) === 0 && out.length < 10) {++ out.push({ text: g, hint: 'js', replaceWord: true });++ }++ });++ }++ return out;++ }++++ function suggUpdate(force) {++ var input = document.getElementById('console-input');++ var box = document.getElementById('console-suggest');++ if (!input || !box) return;++ var line = input.value;++ if (!line.trim() && !force) { suggHide(); return; }++ var realm = (document.getElementById('console-realm') || {}).value || 'server';++ var items = suggCandidates(line, realm);++ if (!items.length) { suggHide(); return; }++ _sugg.items = items;++ _sugg.sel = 0;++ _sugg.open = true;++ box.innerHTML = '';++ items.forEach(function (it, i) {++ var d = document.createElement('div');++ d.className = 'sugg' + (i === 0 ? ' sel' : '');++ var t = document.createElement('span'); t.textContent = it.text;++ var h = document.createElement('span'); h.className = 'hint'; h.textContent = it.hint || '';++ d.appendChild(t); d.appendChild(h);++ d.addEventListener('mousedown', function (ev) { ev.preventDefault(); _sugg.sel = i; suggApply(); });++ box.appendChild(d);++ });++ box.hidden = false;++ }++++ function suggMove(dir) {++ if (!_sugg.open || !_sugg.items.length) return;++ _sugg.sel = (_sugg.sel + dir + _sugg.items.length) % _sugg.items.length;++ var box = document.getElementById('console-suggest');++ if (!box) return;++ Array.prototype.forEach.call(box.children, function (el, i) {++ el.classList.toggle('sel', i === _sugg.sel);++ if (i === _sugg.sel) el.scrollIntoView({ block: 'nearest' });++ });++ }++++ function suggApply() {++ var input = document.getElementById('console-input');++ if (!input || !_sugg.open || _sugg.sel < 0) return;++ var it = _sugg.items[_sugg.sel];++ var line = input.value;++ var m = line.match(/^(.*?)(\S*)$/);++ input.value = (m ? m[1] : '') + it.text + (it.hint === 'js' ? '' : ' ');++ suggHide();++ input.focus();++ }++++ function suggHide() {++ _sugg.open = false;++ _sugg.sel = -1;++ var box = document.getElementById('console-suggest');++ if (box) box.hidden = true;++ }++function conEval(realm, code) {// Runtime control server offline? In-game we go through the engine bridge:// js_run / js_run_cl — the result comes back through the console spew tap.@@@ -1536,9 +1705,9 @@function conEngine(cmd) {if (!cmd) return;if (embedded && shell === 'source') {-- // In-game: allowlisted engine commands via the openvibe:// bridge.++ // In-game: full console via the openvibe:// bridge — the command echoes++ // and its output streams back through the engine console tap.location.href = 'openvibe://cmd?c=' + encodeURIComponent(cmd);-- conAppend('engine', 'sent: ' + cmd, 'ok');return;}// Electron / browser: forward through the server runtime control server.@@@ -1547,7 +1716,13 @@headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ command: cmd })}).then(function (r) { return r.json(); }).then(function (res) {-- conAppend('engine', typeof res === 'string' ? res : JSON.stringify(res), 'ok');++ if (res && res.ok === false) {++ conAppend('engine', res.error || 'command not handled', 'err');++ } else if (res && res.handled === true) {++ conAppend('engine', 'handled by JS command registry', 'ok');++ } else {++ conAppend('engine', 'forwarded to game', 'ok');++ }}).catch(function (e) {conAppend('engine', 'exec failed (' + RT.server + '): ' + e.message, 'err');});@@@ -278,6 +278,11 @@ function onGameMessage(msg) {case 'think':if (ctx && ctx.hook) { try { ctx.hook.Run('Think'); } catch {} }break;++ case 'conline':++ // Engine console line mirrored from the game client's spew tap —++ // republish on the SSE log stream so every GUI console host sees it.++ if (msg.line) pushLog('engine', String(msg.line));++ break;case 'eval':// js_run / js_run_cl from the game console.if (ctx && ctx.OVLoader && typeof ctx.OVLoader.runString === 'function') {@@@ -409,6 +414,10 @@ const ctrl = http.createServer(async (req, res) => {} catch (e) { return json(res, 200, { ok: false, error: e.message }); }if (!handled) {// Forward to the engine (the DLL applies its ov_*/say allowlist).++ if (!sock || sock.destroyed) {++ pushLog('warn', `engine command dropped (game not connected to this runtime): ${line}`);++ return json(res, 200, { ok: false, handled: false, error: 'game not connected to the ' + REALM + ' runtime — in-game use the console directly; the engine realm needs ov_js_backend node for launcher-side forwarding' });++ }sendToGame({ t: 'concmd', cmd: line });handled = 'forwarded';}@@@ -419,6 +428,33 @@ const ctrl = http.createServer(async (req, res) => {const args = Array.isArray(body.args) ? body.args.map(String) : [];return runNpm(args, (result) => json(res, result.ok ? 200 : 400, result));}++ if (req.method === 'GET' && url.pathname === '/maps') {++ let maps = [];++ try {++ maps = fs.readdirSync(path.join(MOD, 'maps'))++ .filter((f) => f.endsWith('.bsp'))++ .map((f) => f.replace(/\.bsp$/, ''))++ .sort();++ } catch { /* no maps dir */ }++ return json(res, 200, { ok: true, maps });++ }++ if (req.method === 'GET' && url.pathname === '/scripts') {++ // js/-relative script paths for js_openscript autocomplete.++ const out = [];++ const walk = (dir, rel, depth) => {++ if (depth > 3) return;++ let entries = [];++ try { entries = fs.readdirSync(path.join(JS, dir), { withFileTypes: true }); } catch { return; }++ for (const e of entries) {++ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;++ const r = rel ? rel + '/' + e.name : e.name;++ if (e.isDirectory()) walk(dir + '/' + e.name, r, depth + 1);++ else if (e.name.endsWith('.js')) out.push(r);++ }++ };++ walk('.', '', 0);++ return json(res, 200, { ok: true, scripts: out.sort().slice(0, 500) });++ }if (req.method === 'POST' && url.pathname === '/openscript') {const body = await readBody(req);const p = String(body.path || '');@@@ -9,4 +9,11 @@ con_enable 0bind "`" "ov_console"bind "F10" "ov_console"bind "F1" "ov_menu_main"++++// REQUIRED for local dev: Source routes 127.0.0.1 through its in-process++// listen-server loopback queue by default, so connects to a separate local++// DEDICATED server never leave the process — "Connection failed after 4++// retries" while the server sees nothing. Real sockets fix it.++net_usesocketsforloopback 1++echo "OpenVibe: stock console disabled; ` and F10 toggle the OpenVibe console"@@@ -1,6 +1,14 @@// Shared local development defaults for OpenVibe: Source.--sv_lan 1--sv_master_share_game_socket 1++//++// sv_lan MUST stay 0 on this engine branch: with sv_lan 1 the Steam++// gameserver never logs on ("Initializing Steam libraries for LAN server")++// and the engine then drops ALL unconnected packets — no A2S query replies,++// no connect challenges, so clients sit at "Connecting to server..." and die++// with "Connection failed after 4 retries". With sv_lan 0 the anonymous++// logon completes ("Connection to Steam servers successful") and local++// connects work. -nomaster still keeps dev shards off the public browser.++sv_lan 0++sv_master_share_game_socket 0sv_cheats 0sv_pure 0developer 1@@@ -23,10 +23,12 @@ snd_mute_losefocus 0mat_dxlevel 90r_3dsky 0--// Local dev listen server: sv_lan 1 stops master-server heartbeats, which--// silences the repeated "Your server is out of date. Please update and--// restart." (MasterRequestRestart) spam from the older engine build.--sv_lan 1++// NOTE: never set sv_lan 1 here. Client-side LAN mode skips the Steam auth++// session, and this engine build then silently fails to emit connect packets:++// every join dies with "Connection failed after 4 retries" while the server++// sees nothing. The out-of-date heartbeat spam it used to silence is gone now++// that the dedicated servers run -nomaster.++sv_lan 0// Auto-open the custom HTML menu so it covers the stock HL2 main menu (which// freezes) — this is the menu override. The menu only joins a server on an@@@ -171,8 +171,41 @@ void OpenVibeJS_Client_Shutdown()g_bClientBridgeStarted = false;}++// Implemented in vgui_openvibe_menu.cpp (console spew ring + menu override).++extern bool OpenVibe_DrainConsoleLine( int64 *pnCursor, char *pszOut, int nOutLen );++extern void OpenVibe_MenuKeepAlive();++++// Escape a console line for embedding in a JSON string value.++static void OVClient_JSONEscape( char *pszOut, int nOutLen, const char *pszIn )++{++ int w = 0;++ for ( const char *p = pszIn; *p && w < nOutLen - 2; ++p )++ {++ unsigned char c = (unsigned char)*p;++ if ( c == '"' || c == '\\' )++ {++ if ( w >= nOutLen - 3 ) break;++ pszOut[w++] = '\\';++ pszOut[w++] = c;++ }++ else if ( c < 0x20 )++ {++ pszOut[w++] = ' ';++ }++ else++ {++ pszOut[w++] = c;++ }++ }++ pszOut[w] = '\0';++}++void OpenVibeJS_Client_Think(){++ // Keep the OpenVibe HTML menu covering the stock GameUI whenever we are++ // out of a level (runs regardless of runtime connectivity).++ OpenVibe_MenuKeepAlive();++g_ClientIPC.Poll();if ( !g_ClientIPC.IsConnected() )@@@ -187,6 +220,21 @@ void OpenVibeJS_Client_Think()s_flNextThink = gpGlobals->curtime + 0.1f;g_ClientIPC.SendLine( "{\"t\":\"think\"}" );}++++ // Mirror engine console lines into the runtime's log stream so the GUI++ // console shows engine output in every host (launcher included).++ static int64 s_nConsoleCursor = 0;++ char szLine[480];++ int nSent = 0;++ while ( nSent < 10 && OpenVibe_DrainConsoleLine( &s_nConsoleCursor, szLine, sizeof( szLine ) ) )++ {++ char szEsc[960];++ OVClient_JSONEscape( szEsc, sizeof( szEsc ), szLine );++ char szMsg[1100];++ Q_snprintf( szMsg, sizeof( szMsg ), "{\"t\":\"conline\",\"line\":\"%s\"}", szEsc );++ g_ClientIPC.SendLine( szMsg );++ ++nSent;++ }}static void OV_ClientJSStatus_f()@@@ -273,14 +321,18 @@ static void OV_JSOpenScriptCl_f( const CCommand &args )static ConCommand js_openscript_cl( "js_openscript_cl", OV_JSOpenScriptCl_f,"Run a script file in the client realm: js_openscript_cl <path relative to js/>. Gated by sv_allowcsjs.", FCVAR_CLIENTDLL );--// Drives the bridge: hook OVNet, connect on level enter, poll each frame.++// Drives the bridge: hook OVNet, connect at boot AND on level enter, poll++// each frame. Connecting at boot means the GUI console's client realm works++// from the main menu, not just in-game.class COpenVibeClientJSSystem : public CAutoGameSystemPerFrame{public:COpenVibeClientJSSystem() : CAutoGameSystemPerFrame( "COpenVibeClientJSSystem" ) {}bool Init() OVERRIDE { usermessages->HookMessage( "OVNet", __MsgFunc_OVNet ); return true; }++ void PostInit() OVERRIDE { OpenVibeJS_Client_Init(); }void LevelInitPostEntity() OVERRIDE { OpenVibeJS_Client_Shutdown(); OpenVibeJS_Client_Init(); }-- void LevelShutdownPostEntity() OVERRIDE { OpenVibeJS_Client_Shutdown(); }++ // Reconnect (rather than just close) so the bridge stays live at the menu.++ void LevelShutdownPostEntity() OVERRIDE { OpenVibeJS_Client_Shutdown(); OpenVibeJS_Client_Init(); }void Update( float ) OVERRIDE { OpenVibeJS_Client_Think(); }};static COpenVibeClientJSSystem g_OpenVibeClientJSSystem;@@@ -84,9 +84,25 @@ static bool OV_IsSafeMode( const char *pszMode )!Q_stricmp( pszMode, "traitortown" ) );}--// openvibe://cmd allowlist: the first token decides. ov_* commands (which--// includes ov_npm and all OpenVibe dev commands) plus the GModJS script--// commands and a few stock engine commands the GUI console needs.++// The OpenVibe HTML console replaces the stock Source console outright. When++// the panel is showing OUR OWN local UI (127.0.0.1/localhost origins) the++// bridge executes any console line, exactly like the real console. Remote++// origins (openvibe.games) stay restricted to the allowlist below.++static ConVar ov_console_unrestricted(++ "ov_console_unrestricted",++ "1",++ FCVAR_CLIENTDLL | FCVAR_ARCHIVE,++ "Allow the local OpenVibe HTML console to run any engine command (remote pages stay allowlisted)." );++++static bool OV_IsLocalUIOrigin( const char *pszUrl )++{++ return OV_URLStartsWith( pszUrl, "http://127.0.0.1:" ) ||++ OV_URLStartsWith( pszUrl, "http://localhost:" );++}++++// openvibe://cmd allowlist for NON-local origins: the first token decides.++// ov_* commands (which includes ov_npm and all OpenVibe dev commands) plus the++// GModJS script commands and a few stock engine commands the GUI console needs.static bool OV_IsAllowedBridgeCommand( const char *pszCommand ){if ( !pszCommand || !pszCommand[0] )@@@ -285,6 +301,7 @@ public:void Open( const char *pszURL = NULL ){const char *pszTarget = ( pszURL && pszURL[0] ) ? pszURL : ov_menu_url.GetString();++ Q_strncpy( m_szCurrentURL, pszTarget, sizeof( m_szCurrentURL ) );MakeFullScreen();SetVisible( true );MoveToFront();@@@ -345,6 +362,9 @@ public:return false;}++ // Track the page origin: the bridge grants full console powers only to++ // the local UI (see openvibe://cmd handling).++ Q_strncpy( m_szCurrentURL, pszURL, sizeof( m_szCurrentURL ) );return true;}@@@ -408,17 +428,30 @@ private:if ( OV_URLStartsWith( pszURL, "openvibe://cmd" ) ){-- // GUI console -> engine command, allowlisted by first token.++ // GUI console -> engine command. This is the user's own console++ // replacement: pages served from the local UI run any command line++ // (like the stock console, incl. map/changelevel/cheats/binds);++ // remote pages stay restricted to the first-token allowlist.char szCmd[1024];if ( !OV_ReadQueryValue( pszURL, "c", szCmd, sizeof( szCmd ) ) )return;-- if ( !OV_IsAllowedBridgeCommand( szCmd ) )++ // Newlines never pass (one console line per bridge action).++ for ( char *p = szCmd; *p; ++p )++ {++ if ( *p == '\n' || *p == '\r' )++ *p = ' ';++ }++++ const bool bLocalConsole = ov_console_unrestricted.GetBool() && OV_IsLocalUIOrigin( m_szCurrentURL );++ if ( !bLocalConsole && !OV_IsAllowedBridgeCommand( szCmd ) ){-- Warning( "[OV HTML] blocked bridge command: %s\n", szCmd );++ Warning( "[OV HTML] blocked bridge command from non-local page: %s\n", szCmd );return;}++ // Echo like the stock console so the command shows in the tap.++ Msg( "] %s\n", szCmd );engine->ClientCmd_Unrestricted( VarArgs( "%s\n", szCmd ) );return;}@@@ -495,6 +528,7 @@ private:}HTML *m_pHTML;++ char m_szCurrentURL[512] = {};};static COpenVibeHTMLPanel *s_pOpenVibeMenu = NULL;@@@ -615,6 +649,46 @@ static void OV_ConsoleTapThink()s_pOpenVibeMenu->RunJS( szScript );s_bInConsoleTap = false;}++++// Second consumer of the console ring: the client JS bridge drains lines and++// forwards them to the Node runtime, whose SSE log stream feeds the GUI++// console in EVERY host (launcher included). Returns false when caught up.++bool OpenVibe_DrainConsoleLine( int64 *pnCursor, char *pszOut, int nOutLen )++{++ if ( !pnCursor || !pszOut || nOutLen <= 0 )++ return false;++++ if ( *pnCursor >= s_nRingWritten )++ return false;++++ if ( s_nRingWritten - *pnCursor > OV_CONSOLE_RING_LINES )++ *pnCursor = s_nRingWritten - OV_CONSOLE_RING_LINES;++++ Q_strncpy( pszOut, s_ConsoleRing[*pnCursor % OV_CONSOLE_RING_LINES], nOutLen );++ ++( *pnCursor );++ return true;++}++++// The OpenVibe HTML menu fully replaces the stock GameUI menu: whenever we are++// not in a level and the panel got hidden (e.g. the engine popped a++// "Disconnected" dialog over the stock menu), bring ours back.++void OpenVibe_MenuKeepAlive()++{++ if ( !ov_menu_auto_open.GetBool() )++ return;++++ static float s_flNextCheck = 0.0f;++ const float flNow = Plat_FloatTime();++ if ( flNow < s_flNextCheck )++ return;++ s_flNextCheck = flNow + 2.0f;++++ if ( engine->IsInGame() )++ return;++++ if ( !s_pOpenVibeMenu || !s_pOpenVibeMenu->IsVisible() )++ OV_GetHTMLMenu()->Open();++}// OPENVIBE_CONSOLE_SPEW_TAP_ENDstatic void OV_OpenMenu_f( const CCommand &args )@@@ -29,7 +29,9 @@ case "$MODE" inecho "Build Windows DLLs first, or use OPENVIBE_CLIENT_MODE=proton-fallback to launch without client DLL." >&2exit 1fi-- exec "$ROOT/tools/run-client-proton.sh" "${@:-}"++ # hl2_win64.exe is the engine our x64 DLLs load into (bin/x64); the 32-bit++ # hl2.exe silently ignores them and boots the stock client.++ exec "$ROOT/tools/run-client-proton-x64.sh" "${@:-}";;proton-fallback)echo "WARNING: Proton fallback may launch, but without game/openvibe.games/bin/client.dll the in-game ov_* client commands/menu will not exist." >&2@@@ -56,7 +56,7 @@ echo " Proton: $GE_PROTON"echo " HL2: $HL2_EXE"[ -n "$CONNECT_ARGS" ] && echo " Connect: $CONNECT_ARGS"--if [ -f "$CLIENT_DLL" ] && command -v strings >/dev/null 2>&1 && strings -a "$CLIENT_DLL" | grep -Fq "ov_join"; then++if [ -f "$CLIENT_DLL" ] && command -v strings >/dev/null 2>&1 && [ "$(strings -a "$CLIENT_DLL" | grep -Fc "ov_join" || true)" -gt 0 ]; thenecho " Client DLL: patched OpenVibe client.dll detected"elif [ -f "$CLIENT_DLL" ]; thenecho " WARNING: client.dll exists, but ov_join string was not found. It may be stock/old." >&2@@@ -91,13 +91,18 @@ fi# Single canonical log via -condebug (writes game/openvibe.games/console.log).# Do NOT also set +con_logfile: it hijacks the console log mid-startup, so the# two split and neither is complete. -condebug alone keeps one full log.++# NOTE: no -insecure here — an insecure client is rejected by VAC-secured++# servers with "You are in insecure mode. You must restart before you can++# connect to secure servers." Local dev servers run -insecure server-side++# instead (see run-server.sh).exec "$GE_PROTON/proton" waitforexitandrun \"$HL2_EXE" \-game "$GAME_DIR" \$CONSOLE_ARGS -dev -condebug -novid -sw -w 1280 -h 720 \-port 27115 -clientport 27105 \-- -nojoy -insecure -nohltv \++ -nojoy -nohltv \+developer 1 \++ +net_usesocketsforloopback 1 \+exec openvibe_proton_stability.cfg \+exec openvibe_proton_client.cfg \+exec openvibe_client_default.cfg \@@@ -32,7 +32,7 @@ echo " Proton: $GE_PROTON"echo " HL2: $HL2_EXE"[ -n "$CONNECT_ARGS" ] && echo " Connect: $CONNECT_ARGS"--if [ -f "$CLIENT_DLL" ] && command -v strings >/dev/null 2>&1 && strings -a "$CLIENT_DLL" | grep -Fq "ov_join"; then++if [ -f "$CLIENT_DLL" ] && command -v strings >/dev/null 2>&1 && [ "$(strings -a "$CLIENT_DLL" | grep -Fc "ov_join" || true)" -gt 0 ]; thenecho " Client DLL: patched OpenVibe client.dll detected"elif [ -f "$CLIENT_DLL" ]; thenecho " WARNING: client.dll exists, but ov_join string was not found. It may be stock/old." >&2@@@ -69,6 +69,13 @@ server_cmd=(-steamport "$STEAMPORT")++# Local dev shards run without VAC (a VAC-secured server rejects clients that++# were ever started -insecure, and dev iterating on DLLs trips VAC anyway).++# Set OPENVIBE_SRCDS_SECURE=1 for a secured public shard.++if [[ "${OPENVIBE_SRCDS_SECURE:-0}" != "1" ]]; then++ server_cmd+=(-insecure)++fi++if [[ "${OPENVIBE_SRCDS_MASTER:-0}" != "1" ]]; thenserver_cmd+=(-nomaster)fi@@@ -77,7 +84,10 @@ server_cmd+=(+clientport "$CLIENTPORT"+maxplayers "$MAXPLAYERS"+tv_port "$TVPORT"-- +sv_master_share_game_socket 1++ # share_game_socket=1 hands unconnected packets (A2S queries + connect++ # challenges) to the Steam GS layer, which stays dead for anonymous local++ # shards — clients then fail with "Connection failed after 4 retries".++ +sv_master_share_game_socket 0+exec "$CFG")
@@ -601,6 +601,19 @@
@@ -601,6 +601,19 @@
font-size: var(--console-font);
font-size: var(--console-font);
}
}
.console-input:focus { outline: none; border-color: var(--accent); }
.console-input:focus { outline: none; border-color: var(--accent); }
+ .console-input-wrap { position: relative; }
+ .console-suggest {
+ position: absolute; bottom: 100%; left: 24px; right: 70px; margin-bottom: 4px;
+ background: var(--bg-elev, #101826); border: 1px solid var(--border, #24324a);
+ border-radius: 6px; max-height: 220px; overflow-y: auto; z-index: 40;
+ font-family: var(--mono, monospace); font-size: var(--console-font, 13px);
+ box-shadow: 0 -4px 18px rgba(0,0,0,0.45);
+ }
+ .console-suggest .sugg {
+ padding: 4px 10px; cursor: pointer; display: flex; justify-content: space-between; gap: 12px;
+ }
+ .console-suggest .sugg .hint { opacity: 0.55; font-size: 0.85em; }
+ .console-suggest .sugg.sel, .console-suggest .sugg:hover { background: rgba(80,140,255,0.18); }
.console-select {
.console-select {
background: var(--bg-card);
background: var(--bg-card);
border: 1px solid var(--border);
border: 1px solid var(--border);
@@ -1041,11 +1054,14 @@
@@ -1041,11 +1054,14 @@
<button class="btn-console" onclick="OVApp.consoleClear()">Clear</button>
<button class="btn-console" onclick="OVApp.consoleClear()">Clear</button>
</div>
</div>
<div id="console-log" class="console-log" aria-live="polite"></div>
<div id="console-log" class="console-log" aria-live="polite"></div>
- <div class="console-input-row">
- <span class="console-prompt">›</span>
- <input id="console-input" class="console-input" type="text" spellcheck="false" autocomplete="off"
- placeholder="JS code — or ]engine_command — or npm install <pkg>" />
- <button class="btn-console" onclick="OVApp.consoleSubmit()">Run</button>
+ <div class="console-input-wrap">
+ <div id="console-suggest" class="console-suggest" hidden></div>
+ <div class="console-input-row">
+ <span class="console-prompt">›</span>
+ <input id="console-input" class="console-input" type="text" spellcheck="false" autocomplete="off"
+ placeholder="JS code — or ]engine_command — or npm install <pkg> — Tab completes" />
+ <button class="btn-console" onclick="OVApp.consoleSubmit()">Run</button>
+ </div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1482,24 +1498,177 @@
@@ -1482,24 +1498,177 @@
input.addEventListener('keydown', function (ev) {
input.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter') {
if (ev.key === 'Enter') {
ev.preventDefault();
ev.preventDefault();
+ if (_sugg.open && _sugg.sel >= 0) { suggApply(); return; }
+ suggHide();
consoleSubmit();
consoleSubmit();
+ } else if (ev.key === 'Tab') {
+ ev.preventDefault();
+ if (_sugg.open) suggApply();
+ else suggUpdate(true);
+ } else if (ev.key === 'Escape') {
+ if (_sugg.open) { ev.preventDefault(); suggHide(); }
} else if (ev.key === 'ArrowUp') {
} else if (ev.key === 'ArrowUp') {
ev.preventDefault();
ev.preventDefault();
+ if (_sugg.open) { suggMove(-1); return; }
if (_con.hIdx > 0) {
if (_con.hIdx > 0) {
_con.hIdx -= 1;
_con.hIdx -= 1;
input.value = _con.history[_con.hIdx] || '';
input.value = _con.history[_con.hIdx] || '';
}
}
} else if (ev.key === 'ArrowDown') {
} else if (ev.key === 'ArrowDown') {
ev.preventDefault();
ev.preventDefault();
+ if (_sugg.open) { suggMove(1); return; }
if (_con.hIdx < _con.history.length) {
if (_con.hIdx < _con.history.length) {
_con.hIdx += 1;
_con.hIdx += 1;
input.value = _con.history[_con.hIdx] || '';
input.value = _con.history[_con.hIdx] || '';
}
}
}
}
});
});
+ input.addEventListener('input', function () { suggUpdate(false); });
+ input.addEventListener('blur', function () { setTimeout(suggHide, 150); });
+ suggPrefetch();
}
}
}
}
+ // ── Console autocomplete ────────────────────────────────────────────────
+ // Engine commands + convars (curated), live map list (/maps) and script
+ // list (/scripts) from the server runtime, ov_join modes, JS globals.
+ var ENGINE_COMMANDS = [
+ ['map', 'load a map (dedicated-style)'], ['changelevel', 'switch map, keep players'],
+ ['connect', 'join server ip:port'], ['disconnect', 'leave server'], ['retry', 'reconnect last server'],
+ ['status', 'server/player status'], ['ping', 'show latency'], ['kill', 'suicide'], ['explode', 'explode'],
+ ['noclip', 'toggle fly/clip (sv_cheats 1)'], ['sv_cheats', '0/1 enable cheats'], ['god', 'godmode (cheat)'],
+ ['echo', 'print text'], ['say', 'chat message'], ['name', 'set player name'],
+ ['bind', 'bind <key> <cmd>'], ['unbind', 'unbind <key>'], ['fov_desired', 'field of view'],
+ ['sensitivity', 'mouse sensitivity'], ['volume', 'master volume 0-1'], ['snd_musicvolume', 'music volume'],
+ ['net_graph', '0-3 net debug HUD'], ['cl_showfps', '0-2 fps meter'], ['quit', 'exit game'],
+ ['ov_join', 'join an OpenVibe mode'], ['ov_menu', 'open the OpenVibe menu'], ['ov_console', 'toggle this console'],
+ ['ov_menu_console', 'console route'], ['ov_menu_options', 'options route'], ['ov_menu_close', 'close menu'],
+ ['ov_js_status', 'server JS runtime status'], ['ov_js_reload', 'reload server JS'],
+ ['ov_js_fire', 'fire a JS hook'], ['ov_js_cmd', 'run a JS chat/console command'],
+ ['ov_npm', 'npm install/ls in js/'], ['js_run', 'eval JS on the server'], ['js_run_cl', 'eval JS on this client'],
+ ['js_openscript', 'run js/<path> on the server'], ['js_openscript_cl', 'run js/<path> on this client'],
+ ['ov_client_js_status', 'client JS bridge status'], ['ov_client_js_reconnect', 'reconnect client JS bridge'],
+ ['ov_prophunt_disguise', 'disguise as prop'], ['ov_fortwars_spawn', 'spawn build prop']
+ ];
+ var JS_GLOBALS = ['hook.Add(', 'hook.Run(', 'hook.Remove(', 'net.Start(', 'net.Receive(', 'net.Broadcast()',
+ 'net.SendToServer()', 'ents.Create(', 'ents.GetAll()', 'ents.FindByClass(', 'player.GetAll()',
+ 'player.GetByUserID(', 'team.GetAllTeams()', 'timer.Simple(', 'timer.Create(', 'GAMEMODE', 'LocalPlayer()',
+ 'util.AddNetworkString(', 'scripted_ents.Register(', 'concommand.Add(', 'include(', 'AddCSJSFile(',
+ 'require(', 'OV.log(', 'OV.getMode()', 'OVLoader.openScript(', 'RunConsoleCommand('];
+ var OV_MODES = ['hub', 'prophunt', 'deathrun', 'fortwars', 'traitortown', 'sandbox'];
+ var _sugg = { open: false, sel: -1, items: [], maps: null, scripts: null };
+
+ function suggPrefetch() {
+ fetch(RT.server + '/maps').then(function (r) { return r.json(); })
+ .then(function (res) { _sugg.maps = res.maps || []; }).catch(function () { _sugg.maps = []; });
+ fetch(RT.server + '/scripts').then(function (r) { return r.json(); })
+ .then(function (res) { _sugg.scripts = res.scripts || []; }).catch(function () { _sugg.scripts = []; });
+ }
+
+ function suggCandidates(line, realm) {
+ var isEngine = realm === 'engine' || line.charAt(0) === ']';
+ var text = line.charAt(0) === ']' ? line.slice(1) : line;
+ var parts = text.split(/\s+/);
+ var word = parts[parts.length - 1];
+ var head = parts[0] ? parts[0].toLowerCase() : '';
+ var out = [];
+
+ if (isEngine && parts.length >= 2) {
+ // Argument completion for known commands.
+ var pool = null, hint = '';
+ if ((head === 'map' || head === 'changelevel') && _sugg.maps) { pool = _sugg.maps; hint = 'map'; }
+ else if (head === 'ov_join') { pool = OV_MODES; hint = 'mode'; }
+ else if ((head === 'js_openscript' || head === 'js_openscript_cl') && _sugg.scripts) { pool = _sugg.scripts; hint = 'js/'; }
+ if (pool) {
+ pool.forEach(function (m) {
+ if (m.toLowerCase().indexOf(word.toLowerCase()) === 0 && out.length < 10) {
+ out.push({ text: m, hint: hint, replaceWord: true });
+ }
+ });
+ }
+ return out;
+ }
+
+ if (isEngine) {
+ ENGINE_COMMANDS.forEach(function (c) {
+ if (c[0].indexOf(word.toLowerCase()) === 0 && word.length > 0 && out.length < 10) {
+ out.push({ text: c[0], hint: c[1], replaceWord: true });
+ }
+ });
+ return out;
+ }
+
+ // JS realms: complete known globals; npm lines: subcommands.
+ if (/^npm\s+\S*$/.test(text)) {
+ ['install', 'uninstall', 'update', 'ls'].forEach(function (s) {
+ if (s.indexOf(word.toLowerCase()) === 0) out.push({ text: s, hint: 'npm', replaceWord: true });
+ });
+ return out;
+ }
+ if (word.length > 1) {
+ JS_GLOBALS.forEach(function (g) {
+ if (g.toLowerCase().indexOf(word.toLowerCase()) === 0 && out.length < 10) {
+ out.push({ text: g, hint: 'js', replaceWord: true });
+ }
+ });
+ }
+ return out;
+ }
+
+ function suggUpdate(force) {
+ var input = document.getElementById('console-input');
+ var box = document.getElementById('console-suggest');
+ if (!input || !box) return;
+ var line = input.value;
+ if (!line.trim() && !force) { suggHide(); return; }
+ var realm = (document.getElementById('console-realm') || {}).value || 'server';
+ var items = suggCandidates(line, realm);
+ if (!items.length) { suggHide(); return; }
+ _sugg.items = items;
+ _sugg.sel = 0;
+ _sugg.open = true;
+ box.innerHTML = '';
+ items.forEach(function (it, i) {
+ var d = document.createElement('div');
+ d.className = 'sugg' + (i === 0 ? ' sel' : '');
+ var t = document.createElement('span'); t.textContent = it.text;
+ var h = document.createElement('span'); h.className = 'hint'; h.textContent = it.hint || '';
+ d.appendChild(t); d.appendChild(h);
+ d.addEventListener('mousedown', function (ev) { ev.preventDefault(); _sugg.sel = i; suggApply(); });
+ box.appendChild(d);
+ });
+ box.hidden = false;
+ }
+
+ function suggMove(dir) {
+ if (!_sugg.open || !_sugg.items.length) return;
+ _sugg.sel = (_sugg.sel + dir + _sugg.items.length) % _sugg.items.length;
+ var box = document.getElementById('console-suggest');
+ if (!box) return;
+ Array.prototype.forEach.call(box.children, function (el, i) {
+ el.classList.toggle('sel', i === _sugg.sel);
+ if (i === _sugg.sel) el.scrollIntoView({ block: 'nearest' });
+ });
+ }
+
+ function suggApply() {
+ var input = document.getElementById('console-input');
+ if (!input || !_sugg.open || _sugg.sel < 0) return;
+ var it = _sugg.items[_sugg.sel];
+ var line = input.value;
+ var m = line.match(/^(.*?)(\S*)$/);
+ input.value = (m ? m[1] : '') + it.text + (it.hint === 'js' ? '' : ' ');
+ suggHide();
+ input.focus();
+ }
+
+ function suggHide() {
+ _sugg.open = false;
+ _sugg.sel = -1;
+ var box = document.getElementById('console-suggest');
+ if (box) box.hidden = true;
+ }
+
function conEval(realm, code) {
function conEval(realm, code) {
// Runtime control server offline? In-game we go through the engine bridge:
// Runtime control server offline? In-game we go through the engine bridge:
// js_run / js_run_cl — the result comes back through the console spew tap.
// js_run / js_run_cl — the result comes back through the console spew tap.
@@ -1536,9 +1705,9 @@
@@ -1536,9 +1705,9 @@
function conEngine(cmd) {
function conEngine(cmd) {
if (!cmd) return;
if (!cmd) return;
if (embedded && shell === 'source') {
if (embedded && shell === 'source') {
- // In-game: allowlisted engine commands via the openvibe:// bridge.
+ // In-game: full console via the openvibe:// bridge — the command echoes
+ // and its output streams back through the engine console tap.
location.href = 'openvibe://cmd?c=' + encodeURIComponent(cmd);
location.href = 'openvibe://cmd?c=' + encodeURIComponent(cmd);
- conAppend('engine', 'sent: ' + cmd, 'ok');
return;
return;
}
}
// Electron / browser: forward through the server runtime control server.
// Electron / browser: forward through the server runtime control server.
@@ -1547,7 +1716,13 @@
@@ -1547,7 +1716,13 @@
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: cmd })
body: JSON.stringify({ command: cmd })
}).then(function (r) { return r.json(); }).then(function (res) {
}).then(function (r) { return r.json(); }).then(function (res) {
- conAppend('engine', typeof res === 'string' ? res : JSON.stringify(res), 'ok');
+ if (res && res.ok === false) {
+ conAppend('engine', res.error || 'command not handled', 'err');
+ } else if (res && res.handled === true) {
+ conAppend('engine', 'handled by JS command registry', 'ok');
+ } else {
+ conAppend('engine', 'forwarded to game', 'ok');
+ }
}).catch(function (e) {
}).catch(function (e) {
conAppend('engine', 'exec failed (' + RT.server + '): ' + e.message, 'err');
conAppend('engine', 'exec failed (' + RT.server + '): ' + e.message, 'err');
});
});
@@ -278,6 +278,11 @@ function onGameMessage(msg) {
@@ -278,6 +278,11 @@ function onGameMessage(msg) {
case 'think':
case 'think':
if (ctx && ctx.hook) { try { ctx.hook.Run('Think'); } catch {} }
if (ctx && ctx.hook) { try { ctx.hook.Run('Think'); } catch {} }
break;
break;
+ case 'conline':
+ // Engine console line mirrored from the game client's spew tap —
+ // republish on the SSE log stream so every GUI console host sees it.
+ if (msg.line) pushLog('engine', String(msg.line));
+ break;
case 'eval':
case 'eval':
// js_run / js_run_cl from the game console.
// js_run / js_run_cl from the game console.
if (ctx && ctx.OVLoader && typeof ctx.OVLoader.runString === 'function') {
if (ctx && ctx.OVLoader && typeof ctx.OVLoader.runString === 'function') {
@@ -409,6 +414,10 @@ const ctrl = http.createServer(async (req, res) => {
@@ -409,6 +414,10 @@ const ctrl = http.createServer(async (req, res) => {
} catch (e) { return json(res, 200, { ok: false, error: e.message }); }
} catch (e) { return json(res, 200, { ok: false, error: e.message }); }
if (!handled) {
if (!handled) {
// Forward to the engine (the DLL applies its ov_*/say allowlist).
// Forward to the engine (the DLL applies its ov_*/say allowlist).
+ if (!sock || sock.destroyed) {
+ pushLog('warn', `engine command dropped (game not connected to this runtime): ${line}`);
+ return json(res, 200, { ok: false, handled: false, error: 'game not connected to the ' + REALM + ' runtime — in-game use the console directly; the engine realm needs ov_js_backend node for launcher-side forwarding' });
+ }
sendToGame({ t: 'concmd', cmd: line });
sendToGame({ t: 'concmd', cmd: line });
handled = 'forwarded';
handled = 'forwarded';
}
}
@@ -419,6 +428,33 @@ const ctrl = http.createServer(async (req, res) => {
@@ -419,6 +428,33 @@ const ctrl = http.createServer(async (req, res) => {
const args = Array.isArray(body.args) ? body.args.map(String) : [];
const args = Array.isArray(body.args) ? body.args.map(String) : [];
return runNpm(args, (result) => json(res, result.ok ? 200 : 400, result));
return runNpm(args, (result) => json(res, result.ok ? 200 : 400, result));
}
}
+ if (req.method === 'GET' && url.pathname === '/maps') {
+ let maps = [];
+ try {
+ maps = fs.readdirSync(path.join(MOD, 'maps'))
+ .filter((f) => f.endsWith('.bsp'))
+ .map((f) => f.replace(/\.bsp$/, ''))
+ .sort();
+ } catch { /* no maps dir */ }
+ return json(res, 200, { ok: true, maps });
+ }
+ if (req.method === 'GET' && url.pathname === '/scripts') {
+ // js/-relative script paths for js_openscript autocomplete.
+ const out = [];
+ const walk = (dir, rel, depth) => {
+ if (depth > 3) return;
+ let entries = [];
+ try { entries = fs.readdirSync(path.join(JS, dir), { withFileTypes: true }); } catch { return; }
+ for (const e of entries) {
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
+ const r = rel ? rel + '/' + e.name : e.name;
+ if (e.isDirectory()) walk(dir + '/' + e.name, r, depth + 1);
+ else if (e.name.endsWith('.js')) out.push(r);
+ }
+ };
+ walk('.', '', 0);
+ return json(res, 200, { ok: true, scripts: out.sort().slice(0, 500) });
+ }
if (req.method === 'POST' && url.pathname === '/openscript') {
if (req.method === 'POST' && url.pathname === '/openscript') {
const body = await readBody(req);
const body = await readBody(req);
const p = String(body.path || '');
const p = String(body.path || '');
@@ -9,4 +9,11 @@ con_enable 0
@@ -9,4 +9,11 @@ con_enable 0
bind "`" "ov_console"
bind "`" "ov_console"
bind "F10" "ov_console"
bind "F10" "ov_console"
bind "F1" "ov_menu_main"
bind "F1" "ov_menu_main"
+
+// REQUIRED for local dev: Source routes 127.0.0.1 through its in-process
+// listen-server loopback queue by default, so connects to a separate local
+// DEDICATED server never leave the process — "Connection failed after 4
+// retries" while the server sees nothing. Real sockets fix it.
+net_usesocketsforloopback 1
+
echo "OpenVibe: stock console disabled; ` and F10 toggle the OpenVibe console"
echo "OpenVibe: stock console disabled; ` and F10 toggle the OpenVibe console"
@@ -1,6 +1,14 @@
@@ -1,6 +1,14 @@
// Shared local development defaults for OpenVibe: Source.
// Shared local development defaults for OpenVibe: Source.
-sv_lan 1
-sv_master_share_game_socket 1
+//
+// sv_lan MUST stay 0 on this engine branch: with sv_lan 1 the Steam
+// gameserver never logs on ("Initializing Steam libraries for LAN server")
+// and the engine then drops ALL unconnected packets — no A2S query replies,
+// no connect challenges, so clients sit at "Connecting to server..." and die
+// with "Connection failed after 4 retries". With sv_lan 0 the anonymous
+// logon completes ("Connection to Steam servers successful") and local
+// connects work. -nomaster still keeps dev shards off the public browser.
+sv_lan 0
+sv_master_share_game_socket 0
sv_cheats 0
sv_cheats 0
sv_pure 0
sv_pure 0
developer 1
developer 1
@@ -23,10 +23,12 @@ snd_mute_losefocus 0
@@ -23,10 +23,12 @@ snd_mute_losefocus 0
mat_dxlevel 90
mat_dxlevel 90
r_3dsky 0
r_3dsky 0
-// Local dev listen server: sv_lan 1 stops master-server heartbeats, which
-// silences the repeated "Your server is out of date. Please update and
-// restart." (MasterRequestRestart) spam from the older engine build.
-sv_lan 1
+// NOTE: never set sv_lan 1 here. Client-side LAN mode skips the Steam auth
+// session, and this engine build then silently fails to emit connect packets:
+// every join dies with "Connection failed after 4 retries" while the server
+// sees nothing. The out-of-date heartbeat spam it used to silence is gone now
+// that the dedicated servers run -nomaster.
+sv_lan 0
// Auto-open the custom HTML menu so it covers the stock HL2 main menu (which
// Auto-open the custom HTML menu so it covers the stock HL2 main menu (which
// freezes) — this is the menu override. The menu only joins a server on an
// freezes) — this is the menu override. The menu only joins a server on an
@@ -171,8 +171,41 @@ void OpenVibeJS_Client_Shutdown()
@@ -171,8 +171,41 @@ void OpenVibeJS_Client_Shutdown()
g_bClientBridgeStarted = false;
g_bClientBridgeStarted = false;
}
}
+// Implemented in vgui_openvibe_menu.cpp (console spew ring + menu override).
+extern bool OpenVibe_DrainConsoleLine( int64 *pnCursor, char *pszOut, int nOutLen );
+extern void OpenVibe_MenuKeepAlive();
+
+// Escape a console line for embedding in a JSON string value.
+static void OVClient_JSONEscape( char *pszOut, int nOutLen, const char *pszIn )
+{
+ int w = 0;
+ for ( const char *p = pszIn; *p && w < nOutLen - 2; ++p )
+ {
+ unsigned char c = (unsigned char)*p;
+ if ( c == '"' || c == '\\' )
+ {
+ if ( w >= nOutLen - 3 ) break;
+ pszOut[w++] = '\\';
+ pszOut[w++] = c;
+ }
+ else if ( c < 0x20 )
+ {
+ pszOut[w++] = ' ';
+ }
+ else
+ {
+ pszOut[w++] = c;
+ }
+ }
+ pszOut[w] = '\0';
+}
+
void OpenVibeJS_Client_Think()
void OpenVibeJS_Client_Think()
{
{
+ // Keep the OpenVibe HTML menu covering the stock GameUI whenever we are
+ // out of a level (runs regardless of runtime connectivity).
+ OpenVibe_MenuKeepAlive();
+
g_ClientIPC.Poll();
g_ClientIPC.Poll();
if ( !g_ClientIPC.IsConnected() )
if ( !g_ClientIPC.IsConnected() )
@@ -187,6 +220,21 @@ void OpenVibeJS_Client_Think()
@@ -187,6 +220,21 @@ void OpenVibeJS_Client_Think()
s_flNextThink = gpGlobals->curtime + 0.1f;
s_flNextThink = gpGlobals->curtime + 0.1f;
g_ClientIPC.SendLine( "{\"t\":\"think\"}" );
g_ClientIPC.SendLine( "{\"t\":\"think\"}" );
}
}
+
+ // Mirror engine console lines into the runtime's log stream so the GUI
+ // console shows engine output in every host (launcher included).
+ static int64 s_nConsoleCursor = 0;
+ char szLine[480];
+ int nSent = 0;
+ while ( nSent < 10 && OpenVibe_DrainConsoleLine( &s_nConsoleCursor, szLine, sizeof( szLine ) ) )
+ {
+ char szEsc[960];
+ OVClient_JSONEscape( szEsc, sizeof( szEsc ), szLine );
+ char szMsg[1100];
+ Q_snprintf( szMsg, sizeof( szMsg ), "{\"t\":\"conline\",\"line\":\"%s\"}", szEsc );
+ g_ClientIPC.SendLine( szMsg );
+ ++nSent;
+ }
}
}
static void OV_ClientJSStatus_f()
static void OV_ClientJSStatus_f()
@@ -273,14 +321,18 @@ static void OV_JSOpenScriptCl_f( const CCommand &args )
@@ -273,14 +321,18 @@ static void OV_JSOpenScriptCl_f( const CCommand &args )
static ConCommand js_openscript_cl( "js_openscript_cl", OV_JSOpenScriptCl_f,
static ConCommand js_openscript_cl( "js_openscript_cl", OV_JSOpenScriptCl_f,
"Run a script file in the client realm: js_openscript_cl <path relative to js/>. Gated by sv_allowcsjs.", FCVAR_CLIENTDLL );
"Run a script file in the client realm: js_openscript_cl <path relative to js/>. Gated by sv_allowcsjs.", FCVAR_CLIENTDLL );
-// Drives the bridge: hook OVNet, connect on level enter, poll each frame.
+// Drives the bridge: hook OVNet, connect at boot AND on level enter, poll
+// each frame. Connecting at boot means the GUI console's client realm works
+// from the main menu, not just in-game.
class COpenVibeClientJSSystem : public CAutoGameSystemPerFrame
class COpenVibeClientJSSystem : public CAutoGameSystemPerFrame
{
{
public:
public:
COpenVibeClientJSSystem() : CAutoGameSystemPerFrame( "COpenVibeClientJSSystem" ) {}
COpenVibeClientJSSystem() : CAutoGameSystemPerFrame( "COpenVibeClientJSSystem" ) {}
bool Init() OVERRIDE { usermessages->HookMessage( "OVNet", __MsgFunc_OVNet ); return true; }
bool Init() OVERRIDE { usermessages->HookMessage( "OVNet", __MsgFunc_OVNet ); return true; }
+ void PostInit() OVERRIDE { OpenVibeJS_Client_Init(); }
void LevelInitPostEntity() OVERRIDE { OpenVibeJS_Client_Shutdown(); OpenVibeJS_Client_Init(); }
void LevelInitPostEntity() OVERRIDE { OpenVibeJS_Client_Shutdown(); OpenVibeJS_Client_Init(); }
- void LevelShutdownPostEntity() OVERRIDE { OpenVibeJS_Client_Shutdown(); }
+ // Reconnect (rather than just close) so the bridge stays live at the menu.
+ void LevelShutdownPostEntity() OVERRIDE { OpenVibeJS_Client_Shutdown(); OpenVibeJS_Client_Init(); }
void Update( float ) OVERRIDE { OpenVibeJS_Client_Think(); }
void Update( float ) OVERRIDE { OpenVibeJS_Client_Think(); }
};
};
static COpenVibeClientJSSystem g_OpenVibeClientJSSystem;
static COpenVibeClientJSSystem g_OpenVibeClientJSSystem;
@@ -84,9 +84,25 @@ static bool OV_IsSafeMode( const char *pszMode )
@@ -84,9 +84,25 @@ static bool OV_IsSafeMode( const char *pszMode )
!Q_stricmp( pszMode, "traitortown" ) );
!Q_stricmp( pszMode, "traitortown" ) );
}
}
-// openvibe://cmd allowlist: the first token decides. ov_* commands (which
-// includes ov_npm and all OpenVibe dev commands) plus the GModJS script
-// commands and a few stock engine commands the GUI console needs.
+// The OpenVibe HTML console replaces the stock Source console outright. When
+// the panel is showing OUR OWN local UI (127.0.0.1/localhost origins) the
+// bridge executes any console line, exactly like the real console. Remote
+// origins (openvibe.games) stay restricted to the allowlist below.
+static ConVar ov_console_unrestricted(
+ "ov_console_unrestricted",
+ "1",
+ FCVAR_CLIENTDLL | FCVAR_ARCHIVE,
+ "Allow the local OpenVibe HTML console to run any engine command (remote pages stay allowlisted)." );
+
+static bool OV_IsLocalUIOrigin( const char *pszUrl )
+{
+ return OV_URLStartsWith( pszUrl, "http://127.0.0.1:" ) ||
+ OV_URLStartsWith( pszUrl, "http://localhost:" );
+}
+
+// openvibe://cmd allowlist for NON-local origins: the first token decides.
+// ov_* commands (which includes ov_npm and all OpenVibe dev commands) plus the
+// GModJS script commands and a few stock engine commands the GUI console needs.
static bool OV_IsAllowedBridgeCommand( const char *pszCommand )
static bool OV_IsAllowedBridgeCommand( const char *pszCommand )
{
{
if ( !pszCommand || !pszCommand[0] )
if ( !pszCommand || !pszCommand[0] )
@@ -285,6 +301,7 @@ public:
@@ -285,6 +301,7 @@ public:
void Open( const char *pszURL = NULL )
void Open( const char *pszURL = NULL )
{
{
const char *pszTarget = ( pszURL && pszURL[0] ) ? pszURL : ov_menu_url.GetString();
const char *pszTarget = ( pszURL && pszURL[0] ) ? pszURL : ov_menu_url.GetString();
+ Q_strncpy( m_szCurrentURL, pszTarget, sizeof( m_szCurrentURL ) );
MakeFullScreen();
MakeFullScreen();
SetVisible( true );
SetVisible( true );
MoveToFront();
MoveToFront();
@@ -345,6 +362,9 @@ public:
@@ -345,6 +362,9 @@ public:
return false;
return false;
}
}
+ // Track the page origin: the bridge grants full console powers only to
+ // the local UI (see openvibe://cmd handling).
+ Q_strncpy( m_szCurrentURL, pszURL, sizeof( m_szCurrentURL ) );
return true;
return true;
}
}
@@ -408,17 +428,30 @@ private:
@@ -408,17 +428,30 @@ private:
if ( OV_URLStartsWith( pszURL, "openvibe://cmd" ) )
if ( OV_URLStartsWith( pszURL, "openvibe://cmd" ) )
{
{
- // GUI console -> engine command, allowlisted by first token.
+ // GUI console -> engine command. This is the user's own console
+ // replacement: pages served from the local UI run any command line
+ // (like the stock console, incl. map/changelevel/cheats/binds);
+ // remote pages stay restricted to the first-token allowlist.
char szCmd[1024];
char szCmd[1024];
if ( !OV_ReadQueryValue( pszURL, "c", szCmd, sizeof( szCmd ) ) )
if ( !OV_ReadQueryValue( pszURL, "c", szCmd, sizeof( szCmd ) ) )
return;
return;
- if ( !OV_IsAllowedBridgeCommand( szCmd ) )
+ // Newlines never pass (one console line per bridge action).
+ for ( char *p = szCmd; *p; ++p )
+ {
+ if ( *p == '\n' || *p == '\r' )
+ *p = ' ';
+ }
+
+ const bool bLocalConsole = ov_console_unrestricted.GetBool() && OV_IsLocalUIOrigin( m_szCurrentURL );
+ if ( !bLocalConsole && !OV_IsAllowedBridgeCommand( szCmd ) )
{
{
- Warning( "[OV HTML] blocked bridge command: %s\n", szCmd );
+ Warning( "[OV HTML] blocked bridge command from non-local page: %s\n", szCmd );
return;
return;
}
}
+ // Echo like the stock console so the command shows in the tap.
+ Msg( "] %s\n", szCmd );
engine->ClientCmd_Unrestricted( VarArgs( "%s\n", szCmd ) );
engine->ClientCmd_Unrestricted( VarArgs( "%s\n", szCmd ) );
return;
return;
}
}
@@ -495,6 +528,7 @@ private:
@@ -495,6 +528,7 @@ private:
}
}
HTML *m_pHTML;
HTML *m_pHTML;
+ char m_szCurrentURL[512] = {};
};
};
static COpenVibeHTMLPanel *s_pOpenVibeMenu = NULL;
static COpenVibeHTMLPanel *s_pOpenVibeMenu = NULL;
@@ -615,6 +649,46 @@ static void OV_ConsoleTapThink()
@@ -615,6 +649,46 @@ static void OV_ConsoleTapThink()
s_pOpenVibeMenu->RunJS( szScript );
s_pOpenVibeMenu->RunJS( szScript );
s_bInConsoleTap = false;
s_bInConsoleTap = false;
}
}
+
+// Second consumer of the console ring: the client JS bridge drains lines and
+// forwards them to the Node runtime, whose SSE log stream feeds the GUI
+// console in EVERY host (launcher included). Returns false when caught up.
+bool OpenVibe_DrainConsoleLine( int64 *pnCursor, char *pszOut, int nOutLen )
+{
+ if ( !pnCursor || !pszOut || nOutLen <= 0 )
+ return false;
+
+ if ( *pnCursor >= s_nRingWritten )
+ return false;
+
+ if ( s_nRingWritten - *pnCursor > OV_CONSOLE_RING_LINES )
+ *pnCursor = s_nRingWritten - OV_CONSOLE_RING_LINES;
+
+ Q_strncpy( pszOut, s_ConsoleRing[*pnCursor % OV_CONSOLE_RING_LINES], nOutLen );
+ ++( *pnCursor );
+ return true;
+}
+
+// The OpenVibe HTML menu fully replaces the stock GameUI menu: whenever we are
+// not in a level and the panel got hidden (e.g. the engine popped a
+// "Disconnected" dialog over the stock menu), bring ours back.
+void OpenVibe_MenuKeepAlive()
+{
+ if ( !ov_menu_auto_open.GetBool() )
+ return;
+
+ static float s_flNextCheck = 0.0f;
+ const float flNow = Plat_FloatTime();
+ if ( flNow < s_flNextCheck )
+ return;
+ s_flNextCheck = flNow + 2.0f;
+
+ if ( engine->IsInGame() )
+ return;
+
+ if ( !s_pOpenVibeMenu || !s_pOpenVibeMenu->IsVisible() )
+ OV_GetHTMLMenu()->Open();
+}
// OPENVIBE_CONSOLE_SPEW_TAP_END
// OPENVIBE_CONSOLE_SPEW_TAP_END
static void OV_OpenMenu_f( const CCommand &args )
static void OV_OpenMenu_f( const CCommand &args )
@@ -29,7 +29,9 @@ case "$MODE" in
@@ -29,7 +29,9 @@ case "$MODE" in
echo "Build Windows DLLs first, or use OPENVIBE_CLIENT_MODE=proton-fallback to launch without client DLL." >&2
echo "Build Windows DLLs first, or use OPENVIBE_CLIENT_MODE=proton-fallback to launch without client DLL." >&2
exit 1
exit 1
fi
fi
- exec "$ROOT/tools/run-client-proton.sh" "${@:-}"
+ # hl2_win64.exe is the engine our x64 DLLs load into (bin/x64); the 32-bit
+ # hl2.exe silently ignores them and boots the stock client.
+ exec "$ROOT/tools/run-client-proton-x64.sh" "${@:-}"
;;
;;
proton-fallback)
proton-fallback)
echo "WARNING: Proton fallback may launch, but without game/openvibe.games/bin/client.dll the in-game ov_* client commands/menu will not exist." >&2
echo "WARNING: Proton fallback may launch, but without game/openvibe.games/bin/client.dll the in-game ov_* client commands/menu will not exist." >&2
@@ -56,7 +56,7 @@ echo " Proton: $GE_PROTON"
@@ -56,7 +56,7 @@ echo " Proton: $GE_PROTON"
echo " HL2: $HL2_EXE"
echo " HL2: $HL2_EXE"
[ -n "$CONNECT_ARGS" ] && echo " Connect: $CONNECT_ARGS"
[ -n "$CONNECT_ARGS" ] && echo " Connect: $CONNECT_ARGS"
-if [ -f "$CLIENT_DLL" ] && command -v strings >/dev/null 2>&1 && strings -a "$CLIENT_DLL" | grep -Fq "ov_join"; then
+if [ -f "$CLIENT_DLL" ] && command -v strings >/dev/null 2>&1 && [ "$(strings -a "$CLIENT_DLL" | grep -Fc "ov_join" || true)" -gt 0 ]; then
echo " Client DLL: patched OpenVibe client.dll detected"
echo " Client DLL: patched OpenVibe client.dll detected"
elif [ -f "$CLIENT_DLL" ]; then
elif [ -f "$CLIENT_DLL" ]; then
echo " WARNING: client.dll exists, but ov_join string was not found. It may be stock/old." >&2
echo " WARNING: client.dll exists, but ov_join string was not found. It may be stock/old." >&2
@@ -91,13 +91,18 @@ fi
@@ -91,13 +91,18 @@ fi
# Single canonical log via -condebug (writes game/openvibe.games/console.log).
# Single canonical log via -condebug (writes game/openvibe.games/console.log).
# Do NOT also set +con_logfile: it hijacks the console log mid-startup, so the
# Do NOT also set +con_logfile: it hijacks the console log mid-startup, so the
# two split and neither is complete. -condebug alone keeps one full log.
# two split and neither is complete. -condebug alone keeps one full log.
+# NOTE: no -insecure here — an insecure client is rejected by VAC-secured
+# servers with "You are in insecure mode. You must restart before you can
+# connect to secure servers." Local dev servers run -insecure server-side
+# instead (see run-server.sh).
exec "$GE_PROTON/proton" waitforexitandrun \
exec "$GE_PROTON/proton" waitforexitandrun \
"$HL2_EXE" \
"$HL2_EXE" \
-game "$GAME_DIR" \
-game "$GAME_DIR" \
$CONSOLE_ARGS -dev -condebug -novid -sw -w 1280 -h 720 \
$CONSOLE_ARGS -dev -condebug -novid -sw -w 1280 -h 720 \
-port 27115 -clientport 27105 \
-port 27115 -clientport 27105 \
- -nojoy -insecure -nohltv \
+ -nojoy -nohltv \
+developer 1 \
+developer 1 \
+ +net_usesocketsforloopback 1 \
+exec openvibe_proton_stability.cfg \
+exec openvibe_proton_stability.cfg \
+exec openvibe_proton_client.cfg \
+exec openvibe_proton_client.cfg \
+exec openvibe_client_default.cfg \
+exec openvibe_client_default.cfg \
@@ -32,7 +32,7 @@ echo " Proton: $GE_PROTON"
@@ -32,7 +32,7 @@ echo " Proton: $GE_PROTON"
echo " HL2: $HL2_EXE"
echo " HL2: $HL2_EXE"
[ -n "$CONNECT_ARGS" ] && echo " Connect: $CONNECT_ARGS"
[ -n "$CONNECT_ARGS" ] && echo " Connect: $CONNECT_ARGS"
-if [ -f "$CLIENT_DLL" ] && command -v strings >/dev/null 2>&1 && strings -a "$CLIENT_DLL" | grep -Fq "ov_join"; then
+if [ -f "$CLIENT_DLL" ] && command -v strings >/dev/null 2>&1 && [ "$(strings -a "$CLIENT_DLL" | grep -Fc "ov_join" || true)" -gt 0 ]; then
echo " Client DLL: patched OpenVibe client.dll detected"
echo " Client DLL: patched OpenVibe client.dll detected"
elif [ -f "$CLIENT_DLL" ]; then
elif [ -f "$CLIENT_DLL" ]; then
echo " WARNING: client.dll exists, but ov_join string was not found. It may be stock/old." >&2
echo " WARNING: client.dll exists, but ov_join string was not found. It may be stock/old." >&2
@@ -69,6 +69,13 @@ server_cmd=(
@@ -69,6 +69,13 @@ server_cmd=(
-steamport "$STEAMPORT"
-steamport "$STEAMPORT"
)
)
+# Local dev shards run without VAC (a VAC-secured server rejects clients that
+# were ever started -insecure, and dev iterating on DLLs trips VAC anyway).
+# Set OPENVIBE_SRCDS_SECURE=1 for a secured public shard.
+if [[ "${OPENVIBE_SRCDS_SECURE:-0}" != "1" ]]; then
+ server_cmd+=(-insecure)
+fi
+
if [[ "${OPENVIBE_SRCDS_MASTER:-0}" != "1" ]]; then
if [[ "${OPENVIBE_SRCDS_MASTER:-0}" != "1" ]]; then
server_cmd+=(-nomaster)
server_cmd+=(-nomaster)
fi
fi
@@ -77,7 +84,10 @@ server_cmd+=(
@@ -77,7 +84,10 @@ server_cmd+=(
+clientport "$CLIENTPORT"
+clientport "$CLIENTPORT"
+maxplayers "$MAXPLAYERS"
+maxplayers "$MAXPLAYERS"
+tv_port "$TVPORT"
+tv_port "$TVPORT"
- +sv_master_share_game_socket 1
+ # share_game_socket=1 hands unconnected packets (A2S queries + connect
+ # challenges) to the Steam GS layer, which stays dead for anonymous local
+ # shards — clients then fail with "Connection failed after 4 retries".
+ +sv_master_share_game_socket 0
+exec "$CFG"
+exec "$CFG"
)
)