From a46e4d827d8da51a5c5514db33780684ef8bb44c Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sat, 24 Oct 2026 13:29:35 +0300 Subject: [PATCH] =?UTF-8?q?Lua:=20=D0=B1=D0=B0=D0=B7=D0=B0=20SWEP=20=D0=B8?= =?UTF-8?q?=20GMod-=D0=B7=D0=B0=D0=B3=D0=BB=D1=83=D1=88=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=80=D1=83=D0=B6=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - swep_base.lua: GetOwner/GetViewModel, NetworkVar, SetNextPrimaryFire, Clip1, SetHoldType, EmitSound и безопасные заглушки; - глобальные CurTime, IsValid, player.GetAll (таблица player), util.TraceLine/TraceHull, trace.*, sound.*, константы CLIENT/SERVER; - game.SinglePlayer стал функцией (как в GMod), GetConVar знает sv_defaultdeployspeed/phys_pushscale — ноль давал inf в Deploy; - загрузчик SWEP даёт базовые методы через метатаблицу, инициализирует SetupDataTables/Initialize и стартовый магазин; - модель без геометрии (c_arms) теперь логирует причину, а не молчит; - тест прогоняет реальные Deploy/PrimaryAttack/Think/Holster трёх SWEP. --- crates/engine/src/world.rs | 10 +- crates/lua/src/ents.rs | 74 ++++++++- crates/lua/src/lib.rs | 176 +++++++++++++++++--- crates/lua/src/stdlib.rs | 26 ++- crates/lua/src/swep.rs | 312 +++++++++++++++++++++++++++++++++++ crates/lua/src/swep_base.lua | 185 +++++++++++++++++++++ crates/lua/tests/scripts.rs | 43 +++++ 7 files changed, 792 insertions(+), 34 deletions(-) create mode 100644 crates/lua/src/swep.rs create mode 100644 crates/lua/src/swep_base.lua diff --git a/crates/engine/src/world.rs b/crates/engine/src/world.rs index a5777ea..f04b07f 100644 --- a/crates/engine/src/world.rs +++ b/crates/engine/src/world.rs @@ -2882,7 +2882,15 @@ impl World { budget: &mut usize, ) -> Option<(usize, u32, u32)> { let key = crate::model::normalize_model_name(model_name); - let model = crate::model::load_model(vfs, &key, budget).ok()?; + let model = match crate::model::load_model(vfs, &key, budget) { + Ok(model) => model, + Err(err) => { + // Молчаливый `None` прятал причину («вьюмодель не + // загрузилась») — в логе должна быть конкретная ошибка. + tracing::warn!(model = %key, "модель не загружена: {err}"); + return None; + } + }; let mut mesh_data = PropMesh::from_vertices(&model.vertices); mesh_data.bones = model.bones.clone(); mesh_data.skins = model.skins.clone(); diff --git a/crates/lua/src/ents.rs b/crates/lua/src/ents.rs index c766f07..64c456c 100644 --- a/crates/lua/src/ents.rs +++ b/crates/lua/src/ents.rs @@ -306,17 +306,81 @@ pub fn install(lua: &Lua, host: HostRef) -> Result<(), mlua::Error> { globals.set("ents", &ents)?; - let host_player = host.clone(); - globals.set( - "player", + // `player` в GMod — таблица: `player.GetAll()`, `player.GetByID()`, и при + // этом её можно вызвать (`player()`, `player(1)`). Функция вместо таблицы + // роняла скрипты на первом же `player.GetAll()`. + let player_table = lua.create_table()?; + let host_all = host.clone(); + player_table.set( + "GetAll", lua.create_function(move |lua, ()| { - let entity = host_player.try_borrow().ok().and_then(|h| h.player()); + let players: Vec = host_all + .try_borrow() + .ok() + .and_then(|h| h.player()) + .into_iter() + .collect(); + Ok(Value::Table(push_list(lua, &players, &host_all)?)) + })?, + )?; + let host_humans = host.clone(); + player_table.set( + "GetHumans", + lua.create_function(move |lua, ()| { + let players: Vec = host_humans + .try_borrow() + .ok() + .and_then(|h| h.player()) + .into_iter() + .collect(); + Ok(Value::Table(push_list(lua, &players, &host_humans)?)) + })?, + )?; + let host_bots = host.clone(); + player_table.set( + "GetBots", + lua.create_function(move |lua, ()| { + let empty: Vec = Vec::new(); + Ok(Value::Table(push_list(lua, &empty, &host_bots)?)) + })?, + )?; + let host_count = host.clone(); + player_table.set( + "GetCount", + lua.create_function(move |_, ()| { + Ok(host_count + .try_borrow() + .ok() + .and_then(|h| h.player()) + .map(|_| 1) + .unwrap_or(0)) + })?, + )?; + let host_by_id = host.clone(); + player_table.set( + "GetByID", + lua.create_function(move |lua, _id: i64| { + let entity = host_by_id.try_borrow().ok().and_then(|h| h.player()); match entity { - Some(entity) => Ok(Value::UserData(wrap(lua, entity, &host_player)?)), + Some(entity) => Ok(Value::UserData(wrap(lua, entity, &host_by_id)?)), None => Ok(Value::Nil), } })?, )?; + let host_call = host.clone(); + let meta = lua.create_table()?; + meta.set( + "__call", + lua.create_function(move |lua, (_table, _id): (Table, Option)| { + let entity = host_call.try_borrow().ok().and_then(|h| h.player()); + match entity { + Some(entity) => Ok(Value::UserData(wrap(lua, entity, &host_call)?)), + None => Ok(Value::Nil), + } + })?, + )?; + player_table.set_metatable(Some(meta))?; + globals.set("player", player_table)?; Ok(()) } diff --git a/crates/lua/src/lib.rs b/crates/lua/src/lib.rs index ac63aaa..fabd179 100644 --- a/crates/lua/src/lib.rs +++ b/crates/lua/src/lib.rs @@ -6,9 +6,11 @@ pub mod ents; pub mod gmod; pub mod stdlib; +pub mod swep; use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value}; use std::cell::RefCell; +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::rc::Rc; use std::time::Instant; @@ -82,7 +84,46 @@ impl LuaRuntime { /// Нужен клиенту для `PrimaryAttack`/`SecondaryAttack`/`Deploy`: скрипты /// определяют их как методы (`function SWEP:PrimaryAttack()`), поэтому /// первым аргументом идёт сама таблица. + /// + /// Паника внутри mlua/FFI не должна ронять клиент (правило #2 AGENTS.md): + /// она превращается в обычную ошибку Lua и логируется вызывающим. pub fn call_weapon_method(&self, class: &str, method: &str) -> Result<(), LuaError> { + match catch_unwind(AssertUnwindSafe(|| { + self.call_weapon_method_inner(class, method) + })) { + Ok(result) => result, + Err(_) => Err(LuaError::Lua(mlua::Error::runtime(format!( + "паника при вызове {class}:{method}" + )))), + } + } + + fn call_weapon_method_inner(&self, class: &str, method: &str) -> Result<(), LuaError> { + let swep = self.weapon_table(class)?; + let Value::Function(function) = swep.get::(method)? else { + return Ok(()); + }; + function.call::<()>(swep)?; + Ok(()) + } + + /// Вызывает метод SWEP и возвращает число (например, `Clip1` или + /// `GetNextPrimaryFire`). Не-число/ошибка — `None`. + pub fn call_weapon_number(&self, class: &str, method: &str) -> Option { + catch_unwind(AssertUnwindSafe(|| { + let swep = self.weapon_table(class).ok()?; + let Value::Function(function) = swep.get::(method).ok()? else { + return None; + }; + let value = function.call::(swep).ok()?; + value_as_f64(&value) + })) + .ok() + .flatten() + } + + /// Таблица SWEP из реестра. + fn weapon_table(&self, class: &str) -> Result { let globals = self.lua.globals(); let weapons: Table = globals.get("weapons")?; let get: Function = weapons.get("Get")?; @@ -91,28 +132,64 @@ impl LuaRuntime { "оружие {class} не зарегистрировано" )))); }; - let Value::Function(function) = swep.get::(method)? else { - return Ok(()); - }; - function.call::<()>(swep)?; - Ok(()) + Ok(swep) + } + + /// Значение поля SWEP по пути через точку (`ViewModel`, `Primary.ClipSize`). + pub fn weapon_field(&self, class: &str, field: &str) -> Option { + catch_unwind(AssertUnwindSafe(|| self.weapon_field_inner(class, field))) + .ok() + .flatten() + } + + fn weapon_field_inner(&self, class: &str, field: &str) -> Option { + let mut current = Value::Table(self.weapon_table(class).ok()?); + for part in field.split('.') { + let Value::Table(table) = current else { + return None; + }; + current = table.get::(part).ok()?; + } + Some(current) } /// Значение строкового поля SWEP (`ViewModel`, `PrintName`, ...). + /// + /// `Model("...")` в GMod-скриптах — таблица с полем `path`; такие поля + /// тоже читаются как строка. `field` может быть путём (`Primary.Ammo`). pub fn weapon_string(&self, class: &str, field: &str) -> Option { - let globals = self.lua.globals(); - let weapons: Table = globals.get("weapons").ok()?; - let get: Function = weapons.get("Get").ok()?; - let Value::Table(swep) = get.call::(class.to_string()).ok()? else { - return None; - }; - match swep.get::(field) { - Ok(Value::String(value)) => Some(value.to_string_lossy().to_string()), - Ok(Value::Table(value)) => value.get::>("path").ok().flatten(), + match self.weapon_field(class, field)? { + Value::String(value) => Some(value.to_string_lossy().to_string()), + Value::Table(value) => value.get::>("path").ok().flatten(), _ => None, } } + /// Числовое поле SWEP (int/float/числовая строка). + pub fn weapon_number(&self, class: &str, field: &str) -> Option { + value_as_f64(&self.weapon_field(class, field)?) + } + + /// Булево поле SWEP по правилам Lua: всё, кроме `nil`/`false`, истинно. + pub fn weapon_bool(&self, class: &str, field: &str) -> Option { + let value = self.weapon_field(class, field)?; + Some(!matches!(value, Value::Nil | Value::Boolean(false))) + } + + /// Запоминает активное оружие для `owner:GetActiveWeapon()` в скриптах. + pub fn set_active_weapon(&self, class: Option<&str>) { + let value = match class.and_then(|class| self.weapon_table(class).ok()) { + Some(swep) => Value::Table(swep), + None => Value::Nil, + }; + let _ = self.lua.globals().set("__sr_active_weapon", value); + } + + /// Время в секундах от запуска клиента — то же, что `CurTime()` в Lua. + pub fn cur_time(&self) -> f64 { + stdlib::cur_time() + } + /// Загружает скрипты GMod: `lua/weapons/*.lua` и `lua/entities/*.lua`. /// /// Каждый скрипт выполняется со свежей таблицей `SWEP`/`ENT` (у оружия @@ -156,6 +233,12 @@ impl LuaRuntime { if kind == "weapon" { table.set("Primary", self.lua.create_table()?)?; table.set("Secondary", self.lua.create_table()?)?; + // Базовые методы (`GetOwner`, `SetNextPrimaryFire`, + // `NetworkVar`, ...) приходят через метатаблицу: скрипт + // видит их как методы SWEP, но не перезаписывает файл. + if let Ok(Value::Table(meta)) = globals.get::("__sr_swep_meta") { + table.set_metatable(Some(meta))?; + } globals.set("SWEP", &table)?; } else { globals.set("ENT", &table)?; @@ -190,11 +273,26 @@ impl LuaRuntime { entries } }; - entries.set(class, &table)?; + entries.set(class.clone(), &table)?; Ok(()) })(); if registered.is_ok() { weapons += 1; + // Стартовый запас: в GMod `DefaultClip` попадает в + // оружие при выдаче. Без этого `Clip1()` у медика был + // бы нулевым, и HUD показывал бы пустой магазин. + if let Some(clip) = self.weapon_number(&class, "Primary.ClipSize") + && clip > 0.0 + { + let _ = table.set("__clip", clip as i64); + } + // Порядок как в GMod: сначала сетевые переменные, + // потом `Initialize` (он на них опирается). + for method in ["SetupDataTables", "Initialize"] { + if let Err(err) = self.call_weapon_method(&class, method) { + eprintln!("оружие {class}: {method}: {err}"); + } + } } else { failed += 1; } @@ -633,13 +731,40 @@ fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), ml globals.set( "GetConVar", lua.create_function(|lua, name: String| { - let _ = name; + // Значения реальных движковых конваров, на которые опираются + // скрипты оружия. Ноль у `sv_defaultdeployspeed` превращал + // `SequenceDuration()/speed` в `inf` — оружие больше не стреляло. + let float: f64 = match name.as_str() { + "sv_defaultdeployspeed" | "phys_pushscale" => 1.0, + "sv_gravity" => 600.0, + "sv_maxspeed" => 320.0, + _ => 0.0, + }; let t = lua.create_table()?; - t.set("GetBool", lua.create_function(|_, ()| Ok(false))?)?; - t.set("GetFloat", lua.create_function(|_, ()| Ok(0.0))?)?; - t.set("GetInt", lua.create_function(|_, ()| Ok(0))?)?; - t.set("GetString", lua.create_function(|_, ()| Ok(String::new()))?)?; + t.set( + "GetBool", + lua.create_function(move |_, ()| Ok(float != 0.0))?, + )?; + t.set("GetFloat", lua.create_function(move |_, ()| Ok(float))?)?; + t.set( + "GetInt", + lua.create_function(move |_, ()| Ok(float as i64))?, + )?; + t.set( + "GetString", + lua.create_function(move |_, ()| { + Ok(if float.fract() == 0.0 { + format!("{}", float as i64) + } else { + float.to_string() + }) + })?, + )?; t.set("SetValue", lua.create_function(|_, _: Value| Ok(()))?)?; + t.set( + "GetName", + lua.create_function(move |_, ()| Ok(name.clone()))?, + )?; Ok(t) })?, )?; @@ -775,6 +900,17 @@ fn long_bracket(bytes: &[u8], at: usize) -> (usize, ()) { (bytes.len() - at, ()) } +/// Число из значения Lua: целые LuaJIT приходят как `Integer`, дробные — +/// как `Number`; строки тоже принимаются (конфиги скриптов). +fn value_as_f64(value: &Value) -> Option { + match value { + Value::Integer(i) => Some(*i as f64), + Value::Number(n) if n.is_finite() => Some(*n), + Value::String(s) => s.to_str().ok()?.trim().parse().ok(), + _ => None, + } +} + /// Преобразует Lua-значение в строку (как `print` в GMod). fn lua_value_to_string(value: &Value) -> String { match value { diff --git a/crates/lua/src/stdlib.rs b/crates/lua/src/stdlib.rs index fe7558f..a85337d 100644 --- a/crates/lua/src/stdlib.rs +++ b/crates/lua/src/stdlib.rs @@ -29,6 +29,7 @@ pub fn install(lua: &Lua, map: &str) -> Result<(), mlua::Error> { install_weapons(lua)?; install_scripted_ents(lua)?; install_stubs(lua)?; + crate::swep::install(lua)?; Ok(()) } @@ -432,10 +433,10 @@ fn install_game(lua: &Lua, map: String) -> Result<(), mlua::Error> { let globals = lua.globals(); let game: Table = globals.get("game")?; - // Одиночная игра. Скрипты Sandbox ветвятся по нему постоянно, и в - // одиночном режиме оно обязано быть `true` — иначе отключится большая - // часть меню. - game.set("SinglePlayer", Value::Boolean(true))?; + // Одиночная игра. В GMod `game.SinglePlayer` — **функция**, и скрипты + // оружия вызывают её как `game.SinglePlayer()`; булево значение падало + // на первом же таком вызове. + game.set("SinglePlayer", lua.create_function(|_, ()| Ok(true))?)?; game.set("IsDedicated", Value::Boolean(false))?; game.set("folder", "Garry's Mod")?; game.set("title", "Garry's Mod")?; @@ -448,9 +449,11 @@ fn install_game(lua: &Lua, map: String) -> Result<(), mlua::Error> { "GetTime", lua.create_function(|_, ()| { // Время от запуска клиента: таймеры GMod считают от него же. - Ok(CLIENT_START.elapsed().as_secs_f64()) + Ok(cur_time()) })?, )?; + lua.globals() + .set("CurTime", lua.create_function(|_, ()| Ok(cur_time()))?)?; game.set( "CleanUpMap", @@ -464,10 +467,15 @@ fn install_game(lua: &Lua, map: String) -> Result<(), mlua::Error> { Ok(()) } -/// Время запуска клиента — общая точка отсчёта для таймеров. +/// Время запуска клиента — общая точка отсчёта для таймеров и `CurTime`. static CLIENT_START: std::sync::LazyLock = std::sync::LazyLock::new(std::time::Instant::now); +/// Секунды от запуска клиента (`CurTime` и `game.GetTime`). +pub fn cur_time() -> f64 { + CLIENT_START.elapsed().as_secs_f64() +} + /// Расширения `string` и `table` из GMod. fn install_string_table(lua: &Lua) -> Result<(), mlua::Error> { let globals = lua.globals(); @@ -850,16 +858,18 @@ mod tests { #[test] fn single_player_is_true() { - // Sandbox ветвится по game.SinglePlayer постоянно. + // Sandbox ветвится по game.SinglePlayer постоянно; в GMod это + // функция, а не поле. let lua = ready("gm_test"); assert!( - eval(&lua, "return game.SinglePlayer") + eval(&lua, "return game.SinglePlayer()") .front() .unwrap() .as_boolean() .unwrap() ); assert_eq!(string(&lua, "return game.GetMap()"), "gm_test"); + assert!(number(&lua, "return CurTime()") >= 0.0); } #[test] diff --git a/crates/lua/src/swep.rs b/crates/lua/src/swep.rs new file mode 100644 index 0000000..38b0830 --- /dev/null +++ b/crates/lua/src/swep.rs @@ -0,0 +1,312 @@ +//! GMod-API для SWEP-скриптов: база оружия, владелец, вьюмодель, трассы. +//! +//! Реальные `lua/weapons/*.lua` из Garry's Mod написаны под движковый API: +//! `self:SetHoldType`, `self:SetNextPrimaryFire`, `CurTime`, `util.TraceLine`, +//! `owner:GetViewModel()` и т. д. Здесь — безопасный минимум, без которого +//! `PrimaryAttack` падает на первом же вызове. +//! +//! Звук и эффекты **не эмулируются**: `EmitSound`/`sound.Play` принимают +//! аргументы и ничего не делают. Трассы возвращают пустой результат +//! (`Hit = false`), поэтому урон и физика не появляются из воздуха — но и +//! клиент не падает от отсутствующего API. + +use mlua::{AnyUserData, Function, Lua, ObjectLike, Table, Value}; + +use crate::gmod::LuaVector; + +/// Ставит глобальные константы, заглушки и базовые методы SWEP. +pub fn install(lua: &Lua) -> Result<(), mlua::Error> { + let globals = lua.globals(); + + // Сторона исполнения: скрипты оружия ветвятся по `CLIENT`/`SERVER`. + globals.set("CLIENT", true)?; + globals.set("SERVER", false)?; + // `NULL` в GMod — специальный userdata; `nil` ведёт себя так же в + // проверках `if ( !IsValid( ... ) )`. + globals.set("NULL", Value::Nil)?; + + for (name, value) in [ + ("PLAYER_ATTACK1", 1), + ("PLAYER_ATTACK2", 2), + ("PLAYER_ATTACK_CRIT", 4), + ("PLAYER_ATTACK_SWING", 8), + ("PLAYER_ATTACK_NODRAW", 16), + ("ACT_VM_PRIMARYATTACK", 1), + ("ACT_VM_SECONDARYATTACK", 2), + ("ACT_VM_IDLE", 3), + ("ACT_VM_DRAW", 4), + ("ACT_VM_HOLSTER", 5), + ("ACT_VM_RELOAD", 6), + // Маски трасс: значения как в Source, но `util.TraceLine` пока + // заглушка — важна не точность, а то, что константа не nil. + ("MASK_SHOT_HULL", 0x0200_400B), + ("MASK_SHOT", 0x1000_400B), + ("MASK_SOLID", 0x0200_400B), + ] { + globals.set(name, value)?; + } + + globals.set( + "IsValid", + lua.create_function(|_, value: Value| Ok(is_valid(&value)))?, + )?; + globals.set( + "EmitSound", + lua.create_function(|_, _: mlua::MultiValue| Ok(()))?, + )?; + + install_trace(lua)?; + install_sound(lua)?; + install_empty_player(lua)?; + install_base(lua)?; + Ok(()) +} + +/// `player` до подключения мира: пустая таблица, но с рабочими методами. +/// +/// `attach_host` заменит её таблицей с настоящими сущностями. Без заглушки +/// скрипт, вызванный до подключения хоста (или в юнит-тесте), падал на +/// `player.GetAll()` с «attempt to index global 'player'». +fn install_empty_player(lua: &Lua) -> Result<(), mlua::Error> { + let globals = lua.globals(); + if !matches!(globals.get::("player")?, Value::Nil) { + return Ok(()); + } + let player = lua.create_table()?; + let empty_list = lua.create_function(|lua, ()| lua.create_table())?; + player.set("GetAll", empty_list.clone())?; + player.set("GetHumans", empty_list.clone())?; + player.set("GetBots", empty_list)?; + player.set("GetCount", lua.create_function(|_, ()| Ok(0))?)?; + player.set( + "GetByID", + lua.create_function(|_, _id: i64| Ok(Value::Nil))?, + )?; + let meta = lua.create_table()?; + meta.set( + "__call", + lua.create_function(|_, (_table, _id): (Table, Option)| Ok(Value::Nil))?, + )?; + player.set_metatable(Some(meta))?; + globals.set("player", player)?; + Ok(()) +} + +/// Проверка значения как `IsValid` в GMod. +/// +/// `nil`/`false` невалидны; userdata спрашивает свой `IsValid()` (так +/// отсеиваются удалённые энтити), таблицы и функции валидны. +fn is_valid(value: &Value) -> bool { + match value { + Value::Nil | Value::Boolean(false) => false, + Value::UserData(ud) => userdata_valid(ud), + // Заглушка NULL-сущности — таблица со своим `IsValid() == false`. + Value::Table(table) => match table.get::>("IsValid") { + Ok(Some(function)) => function.call::(table.clone()).unwrap_or(true), + _ => true, + }, + _ => true, + } +} + +fn userdata_valid(ud: &AnyUserData) -> bool { + match ud.get::>("IsValid") { + Ok(Some(function)) => function.call::(ud.clone()).unwrap_or(true), + _ => true, + } +} + +/// `util.TraceLine`/`TraceHull` и таблица `trace`: пустой результат. +fn install_trace(lua: &Lua) -> Result<(), mlua::Error> { + let globals = lua.globals(); + let util: Table = globals.get("util")?; + let line = lua.create_function(|lua, input: Option| empty_trace(lua, input.as_ref()))?; + util.set("TraceLine", line.clone())?; + util.set("TraceHull", line.clone())?; + util.set("TraceEntity", line.clone())?; + util.set("TracePhysics", line.clone())?; + + let trace = lua.create_table()?; + trace.set("Line", line.clone())?; + trace.set("Hull", line)?; + globals.set("trace", trace)?; + Ok(()) +} + +/// Пустая трасса: ни во что не попали. +/// +/// Поля повторяют структуру GMod достаточно, чтобы скрипт читал +/// `tr.Hit`, `tr.Entity`, `tr.HitPos`, `tr.Normal`, `tr.Fraction`. +fn empty_trace(lua: &Lua, input: Option<&Table>) -> mlua::Result
{ + let start = input + .and_then(|table| table.get::>("start").ok().flatten()) + .unwrap_or_default(); + let endpos = input + .and_then(|table| table.get::>("endpos").ok().flatten()) + .unwrap_or(start); + let trace = lua.create_table()?; + trace.set("Hit", false)?; + // В GMod `tr.Entity` — NULL-сущность, а не nil: скрипты вызывают у неё + // `IsPlayer()`/`IsNPC()`/`Health()` до проверки `IsValid`. + let null_entity = lua + .globals() + .get::("__sr_null_entity") + .unwrap_or(Value::Nil); + trace.set("Entity", null_entity)?; + trace.set("StartPos", lua.create_userdata(start)?)?; + trace.set("HitPos", lua.create_userdata(endpos)?)?; + trace.set( + "Normal", + lua.create_userdata(LuaVector::new(0.0, 0.0, 1.0))?, + )?; + trace.set("Fraction", 1.0)?; + trace.set("Allsolid", false)?; + trace.set("StartSolid", false)?; + trace.set("HitWorld", false)?; + trace.set("HitSky", false)?; + trace.set("HitBox", 0)?; + trace.set("HitGroup", 0)?; + trace.set("MatType", 0)?; + trace.set("Contents", 0)?; + trace.set("Damage", 0)?; + trace.set("SurfaceProps", 0)?; + trace.set("PhysicsObject", Value::Nil)?; + trace.set("HitTexture", "")?; + Ok(trace) +} + +/// `sound.*` — заглушки: звука у клиента пока нет. +fn install_sound(lua: &Lua) -> Result<(), mlua::Error> { + let globals = lua.globals(); + let sound = lua.create_table()?; + for name in ["Play", "PlayURL", "Stop", "Add", "Emit"] { + sound.set(name, lua.create_function(|_, _: mlua::MultiValue| Ok(()))?)?; + } + globals.set("sound", sound)?; + Ok(()) +} + +/// Базовая таблица SWEP и объекты владельца/вьюмодели. +fn install_base(lua: &Lua) -> Result<(), mlua::Error> { + let globals = lua.globals(); + lua.load(include_str!("swep_base.lua")) + .set_name("swep_base.lua") + .exec()?; + + let base: Table = globals.get("__sr_swep_base")?; + let meta = lua.create_table()?; + meta.set("__index", base)?; + globals.set("__sr_swep_meta", meta)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::LuaRuntime; + + fn runtime() -> LuaRuntime { + LuaRuntime::new().expect("рантайм") + } + + /// База SWEP обязана покрывать методы, на которые опираются реальные + /// скрипты: иначе `PrimaryAttack` падает с «attempt to call a nil value». + #[test] + fn base_swep_methods_exist_and_run() { + let runtime = runtime(); + // Используем настоящий загрузчик на временном каталоге. + let dir = std::env::temp_dir().join("source_rs_swep_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("lua/weapons")).expect("каталог"); + std::fs::write( + dir.join("lua/weapons/weapon_test.lua"), + br##" + SWEP.PrintName = "#test" + SWEP.Spawnable = true + SWEP.ViewModel = Model("models/weapons/c_arms.mdl") + SWEP.Primary.ClipSize = 10 + SWEP.Primary.Automatic = true + function SWEP:SetupDataTables() + self:NetworkVar("Float", 0, "NextIdle") + end + function SWEP:Initialize() + self:SetHoldType("smg") + end + function SWEP:Deploy() + self:SetNextPrimaryFire(CurTime() + 0.2) + self:SetNextIdle(CurTime() + 0.5) + return true + end + function SWEP:PrimaryAttack() + self:SetClip1(self:Clip1() - 1) + self:SetNextPrimaryFire(CurTime() + 0.1) + end + function SWEP:Think() + local owner = self:GetOwner() + local vm = owner:GetViewModel() + local _ = vm:LookupSequence("idle") + local tr = util.TraceLine({ start = owner:GetShootPos(), + endpos = owner:GetShootPos() }) + assert(tr.Hit == false) + assert(IsValid(tr.Entity) == false) + assert(IsValid(owner)) + assert(game.SinglePlayer()) + assert(#player.GetAll() >= 0) + end + "##, + ) + .expect("файл"); + let (loaded, _, failed) = runtime.load_scripts(&dir); + assert_eq!((loaded, failed), (1, 0), "скрипт загружен без ошибок"); + let clip = runtime + .probe("return tostring(weapons.Get('weapon_test'):Clip1())") + .expect("probe") + .unwrap_or_default(); + assert_eq!(clip, "10", "стартовый запас из Primary.ClipSize"); + + runtime + .call_weapon_method("weapon_test", "PrimaryAttack") + .expect("PrimaryAttack"); + let clip = runtime + .probe("return tostring(weapons.Get('weapon_test'):Clip1())") + .expect("probe") + .unwrap_or_default(); + assert_eq!(clip, "9", "выстрел уменьшил магазин"); + runtime + .call_weapon_method("weapon_test", "Deploy") + .expect("Deploy"); + runtime + .call_weapon_method("weapon_test", "Think") + .expect("Think"); + runtime + .call_weapon_method("weapon_test", "Holster") + .expect("Holster"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn trace_and_isvalid_are_safe_without_arguments() { + let runtime = runtime(); + runtime + .run( + "stubs.lua", + br##" + local tr = util.TraceLine() + assert(tr.Hit == false) + assert(IsValid(nil) == false) + assert(IsValid(false) == false) + assert(IsValid({}) == true) + sound.Play("x") + EmitSound("x") + timer.Simple(0, function() printed = true end) + "##, + ) + .expect("заглушки не падают"); + std::thread::sleep(std::time::Duration::from_millis(5)); + runtime.tick().expect("таймеры"); + let printed = runtime + .probe("return tostring(printed)") + .expect("probe") + .unwrap_or_default(); + assert_eq!(printed, "true"); + } +} diff --git a/crates/lua/src/swep_base.lua b/crates/lua/src/swep_base.lua new file mode 100644 index 0000000..2e63348 --- /dev/null +++ b/crates/lua/src/swep_base.lua @@ -0,0 +1,185 @@ +-- База SWEP: методы, которые в GMod приходят из base_weapon. +-- +-- Клиент вызывает Initialize/Deploy/Holster/PrimaryAttack/Think прямо у +-- таблицы SWEP. Если метода нет, Lua падает с «attempt to call a nil value»; +-- поэтому базовые методы обязаны существовать до выполнения скрипта. Свои +-- определения из `lua/weapons/*.lua` перекрывают их как обычные поля таблицы. + +__sr_swep_base = {} +local base = __sr_swep_base + +-- Состояние вьюмодели, которую видит скрипт (реальный рендер — в клиенте). +-- SendViewModelMatchingSequence кладёт сюда имя секвенции: клиент может +-- проиграть её, если она есть в модели. +local viewmodel = {} +__sr_swep_viewmodel = viewmodel + +function viewmodel:SequenceDuration() return 0.5 end +function viewmodel:GetPlaybackRate() return self.__playback_rate or 1 end +function viewmodel:SetPlaybackRate(rate) + self.__playback_rate = tonumber(rate) or 1 +end +function viewmodel:LookupSequence(name) return name or 0 end +function viewmodel:GetSequence() return 0 end +function viewmodel:SetSequence(_) end +function viewmodel:SendViewModelMatchingSequence(sequence) + __sr_view_sequence = sequence + __sr_view_sequence_time = CurTime() +end +function viewmodel:GetCycle() return 0 end +function viewmodel:SetCycle(_) end +function viewmodel:IsValid() return true end +function viewmodel:GetModel() return self.__model or "" end +function viewmodel:GetOwner() return __sr_swep_owner end +function viewmodel:SetNoDraw(_) end +function viewmodel:GetNoDraw() return false end + +-- NULL-сущность: в GMod `tr.Entity` не nil, а объект, у которого IsValid() +-- ложно. Скрипты (например, медик) вызывают у него методы до проверки. +__sr_null_entity = {} +function __sr_null_entity:IsValid() return false end +function __sr_null_entity:IsPlayer() return false end +function __sr_null_entity:IsNPC() return false end +function __sr_null_entity:IsWorld() return false end +function __sr_null_entity:Health() return 0 end +function __sr_null_entity:GetMaxHealth() return 0 end +function __sr_null_entity:SetHealth(_) end +function __sr_null_entity:GetInternalVariable(_) return nil end +function __sr_null_entity:GetPhysicsObject() return nil end +function __sr_null_entity:GetPos() return Vector(0, 0, 0) end +function __sr_null_entity:GetClass() return "" end +function __sr_null_entity:GetName() return "" end + +-- Владелец оружия: в GMod это сущность игрока. У клиента пока нет отдельного +-- игрового entity-объекта, поэтому методы возвращают безопасные значения. +local owner = {} +__sr_swep_owner = owner + +function owner:IsPlayer() return true end +function owner:IsNPC() return false end +function owner:IsWorld() return false end +function owner:IsValid() return true end +function owner:Alive() return true end +function owner:GetViewModel() return viewmodel end +function owner:SetViewModel(_) end +function owner:GetActiveWeapon() return __sr_active_weapon end +function owner:GetWeapon(_) return nil end +function owner:GetHeldWeapon() return nil end +function owner:GetWeapons() return {} end +function owner:SetAnimation(_) end +function owner:LagCompensation(_) end +function owner:GetShootPos() return Vector(0, 0, 64) end +function owner:EyePos() return Vector(0, 0, 64) end +function owner:GetPos() return Vector(0, 0, 64) end +function owner:GetAbsOrigin() return Vector(0, 0, 64) end +function owner:SetPos(_) end +function owner:GetAimVector() return Vector(1, 0, 0) end +function owner:GetForward() return Vector(1, 0, 0) end +function owner:GetRight() return Vector(0, -1, 0) end +function owner:GetUp() return Vector(0, 0, 1) end +function owner:GetEyeAngles() return Angle(0, 0, 0) end +function owner:GetVelocity() return Vector(0, 0, 0) end +function owner:GetMoveVelocity() return Vector(0, 0, 0) end +function owner:GetEyeTrace() return util.TraceLine({}) end +function owner:Health() return 100 end +function owner:GetMaxHealth() return 100 end +function owner:SetHealth(_) end +function owner:TakeDamage(_) end +function owner:GetInternalVariable(_) return nil end +function owner:GetPhysicsObject() return nil end +function owner:GetInfoNum(_, default) return tonumber(default) or 0 end +function owner:GetInfo(_, default) return default or "" end +function owner:GetName() return "player" end +function owner:Nick() return "player" end +function owner:SteamID() return "STEAM_0:0:0" end +function owner:SteamID64() return "0" end +function owner:UniqueID() return 1 end +function owner:EntIndex() return 1 end +function owner:GetObserverMode() return 0 end +function owner:InVehicle() return false end +function owner:IsAdmin() return false end +function owner:GetUserGroup() return "player" end +function owner:SetNoDraw(_) end +function owner:DrawViewModel() return true end +function owner:SetFOV(_) end + +-- Основные методы SWEP. + +function base:Initialize() end +function base:SetupDataTables() end +function base:Deploy() return true end +function base:Holster() return true end +function base:Think() end +function base:PrimaryAttack() end +function base:SecondaryAttack() end +function base:Reload() end +function base:OnDrop() end +function base:Draw() end +function base:DrawHUD() end + +function base:GetOwner() return __sr_swep_owner end +function base:SetOwner(who) __sr_swep_owner = who end +function base:GetViewModel() return viewmodel end +function base:SetHoldType(hold) self.HoldType = hold end +function base:GetHoldType() return self.HoldType or "none" end +function base:SetWeaponVisible(_) end +function base:IsWeaponVisible() return true end +function base:GetModel() return self.WorldModel and self.WorldModel.path or "" end +function base:SetModel(_) end + +function base:SetNextPrimaryFire(time) + self.__next_primary_fire = tonumber(time) or 0 +end +function base:GetNextPrimaryFire() return self.__next_primary_fire or 0 end +function base:SetNextSecondaryFire(time) + self.__next_secondary_fire = tonumber(time) or 0 +end +function base:GetNextSecondaryFire() return self.__next_secondary_fire or 0 end +function base:SetNextMeleeAttack(time) + self.__next_melee_attack = tonumber(time) or 0 +end +function base:GetNextMeleeAttack() return self.__next_melee_attack or 0 end + +-- NetworkVar в GMod создаёт пару GetX/SetX. Значение живёт в таблице SWEP, +-- а тип задаёт только значение по умолчанию. +function base:NetworkVar(kind, _index, name) + local key = "__netvar_" .. name + local default = 0 + if kind == "Bool" then + default = false + elseif kind == "String" then + default = "" + elseif kind == "Vector" then + default = Vector(0, 0, 0) + elseif kind == "Angle" then + default = Angle(0, 0, 0) + elseif kind == "Entity" then + default = nil + end + self["Set" .. name] = function(state, value) state[key] = value end + self["Get" .. name] = function(state) + local value = state[key] + if value == nil then return default end + return value + end +end + +function base:Clip1() return self.__clip or 0 end +function base:SetClip1(value) + self.__clip = math.max(0, math.floor(tonumber(value) or 0)) +end +function base:AddClip1(value) self:SetClip1(self:Clip1() + (tonumber(value) or 0)) end +function base:Clip2() return self.__clip2 or 0 end +function base:SetClip2(value) + self.__clip2 = math.max(0, math.floor(tonumber(value) or 0)) +end + +function base:EmitSound(_) end +function base:StopSound(_) end +function base:SendWeaponAnim(_) end +function base:ShootEffects() end +function base:ShootBullet() end +function base:SequenceDuration() return 0.5 end +function base:GetSequenceName(_) return "" end +function base:GetPhysicsObject() return nil end +function base:Remove() end diff --git a/crates/lua/tests/scripts.rs b/crates/lua/tests/scripts.rs index d70ab0c..823d13f 100644 --- a/crates/lua/tests/scripts.rs +++ b/crates/lua/tests/scripts.rs @@ -60,3 +60,46 @@ fn real_gmod_weapons_and_entities_load() { .unwrap_or_default(); assert_eq!(entity, "true", "scripted_ents.Get('sent_ball')"); } + +/// Реальные SWEP-скрипты вызываются клиентом: Deploy, атака, Think, Holster. +/// +/// Тест важен тем, что не мокает GMod-API, а прогоняет **настоящие** файлы +/// оружия из установки: любая отсутствующая функция (`CurTime`, `IsValid`, +/// `util.TraceLine`, `GetViewModel`, ...) здесь станет ошибкой Lua. +#[test] +fn real_gmod_weapon_methods_run_without_errors() { + let Some(game) = game_dir() else { + eprintln!("пропуск: ассеты Garry's Mod не найдены"); + return; + }; + let runtime = LuaRuntime::new().expect("рантайм"); + let (weapons, _, _) = runtime.load_scripts(&game); + assert!(weapons >= 3, "мало оружия: {weapons}"); + + for class in ["weapon_fists", "weapon_flechettegun", "weapon_medkit"] { + for method in [ + "SetupDataTables", + "Initialize", + "Deploy", + "PrimaryAttack", + "Think", + "SecondaryAttack", + "Holster", + ] { + runtime + .call_weapon_method(class, method) + .unwrap_or_else(|err| panic!("{class}:{method}: {err}")); + } + } + + // Магазин медика: после атаки по пустой трассе он не тратится, но + // значение обязано читаться как число (общий контракт Clip1). + let clip = runtime + .call_weapon_number("weapon_medkit", "Clip1") + .expect("Clip1 медика"); + assert!(clip >= 0.0, "Clip1: {clip}"); + let clip_size = runtime + .weapon_number("weapon_medkit", "Primary.ClipSize") + .expect("Primary.ClipSize"); + assert_eq!(clip_size, 100.0); +}