/** * LuaSharedSandbox (v3, main-thread) — wasmoon-VM работает в MAIN потоке, * без Web Worker. Это позволяет: * - Видеть точные Lua-ошибки в DevTools (через console.error) * - Использовать debugger / breakpoints прямо в RobloxShim.js * - Не возиться с молчаливыми Worker-падениями * * Цена: тяжёлые Lua-скрипты могут блокировать UI. Для KillBrick-style * скриптов это нестрашно — они быстрые. * * API совместим с ScriptSandbox: setOnCommand / sendEvent / sendGlobalEvent / * sendSceneSnapshot / sendGuiSnapshot / sendDataSnapshot / sendSkinsSnapshot / * sendTerrainHeightmap / stop / tick / target. * * Что добавлено сверх ScriptSandbox: * - addScript(id, code, target) — добавить скрипт в общий VM. Можно * до или после start(). * - start() — асинхронен (createEngine), но возвращает сразу. После init * стартует main loop (Heartbeat + scheduler). */ import { LuaFactory } from 'wasmoon'; import { registerRobloxShim } from './RobloxShim.js'; export class LuaSharedSandbox { constructor() { this.vm = null; this.api = null; this._onCommand = null; this._isReady = false; this._isStopped = false; this._isKickedOff = false; this._pendingScripts = []; // [{id, code, target, name}] this._scriptsById = new Map(); this._scenes = null; this._guiTree = null; this._loopHandle = null; this._lastTickAt = 0; // Маркер для GameRuntime.routeEvent — этот sandbox принимает все // события и сам маршрутизирует через shim.fireTargetEvent. this._luaShared = true; } setOnCommand(cb) { this._onCommand = cb; } get target() { return null; } tick(_dt, _state) { /* no-op: main-loop запускается изнутри (setInterval) */ } addScript(id, code, target, name, extra) { const entry = { id: String(id || `lua_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`), code: String(code || ''), target: target == null ? null : target, name: name || null, toolName: extra?.toolName || null, }; this._scriptsById.set(entry.id, entry); if (!this._isKickedOff) { this._pendingScripts.push(entry); } else { this._startSingleScript(entry); } } removeScript(id) { this._scriptsById.delete(String(id)); } /** Стартует VM, регистрирует shim, запускает main-loop. */ start() { if (this.vm || this._isStopped) return; // eslint-disable-next-line no-console console.log('[LuaSharedSandbox v3] starting Lua VM in main thread...'); this._initAsync().catch((err) => { // eslint-disable-next-line no-console console.error('[LuaSharedSandbox] FATAL init error:', err); this._emit('log', { level: 'error', text: `Lua-runtime init: ${err?.message || err}` }); }); } async _initAsync() { const factory = new LuaFactory(); this.vm = await factory.createEngine({ openStandardLibs: true }); // eslint-disable-next-line no-console console.log('[LuaSharedSandbox] VM created. Registering Roblox shim...'); // Адаптер для shim'а: он ожидает send(cmd, payload), getSceneSnapshot, getGuiTree, scheduleWait. const send = (cmd, payload) => this._emit(cmd, payload); this.api = registerRobloxShim(this.vm, { send, getSceneSnapshot: () => this._scenes, getGuiTree: () => this._guiTree, scheduleWait: () => null, }); // eslint-disable-next-line no-console console.log('[LuaSharedSandbox] Shim registered. api keys:', Object.keys(this.api || {})); // Применим snapshot если он есть if (this._scenes && this.api?.onSceneSnapshot) { try { this.api.onSceneSnapshot(this._scenes); } catch (e) { console.error('[LuaSharedSandbox] onSceneSnapshot:', e); } } this._isReady = true; this._kickoff(); } _kickoff() { if (this._isKickedOff || this._isStopped) return; this._isKickedOff = true; // eslint-disable-next-line no-console console.log(`[LuaSharedSandbox] kickoff: starting ${this._pendingScripts.length} scripts`); const pending = this._pendingScripts; this._pendingScripts = []; // Запускаем main-loop сразу — он начнёт tick'ать как только будут coroutines. this._lastTickAt = performance.now(); this._startMainLoop(); // Init батчами по 5 с задержкой 20мс между ними, чтобы UI отзывался. const BATCH_SIZE = 5; let idx = 0; const initBatch = () => { if (this._isStopped) return; const end = Math.min(idx + BATCH_SIZE, pending.length); for (let i = idx; i < end; i++) { try { this._startSingleScript(pending[i]); } catch (e) { // eslint-disable-next-line no-console console.error('[LuaSharedSandbox] init batch err:', e); } } idx = end; if (idx < pending.length) { setTimeout(initBatch, 20); } else { // eslint-disable-next-line no-console console.log(`[LuaSharedSandbox] all ${pending.length} scripts kicked off`); // После того как все скрипты подключили хендлеры — фейрим // events для уже существующих сущностей. Roblox-конвенция: // если игрок уже на сервере когда скрипт подключается, // Players.PlayerAdded не сработает повторно. Юзеру нужно // делать ручной обход GetPlayers() — но это редко кто помнит. // Мы дублируем событие через короткую задержку. setTimeout(() => { try { if (this.api?.fireExistingPlayers) { this.api.fireExistingPlayers(); } } catch (e) { console.warn('[LuaSharedSandbox] fireExistingPlayers failed:', e); } }, 100); } }; setTimeout(initBatch, 0); } _startSingleScript(entry) { if (!this.vm || !entry || typeof entry.code !== 'string') return; let primId = null; if (typeof entry.target === 'number') primId = entry.target; else if (entry.target && typeof entry.target === 'object') { if (entry.target.kind === 'primitive') primId = entry.target.id ?? entry.target.ref; } const safeId = entry.id.replace(/[^a-zA-Z0-9_]/g, '_'); const scriptName = entry.name || `Script_${safeId}`; // Скрипт оборачиваем в coroutine — это позволяет task.wait через yield. // Резюмим coroutine из main-loop когда наступило время. // Регистрируем coroutine в __rbxl_coroutines с id для возобновления. // Скрипт оборачиваем в coroutine. task.wait()→coroutine.yield(sec) возвращает // delay из resume → планируем следующий resume через scheduleResume. // Fallback Parent: если скрипт связан с Tool (по имени Tool из metadata) — // подсовываем виртуальный Tool как script.Parent. Иначе primitive по id, // иначе workspace. let parentExpr; if (entry.toolName) { // Tool создаётся в shim как Instance.new('Tool'). По имени достаём. // Если не нашли — fallback на новый Tool того же имени. const safeName = JSON.stringify(entry.toolName); parentExpr = `(function() local existing = __rbxl_get_tool_by_name(${safeName}) if existing then return existing end local t = Instance.new("Tool") t.Name = ${safeName} return t end)()`; } else if (primId != null) { parentExpr = `(__rbxl_get_part_by_id(${Number(primId)}) or workspace)`; } else { parentExpr = 'workspace'; } const wrapped = ` do -- Если parentExpr вернул primitive — у него уже есть :FindFirstChild и пр. -- Если ничего не вернёт — workspace (всегда валидный). -- script.Parent.Parent (Tool.Parent = StarterPack / Backpack / workspace). local _scriptParent = ${parentExpr} if _scriptParent == nil then _scriptParent = workspace end if _scriptParent.Parent == nil then _scriptParent.Parent = workspace end local script = setmetatable({ Name = ${JSON.stringify(scriptName)}, Parent = _scriptParent, ClassName = "Script", Disabled = false, Source = nil, }, { -- Любой доступ к несуществующему полю → workspace -- (на случай script.Foo:Bar() в старом коде) __index = function(t, k) if k == "FindFirstChild" or k == "WaitForChild" or k == "GetChildren" then return function() return nil end end return workspace[k] end, }) local co = coroutine.create(function() -- WATCHDOG: каждые 100000 инструкций — yield 1 кадр. -- НЕ оборачиваем в pcall — внутри C-call boundary yield -- упадёт ошибкой, что прервёт скрипт. Это лучше чем виснуть. debug.sethook(function() coroutine.yield(0.016) end, "", 20000) -- pcall защищает от runtime-ошибок которые иначе крашат -- coroutine и могут повредить WASM-стейт. Возвраты -- handler'а намеренно поглощаются. local ok_, err_ = pcall(function() ${entry.code} end) if not ok_ then __rbxl_send_error(${JSON.stringify(entry.id)}, tostring(err_)) end end) __rbxl_register_coroutine(${JSON.stringify(entry.id)}, co) local ok, ret = coroutine.resume(co) if not ok then __rbxl_send_error(${JSON.stringify(entry.id)}, tostring(ret)) __rbxl_unregister_coroutine(${JSON.stringify(entry.id)}) elseif type(ret) == 'number' then -- скрипт yield'нул с delay (через task.wait) — планируем resume __rbxl_schedule_resume(${JSON.stringify(entry.id)}, ret) elseif coroutine.status(co) == 'dead' then __rbxl_unregister_coroutine(${JSON.stringify(entry.id)}) end end `; try { this.vm.doStringSync(wrapped); // eslint-disable-next-line no-console console.log(`[LuaSharedSandbox] script ${entry.id} initialized OK`); } catch (err) { // eslint-disable-next-line no-console console.error(`[LuaSharedSandbox] script ${entry.id} init FAILED:`, err); this._emit('log', { level: 'error', text: `[Lua ${entry.id}] ${err?.message || err}` }); } } _startMainLoop() { const tick = () => { if (this._isStopped) return; try { const now = performance.now(); const dt = Math.min(0.1, (now - this._lastTickAt) / 1000); this._lastTickAt = now; if (this.api?.tickScheduler) this.api.tickScheduler(dt); if (this.api?.fireHeartbeat) this.api.fireHeartbeat(dt); } catch (e) { // eslint-disable-next-line no-console console.error('[LuaSharedSandbox tick]', e); } this._loopHandle = setTimeout(tick, 16); }; this._loopHandle = setTimeout(tick, 16); } _emit(cmd, payload) { if (typeof this._onCommand === 'function') { try { this._onCommand({ cmd, payload }); } catch (_) {} } } // ----- API совместимый с ScriptSandbox ----- sendEvent(payload) { if (!this.api?.fireTargetEvent || !this._isReady) return; try { this.api.fireTargetEvent(payload); } catch (e) { console.error('[LuaSharedSandbox] sendEvent:', e); } } sendGlobalEvent(payload) { if (!this.api?.fireGlobalEvent || !this._isReady) return; try { this.api.fireGlobalEvent(payload); } catch (e) { console.error('[LuaSharedSandbox] sendGlobalEvent:', e); } } sendSceneSnapshot(snapshot) { this._scenes = snapshot; if (this.api?.onSceneSnapshot && this._isReady) { try { this.api.onSceneSnapshot(snapshot); } catch (e) { console.error('[LuaSharedSandbox] onSceneSnapshot:', e); } } } sendGuiSnapshot(snapshot) { this._guiTree = snapshot; if (this.api?.onGuiSnapshot && this._isReady) { try { this.api.onGuiSnapshot(snapshot); } catch (_) {} } } sendDataSnapshot(snapshot) { if (this.api?.onDataSnapshot && this._isReady) { try { this.api.onDataSnapshot(snapshot); } catch (_) {} } } sendSkinsSnapshot(_) { /* no-op для Этапа 3 */ } sendTerrainHeightmap(_) { /* no-op */ } stop() { this._isStopped = true; if (this._loopHandle) { clearTimeout(this._loopHandle); this._loopHandle = null; } if (this.vm) { try { this.vm.global.close(); } catch (_) {} this.vm = null; } this.api = null; } } export default LuaSharedSandbox;