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:
parent
300a6c6d98
commit
0b82cd77f3
6 changed files with 1487 additions and 11 deletions
209
crates/lua/src/include.rs
Normal file
209
crates/lua/src/include.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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("рантайм");
|
||||
|
|
|
|||
|
|
@ -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::<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
196
crates/lua/tests/include.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
11
docs/PLAN.md
11
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/<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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue