From f41bfde6908cf65113dd9574dceb4bac816ea518 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 24 Oct 2026 13:33:34 +0300 Subject: [PATCH] =?UTF-8?q?Lua:=20=D0=BF=D0=BE=D0=BB=D1=8F=20ENT-=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C-=D0=BF=D1=80=D0=B5=D0=B4=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=81=D0=BF=D0=B0=D0=B2=D0=BD-=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/lua/src/lib.rs | 132 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/crates/lua/src/lib.rs b/crates/lua/src/lib.rs index ac63aaa..5bf4a79 100644 --- a/crates/lua/src/lib.rs +++ b/crates/lua/src/lib.rs @@ -113,6 +113,40 @@ impl LuaRuntime { } } + /// Строковое поле ENT-скрипта (`Model`, `WorldModel`, `PrintName`, ...). + /// + /// Нужен спавн-меню: список `SpawnableEntities` хранит только имена, а + /// модель сущности лежит в её таблице. Как и у оружия, `Model("...")` + /// приходит таблицей с полем `path`. + pub fn entity_string(&self, class: &str, field: &str) -> Option { + let globals = self.lua.globals(); + let store: Table = globals.get("__sr_ents").ok()?; + let Value::Table(ent) = store.get::(class).ok()? else { + return None; + }; + match ent.get::(field) { + Ok(Value::String(value)) => Some(value.to_string_lossy().to_string()), + Ok(Value::Table(value)) => value.get::>("path").ok().flatten(), + _ => None, + } + } + + /// Булево поле ENT-скрипта (в спавн-меню — `Spawnable`). + /// + /// `nil` и не-булевы значения дают `None`: фильтр меню отличает + /// «явно false» от «поле не задано». + pub fn entity_bool(&self, class: &str, field: &str) -> Option { + let globals = self.lua.globals(); + let store: Table = globals.get("__sr_ents").ok()?; + let Value::Table(ent) = store.get::(class).ok()? else { + return None; + }; + match ent.get::(field) { + Ok(Value::Boolean(value)) => Some(value), + _ => None, + } + } + /// Загружает скрипты GMod: `lua/weapons/*.lua` и `lua/entities/*.lua`. /// /// Каждый скрипт выполняется со свежей таблицей `SWEP`/`ENT` (у оружия @@ -166,6 +200,16 @@ impl LuaRuntime { failed += 1; continue; } + // Модель спавн-меню: у ENT-скриптов её часто задают только в + // коде (`self:SetModel("models/...")`), а не полем `ENT.Model`. + // Запоминаем первый литерал `models/*.mdl` — он и будет + // представителем сущности в списке спавна. + if kind == "entity" + && let Some(model) = find_model_literal(&source) + && let Err(err) = table.set("__sr_preview_model", model) + { + eprintln!("скрипт {}: модель для спавна: {err}", file.display()); + } // GMod-синтаксис (!, &&, ||) приводится препроцессором — // без него скрипты падают на первой же строке с `!`. if let Err(err) = self.run(name, source.as_bytes()) { @@ -742,6 +786,47 @@ pub fn preprocess(source: &str) -> String { out } +/// Первый литерал `models/*.mdl` в исходнике ENT-скрипта. +/// +/// Спавн-меню нужно чем-то показать сущность, у которой модель задаётся +/// вызовом `SetModel` в `Initialize`, а не полем `ENT.Model`. Поиск +/// ограничен: путь короче 256 байт, только до кавычки/пробела/закрывающей +/// скобки — данные из файла не должны влиять на границы среза. +fn find_model_literal(source: &str) -> Option { + const PREFIX: &[u8] = b"models/"; + const MAX_PATH: usize = 256; + let bytes = source.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if i + PREFIX.len() <= bytes.len() + && bytes[i..i + PREFIX.len()].eq_ignore_ascii_case(PREFIX) + { + let mut end = i + PREFIX.len(); + while end < bytes.len() && end - i < MAX_PATH { + let b = bytes[end]; + if b.is_ascii_whitespace() || matches!(b, b'"' | b'\'' | b')' | b',' | b';' | 0) { + break; + } + end += 1; + } + let Some(candidate) = source.get(i..end) else { + // Лимит длины мог разрезать многобайтовый символ: пропускаем. + i += 1; + continue; + }; + if candidate.len() > PREFIX.len() + && candidate + .get(candidate.len().saturating_sub(4)..) + .is_some_and(|ext| ext.eq_ignore_ascii_case(".mdl")) + { + return Some(candidate.to_string()); + } + } + i += 1; + } + None +} + /// Если на позиции длинная скобка `[=*[`, возвращает её длину. fn long_bracket(bytes: &[u8], at: usize) -> (usize, ()) { if bytes.get(at) != Some(&b'[') { @@ -889,4 +974,51 @@ local t = [[raw ! text]] assert_eq!(out[4], "5 0 0", "поле записывается"); assert_eq!(out[5], "10", "math.Clamp"); } + + /// Реестр ENT-скриптов отдаёт поля, по которым строится спавн-меню: + /// класс, `Spawnable` и модель. + #[test] + fn entity_registry_exposes_spawn_fields() { + let rt = LuaRuntime::new().expect("рантайм"); + rt.run( + "sent_test.lua", + br#" + local ENT = { + Type = "sent_test", + Spawnable = true, + Model = Model("models/props_junk/watermelon01.mdl"), + } + scripted_ents.Register(ENT, "sent_test") + "#, + ) + .expect("скрипт"); + assert_eq!( + rt.entity_string("sent_test", "Model").as_deref(), + Some("models/props_junk/watermelon01.mdl") + ); + assert_eq!(rt.entity_bool("sent_test", "Spawnable"), Some(true)); + assert_eq!(rt.entity_bool("sent_test", "AdminOnly"), None); + assert_eq!(rt.entity_string("нет_такого", "Model"), None); + } + + /// Модель `SetModel` в коде ENT-скрипта находится без разбора Lua: + /// только литерал, с границами и лимитом длины. + #[test] + fn model_literal_from_entity_source() { + let src = r#"function ENT:Initialize() self:SetModel( "models/Combine_Helicopter/helicopter_bomb01.mdl" ) end"#; + assert_eq!( + find_model_literal(src).as_deref(), + Some("models/Combine_Helicopter/helicopter_bomb01.mdl") + ); + assert_eq!(find_model_literal("no models here"), None); + // Обрывок без расширения не считается моделью. + assert_eq!( + find_model_literal("SetModel('models/props_junk/a b.mdl')"), + None + ); + assert_eq!( + find_model_literal("prefab = 'models/props_junk/popcan01a.mdl'"), + Some("models/props_junk/popcan01a.mdl".to_string()) + ); + } }