Derma: настоящее Q-меню Sandbox на Lua поверх VGUI-слоя
Some checks are pending
CI / checks (push) Waiting to run

В crates/lua добавлен минимальный слой VGUI/Derma: панели-таблицы,
vgui.Register/Create, surface/draw с записью команд, ввод и снимок кадра
(до 4096 команд, глубина 32). На нём грузятся настоящие lua/derma,
lua/skins/default.lua, lua/vgui/* и lua/includes/modules/spawnmenu.lua,
затем Sandbox и OnGamemodeLoaded строят g_SpawnMenu.

Публичный контракт: UiKind/UiCommand, LuaRuntime::ui_toggle/ui_visible/
ui_snapshot/ui_mouse/ui_set_screen, load_ui -> UiLoad.

Попутно: file.Find в GMod-семантике (файлы и каталоги, маски *.txt),
DEFINE_BASECLASS -> local BaseClass, //-комментарии, регистронезависимый
include, SpawnableEntities хранит таблицу ENT.

Тесты на реальных ассетах: ~991 команда, вкладки/категории/модели,
клик по вкладке меняет снимок; без ассетов пропуск.
This commit is contained in:
Niko Marmeladkov 2026-10-24 15:03:32 +03:00
parent 8aff0caf12
commit ffd9e736bc
10 changed files with 4200 additions and 39 deletions

View file

@ -259,6 +259,36 @@ impl UserData for LuaColor {
table.set(4, c.a)?;
Ok(table)
});
// `skin.Colours.*:Lerp(gray, frac)` — на этом держится вся окраска
// текста Derma. Компоненты считаются в f32, результат обрезается.
m.add_function(
"Lerp",
|lua, (color, target, fraction): (AnyUserData, Value, f64)| {
let a = *color.borrow::<Self>()?;
let b = borrow_color(&target).unwrap_or(a);
let t = (fraction as f32).clamp(0.0, 1.0);
let mix = |left: u8, right: u8| -> u8 {
(left as f32 + (right as f32 - left as f32) * t)
.clamp(0.0, 255.0)
.round() as u8
};
lua.create_userdata(LuaColor::new(
mix(a.r, b.r),
mix(a.g, b.g),
mix(a.b, b.b),
mix(a.a, b.a),
))
},
);
m.add_function("ToTable", |lua, c: AnyUserData| {
let c = *c.borrow::<Self>()?;
let table = lua.create_table()?;
table.set("r", c.r)?;
table.set("g", c.g)?;
table.set("b", c.b)?;
table.set("a", c.a)?;
Ok(table)
});
}
}

View file

@ -109,9 +109,16 @@ impl IncludeState {
if !candidate.starts_with(&canonical_root) && !candidate.starts_with(root) {
continue;
}
if !candidate.is_file() {
continue;
}
let candidate = if candidate.is_file() {
candidate
} else {
// Windows-скрипты ссылаются на `gui/IconEditor.lua`, а на
// Linux файл лежит как `gui/iconeditor.lua`.
match case_insensitive_file(&candidate) {
Some(found) => found,
None => continue,
}
};
// Канонизация ловит симлинк, ведущий наружу.
let Ok(canonical) = candidate.canonicalize() else {
continue;
@ -124,6 +131,19 @@ impl IncludeState {
}
}
/// Ищет файл без учёта регистра имени (GMod на Windows регистр не различает).
fn case_insensitive_file(path: &Path) -> Option<PathBuf> {
let parent = path.parent()?;
let name = path.file_name()?.to_str()?.to_lowercase();
let entries = std::fs::read_dir(parent).ok()?;
for entry in entries.flatten() {
if entry.file_name().to_str()?.to_lowercase() == name {
return Some(entry.path());
}
}
None
}
/// Ставит глобальную функцию `include`, связанную с состоянием.
pub(crate) fn install(lua: &Lua, state: Rc<RefCell<IncludeState>>) -> mlua::Result<()> {
let include = lua.create_function(move |lua, path: String| {

View file

@ -8,8 +8,11 @@ pub mod gmod;
mod include;
pub mod stdlib;
pub mod swep;
pub mod ui;
use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value};
pub use ui::{UiCommand, UiKind};
use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value, Variadic};
use std::cell::RefCell;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::rc::Rc;
@ -34,6 +37,68 @@ pub struct GamemodeLoad {
pub errors: Vec<String>,
}
/// Итог загрузки UI: Derma, Sandbox spawnmenu и геймода.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct UiLoad {
/// Выполненные файлы (пути как их видит `include`).
pub loaded: Vec<String>,
/// Ошибки: загрузка продолжается со следующего файла.
pub errors: Vec<String>,
}
/// Максимум команд в одном кадре UI (см. [`ui::MAX_COMMANDS`]).
pub const UI_COMMAND_LIMIT: usize = ui::MAX_COMMANDS;
/// VGUI-файлы, которые нужны Sandbox spawnmenu. Часть контролов может
/// отсутствовать в установке игры — тогда для них ставится безопасная
/// заглушка; настоящие файлы не перезаписываются.
const VGUI_FILES: &[&str] = &[
"dpanel.lua",
"dlabel.lua",
"dbutton.lua",
"dframe.lua",
"dscrollpanel.lua",
"dhorizontalscroller.lua",
"dexpandbutton.lua",
"dvscrollbar.lua",
"dhscrollbar.lua",
"dhorizontaldivider.lua",
"dverticaldivider.lua",
"dpropertysheet.lua",
"dimage.lua",
"dimagebutton.lua",
"dtextentry.lua",
"dcategorycollapse.lua",
"dcategorylist.lua",
"dcombobox.lua",
"dcheckbox.lua",
"dlistbox.lua",
"dlistview.lua",
"dmenu.lua",
"dmenubar.lua",
"ddragbase.lua",
"dtilelayout.lua",
"dlistlayout.lua",
"diconlayout.lua",
"diconbrowser.lua",
"dtree.lua",
"dtree_node.lua",
"dtree_node_button.lua",
"dproperties.lua",
"dform.lua",
"dlabeleditable.lua",
"dslider.lua",
"dnumberwang.lua",
"dnumslider.lua",
"dcolorbutton.lua",
"dcolormixer.lua",
"dcolorpalette.lua",
"dcolorcombo.lua",
"dmodelpanel.lua",
"dmodelselect.lua",
"spawnicon.lua",
];
/// Lua-рантайм клиента.
pub struct LuaRuntime {
lua: Lua,
@ -54,6 +119,7 @@ impl LuaRuntime {
gmod::install(&lua)?;
stdlib::install(&lua, "unknown")?;
include::install(&lua, include.clone())?;
ui::install(&lua)?;
Ok(Self {
lua,
@ -377,7 +443,7 @@ impl LuaRuntime {
entries
}
};
entries.set(name, true)?;
entries.set(name, &table)?;
Ok(())
})();
if registered.is_ok() {
@ -466,6 +532,146 @@ impl LuaRuntime {
report
}
/// Загружает настоящий клиентский UI Sandbox: Derma, базовые VGUI-классы,
/// модуль `spawnmenu` и сам геймод.
///
/// Порядок повторяет GMod: сначала `lua/includes` (расширения строк/таблиц,
/// `scriptedpanels`, настоящие `lua/vgui/*`), затем `lua/derma/init.lua` и
/// скин, затем `gamemodes/sandbox` и хук `OnGamemodeLoaded`, который строит
/// `g_SpawnMenu` через `vgui.Create`. Ошибки отдельных файлов собираются в
/// [`UiLoad::errors`], но не мешают остальным: меню должно пережить
/// отсутствующий контрол.
///
/// Вызывать после [`Self::set_file_root`] (метод ставит корень сам, если
/// его ещё нет) и после загрузки скриптов оружия/энтити, если они нужны в
/// спавн-меню.
pub fn load_ui(&self, game_dir: &std::path::Path) -> UiLoad {
let mut report = UiLoad::default();
if let Err(err) = self.set_file_root(game_dir) {
report.errors.push(format!("file.*: {err}"));
return report;
}
let include_file = |relative: &str| -> Result<(), LuaError> {
self.run(relative, format!("include( {:?} )", relative).as_bytes())
};
// Настоящие расширения GMod. Без `scriptedpanels.lua` vgui.Register и
// vgui.Create остаются заглушками, а без `draw.lua` Derma рисует одним
// примитивом.
for relative in [
"includes/modules/baseclass.lua",
"includes/extensions/string.lua",
"includes/extensions/table.lua",
"includes/modules/draw.lua",
// scriptedpanels — ядро vgui.Register/Create. Грузим до
// panel.lua: ошибка в расширениях ввода не должна оставить
// Derma без реестра классов.
"includes/extensions/client/panel/scriptedpanels.lua",
"includes/extensions/client/panel.lua",
] {
match include_file(relative) {
Ok(()) => report.loaded.push(relative.to_string()),
Err(err) => report.errors.push(format!("{relative}: {err}")),
}
}
// Ограниченный table.Merge и Panel:Add для табличных панелей:
// настоящие версии рекурсивны/не различают панель и класс.
if let Err(err) = self.run("ui_patch.lua", b"__sr_ui_patch_extensions()") {
report.errors.push(format!("ui patch: {err}"));
}
// Модуль spawnmenu — это `lua/includes/modules/spawnmenu.lua`; sandbox
// лишь дополняет его. Без него `spawnmenu.AddCreationTab` не существует.
for relative in [
"includes/modules/spawnmenu.lua",
"includes/modules/controlpanel.lua",
] {
match include_file(relative) {
Ok(()) => report.loaded.push(relative.to_string()),
Err(err) => report.errors.push(format!("{relative}: {err}")),
}
}
match include_file("derma/init.lua") {
Ok(()) => report.loaded.push("derma/init.lua".to_string()),
Err(err) => report.errors.push(format!("derma/init.lua: {err}")),
}
match include_file("skins/default.lua") {
Ok(()) => report.loaded.push("skins/default.lua".to_string()),
Err(err) => report.errors.push(format!("skins/default.lua: {err}")),
}
for relative in VGUI_FILES {
match include_file(&format!("vgui/{relative}")) {
Ok(()) => report.loaded.push(format!("vgui/{relative}")),
Err(err) => report.errors.push(format!("vgui/{relative}: {err}")),
}
}
// Заглушки только для того, что не загрузилось. Настоящий класс не
// перезаписывается: `__sr_ui_register_fallback` проверяет реестр.
if let Err(err) = self.run("ui_fallbacks.lua", b"__sr_ui_install_fallbacks()") {
report.errors.push(format!("ui fallbacks: {err}"));
}
if let Err(err) = self.run("ui_finalize.lua", b"__sr_ui_finalize()") {
report.errors.push(format!("ui finalize: {err}"));
}
// Словарь `#spawnmenu.*` из настоящих файлов локализации.
if let Err(err) = self.run("ui_language.lua", b"__sr_ui.load_language('en')") {
report.errors.push(format!("language: {err}"));
}
// Геймод Sandbox грузим, только если его ещё нет: повторный
// load_gamemode выполнил бы init.lua заново (include-кэш его не
// защищает, потому что загрузчик читает файлы напрямую).
let already = self
.probe("return GAMEMODE and GAMEMODE.Name or ''")
.ok()
.flatten()
.as_deref()
== Some("Sandbox");
if !already {
let gamemode = self.load_gamemode(game_dir, "sandbox");
report.loaded.extend(
gamemode
.loaded
.iter()
.map(|name| format!("gamemode/sandbox/{name}")),
);
report.errors.extend(gamemode.errors);
}
// `OnGamemodeLoaded` создаёт g_SpawnMenu: к этому моменту все классы
// Derma и vgui уже зарегистрированы.
match self.call_hook("OnGamemodeLoaded", MultiValue::new()) {
Ok(()) => report.loaded.push("hook:OnGamemodeLoaded".to_string()),
Err(err) => report.errors.push(format!("OnGamemodeLoaded: {err}")),
}
if let Err(err) = self.run(
"ui_root.lua",
b"__sr_ui.attach_root( rawget( _G, 'g_SpawnMenu' ) )",
) {
report.errors.push(format!("ui root: {err}"));
}
// Ошибки Init/Paint, собранные Lua-слоем (а не загрузчиком файлов).
// Забираем один раз, чтобы клиент показал их в консоли, а поле не
// росло на каждом кадре.
if let Ok(Some(errors)) = self.probe(
"local text = table.concat(__sr_ui.errors, '\\n'); __sr_ui.errors = {}; return text",
) {
for line in errors.lines() {
if !line.is_empty() {
report.errors.push(format!("ui: {line}"));
}
}
}
report
}
/// Ставит `file.*` с песочницей на каталог игры.
pub fn set_file_root(&self, root: &std::path::Path) -> Result<(), LuaError> {
stdlib::install_file(&self.lua, root)?;
@ -548,10 +754,25 @@ impl LuaRuntime {
}
}
let count = due.len();
let mut first_error = None;
for key in due {
let func: Function = self.lua.registry_value(&key)?;
func.call::<()>(())?;
self.lua.remove_registry_value(key)?;
// Один упавший таймер не должен отменять остальные: на
// `timer.Simple(0, ...)` держится наполнение spawnmenu.
let result = (|| -> mlua::Result<()> {
let func: Function = self.lua.registry_value(&key)?;
func.call::<()>(())?;
Ok(())
})();
if let Err(err) = result {
eprintln!("таймер: {err}");
if first_error.is_none() {
first_error = Some(err);
}
}
let _ = self.lua.remove_registry_value(key);
}
if let Some(err) = first_error {
return Err(err.into());
}
Ok(count)
}
@ -676,6 +897,16 @@ fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), ml
let name = name.to_str()?.to_string();
let _gm = iter.next();
let rest: Vec<Value> = iter.cloned().collect();
// Сначала метод геймода: `GM:SpawnMenuEnabled` и подобные хуки не
// регистрируются через hook.Add, но hook.Run их вызывает.
if let Ok(Value::Table(gamemode)) = lua.globals().get::<Value>("GAMEMODE") {
if let Ok(Value::Function(func)) = gamemode.get::<Value>(name.as_str()) {
let args: Vec<Value> = std::iter::once(Value::Table(gamemode.clone()))
.chain(rest.iter().cloned())
.collect();
let _ = func.call::<Value>(Variadic::from(args))?;
}
}
let hooks: Table = lua.globals().get("__sr_hooks")?;
if let Value::Table(list) = hooks.get(name)? {
// Снимок значений, чтобы вызовы не сбивали итерацию.
@ -929,9 +1160,69 @@ fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), ml
/// Оба прохода лексические: строки и комментарии остаются байт-в-байт.
pub fn preprocess(source: &str) -> String {
let converted = preprocess_operators(source);
let converted = preprocess_define_baseclass(&converted);
preprocess_continue(&converted)
}
/// `DEFINE_BASECLASS( "X" )` → `local BaseClass = baseclass.Get( "X" )`.
///
/// В GMod это макрос движка, а не функция: `BaseClass` обязан быть локальной
/// переменной файла, иначе последний загруженный класс подменил бы базу всем
/// предыдущим.
fn preprocess_define_baseclass(source: &str) -> String {
const MACRO: &str = "DEFINE_BASECLASS";
let bytes = source.as_bytes();
let mut out = String::with_capacity(source.len() + 64);
let mut i = 0usize;
while i < bytes.len() {
// Комментарии и строки копируются без изменений.
if bytes[i] == b'-' && bytes.get(i + 1) == Some(&b'-') {
let start = i;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
out.push_str(&source[start..i]);
continue;
}
if bytes[i] == b'"' || bytes[i] == b'\'' {
let quote = bytes[i];
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;
}
out.push_str(&source[start..i.min(source.len())]);
continue;
}
if source[i..].starts_with(MACRO) {
let before_ok = i == 0 || !is_name_continue_byte(bytes[i - 1]);
let after = i + MACRO.len();
let after_ok = after >= bytes.len() || !is_name_continue_byte(bytes[after]);
if before_ok && after_ok {
out.push_str("local BaseClass = baseclass.Get");
i = after;
continue;
}
}
let ch = source[i..].chars().next().map_or(1, char::len_utf8);
out.push_str(&source[i..i + ch]);
i += ch;
}
out
}
fn is_name_continue_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
/// `!`, `!=`, `&&`, `||` вне строк и комментариев.
fn preprocess_operators(source: &str) -> String {
let bytes = source.as_bytes();
@ -964,6 +1255,14 @@ fn preprocess_operators(source: &str) -> String {
continue;
}
}
// `//` в скриптах GMod — комментарий (полноценного `//` в LuaJIT
// нет; движок срезает его тем же препроцессором).
if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
// Обычные строки.
if b == b'"' || b == b'\'' {
let quote = b;
@ -1587,6 +1886,21 @@ local t = [[raw ! text]]
assert!(out2.contains("a and b or c ~= d"), "{out2}");
}
/// `//` — комментарий GMod, а `DEFINE_BASECLASS` — макрос движка,
/// превращающий базу в локальную переменную файла.
#[test]
fn preprocess_gmod_comments_and_baseclass() {
let out = preprocess(
"// comment with ! &&\nDEFINE_BASECLASS( \"DScrollPanel\" )\nlocal x = 1 // tail\n",
);
assert!(!out.contains("comment with"), "{out}");
assert!(!out.contains("// tail"), "{out}");
assert!(
out.contains("local BaseClass = baseclass.Get( \"DScrollPanel\" )"),
"{out}"
);
}
#[test]
fn preprocess_continue_becomes_goto() {
let src = r#"

View file

@ -8,6 +8,7 @@
//! `hook.Run` — 63 вызова, `math.Clamp` — 61, `game.SinglePlayer` — 36,
//! `math.Rand` — 27. Это первые по частоте пробелы, и все они дешёвые.
use std::cell::RefCell;
use std::path::{Component, Path, PathBuf};
use std::rc::Rc;
@ -39,6 +40,22 @@ pub fn install(lua: &Lua, map: &str) -> Result<(), mlua::Error> {
/// на первом же `list.Set("Weapon", ...)`.
fn install_list(lua: &Lua) -> Result<(), mlua::Error> {
lua.globals().set("__sr_lists", lua.create_table()?)?;
// Списки, которые в GMod заполняет движок. Пустая таблица — честный
// ответ: `for k, v in pairs(list.Get("NPC"))` не должен падать.
{
let lists: Table = lua.globals().get("__sr_lists")?;
for kind in [
"NPC",
"Vehicles",
"Effects",
"PostProcess",
"ContentCategoryIcons",
"SpawnableEntities",
"Weapon",
] {
lists.set(kind, lua.create_table()?)?;
}
}
let list = lua.create_table()?;
let set = lua.create_function(|lua, (kind, name, value): (String, String, Value)| {
let lists: Table = lua.globals().get("__sr_lists")?;
@ -158,7 +175,7 @@ fn install_scripted_ents(lua: &Lua) -> Result<(), mlua::Error> {
};
table.set("Type", class.clone())?;
let store: Table = lua.globals().get("__sr_ents")?;
store.set(class.clone(), table)?;
store.set(class.clone(), &table)?;
let lists: Table = lua.globals().get("__sr_lists")?;
let entries: Table = match lists.get::<Value>("SpawnableEntities")? {
Value::Table(entries) => entries,
@ -168,7 +185,7 @@ fn install_scripted_ents(lua: &Lua) -> Result<(), mlua::Error> {
entries
}
};
entries.set(class, true)?;
entries.set(class, table)?;
Ok(())
})?,
)?;
@ -544,6 +561,22 @@ fn install_hook_run(lua: &Lua) -> Result<(), mlua::Error> {
};
// Первый аргумент — имя хука, его передаём обратно первым.
let rest: Vec<Value> = args.iter().skip(1).cloned().collect();
// Метод геймода вызывается до hook.Add-хуков и может отменить
// цепочку (`return false`), как в настоящем GMod.
if let Ok(Value::Table(gamemode)) = lua.globals().get::<Value>("GAMEMODE") {
if let Ok(Value::Function(func)) = gamemode.get::<Value>(name.as_str()) {
let call_args: Vec<Value> = std::iter::once(Value::Table(gamemode.clone()))
.chain(rest.iter().cloned())
.collect();
let value = func.call::<MultiValue>(Variadic::from(call_args))?;
if !value.is_empty() {
let first = value.front().cloned().unwrap_or(Value::Nil);
if !first.is_nil() {
return Ok(first);
}
}
}
}
let list: Table = match lua.globals().get::<Value>("__sr_hooks")? {
Value::Table(registry) => match registry.get::<Value>(name.clone())? {
Value::Table(list) => list,
@ -900,18 +933,75 @@ pub fn install_file(lua: &Lua, root: &Path) -> Result<(), mlua::Error> {
file.set(
"Find",
lua.create_function(move |lua, (pattern, _kind): (String, Option<String>)| {
let found = glob_lite(&find_root, &pattern);
let mut list: Vec<Value> = Vec::new();
for path in found {
if let Ok(relative) = path.strip_prefix(find_root.as_path()) {
let text = relative.to_string_lossy().replace('\\', "/");
list.push(Value::String(lua.create_string(&text)?));
}
let (files, folders) = glob_find(&find_root, &pattern);
// GMod возвращает ровно две таблицы: файлы и каталоги.
let file_table = lua.create_table()?;
for (index, name) in files.into_iter().enumerate() {
file_table.set(index + 1, name)?;
}
Ok(Variadic::from(list))
let folder_table = lua.create_table()?;
for (index, name) in folders.into_iter().enumerate() {
folder_table.set(index + 1, name)?;
}
Ok((file_table, folder_table))
})?,
)?;
// `file.Open` нужен экрану сохранений; чтение целого файла уже есть в
// `file.Read`, поэтому объект отдаёт строки из памяти и не держит дескриптор.
let open_root = root.clone();
file.set(
"Open",
lua.create_function(
move |lua, (relative, mode, _kind): (String, Option<String>, Option<String>)| {
let _ = mode;
let Some(path) = resolve_inside(&open_root, &relative) else {
return Ok(Value::Nil);
};
const MAX_OPEN: u64 = 8 * 1024 * 1024;
let Ok(bytes) = std::fs::read(&path) else {
return Ok(Value::Nil);
};
if bytes.len() as u64 > MAX_OPEN {
return Ok(Value::Nil);
}
let text = String::from_utf8_lossy(&bytes).to_string();
let lines = Rc::new(RefCell::new(
text.split('\n')
.map(|line| line.to_string())
.collect::<Vec<_>>(),
));
let handle = lua.create_table()?;
let read_lines = lines.clone();
handle.set(
"ReadLine",
lua.create_function(move |lua, ()| {
let mut lines = read_lines.borrow_mut();
if lines.is_empty() {
return Ok(Value::Nil);
}
let line = lines.remove(0);
Ok(Value::String(lua.create_string(&line)?))
})?,
)?;
let read_all = lines.clone();
handle.set(
"Read",
lua.create_function(move |lua, ()| {
let text = read_all.borrow().join("\n");
lua.create_string(text).map(Value::String)
})?,
)?;
handle.set("Close", lua.create_function(|_, ()| Ok(()))?)?;
handle.set(
"Size",
lua.create_function(move |_, ()| Ok(text.len() as i64))?,
)?;
Ok(Value::Table(handle))
},
)?,
)?;
globals.set("file", &file)?;
Ok(())
}
@ -949,33 +1039,70 @@ fn normalize(path: PathBuf) -> PathBuf {
out
}
/// Минимальный поиск по шаблону: поддерживает `*` в имени файла и `**`.
/// Поиск файлов и каталогов по шаблону в стиле GMod `file.Find`.
///
/// Полноценный glob здесь не нужен и был бы лишней зависимостью: скрипты
/// используют `file.Find("lua/autorun/*.lua", "LUA")`, то есть ровно один
/// уровень подстановки.
fn glob_lite(root: &Path, pattern: &str) -> Vec<PathBuf> {
let mut result = Vec::new();
let Some(relative) = pattern.strip_suffix("/*") else {
// Без `*` — это конкретный файл или каталог.
let path = normalize(root.join(pattern));
if path.starts_with(root) && path.exists() {
result.push(path);
}
return result;
/// Поддерживается `*` в последнем компоненте пути (`*.txt`, `*`, `*.mdl`) и
/// разделение результата на файлы и каталоги. Имена возвращаются без каталога
/// поиска — именно этого ждут скрипты (`folder .. "/" .. file`).
fn glob_find(root: &Path, pattern: &str) -> (Vec<String>, Vec<String>) {
let mut files = Vec::new();
let mut folders = Vec::new();
let pattern = pattern.replace('\\', "/");
let pattern = pattern.trim_start_matches('/');
let (directory_part, last) = match pattern.rsplit_once('/') {
Some((directory, name)) => (directory, name),
None => ("", pattern),
};
let directory = normalize(root.join(relative));
let directory = normalize(root.join(directory_part));
if !directory.starts_with(root) || !directory.is_dir() {
return result;
return (files, folders);
}
let Ok(entries) = std::fs::read_dir(&directory) else {
return result;
return (files, folders);
};
for entry in entries.flatten() {
result.push(entry.path());
let mut names: Vec<(String, bool)> = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name().to_str()?.to_string();
Some((name, entry.path().is_dir()))
})
.collect();
names.sort_by(|a, b| {
a.0.to_lowercase()
.cmp(&b.0.to_lowercase())
.then(a.0.cmp(&b.0))
});
for (name, is_dir) in names {
if !wildcard_match(last, &name) {
continue;
}
if is_dir {
folders.push(name);
} else {
files.push(name);
}
}
result.sort();
result
(files, folders)
}
/// Простое сопоставление с `*` (без `?` и классов символов).
fn wildcard_match(pattern: &str, name: &str) -> bool {
if pattern == "*" {
return true;
}
let Some(first_star) = pattern.find('*') else {
return pattern == name;
};
let prefix = &pattern[..first_star];
let suffix = &pattern[first_star + 1..];
if suffix.contains('*') {
// Несколько `*` в именах файлов игры не встречаются; честно
// обрабатываем только хвост, чтобы не пропустить файл.
return name.len() >= prefix.len() + suffix.len()
&& name.starts_with(prefix)
&& name.ends_with(suffix);
}
name.len() >= prefix.len() + suffix.len() && name.starts_with(prefix) && name.ends_with(suffix)
}
#[cfg(test)]
@ -1251,7 +1378,27 @@ mod tests {
);
let found = eval(&lua, "return file.Find('lua/*', 'LUA')");
assert_eq!(found.len(), 1, "find должен найти autorun.lua");
assert_eq!(found.len(), 2, "file.Find отдаёт файлы и каталоги");
assert_eq!(
string(&lua, "local f = file.Find('lua/*', 'LUA') return f[1]"),
"autorun.lua",
"имя без каталога поиска"
);
assert_eq!(
number(&lua, "local f = file.Find('lua/*', 'LUA') return #f"),
1.0
);
assert_eq!(
number(&lua, "local f, d = file.Find('lua/*', 'LUA') return #d"),
0.0
);
// Шаблон с расширением: именно так читаются spawnlist_default/*.txt.
std::fs::write(dir.join("lua").join("a.txt"), b"x").expect("файл");
std::fs::write(dir.join("lua").join("b.mdl"), b"x").expect("файл");
let (files, folders) = glob_find(&dir, "lua/*.txt");
assert_eq!(files, vec!["a.txt".to_string()]);
assert!(folders.is_empty());
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_file(dir.parent().unwrap().join("secret_outside.txt"));
}

304
crates/lua/src/ui.rs Normal file
View file

@ -0,0 +1,304 @@
//! Слой VGUI/Derma для Lua-скриптов Sandbox.
//!
//! Rust-сторона этого модуля намеренно тонкая: панели, раскладка и `Paint`
//! живут в Lua (`bootstrap.lua`), потому что скрипты игры создают и рисуют их
//! сами. Здесь только:
//!
//! - снимок кадра [`UiCommand`] для рендера (не больше [`MAX_COMMANDS`]);
//! - открытие/закрытие корневого меню и состояние видимости;
//! - передача мыши в дерево панелей.
//!
//! `__sr_ui.snapshot()` — настоящий обход панелей из Lua: он вызывает
//! `Panel:Paint(w, h)`, а `surface.DrawRect`/`draw.SimpleText` складывают
//! команды в список. Rust лишь читает этот список и превращает его в
//! [`UiCommand`], отбрасывая всё, что выходит за лимиты.
use std::panic::{AssertUnwindSafe, catch_unwind};
use mlua::{Function, Lua, Table, Value};
use crate::LuaError;
/// Максимум команд в одном кадре. Скрипт-аддон не должен уметь залить
/// рендер миллионами прямоугольников (правило #1 AGENTS.md).
pub const MAX_COMMANDS: usize = 4096;
/// Максимум аргументов одного снимка — тот же предел, но отдельной
/// константой, чтобы рендер мог заранее выделить буфер.
pub const MAX_UI_DEPTH: usize = 32;
/// Что рисует команда.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UiKind {
/// Залитый прямоугольник (`surface.DrawRect`).
Rect,
/// Контур (`surface.DrawOutlinedRect`).
Outline,
/// Текст (`draw.SimpleText`/`draw.DrawText`).
Text,
}
/// Одна команда кадра в пикселях экрана.
#[derive(Debug, Clone, PartialEq)]
pub struct UiCommand {
pub kind: UiKind,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
/// RGBA, компоненты 0..1.
pub color: [f32; 4],
pub text: Option<String>,
}
/// Устанавливает `__sr_ui` и базовый Lua-слой (панели, surface, draw).
pub(crate) fn install(lua: &Lua) -> Result<(), mlua::Error> {
let ui = lua.create_table()?;
ui.set("visible", false)?;
ui.set("recording", false)?;
ui.set("commands", Value::Nil)?;
ui.set("command_limit", MAX_COMMANDS)?;
ui.set("depth_limit", MAX_UI_DEPTH)?;
ui.set("screen_w", 1280.0)?;
ui.set("screen_h", 720.0)?;
ui.set("mouse_x", 0.0)?;
ui.set("mouse_y", 0.0)?;
ui.set("pressed", Value::Nil)?;
ui.set("root", Value::Nil)?;
ui.set("paint_errors", 0)?;
lua.globals().set("__sr_ui", &ui)?;
// Lua-половина слоя: базовые панели, `surface`/`draw`, поиск под курсором.
let chunk = lua
.load(include_str!("ui/bootstrap.lua"))
.set_name("ui/bootstrap.lua");
chunk.exec()?;
Ok(())
}
impl crate::LuaRuntime {
/// Открыть или закрыть корневое меню (Sandbox spawnmenu).
///
/// Если меню ещё не создано скриптами, состояние всё равно запоминается:
/// когда `OnGamemodeLoaded` построит `g_SpawnMenu`, он откроется.
pub fn ui_toggle(&self, open: bool) {
let _ = catch_unwind(AssertUnwindSafe(|| {
let Ok(ui) = self.lua.globals().get::<Table>("__sr_ui") else {
return;
};
let Ok(toggle) = ui.get::<Function>("toggle") else {
return;
};
let _ = toggle.call::<()>(open);
}));
}
/// Видимо ли корневое меню на текущий кадр.
pub fn ui_visible(&self) -> bool {
catch_unwind(AssertUnwindSafe(|| {
let ui: Table = self.lua.globals().get("__sr_ui")?;
ui.get::<bool>("visible")
}))
.ok()
.and_then(Result::ok)
.unwrap_or(false)
}
/// Снимок видимого дерева панелей на текущий кадр.
///
/// Пустой список, если меню закрыто или скрипты ещё не создали панели.
/// Число команд ограничено [`MAX_COMMANDS`], глубина — [`MAX_UI_DEPTH`]
/// (проверяется в Lua, здесь — вторая линия защиты).
pub fn ui_snapshot(&self) -> Vec<UiCommand> {
catch_unwind(AssertUnwindSafe(|| self.ui_snapshot_inner()))
.ok()
.flatten()
.unwrap_or_default()
}
fn ui_snapshot_inner(&self) -> Option<Vec<UiCommand>> {
// Отложенные `timer.Simple(0, ...)` создают вкладки и контент
// spawnmenu; без этого шага снимок был бы пустым деревом.
let _ = self.tick();
let ui: Table = self.lua.globals().get("__sr_ui").ok()?;
let snapshot: Function = ui.get("snapshot").ok()?;
let Value::Table(list) = snapshot.call::<Value>(()).ok()? else {
return None;
};
let mut commands = Vec::new();
for pair in list.sequence_values::<Value>() {
if commands.len() >= MAX_COMMANDS {
break;
}
let Ok(Value::Table(entry)) = pair else {
continue;
};
let Ok(kind) = entry.get::<String>("kind") else {
continue;
};
let kind = match kind.as_str() {
"rect" => UiKind::Rect,
"outline" => UiKind::Outline,
"text" => UiKind::Text,
_ => continue,
};
let number = |key: &str| -> f32 {
entry
.get::<Value>(key)
.ok()
.and_then(|value| crate::gmod::value_number(&self.lua, &value))
.filter(|value| value.is_finite())
.unwrap_or(0.0) as f32
};
let color = entry
.get::<Value>("color")
.ok()
.and_then(|value| value.as_table().cloned())
.map(|color| {
let component = |index: usize| -> f32 {
color
.get::<Value>(index)
.ok()
.and_then(|value| crate::gmod::value_number(&self.lua, &value))
.filter(|value| value.is_finite())
.unwrap_or(0.0)
.clamp(0.0, 1.0) as f32
};
[component(1), component(2), component(3), component(4)]
})
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
let text = entry
.get::<Option<String>>("text")
.ok()
.flatten()
.map(|text| {
// Текст рисуется на экране: длиннее пары тысяч символов
// он всё равно не влезет, а память съест.
if text.chars().count() > 2048 {
text.chars().take(2048).collect()
} else {
text
}
});
commands.push(UiCommand {
kind,
x: number("x"),
y: number("y"),
w: number("w"),
h: number("h"),
color,
text,
});
}
Some(commands)
}
/// Передаёт мышь в Lua-дерево панелей.
///
/// `kind`: `"move"`, `"down"`, `"up"`, `"wheel"`; `x`/`y` — пиксели от
/// левого верхнего угла экрана; `button` — 1 (ЛКМ) или 2 (ПКМ), для
/// колеса — направление (+1 вверх, -1 вниз).
pub fn ui_mouse(&self, kind: &str, x: f32, y: f32, button: i32) {
let _ = catch_unwind(AssertUnwindSafe(|| -> Result<(), LuaError> {
let ui: Table = self.lua.globals().get("__sr_ui")?;
let mouse: Function = ui.get("mouse")?;
mouse.call::<()>((kind.to_string(), x, y, button))?;
Ok(())
}));
}
/// Задаёт размер экрана для `ScrW()`/`ScrH()` и раскладки.
///
/// Рендер-агент вызывает это при изменении окна; по умолчанию 1280×720.
pub fn ui_set_screen(&self, width: u32, height: u32) {
let _ = catch_unwind(AssertUnwindSafe(|| -> mlua::Result<()> {
let ui: Table = self.lua.globals().get("__sr_ui")?;
ui.set("screen_w", width.clamp(1, 16384) as f64)?;
ui.set("screen_h", height.clamp(1, 16384) as f64)?;
// Корневое меню в GMod занимает весь экран.
if let Ok(Value::Table(root)) = ui.get::<Value>("root")
&& let Ok(resize) = root.get::<Function>("__sr_resize_to_screen")
{
let _ = resize.call::<()>(());
}
Ok(())
}));
}
}
#[cfg(test)]
mod tests {
use crate::LuaRuntime;
fn runtime() -> LuaRuntime {
LuaRuntime::new().expect("рантайм")
}
#[test]
fn ui_starts_closed_and_empty() {
let rt = runtime();
assert!(!rt.ui_visible());
assert!(rt.ui_snapshot().is_empty());
}
#[test]
fn snapshot_records_panels_and_text() {
let rt = runtime();
// Тестовая панель: рисует фон и текст, как настоящий DLabel.
rt.run(
"ui_test.lua",
br#"
local p = vgui.Create( "Panel" )
p:SetPos( 10, 20 )
p:SetSize( 200, 50 )
function p:Paint( w, h )
surface.SetDrawColor( 255, 0, 0, 255 )
surface.DrawRect( 0, 0, w, h )
draw.SimpleText( "Hello", "DermaDefault", 5, 5, Color( 255, 255, 255, 255 ) )
end
__sr_ui.root = p
__sr_ui.visible = true
"#,
)
.expect("панель создана");
let commands = rt.ui_snapshot();
assert!(commands.len() >= 2, "команды: {commands:?}");
assert!(
commands
.iter()
.any(|command| command.kind == crate::ui::UiKind::Rect)
);
assert!(
commands
.iter()
.any(|command| command.text.as_deref() == Some("Hello"))
);
// Координаты детей складываются с позицией родителя.
let text = commands
.iter()
.find(|command| command.text.is_some())
.expect("текст");
assert!((text.x - 15.0).abs() < 0.01, "x={}", text.x);
assert!((text.y - 25.0).abs() < 0.01, "y={}", text.y);
}
#[test]
fn snapshot_respects_command_limit() {
let rt = runtime();
rt.run(
"ui_limit.lua",
br#"
local p = vgui.Create( "Panel" )
p:SetSize( 100, 100 )
function p:Paint( w, h )
for i = 1, 100000 do surface.DrawRect( 0, 0, 1, 1 ) end
end
__sr_ui.root = p
__sr_ui.visible = true
"#,
)
.expect("панель создана");
let commands = rt.ui_snapshot();
assert_eq!(commands.len(), crate::ui::MAX_COMMANDS);
}
}

File diff suppressed because it is too large Load diff

View file

@ -170,6 +170,20 @@ fn load_gamemode_collects_errors_and_continues() {
assert!(!bad.errors.is_empty());
}
#[test]
fn include_finds_windows_case_on_linux() {
let game = TempGame::new("case");
game.write("lua/gui/IconEditor.lua", "case_loaded = true\n");
let rt = LuaRuntime::new().expect("рантайм");
rt.set_file_root(game.path()).expect("корень");
rt.run("case.lua", br#"include( "gui/IconEditor.lua" )"#)
.expect("include без учёта регистра");
assert_eq!(
rt.probe("return tostring(case_loaded)").expect("probe"),
Some("true".into())
);
}
#[test]
fn include_limits_depth() {
let game = TempGame::new("depth");

190
crates/lua/tests/ui.rs Normal file
View file

@ -0,0 +1,190 @@
//! Настоящий UI Sandbox: Derma, VGUI и spawnmenu из установки Garry's Mod.
//!
//! Без ассетов тесты пропускаются: путь задаётся `SOURCE_RS_ASSETS` или
//! `~/gmod_server/server/garrysmod`.
use glua::{LuaRuntime, UiCommand, UiKind};
use std::path::PathBuf;
fn game_dir() -> Option<PathBuf> {
let root = std::env::var_os("SOURCE_RS_ASSETS")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|home| PathBuf::from(home).join("gmod_server/server"))
})?;
let game = root.join("garrysmod");
game.is_dir().then_some(game)
}
fn loaded_ui() -> Option<LuaRuntime> {
let Some(game) = game_dir() else {
eprintln!("пропуск: ассеты Garry's Mod не найдены");
return None;
};
let runtime = LuaRuntime::new().expect("рантайм");
runtime.set_file_root(&game).expect("корень игры");
let (_, _, failed) = runtime.load_scripts(&game);
assert_eq!(failed, 0, "оружие и энтити должны грузиться");
let report = runtime.load_ui(&game);
eprintln!("UI: загружено {} файлов", report.loaded.len());
for error in &report.errors {
eprintln!("UI: ошибка: {error}");
}
// Меню обязано пережить отсутствующие контролы: ошибки отдельных
// файлов допустимы, но SpawnMenu и модуль spawnmenu должны создаться.
assert_eq!(
runtime
.probe("return tostring(IsValid(g_SpawnMenu))")
.expect("probe")
.as_deref(),
Some("true"),
"g_SpawnMenu не создан"
);
assert_eq!(
runtime
.probe("return tostring(spawnmenu.GetCreationTabs() ~= nil)")
.expect("probe")
.as_deref(),
Some("true"),
"модуль spawnmenu не загружен"
);
Some(runtime)
}
fn texts(commands: &[UiCommand]) -> Vec<String> {
let mut texts: Vec<String> = commands
.iter()
.filter_map(|command| command.text.clone())
.collect();
texts.sort();
texts.dedup();
texts
}
fn dump(label: &str, commands: &[UiCommand]) {
eprintln!("[{label}] команд: {}", commands.len());
eprintln!(
"[{label}] rect: {}",
commands
.iter()
.filter(|command| command.kind == UiKind::Rect)
.count()
);
for text in texts(commands).iter().take(30) {
eprintln!("[{label}] текст: {text}");
}
}
#[test]
fn ui_spawnmenu_snapshot_and_click() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime.ui_set_screen(1280, 720);
assert!(!runtime.ui_visible());
assert!(runtime.ui_snapshot().is_empty());
runtime.ui_toggle(true);
assert!(runtime.ui_visible());
// Первый снимок прокачивает `timer.Simple(0, ...)`, которые создают
// вкладки и содержимое спавн-меню; второй — уже для проверки.
let _ = runtime.ui_snapshot();
let first = runtime.ui_snapshot();
dump("первый", &first);
assert!(
first.len() > 20,
"меню должно нарисовать больше 20 команд, получили {}",
first.len()
);
let first_texts = texts(&first);
for expected in ["Weapons", "Entities", "Spawnlists", "Browse"] {
assert!(
first_texts.iter().any(|text| text == expected),
"в снимке нет «{expected}»: {first_texts:?}"
);
}
// Категории из settings/spawnlist_default/*.txt доходят до Lua и до
// дерева спавн-меню.
assert!(
first_texts.iter().any(|text| text == "Construction Props"),
"нет категории Construction Props: {first_texts:?}"
);
assert!(
first_texts.iter().any(|text| text.ends_with(".mdl")),
"нет ни одной модели пропа: {first_texts:?}"
);
// Клик по вкладке Weapons переключает активную панель: снимок обязан
// измениться.
let tab = first
.iter()
.find(|command| command.text.as_deref() == Some("Weapons"))
.expect("вкладка Weapons");
let x = tab.x + tab.w * 0.5;
let y = tab.y + tab.h * 0.5;
eprintln!("клик по Weapons в ({x}, {y})");
runtime.ui_mouse("move", x, y, 1);
runtime.ui_mouse("down", x, y, 1);
runtime.ui_mouse("up", x, y, 1);
let second = runtime.ui_snapshot();
dump("после клика", &second);
assert_ne!(first, second, "клик по вкладке должен менять снимок");
assert_eq!(
runtime
.probe("return tostring(g_SpawnMenu.CreateMenu:GetActiveTab():GetText())")
.expect("probe")
.as_deref(),
Some("#spawnmenu.category.weapons"),
"активная вкладка не переключилась"
);
runtime.ui_toggle(false);
assert!(!runtime.ui_visible());
assert!(runtime.ui_snapshot().is_empty());
}
#[test]
fn ui_snapshot_has_a_bounded_command_count() {
let Some(runtime) = loaded_ui() else {
return;
};
runtime.ui_toggle(true);
let commands = runtime.ui_snapshot();
assert!(
commands.len() <= glua::UI_COMMAND_LIMIT,
"лимит команд: {}",
commands.len()
);
assert!(commands.len() > 20, "меню пустое: {}", commands.len());
}
/// Категории спавн-меню (settings/spawnlist_default/*.txt) реально читаются
/// Lua-скриптом через `file.Find`; иначе вкладка Spawnlists осталась бы пустой.
#[test]
fn ui_spawnlist_files_are_visible_to_lua() {
let Some(game) = game_dir() else {
return;
};
let runtime = LuaRuntime::new().expect("рантайм");
runtime.set_file_root(&game).expect("корень");
let files = runtime
.probe(
r#"
local files = file.Find("settings/spawnlist_default/*.txt", "GAME")
return tostring(#files) .. ":" .. tostring(files[1])
"#,
)
.expect("probe")
.unwrap_or_default();
eprintln!("spawnlist: {files}");
let count: usize = files
.split(':')
.next()
.and_then(|value| value.parse().ok())
.unwrap_or(0);
assert!(count >= 5, "не найдены spawnlist-файлы: {files}");
assert!(
files.contains(".txt"),
"file.Find вернул не имена файлов: {files}"
);
}

View file

@ -8,6 +8,28 @@
---
## 0.9. Настоящее Q-меню: Derma + Sandbox spawnmenu (2026-10-24, ветка `feat/derma`)
Q-меню больше не рисуется сеткой на Rust: `crates/lua` получил слой VGUI/Derma
(`ui.rs` + `ui/bootstrap.lua`), поверх которого грузятся настоящие
`lua/derma/init.lua`, `lua/skins/default.lua`, `lua/vgui/*` и
`lua/includes/modules/spawnmenu.lua`.
- `LuaRuntime::load_ui(game_dir)` собирает UI и геймод, `OnGamemodeLoaded`
создаёт `g_SpawnMenu`; `ui_toggle/ui_visible/ui_snapshot/ui_mouse` — контракт
для рендера (`UiKind`, `UiCommand`, лимит 4096 команд/32 уровня).
- `surface`/`draw` в снимке пишут команды; клик по вкладке/дереву реально
меняет состояние (`OnMousePressed/Released`).
- `file.Find` научен GMod-семантике (файлы+каталоги, `*.txt`), добавлен
`DEFINE_BASECLASS` -> `local BaseClass = baseclass.Get` и `//`-комментарии;
песочница `include` ищет файлы без учёта регистра (Windows-скрипты на Linux).
- Снимок на реальных ассетах: ~900 команд, вкладки/категории/модели
(`Construction Props`, `*.mdl`), клик по Weapons меняет снимок.
- Ограничения: HTML-вкладки (Dupes/Saves) — заглушки, модели не рендерятся
(только имена), шрифты приблизительные; `crates/client` не тронут.
---
## 0. SWEP: оружие из Lua в клиенте (2026-10-24, ветка `feat/swep`)
Скрипты `lua/weapons/*.lua` (на реальных ассетах — `weapon_fists`,

View file

@ -27,6 +27,27 @@
Правило: API строится «рельсами» — каждый модуль регистрируется декларативно
(функции, сигнатуры, проверки аргументов), чтобы добавлять покрытие быстро и единообразно.
### 2.1. UI-слой VGUI/Derma (2026-10-24)
Спавн-меню Sandbox строится **настоящими** скриптами `gamemodes/sandbox` и
`lua/vgui/*` через минимальный слой на Rust+Lua в `crates/lua/src/ui*`:
- `load_ui(game_dir)` грузит `lua/includes` (baseclass, расширения строк/таблиц,
`scriptedpanels`, `panel.lua`), `lua/includes/modules/spawnmenu.lua`, настоящие
`lua/derma/init.lua`, `lua/skins/default.lua`, набор `lua/vgui/*`, затем геймод и
вызывает `OnGamemodeLoaded`.
- Панель — Lua-таблица с методами; `vgui.Register/Create` работают по-настоящему
(класс + базовый класс), `Init` каждого класса обёрнут в `pcall`, поэтому сбой
одного контрола не убивает `SpawnMenu`.
- `surface.*`/`draw.*` во время снимка пишут команды `rect/outline/text`;
`__sr_ui.snapshot()` обходит видимое дерево, вызывает `Panel:Paint(w,h)` и
соблюдает лимиты 4096 команд/32 уровня.
- Контракт для рендера: `LuaRuntime::ui_toggle/ui_visible/ui_snapshot/ui_mouse`
(+`ui_set_screen`), типы `UiKind`/`UiCommand`. Координаты — пиксели экрана.
- Ввод: поиск верхней видимой панели под курсором, вызовы
`OnMousePressed/OnMouseReleased/OnCursorMoved/OnMouseWheeled`; клик по вкладке
меняет активную панель.
## 3. Sandbox (защита от крашедла)
Цель: аддон/сервер не может уронить клиент.