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:
parent
300a6c6d98
commit
a46e4d827d
7 changed files with 792 additions and 34 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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<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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<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 weapons: Table = globals.get("weapons")?;
|
||||
let get: Function = weapons.get("Get")?;
|
||||
|
|
@ -91,28 +132,64 @@ impl LuaRuntime {
|
|||
"оружие {class} не зарегистрировано"
|
||||
))));
|
||||
};
|
||||
let Value::Function(function) = swep.get::<Value>(method)? else {
|
||||
return Ok(());
|
||||
};
|
||||
function.call::<()>(swep)?;
|
||||
Ok(())
|
||||
Ok(swep)
|
||||
}
|
||||
|
||||
/// Значение поля SWEP по пути через точку (`ViewModel`, `Primary.ClipSize`).
|
||||
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`, ...).
|
||||
///
|
||||
/// `Model("...")` в GMod-скриптах — таблица с полем `path`; такие поля
|
||||
/// тоже читаются как строка. `field` может быть путём (`Primary.Ammo`).
|
||||
pub fn weapon_string(&self, class: &str, field: &str) -> Option<String> {
|
||||
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::<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(),
|
||||
match self.weapon_field(class, field)? {
|
||||
Value::String(value) => Some(value.to_string_lossy().to_string()),
|
||||
Value::Table(value) => value.get::<Option<String>>("path").ok().flatten(),
|
||||
_ => 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`.
|
||||
///
|
||||
/// Каждый скрипт выполняется со свежей таблицей `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::<Value>("__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<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).
|
||||
fn lua_value_to_string(value: &Value) -> String {
|
||||
match value {
|
||||
|
|
|
|||
|
|
@ -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::time::Instant> =
|
||||
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]
|
||||
|
|
|
|||
312
crates/lua/src/swep.rs
Normal file
312
crates/lua/src/swep.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
185
crates/lua/src/swep_base.lua
Normal file
185
crates/lua/src/swep_base.lua
Normal 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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue