diff --git a/crates/lua/src/include.rs b/crates/lua/src/include.rs new file mode 100644 index 0000000..923a658 --- /dev/null +++ b/crates/lua/src/include.rs @@ -0,0 +1,209 @@ +//! `include()`: загрузка Lua-файлов игры. +//! +//! GMod-скрипты собираются из кусочков: `gamemodes/sandbox/gamemode/init.lua` +//! подключает `shared.lua`, тот — `player_extension.lua` и так далее. Без +//! настоящего `include` загружается только первый файл, а остальные молча +//! теряются. Поиск идёт сначала рядом с текущим файлом (`spawnmenu/toolmenu.lua`), +//! затем в `<игра>/lua/` (`includes/util.lua`, `drive/drive_sandbox.lua`). +//! +//! Песочница: путь разрешается только внутри каталога игры (по умолчанию — +//! `<игра>/lua/`), проверяется канонизация (симлинк наружу — отказ), размер +//! файла и глубина вложенности. Повторный `include` уже загруженного файла — +//! no-op: это одновременно и защита от циклов `a -> b -> a`. + +use std::cell::RefCell; +use std::collections::HashSet; +use std::path::{Component, Path, PathBuf}; +use std::rc::Rc; + +use mlua::{Lua, Value}; + +/// Максимальная глубина вложенных `include`. +pub(crate) const MAX_DEPTH: usize = 8; + +/// Максимальный размер одного включаемого файла. +const MAX_SIZE: u64 = 1 << 20; + +/// Состояние загрузчика, общее для замыкания `include` и рантайма. +#[derive(Default)] +pub(crate) struct IncludeState { + /// Корень игры (`.../garrysmod`). + game_root: Option, + /// Каталоги текущего файла и включающих его файлов (внутренний — сверху). + /// + /// GMod ищет `include("toolmenu.lua")` сначала рядом с текущим скриптом + /// (`spawnmenu/toolmenu.lua`), а уже потом в `<игра>/lua`. Без этого + /// вложенные include из `lua/includes` и `sandbox/gamemode/spawnmenu` не + /// находятся. + script_dirs: Vec, + /// Уже выполненные файлы (канонические пути). + loaded: HashSet, + /// Текущая глубина рекурсии. + depth: usize, +} + +impl IncludeState { + /// Задаёт корень игры и сбрасывает кэш загруженных файлов. + pub(crate) fn set_root(&mut self, root: &Path) { + let normalized = canonical_or_normalized(root); + if self.game_root.as_deref() != Some(normalized.as_path()) { + self.loaded.clear(); + self.script_dirs.clear(); + } + self.game_root = Some(normalized); + } + + /// Задаёт корень, только если он ещё не задан. + pub(crate) fn ensure_root(&mut self, root: &Path) { + if self.game_root.is_none() { + self.set_root(root); + } + } + + /// Добавляет каталог текущего файла в стек поиска. + pub(crate) fn push_script_dir(&mut self, directory: &Path) { + self.script_dirs.push(canonical_or_normalized(directory)); + } + + /// Убирает каталог текущего файла из стека. + pub(crate) fn pop_script_dir(&mut self, directory: &Path) { + let normalized = canonical_or_normalized(directory); + if self.script_dirs.last() == Some(&normalized) { + self.script_dirs.pop(); + } else if let Some(position) = self + .script_dirs + .iter() + .rposition(|path| *path == normalized) + { + self.script_dirs.remove(position); + } + } + + /// Ищет файл: сначала дополнительные корни, затем `<игра>/lua/`. + fn resolve(&self, relative: &str) -> Result { + let Some(root) = self.game_root.as_ref() else { + return Err("include: каталог игры не задан (set_file_root)".to_string()); + }; + if relative.is_empty() { + return Err("include: пустой путь".to_string()); + } + // В файлах игры встречаются обратные слэши (Windows-аддоны). + let relative = relative.replace('\\', "/"); + let requested = Path::new(&relative); + if requested.is_absolute() { + return Err(format!("include: абсолютный путь запрещён: {relative}")); + } + // `..` не должен выводить за пределы каталога игры. + let canonical_root = root.canonicalize().unwrap_or_else(|_| root.clone()); + // От ближайшего каталога к дальнему: файл рядом с текущим, затем + // каталоги включающих файлов, затем `<игра>/lua`. + let mut candidates: Vec = self + .script_dirs + .iter() + .rev() + .map(|base| normalize(base.join(requested))) + .collect(); + candidates.push(normalize(root.join("lua").join(requested))); + + for candidate in candidates { + if !candidate.starts_with(&canonical_root) && !candidate.starts_with(root) { + continue; + } + if !candidate.is_file() { + continue; + } + // Канонизация ловит симлинк, ведущий наружу. + let Ok(canonical) = candidate.canonicalize() else { + continue; + }; + if canonical.starts_with(&canonical_root) && canonical.is_file() { + return Ok(canonical); + } + } + Err(format!("include: файл не найден: {relative}")) + } +} + +/// Ставит глобальную функцию `include`, связанную с состоянием. +pub(crate) fn install(lua: &Lua, state: Rc>) -> mlua::Result<()> { + let include = lua.create_function(move |lua, path: String| { + let resolved = { + let state = state.borrow(); + state.resolve(&path).map_err(mlua::Error::runtime)? + }; + { + let mut state = state.borrow_mut(); + // Уже загруженный файл: повторный include не должен ни зацикливать, + // ни повторно выполнять код (иначе `shared.lua` из init и cl_init + // переопределял бы уже созданные таблицы). + if state.loaded.contains(&resolved) { + return Ok(Value::Nil); + } + if state.depth >= MAX_DEPTH { + return Err(mlua::Error::runtime(format!( + "include: превышена глубина вложенности {MAX_DEPTH}: {}", + resolved.display() + ))); + } + state.loaded.insert(resolved.clone()); + state.depth += 1; + } + + let script_dir = resolved.parent().map(canonical_or_normalized); + if let Some(directory) = script_dir.as_deref() { + state.borrow_mut().push_script_dir(directory); + } + let result = (|| -> mlua::Result<()> { + let metadata = std::fs::metadata(&resolved).map_err(|err| { + mlua::Error::runtime(format!("include: {}: {err}", resolved.display())) + })?; + if metadata.len() > MAX_SIZE { + return Err(mlua::Error::runtime(format!( + "include: файл больше {} байт: {}", + MAX_SIZE, + resolved.display() + ))); + } + let bytes = std::fs::read(&resolved).map_err(|err| { + mlua::Error::runtime(format!("include: {}: {err}", resolved.display())) + })?; + let source = String::from_utf8_lossy(&bytes); + let processed = crate::preprocess(&source); + lua.load(processed.as_bytes()) + .set_name(resolved.display().to_string()) + .exec() + })(); + + // Стек и счётчик глубины обязаны вернуться назад и при ошибке. + if let Ok(mut state) = state.try_borrow_mut() { + if let Some(directory) = script_dir.as_deref() { + state.pop_script_dir(directory); + } + state.depth = state.depth.saturating_sub(1); + } + result.map(|()| Value::Nil) + })?; + lua.globals().set("include", include)?; + Ok(()) +} + +/// Канонический путь, а если файла ещё нет — лексически нормализованный. +fn canonical_or_normalized(path: &Path) -> PathBuf { + path.canonicalize() + .unwrap_or_else(|_| normalize(path.to_path_buf())) +} + +/// Убирает `.` и `..` без обращения к файловой системе. +fn normalize(path: PathBuf) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other), + } + } + out +} diff --git a/crates/lua/src/lib.rs b/crates/lua/src/lib.rs index ac63aaa..b175f89 100644 --- a/crates/lua/src/lib.rs +++ b/crates/lua/src/lib.rs @@ -5,6 +5,7 @@ pub mod ents; pub mod gmod; +mod include; pub mod stdlib; use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value}; @@ -22,11 +23,21 @@ pub enum LuaError { type TimerQueue = Rc>>; type Output = Rc>>; +/// Итог загрузки геймода. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct GamemodeLoad { + /// Выполненные файлы (относительно каталога геймода). + pub loaded: Vec, + /// Ошибки по файлам: загрузка продолжается со следующего файла. + pub errors: Vec, +} + /// Lua-рантайм клиента. pub struct LuaRuntime { lua: Lua, timers: TimerQueue, output: Output, + include: Rc>, } impl LuaRuntime { @@ -35,15 +46,18 @@ impl LuaRuntime { let lua = Lua::new(); let timers: TimerQueue = Rc::new(RefCell::new(Vec::new())); let output: Output = Rc::new(RefCell::new(Vec::new())); + let include = Rc::new(RefCell::new(include::IncludeState::default())); install_api(&lua, &timers, &output)?; gmod::install(&lua)?; stdlib::install(&lua, "unknown")?; + include::install(&lua, include.clone())?; Ok(Self { lua, timers, output, + include, }) } @@ -122,6 +136,9 @@ impl LuaRuntime { /// /// Возвращает `(оружий, энтити, ошибок)`. pub fn load_scripts(&self, game_dir: &std::path::Path) -> (usize, usize, usize) { + // `include()` внутри скриптов ищет файлы в `<игра>/lua`; если клиент + // ещё не настроил `file.*`, корень берём отсюда же. + self.include.borrow_mut().ensure_root(game_dir); let (mut weapons, mut entities, mut failed) = (0usize, 0usize, 0usize); for (kind, relative) in [("weapon", "lua/weapons"), ("entity", "lua/entities")] { let dir = game_dir.join(relative); @@ -232,9 +249,85 @@ impl LuaRuntime { (weapons, entities, failed) } + /// Загружает геймод: `gamemodes//gamemode/init.lua` и `cl_init.lua`. + /// + /// В отличие от [`Self::load_scripts`] ошибки не прерывают загрузку: файл + /// `cl_init.lua` выполняется даже если `init.lua` упал, а все ошибки + /// возвращаются в [`GamemodeLoad`]. Настоящий Sandbox тянет VGUI/Derma, + /// которых у нас нет, поэтому часть файлов ожидаемо не догружается — + /// важно, что реестры (`list`, `weapons`, `scripted_ents`) при этом целы. + pub fn load_gamemode(&self, game_dir: &std::path::Path, mode: &str) -> GamemodeLoad { + let mut report = GamemodeLoad::default(); + // Имя режима приходит из конфига/командной строки: не даём ему стать + // путём (`../..`) или пустой строкой. + let mut components = std::path::Path::new(mode).components(); + let single_component = matches!(components.next(), Some(std::path::Component::Normal(_))) + && components.next().is_none(); + if mode.is_empty() || mode.contains('/') || mode.contains('\\') || !single_component { + report + .errors + .push(format!("некорректное имя геймода: {mode:?}")); + return report; + } + + self.include.borrow_mut().set_root(game_dir); + let directory = game_dir.join("gamemodes").join(mode).join("gamemode"); + if !directory.is_dir() { + report.errors.push(format!( + "каталог геймода не найден: {}", + directory.display() + )); + return report; + } + + // GMod создаёт GAMEMODE (он же GM) до выполнения init.lua: `shared.lua` + // пишет в него Name/Author, а init.lua определяет методы. + let globals = self.lua.globals(); + let gm = match globals.get::("GAMEMODE") { + Ok(Value::Table(table)) => table, + _ => match self.lua.create_table() { + Ok(table) => table, + Err(err) => { + report.errors.push(format!("GAMEMODE: {err}")); + return report; + } + }, + }; + if let Err(err) = globals + .set("GAMEMODE", &gm) + .and_then(|()| globals.set("GM", &gm)) + { + report.errors.push(format!("GAMEMODE: {err}")); + return report; + } + + // include() внутри геймода сначала ищет файл рядом с init.lua + // (`shared.lua`), затем в `<игра>/lua` (`drive/drive_sandbox.lua`). + self.include.borrow_mut().push_script_dir(&directory); + for name in ["init.lua", "cl_init.lua"] { + let path = directory.join(name); + if !path.is_file() { + continue; + } + let Some(source) = read_script(&path) else { + report + .errors + .push(format!("{name}: файл не прочитан или больше лимита")); + continue; + }; + match self.run(&path.display().to_string(), source.as_bytes()) { + Ok(()) => report.loaded.push(name.to_string()), + Err(err) => report.errors.push(format!("{name}: {err}")), + } + } + self.include.borrow_mut().pop_script_dir(&directory); + report + } + /// Ставит `file.*` с песочницей на каталог игры. pub fn set_file_root(&self, root: &std::path::Path) -> Result<(), LuaError> { stdlib::install_file(&self.lua, root)?; + self.include.borrow_mut().set_root(root); Ok(()) } @@ -495,6 +588,11 @@ fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), ml )?; globals.set("concommand", cc_table)?; + // Клиент исполняет клиентскую ветку GMod-скриптов; обе константы в GMod + // всегда определены, поэтому `if ( SERVER )` не должен падать на nil. + globals.set("CLIENT", true)?; + globals.set("SERVER", false)?; + // Заглушки, которые часто нужны аддонам. let game_table = lua.create_table()?; game_table.set( @@ -653,11 +751,20 @@ fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), ml Ok(()) } -/// Препроцессинг GMod-исходников: `!` вне строк и комментариев → `not `. +/// Препроцессинг GMod-исходников. /// -/// GMod патчит LuaJIT, добавляя C-подобное отрицание; оригинальные файлы игры -/// его используют (`if ( !foo ) then`). +/// Два расширения LuaJIT из GMod: +/// - C-подобные операторы: `!` → `not`, `!=` → `~=`, `&&` → `and`, `||` → `or`; +/// - `continue` внутри циклов → `goto` на метку в конце тела цикла. +/// +/// Оба прохода лексические: строки и комментарии остаются байт-в-байт. pub fn preprocess(source: &str) -> String { + let converted = preprocess_operators(source); + preprocess_continue(&converted) +} + +/// `!`, `!=`, `&&`, `||` вне строк и комментариев. +fn preprocess_operators(source: &str) -> String { let bytes = source.as_bytes(); let mut out = String::with_capacity(source.len() + 16); let mut i = 0usize; @@ -742,6 +849,421 @@ pub fn preprocess(source: &str) -> String { out } +/// Вид лексического токена (нужен только для поиска `continue` и меток). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TokenKind { + Name, + Number, + String, + Symbol, +} + +/// Токен с байтовыми границами в исходнике. +#[derive(Debug, Clone, Copy)] +struct Token<'a> { + kind: TokenKind, + start: usize, + end: usize, + text: &'a str, +} + +/// Лексер Lua: комментарии пропускаются, строки — один токен. +/// +/// Полноценный парсер здесь не нужен, но структура блоков (`for`/`while`/ +/// `repeat`/`function`/`if`/`do` ... `end`) обязана учитываться: `continue` +/// принадлежит **ближайшему** циклу, а `continue` внутри вложенной функции — +/// не принадлежит внешнему циклу вовсе. +fn tokenize(source: &str) -> Vec> { + let bytes = source.as_bytes(); + let mut tokens = Vec::new(); + let mut i = 0usize; + while i < bytes.len() { + let b = bytes[i]; + if b.is_ascii_whitespace() { + i += 1; + continue; + } + // Комментарий: длинный (`--[[`) или до конца строки. + if b == b'-' && bytes.get(i + 1) == Some(&b'-') { + let (skip, _) = long_bracket(bytes, i + 2); + if skip > 0 { + i = (i + 2 + skip).min(bytes.len()); + continue; + } + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + continue; + } + // Длинная строка. + if b == b'[' { + let (skip, _) = long_bracket(bytes, i); + if skip > 0 { + tokens.push(Token { + kind: TokenKind::String, + start: i, + end: (i + skip).min(bytes.len()), + text: &source[i..(i + skip).min(bytes.len())], + }); + i = (i + skip).min(bytes.len()); + continue; + } + } + // Обычная строка. + if b == b'"' || b == b'\'' { + let quote = b; + let start = i; + i += 1; + while i < bytes.len() { + if bytes[i] == b'\\' { + i = (i + 2).min(bytes.len()); + continue; + } + if bytes[i] == quote { + i += 1; + break; + } + i += 1; + } + tokens.push(Token { + kind: TokenKind::String, + start, + end: i, + text: &source[start..i], + }); + continue; + } + if is_name_start(b) { + let start = i; + i += 1; + while i < bytes.len() && is_name_continue(bytes[i]) { + i += 1; + } + tokens.push(Token { + kind: TokenKind::Name, + start, + end: i, + text: &source[start..i], + }); + continue; + } + if b.is_ascii_digit() + || (b == b'.' && bytes.get(i + 1).is_some_and(|next| next.is_ascii_digit())) + { + let start = i; + i += 1; + while i < bytes.len() { + let c = bytes[i]; + let exponent_sign = (c == b'+' || c == b'-') + && i > start + && matches!(bytes[i - 1], b'e' | b'E' | b'p' | b'P'); + if c.is_ascii_alphanumeric() || c == b'_' || c == b'.' || exponent_sign { + i += 1; + } else { + break; + } + } + tokens.push(Token { + kind: TokenKind::Number, + start, + end: i, + text: &source[start..i], + }); + continue; + } + // Символы: для нас важен только `::` (метки), остальные — по одному. + let start = i; + let width = if source[start..].starts_with("::") + || source[start..].starts_with("...") + || source[start..].starts_with("..") + || source[start..].starts_with("==") + || source[start..].starts_with("~=") + || source[start..].starts_with("<=") + || source[start..].starts_with(">=") + || source[start..].starts_with("//") + || source[start..].starts_with("<<") + || source[start..].starts_with(">>") + { + if source[start..].starts_with("...") { + 3 + } else { + 2 + } + } else { + source[start..].chars().next().map_or(1, char::len_utf8) + }; + i = (i + width).min(bytes.len()); + tokens.push(Token { + kind: TokenKind::Symbol, + start, + end: i, + text: &source[start..i], + }); + } + tokens +} + +fn is_name_start(byte: u8) -> bool { + byte.is_ascii_alphabetic() || byte == b'_' || byte >= 0x80 +} + +fn is_name_continue(byte: u8) -> bool { + is_name_start(byte) || byte.is_ascii_digit() +} + +/// Тип блока в стеке структурного разбора. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FrameKind { + Root, + Function, + If, + Do, + /// Ждём `do` после заголовка `for`/`while`. + LoopPending(usize), + /// Тело `for`/`while` (до соответствующего `end`). + LoopBody(usize), + /// Тело `repeat` (до `until`). + Repeat(usize), +} + +#[derive(Debug, Clone, Copy)] +struct Frame { + kind: FrameKind, + /// Ближайший цикл, к которому относится `continue`; `None` внутри функции. + loop_id: Option, +} + +#[derive(Debug, Default)] +struct LoopInfo { + body_end: Option, + /// Индексы токенов непосредственно в теле цикла (не во вложенных блоках). + body_tokens: Vec, + continues: Vec<(usize, usize)>, +} + +/// Превращает `continue` в `goto` + метку в конце тела ближайшего цикла. +fn preprocess_continue(source: &str) -> String { + let tokens = tokenize(source); + let mut loops: Vec = Vec::new(); + let mut stack = vec![Frame { + kind: FrameKind::Root, + loop_id: None, + }]; + let mut existing_labels: std::collections::HashSet = std::collections::HashSet::new(); + + for (index, token) in tokens.iter().enumerate() { + // Токены на верхнем уровне тела: по ним ищем завершающие `break`/ + // `return`, после которых Lua запрещает ставить метку. + let immediate_loop = match stack.last().map(|frame| frame.kind) { + Some(FrameKind::LoopBody(id) | FrameKind::Repeat(id)) => Some(id), + _ => None, + }; + if let Some(id) = immediate_loop { + if !matches!(token.text, "end" | "until") { + if let Some(info) = loops.get_mut(id) { + info.body_tokens.push(index); + } + } + } + match token.text { + "for" | "while" if token.kind == TokenKind::Name => { + let id = loops.len(); + loops.push(LoopInfo::default()); + stack.push(Frame { + kind: FrameKind::LoopPending(id), + loop_id: Some(id), + }); + } + "repeat" if token.kind == TokenKind::Name => { + let id = loops.len(); + loops.push(LoopInfo::default()); + stack.push(Frame { + kind: FrameKind::Repeat(id), + loop_id: Some(id), + }); + } + "function" if token.kind == TokenKind::Name => { + stack.push(Frame { + kind: FrameKind::Function, + loop_id: None, + }); + } + "if" if token.kind == TokenKind::Name => { + let loop_id = stack.last().and_then(|frame| frame.loop_id); + stack.push(Frame { + kind: FrameKind::If, + loop_id, + }); + } + "do" if token.kind == TokenKind::Name => { + let current = stack.last().copied(); + if let Some(Frame { + kind: FrameKind::LoopPending(id), + .. + }) = current + { + if let Some(frame) = stack.last_mut() { + frame.kind = FrameKind::LoopBody(id); + } + } else { + stack.push(Frame { + kind: FrameKind::Do, + loop_id: current.and_then(|frame| frame.loop_id), + }); + } + } + "end" if token.kind == TokenKind::Name => { + if stack.len() <= 1 { + continue; + } + if let Some(frame) = stack.pop() { + let loop_id = match frame.kind { + FrameKind::LoopBody(id) | FrameKind::LoopPending(id) => Some(id), + _ => None, + }; + if let Some(id) = loop_id { + if let Some(info) = loops.get_mut(id) { + info.body_end = Some(token.start); + } + } + } + } + "until" if token.kind == TokenKind::Name => { + let repeat_id = match stack.last().copied() { + Some(Frame { + kind: FrameKind::Repeat(id), + .. + }) => Some(id), + _ => None, + }; + if let Some(id) = repeat_id { + stack.pop(); + if let Some(info) = loops.get_mut(id) { + info.body_end = Some(token.start); + } + } + } + "continue" + if token.kind == TokenKind::Name && is_statement_continue(&tokens, index) => + { + if let Some(loop_id) = stack.last().and_then(|frame| frame.loop_id) { + if let Some(info) = loops.get_mut(loop_id) { + info.continues.push((token.start, token.end)); + } + } + } + "::" if token.kind == TokenKind::Symbol => { + if let Some(next) = tokens.get(index + 1) { + if next.kind == TokenKind::Name { + existing_labels.insert(next.text.to_string()); + } + } + } + _ => {} + } + } + + let mut edits: Vec<(usize, usize, String)> = Vec::new(); + let mut counter = 1usize; + for info in &loops { + if info.continues.is_empty() { + continue; + } + let Some(body_end) = info.body_end else { + // Незакрытый цикл — исходник всё равно не скомпилируется; + // не добавляем метку в случайное место. + continue; + }; + let label = loop { + let candidate = format!("__sr_continue_{counter}"); + counter += 1; + if !existing_labels.contains(&candidate) { + break candidate; + } + }; + for (start, end) in &info.continues { + edits.push((*start, *end, format!("goto {label}"))); + } + + // `return` и `break` обязаны быть последними операторами блока — + // метку после них Lua не примет. Оборачиваем завершающий `return` + // (вместе с выражением) или `break` в `do ... end`, тогда метка + // оказывается уже после закрытого блока. Семантика не меняется. + let last = info + .body_tokens + .iter() + .rev() + .copied() + .find(|&index| tokens[index].text != ";"); + if let Some(index) = last { + if tokens[index].text == "break" { + edits.push(( + tokens[index].start, + tokens[index].end, + "do break end".to_string(), + )); + } + } + let trailing_return = info + .body_tokens + .iter() + .rev() + .copied() + .find(|&index| tokens[index].text == "return"); + if let Some(index) = trailing_return { + let start = tokens[index].start; + edits.push(( + start, + body_end, + format!("do {} end::{label}::", &source[start..body_end]), + )); + } else { + edits.push((body_end, body_end, format!("::{label}::"))); + } + } + if edits.is_empty() { + return source.to_string(); + } + + edits.sort_by_key(|(start, _, _)| *start); + let mut out = String::with_capacity(source.len() + edits.len() * 8); + let mut position = 0usize; + for (start, end, replacement) in edits { + if start < position || end > source.len() { + continue; + } + out.push_str(&source[position..start]); + out.push_str(&replacement); + position = end; + } + out.push_str(&source[position..]); + out +} + +/// `continue` — самостоятельный оператор, а не поле `t.continue`. +/// +/// В GMod `continue` — ключевое слово, но в обычном Lua это допустимое имя; +/// не превращаем `obj:continue()` и `local continue = 1` в `goto`. +fn is_statement_continue(tokens: &[Token<'_>], index: usize) -> bool { + let previous = index.checked_sub(1).and_then(|i| tokens.get(i)); + if let Some(previous) = previous { + if matches!(previous.text, "." | ":" | "function" | "local") { + return false; + } + } + let next = tokens.get(index + 1); + !matches!(next.map(|token| token.text), Some("=" | "," | "." | ":")) +} + +/// Читает Lua-файл с лимитом размера (1 МиБ). +fn read_script(path: &std::path::Path) -> Option { + let metadata = std::fs::metadata(path).ok()?; + if metadata.len() > 1 << 20 { + return None; + } + std::fs::read_to_string(path).ok() +} + /// Если на позиции длинная скобка `[=*[`, возвращает её длину. fn long_bracket(bytes: &[u8], at: usize) -> (usize, ()) { if bytes.get(at) != Some(&b'[') { @@ -844,6 +1366,161 @@ local t = [[raw ! text]] assert!(out2.contains("a and b or c ~= d"), "{out2}"); } + #[test] + fn preprocess_continue_becomes_goto() { + let src = r#" +for i = 1, 3 do + if ( i == 2 ) then continue end + print( i ) +end +"#; + let out = preprocess(src); + assert!(out.contains("goto __sr_continue_1"), "{out}"); + assert!(out.contains("::__sr_continue_1::"), "{out}"); + // Метка обязана оказаться до `end`, а не после него. + let label = out.find("::__sr_continue_1::").expect("метка"); + let end = out.rfind("end").expect("end"); + assert!(label < end, "метка не в теле цикла:\n{out}"); + } + + #[test] + fn preprocess_continue_nested_loops_get_unique_labels() { + let src = r#" +local out = {} +for i = 1, 3 do + if ( i == 2 ) then continue end + for j = 1, 3 do + if ( j == 1 ) then continue end + out[ #out + 1 ] = i .. ":" .. j + end +end +result = table.concat( out, "," ) +"#; + let out = preprocess(src); + assert!(out.contains("goto __sr_continue_1"), "{out}"); + assert!(out.contains("goto __sr_continue_2"), "{out}"); + assert!(out.contains("::__sr_continue_1::"), "{out}"); + assert!(out.contains("::__sr_continue_2::"), "{out}"); + assert_ne!( + out.find("goto __sr_continue_1"), + out.find("goto __sr_continue_2") + ); + + let rt = LuaRuntime::new().expect("рантайм"); + rt.run("continue_nested.lua", src.as_bytes()) + .expect("вложенные continue"); + assert_eq!( + rt.probe("return result").expect("probe"), + Some("1:2,1:3,3:2,3:3".to_string()) + ); + } + + #[test] + fn preprocess_continue_ignores_strings_and_comments() { + let src = r#" +local a = "continue" +local b = 'continue' +local c = [[continue]] +-- continue в комментарии +result = a .. " " .. b .. " " .. c +"#; + assert_eq!(preprocess(src), src); + let rt = LuaRuntime::new().expect("рантайм"); + rt.run("continue_strings.lua", src.as_bytes()) + .expect("строки не тронуты"); + assert_eq!( + rt.probe("return result").expect("probe"), + Some("continue continue continue".to_string()) + ); + } + + #[test] + fn preprocess_continue_works_in_while_and_repeat() { + let src = r#" +local i = 0 +local out = "" +while i < 4 do + i = i + 1 + if ( i == 2 ) then continue end + out = out .. i +end +repeat + i = i + 1 + if ( i == 6 ) then continue end + out = out .. i +until i >= 7 +result = out +"#; + let out = preprocess(src); + assert!(out.contains("goto __sr_continue_1"), "{out}"); + assert!(out.contains("goto __sr_continue_2"), "{out}"); + let rt = LuaRuntime::new().expect("рантайм"); + rt.run("continue_loops.lua", src.as_bytes()) + .expect("while/repeat"); + // while: 1,3,4; repeat: 5,7 (6 пропущен). + assert_eq!( + rt.probe("return result").expect("probe"), + Some("13457".to_string()) + ); + } + + #[test] + fn preprocess_continue_wraps_trailing_break_and_return() { + // `break`/`return` обязаны быть последними в блоке: метка после них — + // синтаксическая ошибка. Препроцессор оборачивает их в `do ... end`. + let src = r#" +local seen = "" +for i = 1, 3 do + if ( i == 2 ) then continue end + seen = seen .. i + break +end +local function first() + for i = 1, 3 do + if ( i == 2 ) then continue end + return i + end +end +result = seen .. ":" .. tostring( first() ) +"#; + let out = preprocess(src); + assert!(out.contains("do break end"), "{out}"); + assert!(out.contains("do return i"), "{out}"); + assert!(out.contains("end::__sr_continue_2::"), "{out}"); + let rt = LuaRuntime::new().expect("рантайм"); + rt.run("continue_break.lua", src.as_bytes()) + .expect("break/return с continue"); + assert_eq!( + rt.probe("return result").expect("probe"), + Some("1:1".to_string()) + ); + } + + #[test] + fn preprocess_continue_in_function_does_not_leak_to_outer_loop() { + let src = r#" +local out = "" +for i = 1, 2 do + local function inner() + for j = 1, 2 do + if ( j == 1 ) then continue end + out = out .. j + end + end + inner() + out = out .. i +end +result = out +"#; + let rt = LuaRuntime::new().expect("рантайм"); + rt.run("continue_function.lua", src.as_bytes()) + .expect("цикл внутри функции"); + assert_eq!( + rt.probe("return result").expect("probe"), + Some("2122".to_string()) + ); + } + #[test] fn concommands_register() { let rt = LuaRuntime::new().expect("рантайм"); diff --git a/crates/lua/src/stdlib.rs b/crates/lua/src/stdlib.rs index fe7558f..3a42061 100644 --- a/crates/lua/src/stdlib.rs +++ b/crates/lua/src/stdlib.rs @@ -207,9 +207,8 @@ fn install_stubs(lua: &Lua) -> Result<(), mlua::Error> { Ok(value) })?, )?; - // `include` пока не подгружает файлы (базовые классы не нужны первым - // SWEP'ам), но обязан существовать. - globals.set("include", lua.create_function(|_, _: String| Ok(()))?)?; + // `include` ставит `LuaRuntime` (см. `include.rs`): ему нужен доступ к + // корню игры и общий стек загрузки. // `DEFINE_BASECLASS` — связывание с базовым классом; для первых скриптов // достаточно no-op (наследование методов пока не используется). globals.set( @@ -228,6 +227,301 @@ fn install_stubs(lua: &Lua) -> Result<(), mlua::Error> { // `game.AddParticles` — регистрация частиц; рендера частиц у нас нет. let game: Table = globals.get("game")?; game.set("AddParticles", lua.create_function(|_, _: String| Ok(()))?)?; + + install_type_helpers(lua)?; + install_meta_tables(lua)?; + install_game_libraries(lua)?; + Ok(()) +} + +/// Предикаты типов GMod (`isfunction`, `istable`, ...). +/// +/// В LuaJIT их нет, а в GMod-скриптах они встречаются на каждом шаге (в том +/// числе в загрузочных файлах), поэтому это не «эмуляция GUI», а базовый слой. +fn install_type_helpers(lua: &Lua) -> Result<(), mlua::Error> { + let globals = lua.globals(); + for (name, expected) in [ + ("isfunction", "function"), + ("istable", "table"), + ("isstring", "string"), + ("isnumber", "number"), + ("isbool", "boolean"), + ("isuserdata", "userdata"), + ("isthread", "thread"), + ] { + globals.set( + name, + lua.create_function(move |_, value: Value| { + let actual = match value { + Value::Nil => "nil", + Value::Boolean(_) => "boolean", + Value::LightUserData(_) => "userdata", + Value::Integer(_) | Value::Number(_) => "number", + Value::String(_) => "string", + Value::Table(_) => "table", + Value::Function(_) => "function", + Value::Thread(_) => "thread", + Value::UserData(_) => "userdata", + Value::Error(_) => "error", + _ => "unknown", + }; + Ok(actual == expected) + })?, + )?; + } + // Эти предикаты относятся к движковым типам, которых пока нет: честный + // `false`, а не падение на nil. + for name in [ + "isentity", + "isplayer", + "isvector", + "isangle", + "iscolor", + "ispanel", + "isvehicle", + "isweapon", + ] { + globals.set(name, lua.create_function(|_, _: Value| Ok(false))?)?; + } + Ok(()) +} + +/// `FindMetaTable`/`RegisterMetaTable`: общие таблицы метаметодов по имени. +/// +/// Сами методы (`FindMetaTable("Player"):Foo`) пока не привязаны к движковым +/// userdata — это следующий слой, — но загрузочные файлы (`player_extension`) +/// получают стабильную таблицу вместо `nil`. +fn install_meta_tables(lua: &Lua) -> Result<(), mlua::Error> { + let globals = lua.globals(); + let registry = lua.create_table()?; + globals.set("__sr_meta_tables", ®istry)?; + + let find = lua.create_function(|lua, name: String| { + let registry: Table = lua.globals().get("__sr_meta_tables")?; + match registry.get::(name.clone())? { + Value::Table(table) => Ok(Value::Table(table)), + _ => { + let table = lua.create_table()?; + registry.set(name, &table)?; + Ok(Value::Table(table)) + } + } + })?; + globals.set("FindMetaTable", find)?; + globals.set( + "RegisterMetaTable", + lua.create_function(|lua, (name, table): (String, Table)| { + let registry: Table = lua.globals().get("__sr_meta_tables")?; + registry.set(name, table)?; + Ok(()) + })?, + )?; + Ok(()) +} + +/// Минимальные библиотеки, которые нужны загрузочным файлам Sandbox. +/// +/// Здесь только «данные/реестры» без поведения: `cleanup.Register`, +/// `drive.Register`, `net.Receive`-заглушка. VGUI/Derma сюда не входят. +fn install_game_libraries(lua: &Lua) -> Result<(), mlua::Error> { + let globals = lua.globals(); + + let cleanup = lua.create_table()?; + cleanup.set("Register", lua.create_function(|_, _: String| Ok(()))?)?; + cleanup.set("Add", lua.create_function(|_, _: (String, Value)| Ok(()))?)?; + cleanup.set( + "ReplaceEntity", + lua.create_function(|_, _: (Value, Value)| Ok(()))?, + )?; + cleanup.set( + "Replace", + lua.create_function(|_, _: (Value, Value)| Ok(()))?, + )?; + globals.set("cleanup", cleanup)?; + + let drive = lua.create_table()?; + drive.set( + "Register", + lua.create_function(|_, _: (String, Option)| Ok(()))?, + )?; + globals.set("drive", drive)?; + + // Duplicator — реестр классов/модификаторов. Sandbox регистрирует в нём + // prop_physics, ragdoll и т. п. прямо при загрузке commands.lua. + let duplicator = lua.create_table()?; + duplicator.set( + "RegisterEntityClass", + lua.create_function(|_, _: MultiValue| Ok(()))?, + )?; + duplicator.set( + "RegisterEntityModifier", + lua.create_function(|_, _: MultiValue| Ok(()))?, + )?; + for name in [ + "DoGeneric", + "DoGenericPhysics", + "StoreEntityModifier", + "CopyEnts", + "Paste", + "RemoveMapCreatedEntities", + "FigureOutRequiredAddons", + ] { + duplicator.set( + name, + lua.create_function(|lua, _: MultiValue| { + let _ = lua; + Ok(Value::Nil) + })?, + )?; + } + globals.set("duplicator", duplicator)?; + + // `net.Receive` вызывается прямо при загрузке `save_load.lua`, остальные + // методы — внутри обработчиков. Сеть — отдельный слой; здесь безопасные + // no-op, чтобы загрузка не обрывалась. + let net = lua.create_table()?; + net.set( + "Receive", + lua.create_function(|_, _: (String, Option)| Ok(()))?, + )?; + for name in [ + "Start", + "Send", + "SendToServer", + "Broadcast", + "WriteBool", + "WriteUInt", + "WriteData", + "WriteString", + "WriteTable", + "WriteEntity", + "WriteVector", + "WriteAngle", + "WriteFloat", + "WriteInt", + "WriteNVarInt", + "WriteDouble", + "ReadBool", + "ReadUInt", + "ReadData", + "ReadString", + "ReadTable", + "ReadEntity", + "ReadVector", + "ReadAngle", + "ReadFloat", + "ReadInt", + "ReadDouble", + ] { + net.set( + name, + lua.create_function(|lua, _: MultiValue| { + // Read* в GMod возвращают значения; nil — безопасный дефолт, + // `net.Receive`-колбэки у нас не выполняются. + let _ = lua; + Ok(Value::Nil) + })?, + )?; + } + globals.set("net", net)?; + + let cvars = lua.create_table()?; + cvars.set( + "Bool", + lua.create_function(|_, (_name, default): (String, Option)| { + Ok(default.unwrap_or(false)) + })?, + )?; + cvars.set( + "Number", + lua.create_function(|_, (_name, default): (String, Option)| { + Ok(default.unwrap_or(0.0)) + })?, + )?; + cvars.set( + "String", + lua.create_function(|_, (_name, default): (String, Option)| { + Ok(default.unwrap_or_default()) + })?, + )?; + cvars.set( + "AddChangeCallback", + lua.create_function(|_, _: (String, Option, Option)| Ok(()))?, + )?; + globals.set("cvars", cvars)?; + + // `player_manager` — реестр классов игрока. Sandbox регистрирует + // `player_sandbox` прямо при загрузке shared.lua, поэтому таблица должна + // существовать; сами классы нам пока нужны только как данные. + let player_manager = lua.create_table()?; + player_manager.set("__classes", lua.create_table()?)?; + player_manager.set( + "RegisterClass", + lua.create_function( + |lua, (name, class, base): (String, Table, Option)| { + let manager: Table = lua.globals().get("player_manager")?; + let classes: Table = manager.get("__classes")?; + classes.set(name, class)?; + let _ = base; + Ok(()) + }, + )?, + )?; + player_manager.set( + "GetClass", + lua.create_function(|lua, name: String| { + let manager: Table = lua.globals().get("player_manager")?; + let classes: Table = manager.get("__classes")?; + classes.get::(name) + })?, + )?; + player_manager.set( + "SetPlayerClass", + lua.create_function(|lua, (player, name): (Value, String)| { + let _ = (lua, player, name); + Ok(()) + })?, + )?; + player_manager.set( + "GetPlayerClass", + lua.create_function(|_, _player: Value| Ok(Value::Nil))?, + )?; + player_manager.set( + "TranslatePlayerModel", + lua.create_function(|_, model: String| Ok(model))?, + )?; + player_manager.set( + "AllValidModels", + lua.create_function(|lua, ()| lua.create_table())?, + )?; + globals.set("player_manager", player_manager)?; + + globals.set( + "TauntCamera", + lua.create_function(|lua, ()| lua.create_table())?, + )?; + globals.set( + "ErrorNoHalt", + lua.create_function(|_, args: MultiValue| { + let text: Vec = args + .iter() + .map(crate::lua_value_to_string) + .collect::>(); + eprintln!("{}", text.join(" ")); + Ok(()) + })?, + )?; + globals.set( + "ErrorNoHaltWithStack", + lua.create_function(|_, args: MultiValue| { + let text: Vec = args + .iter() + .map(crate::lua_value_to_string) + .collect::>(); + eprintln!("{}", text.join(" ")); + Ok(()) + })?, + )?; Ok(()) } diff --git a/crates/lua/tests/include.rs b/crates/lua/tests/include.rs new file mode 100644 index 0000000..e1efa76 --- /dev/null +++ b/crates/lua/tests/include.rs @@ -0,0 +1,196 @@ +//! Настоящий `include()`: поиск в `lua/`, препроцессор, циклы и песочница. + +use glua::LuaRuntime; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static NEXT_DIR: AtomicUsize = AtomicUsize::new(0); + +/// Уникальный временный каталог игры для одного теста. +struct TempGame { + root: PathBuf, +} + +impl TempGame { + fn new(name: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "glua_include_{name}_{}_{}", + std::process::id(), + NEXT_DIR.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("lua")).expect("каталог игры"); + Self { root } + } + + fn write(&self, relative: &str, contents: &str) { + let path = self.root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("каталог скрипта"); + } + std::fs::write(path, contents).expect("файл скрипта"); + } + + fn path(&self) -> &Path { + &self.root + } +} + +impl Drop for TempGame { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +#[test] +fn include_preprocesses_and_does_not_repeat_files() { + let game = TempGame::new("basic"); + game.write("lua/inc/counter.lua", "counter = ( counter or 0 ) + 1\n"); + game.write( + "lua/inc/syntax.lua", + r#" +local out = {} +for i = 1, 3 do + if ( i == 2 ) then continue end + out[ #out + 1 ] = i +end +continued = table.concat( out, "" ) +if ( !flag ) then not_flag = true end +"#, + ); + game.write( + "lua/inc/cycle_a.lua", + "cycle_a = ( cycle_a or 0 ) + 1\ninclude( \"inc/cycle_b.lua\" )\n", + ); + game.write( + "lua/inc/cycle_b.lua", + "cycle_b = ( cycle_b or 0 ) + 1\ninclude( \"inc/cycle_a.lua\" )\n", + ); + + let rt = LuaRuntime::new().expect("рантайм"); + rt.set_file_root(game.path()).expect("корень"); + rt.run( + "include_test.lua", + br#" + flag = false + include( "inc/counter.lua" ) + include( "inc/counter.lua" ) + include( "inc/syntax.lua" ) + include( "inc/cycle_a.lua" ) + "#, + ) + .expect("include выполнился"); + + assert_eq!( + rt.probe("return tostring(counter)").expect("probe"), + Some("1".into()) + ); + // Препроцессор тот же: continue превратился в goto, `!` — в not. + assert_eq!( + rt.probe("return continued").expect("probe"), + Some("13".into()) + ); + assert_eq!( + rt.probe("return tostring(not_flag)").expect("probe"), + Some("true".into()) + ); + // Цикл a -> b -> a не зациклился и не выполнил a второй раз. + assert_eq!( + rt.probe("return tostring(cycle_a) .. tostring(cycle_b)") + .expect("probe"), + Some("11".into()) + ); +} + +#[test] +fn include_refuses_escape_and_oversized_files() { + let game = TempGame::new("sandbox"); + game.write("lua/ok.lua", "ok = true\n"); + let secret_name = format!( + "glua_include_secret_{}_{}.lua", + std::process::id(), + NEXT_DIR.fetch_add(1, Ordering::Relaxed) + ); + let outside = game.path().parent().expect("родитель").join(&secret_name); + std::fs::write(&outside, "leaked = true\n").expect("файл вне корня"); + + let rt = LuaRuntime::new().expect("рантайм"); + rt.set_file_root(game.path()).expect("корень"); + rt.run("ok.lua", b"include( \"ok.lua\" )") + .expect("обычный include"); + + let script = format!("include( \"../../{secret_name}\" )"); + let escape = rt.run("escape.lua", script.as_bytes()); + assert!(escape.is_err(), "выход за пределы каталога игры запрещён"); + + game.write( + "lua/big.lua", + &"-- большой файл\n".repeat((1 << 20) / 16 + 1), + ); + let big = rt.run("big.lua", b"include( \"big.lua\" )"); + assert!(big.is_err(), "файл больше лимита должен быть отклонён"); + let _ = std::fs::remove_file(outside); +} + +#[test] +fn load_gamemode_collects_errors_and_continues() { + let game = TempGame::new("gamemode"); + game.write( + "gamemodes/test/gamemode/init.lua", + "error( \"init упал\" )\n", + ); + game.write( + "gamemodes/test/gamemode/cl_init.lua", + "include( \"shared.lua\" )\ncl_loaded = true\n", + ); + game.write( + "gamemodes/test/gamemode/shared.lua", + "shared_loaded = true\n", + ); + + let rt = LuaRuntime::new().expect("рантайм"); + let report = rt.load_gamemode(game.path(), "test"); + assert_eq!(report.loaded, vec!["cl_init.lua".to_string()]); + assert_eq!(report.errors.len(), 1, "{:?}", report.errors); + assert!( + report.errors[0].contains("init упал"), + "{:?}", + report.errors + ); + assert_eq!( + rt.probe("return tostring(cl_loaded) .. tostring(shared_loaded)") + .expect("probe"), + Some("truetrue".into()), + "cl_init и include рядом с геймодом должны выполниться" + ); + + // Имя режима не должно становиться путём. + let bad = rt.load_gamemode(game.path(), "../escape"); + assert_eq!(bad.loaded.len(), 0); + assert!(!bad.errors.is_empty()); +} + +#[test] +fn include_limits_depth() { + let game = TempGame::new("depth"); + for index in 1..=10 { + game.write( + &format!("lua/depth/f{index}.lua"), + &format!( + "depth = ( depth or 0 ) + 1\ninclude( \"depth/f{}.lua\" )\n", + index + 1 + ), + ); + } + // Последний файл цепочки ничего не включает. + game.write("lua/depth/f11.lua", "depth_end = true\n"); + + let rt = LuaRuntime::new().expect("рантайм"); + rt.set_file_root(game.path()).expect("корень"); + let result = rt.run("depth.lua", b"include( \"depth/f1.lua\" )"); + let error = result.expect_err("глубина должна быть ограничена"); + assert!( + error.to_string().contains("глубина"), + "неожиданная ошибка: {error}" + ); +} diff --git a/crates/lua/tests/scripts.rs b/crates/lua/tests/scripts.rs index d70ab0c..9d63e9c 100644 --- a/crates/lua/tests/scripts.rs +++ b/crates/lua/tests/scripts.rs @@ -26,9 +26,10 @@ fn real_gmod_weapons_and_entities_load() { let (weapons, entities, failed) = runtime.load_scripts(&game); eprintln!("скрипты: оружия {weapons}, энтити {entities}, ошибок {failed}"); assert!(weapons >= 3, "мало оружия: {weapons}"); - // 5 из 6: `widget_bones.lua` использует GMod-расширение `continue`, - // которого нет в стандартном Lua (отдельная задача препроцессора). - assert!(entities >= 5, "мало энтити: {entities}"); + // `widget_bones.lua` использует GMod-расширение `continue`; после + // препроцессора грузятся все 6 энтити. + assert!(entities >= 6, "мало энтити: {entities}"); + assert_eq!(failed, 0, "ошибки загрузки скриптов"); // Поля SWEP прочитаны (Primary создаётся до выполнения скрипта). let clip = runtime @@ -60,3 +61,95 @@ fn real_gmod_weapons_and_entities_load() { .unwrap_or_default(); assert_eq!(entity, "true", "scripted_ents.Get('sent_ball')"); } + +#[test] +fn real_gmod_include_loads_lua_includes() { + let Some(game) = game_dir() else { + eprintln!("пропуск: ассеты Garry's Mod не найдены"); + return; + }; + let runtime = LuaRuntime::new().expect("рантайм"); + runtime.set_file_root(&game).expect("корень игры"); + // Реальный файл из lua/includes: расширение метатаблицы Vector. + runtime + .run( + "includes/extensions/vector.lua", + br#" + include( "includes/extensions/vector.lua" ) + include( "includes/extensions/angle.lua" ) + "#, + ) + .expect("lua/includes загрузились"); + let to_color = runtime + .probe("return type(FindMetaTable('Vector').ToColor)") + .expect("probe") + .unwrap_or_default(); + assert_eq!(to_color, "function", "Vector:ToColor из includes"); + let snap = runtime + .probe("return type(FindMetaTable('Angle').SnapTo)") + .expect("probe") + .unwrap_or_default(); + assert_eq!(snap, "function", "Angle:SnapTo из includes"); +} + +#[test] +fn real_gmod_sandbox_gamemode_loads_without_breaking_registries() { + let Some(game) = game_dir() else { + eprintln!("пропуск: ассеты Garry's Mod не найдены"); + return; + }; + let runtime = LuaRuntime::new().expect("рантайм"); + runtime.set_file_root(&game).expect("корень игры"); + let (_, entities, failed) = runtime.load_scripts(&game); + assert_eq!( + failed, 0, + "скрипты оружия/энтити должны грузиться без ошибок" + ); + + let before = runtime + .probe("local n=0 for _ in pairs(list.Get('SpawnableEntities') or {}) do n=n+1 end return tostring(n)") + .expect("probe") + .unwrap_or_default(); + assert!( + before.parse::().unwrap_or(0) >= entities, + "SpawnableEntities до геймода: {before}" + ); + + let report = runtime.load_gamemode(&game, "sandbox"); + eprintln!("геймод sandbox: загружено {:?}", report.loaded); + for error in &report.errors { + eprintln!("геймод sandbox: ошибка: {error}"); + } + assert!( + report.loaded.iter().any(|file| file == "init.lua"), + "init.lua Sandbox должен выполниться" + ); + // cl_init.lua тянет VGUI/Derma: ошибка ожидаема, но загрузчик обязан + // попытаться выполнить файл и собрать ошибку, а не упасть. + assert!( + report + .errors + .iter() + .any(|error| error.starts_with("cl_init.lua:")), + "ошибка cl_init.lua должна быть собрана: {:?}", + report.errors + ); + + // Реестры не должны пострадать от загрузки геймода: именно из них + // строятся меню спавна и выдача оружия. + let after = runtime + .probe("local n=0 for _ in pairs(list.Get('SpawnableEntities') or {}) do n=n+1 end return tostring(n)") + .expect("probe") + .unwrap_or_default(); + assert_eq!(after, before, "SpawnableEntities после геймода"); + let ball = runtime + .probe("return tostring(scripted_ents.Get('sent_ball') ~= nil)") + .expect("probe") + .unwrap_or_default(); + assert_eq!(ball, "true", "sent_ball после геймода"); + let gm_name = runtime + .probe("return GAMEMODE and GAMEMODE.Name or ''") + .expect("probe") + .unwrap_or_default(); + assert_eq!(gm_name, "Sandbox", "GAMEMODE.Name из shared.lua"); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 251c689..13e07e6 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -184,8 +184,15 @@ Source RS — клиент Garry's Mod, написанный с нуля на Ru timer.Simple, concommand.Add, CreateConVar/GetConVar, util/engine/game заглушки, FCVAR_* - [x] Препроцессор GMod-синтаксиса: `!` → `not`, `!=` → `~=`, `&&` → `and`, - `||` → `or` (вне строк/комментариев) — оригинальные клиентские скрипты GMod - (demo_recording.lua, gm_demo.lua) грузятся + `||` → `or`, `continue` → `goto` + метка в конце тела цикла (вне + строк/комментариев) — оригинальные клиентские скрипты GMod + (demo_recording.lua, gm_demo.lua, widget_bones.lua) грузятся +- [x] `include()`: поиск рядом с текущим файлом и в `lua/`, тот же + препроцессор, лимиты размера/глубины, защита от циклов и выхода за + каталог игры +- [x] `LuaRuntime::load_gamemode`: `gamemodes//gamemode/init.lua` и + `cl_init.lua`, сбор ошибок без падения; Sandbox `init.lua` выполняется, + `cl_init.lua` упирается в VGUI (`AccessorFunc`) - [x] Автозагрузка `lua/autorun/client/*.lua` из VFS, Think-хук и таймеры в кадре, вывод в HUD-лог; `lua run `, `lua list` - [ ] surface/draw/render-таргеты, Derma, Entity/Player API, серверный Lua