Lua: база SWEP и GMod-заглушки для оружия

- 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.
This commit is contained in:
Niko Marmeladkov 2026-10-24 13:29:35 +03:00
parent 300a6c6d98
commit a46e4d827d
7 changed files with 792 additions and 34 deletions

View file

@ -2882,7 +2882,15 @@ impl World {
budget: &mut usize, budget: &mut usize,
) -> Option<(usize, u32, u32)> { ) -> Option<(usize, u32, u32)> {
let key = crate::model::normalize_model_name(model_name); 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); let mut mesh_data = PropMesh::from_vertices(&model.vertices);
mesh_data.bones = model.bones.clone(); mesh_data.bones = model.bones.clone();
mesh_data.skins = model.skins.clone(); mesh_data.skins = model.skins.clone();

View file

@ -306,17 +306,81 @@ pub fn install(lua: &Lua, host: HostRef) -> Result<(), mlua::Error> {
globals.set("ents", &ents)?; globals.set("ents", &ents)?;
let host_player = host.clone(); // `player` в GMod — таблица: `player.GetAll()`, `player.GetByID()`, и при
globals.set( // этом её можно вызвать (`player()`, `player(1)`). Функция вместо таблицы
"player", // роняла скрипты на первом же `player.GetAll()`.
let player_table = lua.create_table()?;
let host_all = host.clone();
player_table.set(
"GetAll",
lua.create_function(move |lua, ()| { lua.create_function(move |lua, ()| {
let entity = host_player.try_borrow().ok().and_then(|h| h.player()); let players: Vec<EntityRef> = 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<EntityRef> = 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<EntityRef> = 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 { 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), 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<i64>)| {
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(()) Ok(())
} }

View file

@ -6,9 +6,11 @@
pub mod ents; pub mod ents;
pub mod gmod; pub mod gmod;
pub mod stdlib; pub mod stdlib;
pub mod swep;
use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value}; use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value};
use std::cell::RefCell; use std::cell::RefCell;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::rc::Rc; use std::rc::Rc;
use std::time::Instant; use std::time::Instant;
@ -82,7 +84,46 @@ impl LuaRuntime {
/// Нужен клиенту для `PrimaryAttack`/`SecondaryAttack`/`Deploy`: скрипты /// Нужен клиенту для `PrimaryAttack`/`SecondaryAttack`/`Deploy`: скрипты
/// определяют их как методы (`function SWEP:PrimaryAttack()`), поэтому /// определяют их как методы (`function SWEP:PrimaryAttack()`), поэтому
/// первым аргументом идёт сама таблица. /// первым аргументом идёт сама таблица.
///
/// Паника внутри mlua/FFI не должна ронять клиент (правило #2 AGENTS.md):
/// она превращается в обычную ошибку Lua и логируется вызывающим.
pub fn call_weapon_method(&self, class: &str, method: &str) -> Result<(), LuaError> { 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::<Value>(method)? else {
return Ok(());
};
function.call::<()>(swep)?;
Ok(())
}
/// Вызывает метод SWEP и возвращает число (например, `Clip1` или
/// `GetNextPrimaryFire`). Не-число/ошибка — `None`.
pub fn call_weapon_number(&self, class: &str, method: &str) -> Option<f64> {
catch_unwind(AssertUnwindSafe(|| {
let swep = self.weapon_table(class).ok()?;
let Value::Function(function) = swep.get::<Value>(method).ok()? else {
return None;
};
let value = function.call::<Value>(swep).ok()?;
value_as_f64(&value)
}))
.ok()
.flatten()
}
/// Таблица SWEP из реестра.
fn weapon_table(&self, class: &str) -> Result<Table, LuaError> {
let globals = self.lua.globals(); let globals = self.lua.globals();
let weapons: Table = globals.get("weapons")?; let weapons: Table = globals.get("weapons")?;
let get: Function = weapons.get("Get")?; let get: Function = weapons.get("Get")?;
@ -91,28 +132,64 @@ impl LuaRuntime {
"оружие {class} не зарегистрировано" "оружие {class} не зарегистрировано"
)))); ))));
}; };
let Value::Function(function) = swep.get::<Value>(method)? else { Ok(swep)
return Ok(()); }
};
function.call::<()>(swep)?; /// Значение поля SWEP по пути через точку (`ViewModel`, `Primary.ClipSize`).
Ok(()) pub fn weapon_field(&self, class: &str, field: &str) -> Option<Value> {
catch_unwind(AssertUnwindSafe(|| self.weapon_field_inner(class, field)))
.ok()
.flatten()
}
fn weapon_field_inner(&self, class: &str, field: &str) -> Option<Value> {
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::<Value>(part).ok()?;
}
Some(current)
} }
/// Значение строкового поля SWEP (`ViewModel`, `PrintName`, ...). /// Значение строкового поля SWEP (`ViewModel`, `PrintName`, ...).
///
/// `Model("...")` в GMod-скриптах — таблица с полем `path`; такие поля
/// тоже читаются как строка. `field` может быть путём (`Primary.Ammo`).
pub fn weapon_string(&self, class: &str, field: &str) -> Option<String> { pub fn weapon_string(&self, class: &str, field: &str) -> Option<String> {
let globals = self.lua.globals(); match self.weapon_field(class, field)? {
let weapons: Table = globals.get("weapons").ok()?; Value::String(value) => Some(value.to_string_lossy().to_string()),
let get: Function = weapons.get("Get").ok()?; Value::Table(value) => value.get::<Option<String>>("path").ok().flatten(),
let Value::Table(swep) = get.call::<Value>(class.to_string()).ok()? else {
return None;
};
match swep.get::<Value>(field) {
Ok(Value::String(value)) => Some(value.to_string_lossy().to_string()),
Ok(Value::Table(value)) => value.get::<Option<String>>("path").ok().flatten(),
_ => None, _ => None,
} }
} }
/// Числовое поле SWEP (int/float/числовая строка).
pub fn weapon_number(&self, class: &str, field: &str) -> Option<f64> {
value_as_f64(&self.weapon_field(class, field)?)
}
/// Булево поле SWEP по правилам Lua: всё, кроме `nil`/`false`, истинно.
pub fn weapon_bool(&self, class: &str, field: &str) -> Option<bool> {
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`. /// Загружает скрипты GMod: `lua/weapons/*.lua` и `lua/entities/*.lua`.
/// ///
/// Каждый скрипт выполняется со свежей таблицей `SWEP`/`ENT` (у оружия /// Каждый скрипт выполняется со свежей таблицей `SWEP`/`ENT` (у оружия
@ -156,6 +233,12 @@ impl LuaRuntime {
if kind == "weapon" { if kind == "weapon" {
table.set("Primary", self.lua.create_table()?)?; table.set("Primary", self.lua.create_table()?)?;
table.set("Secondary", self.lua.create_table()?)?; table.set("Secondary", self.lua.create_table()?)?;
// Базовые методы (`GetOwner`, `SetNextPrimaryFire`,
// `NetworkVar`, ...) приходят через метатаблицу: скрипт
// видит их как методы SWEP, но не перезаписывает файл.
if let Ok(Value::Table(meta)) = globals.get::<Value>("__sr_swep_meta") {
table.set_metatable(Some(meta))?;
}
globals.set("SWEP", &table)?; globals.set("SWEP", &table)?;
} else { } else {
globals.set("ENT", &table)?; globals.set("ENT", &table)?;
@ -190,11 +273,26 @@ impl LuaRuntime {
entries entries
} }
}; };
entries.set(class, &table)?; entries.set(class.clone(), &table)?;
Ok(()) Ok(())
})(); })();
if registered.is_ok() { if registered.is_ok() {
weapons += 1; 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 { } else {
failed += 1; failed += 1;
} }
@ -633,13 +731,40 @@ fn install_api(lua: &Lua, timers: &TimerQueue, output: &Output) -> Result<(), ml
globals.set( globals.set(
"GetConVar", "GetConVar",
lua.create_function(|lua, name: String| { 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()?; let t = lua.create_table()?;
t.set("GetBool", lua.create_function(|_, ()| Ok(false))?)?; t.set(
t.set("GetFloat", lua.create_function(|_, ()| Ok(0.0))?)?; "GetBool",
t.set("GetInt", lua.create_function(|_, ()| Ok(0))?)?; lua.create_function(move |_, ()| Ok(float != 0.0))?,
t.set("GetString", lua.create_function(|_, ()| Ok(String::new()))?)?; )?;
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("SetValue", lua.create_function(|_, _: Value| Ok(()))?)?;
t.set(
"GetName",
lua.create_function(move |_, ()| Ok(name.clone()))?,
)?;
Ok(t) Ok(t)
})?, })?,
)?; )?;
@ -775,6 +900,17 @@ fn long_bracket(bytes: &[u8], at: usize) -> (usize, ()) {
(bytes.len() - at, ()) (bytes.len() - at, ())
} }
/// Число из значения Lua: целые LuaJIT приходят как `Integer`, дробные —
/// как `Number`; строки тоже принимаются (конфиги скриптов).
fn value_as_f64(value: &Value) -> Option<f64> {
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). /// Преобразует Lua-значение в строку (как `print` в GMod).
fn lua_value_to_string(value: &Value) -> String { fn lua_value_to_string(value: &Value) -> String {
match value { match value {

View file

@ -29,6 +29,7 @@ pub fn install(lua: &Lua, map: &str) -> Result<(), mlua::Error> {
install_weapons(lua)?; install_weapons(lua)?;
install_scripted_ents(lua)?; install_scripted_ents(lua)?;
install_stubs(lua)?; install_stubs(lua)?;
crate::swep::install(lua)?;
Ok(()) Ok(())
} }
@ -432,10 +433,10 @@ fn install_game(lua: &Lua, map: String) -> Result<(), mlua::Error> {
let globals = lua.globals(); let globals = lua.globals();
let game: Table = globals.get("game")?; let game: Table = globals.get("game")?;
// Одиночная игра. Скрипты Sandbox ветвятся по нему постоянно, и в // Одиночная игра. В GMod `game.SinglePlayer` — **функция**, и скрипты
// одиночном режиме оно обязано быть `true` — иначе отключится большая // оружия вызывают её как `game.SinglePlayer()`; булево значение падало
// часть меню. // на первом же таком вызове.
game.set("SinglePlayer", Value::Boolean(true))?; game.set("SinglePlayer", lua.create_function(|_, ()| Ok(true))?)?;
game.set("IsDedicated", Value::Boolean(false))?; game.set("IsDedicated", Value::Boolean(false))?;
game.set("folder", "Garry's Mod")?; game.set("folder", "Garry's Mod")?;
game.set("title", "Garry's Mod")?; game.set("title", "Garry's Mod")?;
@ -448,9 +449,11 @@ fn install_game(lua: &Lua, map: String) -> Result<(), mlua::Error> {
"GetTime", "GetTime",
lua.create_function(|_, ()| { lua.create_function(|_, ()| {
// Время от запуска клиента: таймеры GMod считают от него же. // Время от запуска клиента: таймеры GMod считают от него же.
Ok(CLIENT_START.elapsed().as_secs_f64()) Ok(cur_time())
})?, })?,
)?; )?;
lua.globals()
.set("CurTime", lua.create_function(|_, ()| Ok(cur_time()))?)?;
game.set( game.set(
"CleanUpMap", "CleanUpMap",
@ -464,10 +467,15 @@ fn install_game(lua: &Lua, map: String) -> Result<(), mlua::Error> {
Ok(()) Ok(())
} }
/// Время запуска клиента — общая точка отсчёта для таймеров. /// Время запуска клиента — общая точка отсчёта для таймеров и `CurTime`.
static CLIENT_START: std::sync::LazyLock<std::time::Instant> = static CLIENT_START: std::sync::LazyLock<std::time::Instant> =
std::sync::LazyLock::new(std::time::Instant::now); 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. /// Расширения `string` и `table` из GMod.
fn install_string_table(lua: &Lua) -> Result<(), mlua::Error> { fn install_string_table(lua: &Lua) -> Result<(), mlua::Error> {
let globals = lua.globals(); let globals = lua.globals();
@ -850,16 +858,18 @@ mod tests {
#[test] #[test]
fn single_player_is_true() { fn single_player_is_true() {
// Sandbox ветвится по game.SinglePlayer постоянно. // Sandbox ветвится по game.SinglePlayer постоянно; в GMod это
// функция, а не поле.
let lua = ready("gm_test"); let lua = ready("gm_test");
assert!( assert!(
eval(&lua, "return game.SinglePlayer") eval(&lua, "return game.SinglePlayer()")
.front() .front()
.unwrap() .unwrap()
.as_boolean() .as_boolean()
.unwrap() .unwrap()
); );
assert_eq!(string(&lua, "return game.GetMap()"), "gm_test"); assert_eq!(string(&lua, "return game.GetMap()"), "gm_test");
assert!(number(&lua, "return CurTime()") >= 0.0);
} }
#[test] #[test]

312
crates/lua/src/swep.rs Normal file
View file

@ -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::<Value>("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<i64>)| 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::<Option<Function>>("IsValid") {
Ok(Some(function)) => function.call::<bool>(table.clone()).unwrap_or(true),
_ => true,
},
_ => true,
}
}
fn userdata_valid(ud: &AnyUserData) -> bool {
match ud.get::<Option<Function>>("IsValid") {
Ok(Some(function)) => function.call::<bool>(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<Table>| 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<Table> {
let start = input
.and_then(|table| table.get::<Option<LuaVector>>("start").ok().flatten())
.unwrap_or_default();
let endpos = input
.and_then(|table| table.get::<Option<LuaVector>>("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::<Value>("__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");
}
}

View file

@ -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

View file

@ -60,3 +60,46 @@ fn real_gmod_weapons_and_entities_load() {
.unwrap_or_default(); .unwrap_or_default();
assert_eq!(entity, "true", "scripted_ents.Get('sent_ball')"); 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);
}