Lua: continue, include и загрузка геймода

- Препроцессор GMod: continue внутри for/while/repeat -> goto + уникальная
  метка; учтены строки/комментарии, вложенные циклы и функции, завершающие
  break/return (оборачиваются в do ... end). widget_bones.lua грузится:
  энтити 6 из 6.
- include(): поиск рядом с текущим файлом и в lua/ игры, тот же препроцессор,
  лимиты размера/глубины, защита от циклов и выхода за каталог игры.
- LuaRuntime::load_gamemode(): init.lua и cl_init.lua, сбор ошибок без
  падения. Sandbox init.lua выполняется, cl_init.lua упирается в VGUI
  (AccessorFunc); реестры list/scripted_ents после загрузки целы.
- Безопасные заглушки загрузочного слоя Sandbox: CLIENT/SERVER, is*,
  FindMetaTable, cleanup, drive, duplicator, net, cvars, player_manager.
- Тесты: юнит-тесты препроцессора, include на временных файлах и реальных
  lua/includes, загрузка sandbox на ассетах.
This commit is contained in:
Niko Marmeladkov 2026-10-24 13:29:30 +03:00
parent 300a6c6d98
commit 0b82cd77f3
6 changed files with 1487 additions and 11 deletions

209
crates/lua/src/include.rs Normal file
View file

@ -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<PathBuf>,
/// Каталоги текущего файла и включающих его файлов (внутренний — сверху).
///
/// GMod ищет `include("toolmenu.lua")` сначала рядом с текущим скриптом
/// (`spawnmenu/toolmenu.lua`), а уже потом в `<игра>/lua`. Без этого
/// вложенные include из `lua/includes` и `sandbox/gamemode/spawnmenu` не
/// находятся.
script_dirs: Vec<PathBuf>,
/// Уже выполненные файлы (канонические пути).
loaded: HashSet<PathBuf>,
/// Текущая глубина рекурсии.
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<PathBuf, String> {
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<PathBuf> = 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<RefCell<IncludeState>>) -> 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
}

View file

@ -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<RefCell<Vec<(Instant, RegistryKey)>>>;
type Output = Rc<RefCell<Vec<String>>>;
/// Итог загрузки геймода.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct GamemodeLoad {
/// Выполненные файлы (относительно каталога геймода).
pub loaded: Vec<String>,
/// Ошибки по файлам: загрузка продолжается со следующего файла.
pub errors: Vec<String>,
}
/// Lua-рантайм клиента.
pub struct LuaRuntime {
lua: Lua,
timers: TimerQueue,
output: Output,
include: Rc<RefCell<include::IncludeState>>,
}
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/<mode>/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::<Value>("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<Token<'_>> {
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<usize>,
}
#[derive(Debug, Default)]
struct LoopInfo {
body_end: Option<usize>,
/// Индексы токенов непосредственно в теле цикла (не во вложенных блоках).
body_tokens: Vec<usize>,
continues: Vec<(usize, usize)>,
}
/// Превращает `continue` в `goto` + метку в конце тела ближайшего цикла.
fn preprocess_continue(source: &str) -> String {
let tokens = tokenize(source);
let mut loops: Vec<LoopInfo> = Vec::new();
let mut stack = vec![Frame {
kind: FrameKind::Root,
loop_id: None,
}];
let mut existing_labels: std::collections::HashSet<String> = 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<String> {
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("рантайм");

View file

@ -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", &registry)?;
let find = lua.create_function(|lua, name: String| {
let registry: Table = lua.globals().get("__sr_meta_tables")?;
match registry.get::<Value>(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<Table>)| 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<Function>)| 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<bool>)| {
Ok(default.unwrap_or(false))
})?,
)?;
cvars.set(
"Number",
lua.create_function(|_, (_name, default): (String, Option<f64>)| {
Ok(default.unwrap_or(0.0))
})?,
)?;
cvars.set(
"String",
lua.create_function(|_, (_name, default): (String, Option<String>)| {
Ok(default.unwrap_or_default())
})?,
)?;
cvars.set(
"AddChangeCallback",
lua.create_function(|_, _: (String, Option<Function>, Option<String>)| 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<String>)| {
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::<Value>(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<String> = args
.iter()
.map(crate::lua_value_to_string)
.collect::<Vec<_>>();
eprintln!("{}", text.join(" "));
Ok(())
})?,
)?;
globals.set(
"ErrorNoHaltWithStack",
lua.create_function(|_, args: MultiValue| {
let text: Vec<String> = args
.iter()
.map(crate::lua_value_to_string)
.collect::<Vec<_>>();
eprintln!("{}", text.join(" "));
Ok(())
})?,
)?;
Ok(())
}

196
crates/lua/tests/include.rs Normal file
View file

@ -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}"
);
}

View file

@ -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::<usize>().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");
}

View file

@ -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/<mode>/gamemode/init.lua` и
`cl_init.lua`, сбор ошибок без падения; Sandbox `init.lua` выполняется,
`cl_init.lua` упирается в VGUI (`AccessorFunc`)
- [x] Автозагрузка `lua/autorun/client/*.lua` из VFS, Think-хук и таймеры в кадре,
вывод в HUD-лог; `lua run <file>`, `lua list`
- [ ] surface/draw/render-таргеты, Derma, Entity/Player API, серверный Lua