338 lines
15 KiB
JavaScript
338 lines
15 KiB
JavaScript
/**
|
||
* 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;
|