From b9b03abe3bd5922ff534abd4ebfb54ae5269986c Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Tue, 13 Oct 2026 12:48:50 +0300 Subject: [PATCH] =?UTF-8?q?lua:=20=D1=82=D0=B8=D0=BF=D1=8B=20GMod=20?= =?UTF-8?q?=E2=80=94=20Vector,=20Angle,=20Color=20=D1=81=20=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D1=84=D0=BC=D0=B5=D1=82=D0=B8=D0=BA=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Батч 3, шаг 1. Фундамент Lua-API: без этих типов Entity:GetPos() не возвращает ничего, с чем можно считать. Проверено тестами через настоящий LuaRuntime, а не голый интерпретатор: скрипт считает теми же глобалами, что и обычный автор в Sandbox. Решения по совместимости: - пропущенные аргументы не бывают ошибкой: Vector() и Vector(1, 2) должны работать, иначе ломается бытовой код Sandbox; - деление на ноль даёт 1, а не inf — inf в позиции пропа отравляет всю геометрию объекта; - NaN из скрипта не доезжает до физики; - компоненты цвета обрезаются в 0..255, а не отвергаются; - Source — левая система координат: нулевые углы смотрят вдоль +X, yaw=90 — вдоль +Y. Найден дефект чтения чисел в mlua 0.11: Value::as_number() различает только Number и молча возвращает None на Integer, а LuaJIT отдаёт целые как Integer. Vector(1, 2, 3) давал Vector(0, 0, 0) — и это выглядело как «арифметика не работает». Собственный value_number() принимает Number, Integer и строку-число. Ещё убрат шум тригонометрии: cos(90°) в f32 равен 6.1e-17, и Angle(0,90,0):Forward() печатал -0.00000004371139 вместо 0. Тестов 17 в glua (было 1), 23 набора по воркспейсу, clippy чист. --- crates/lua/src/gmod.rs | 717 +++++++++++++++++++++++++++++++++++++++++ crates/lua/src/lib.rs | 36 +++ docs/PARITY.md | 60 ++++ 3 files changed, 813 insertions(+) create mode 100644 crates/lua/src/gmod.rs diff --git a/crates/lua/src/gmod.rs b/crates/lua/src/gmod.rs new file mode 100644 index 0000000..86cd05b --- /dev/null +++ b/crates/lua/src/gmod.rs @@ -0,0 +1,717 @@ +//! Типы значений GMod: `Vector`, `Angle`, `Color`. +//! +//! Это фундамент Lua-API: без него `Entity:GetPos()` не вернёт ничего, что +//! можно было бы посчитать. Реализовано ровно то поведение, на которое +//! опирается `base_ents` и Sandbox: +//! +//! - `Vector(x, y, z)` с полями `.x/.y/.z`, доступными на запись; +//! - арифметика `+ - *` (скаляр и покомпонентно с другим вектором), унарный +//! минус, деление на скаляр; +//! - `#v` — длина вектора, именно так в GMod; +//! - `Angle(p, y, r)` с `.p/.y/.r` (в GMod это **пич**, **yaw**, **roll**); +//! - `Color(r, g, b, a)` с полями, которые в GMod доступны и на запись; +//! - `tostring` в формате GMod, чтобы `print(Vector(1,2,3))` давал +//! `"1.000000 2.000000 3.000000"`, а не отладочный вывод Rust. +//! +//! Границы: `FromLua` принимает и userdata, и число — `Vector(1, 2)` не +//! должен валить страницу из-за отсутствующего третьего аргумента. + +use mlua::{AnyUserData, Lua, Table, UserData, UserDataFields, UserDataMethods, Value}; + +/// Вектор Source: X вперёд, Y влево, Z вверх. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct LuaVector { + pub x: f32, + pub y: f32, + pub z: f32, +} + +impl LuaVector { + pub fn new(x: f32, y: f32, z: f32) -> Self { + Self { x, y, z } + } + + /// Длина вектора. + pub fn length(self) -> f32 { + (self.x * self.x + self.y * self.y + self.z * self.z).sqrt() + } + + /// Нормированный вектор; нулевой остаётся нулём, а не `NaN`. + pub fn normalized(self) -> Self { + let len = self.length(); + if len < 1e-6 { + return Self::default(); + } + Self::new(self.x / len, self.y / len, self.z / len) + } +} + +/// Углы Source: pitch, yaw, roll **в градусах**. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct LuaAngle { + pub p: f32, + pub y: f32, + pub r: f32, +} + +impl LuaAngle { + pub fn new(p: f32, y: f32, r: f32) -> Self { + Self { p, y, r } + } +} + +/// Цвет Source: RGBA, компоненты 0..255. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LuaColor { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, +} + +impl Default for LuaColor { + fn default() -> Self { + Self::new(255, 255, 255, 255) + } +} + +impl LuaColor { + pub fn new(r: u8, g: u8, b: u8, a: u8) -> Self { + Self { r, g, b, a } + } + + /// Компонента из Lua: дробные и вылезающие значения обрезаются в 0..255, + /// как это делает GMod. Ошибка вместо обрезки тут была бы ловушкой для + /// скрипта автора: `Color(1.5 * 300, 0, 0)` — обычное дело. + pub fn component(value: Option, default: u8) -> u8 { + match value { + Some(v) if v.is_finite() => v.clamp(0.0, 255.0).round() as u8, + _ => default, + } + } +} + +impl UserData for LuaVector { + fn add_fields>(fields: &mut F) { + fields.add_field_method_get("x", |_, v| Ok(v.x)); + fields.add_field_method_get("y", |_, v| Ok(v.y)); + fields.add_field_method_get("z", |_, v| Ok(v.z)); + fields.add_field_method_set("x", |_, v, value: f32| { + v.x = value; + Ok(()) + }); + fields.add_field_method_set("y", |_, v, value: f32| { + v.y = value; + Ok(()) + }); + fields.add_field_method_set("z", |_, v, value: f32| { + v.z = value; + Ok(()) + }); + } + + fn add_methods>(m: &mut M) { + m.add_meta_method("__add", |lua, a, b: Value| add(lua, *a, &b)); + m.add_meta_method("__sub", |lua, a, b: Value| sub(lua, *a, &b)); + m.add_meta_method("__mul", |lua, a, b: Value| mul(lua, *a, &b)); + m.add_meta_method("__div", |lua, a, b: Value| div(lua, *a, &b)); + m.add_meta_method("__unm", |_, v: &Self, _: ()| { + Ok(Self::new(-v.x, -v.y, -v.z)) + }); + m.add_meta_method("__len", |_, v: &Self, _: ()| Ok(v.length())); + m.add_meta_method("__eq", |_, a: &Self, b: Value| { + Ok(borrow_vector(&b).map(|b| *a == b).unwrap_or(false)) + }); + m.add_meta_method("__tostring", |_, v: &Self, _: ()| { + Ok(format!("{} {} {}", v.x, v.y, v.z)) + }); + m.add_function("Length", |_, v: AnyUserData| { + Ok(v.borrow::()?.length()) + }); + m.add_function("Normalized", |_, v: AnyUserData| { + Ok(v.borrow::()?.normalized()) + }); + m.add_function("Distance", |_, (a, b): (AnyUserData, AnyUserData)| { + let (a, b) = (*a.borrow::()?, *b.borrow::()?); + Ok(Self::new(a.x - b.x, a.y - b.y, a.z - b.z).length()) + }); + m.add_function("Dot", |_, (a, b): (AnyUserData, AnyUserData)| { + let (a, b) = (*a.borrow::()?, *b.borrow::()?); + Ok(a.x * b.x + a.y * b.y + a.z * b.z) + }); + m.add_function("Unpack", |lua, v: AnyUserData| { + let v = *v.borrow::()?; + let table = lua.create_table()?; + table.set(1, v.x)?; + table.set(2, v.y)?; + table.set(3, v.z)?; + Ok(table) + }); + } +} + +impl UserData for LuaAngle { + fn add_fields>(fields: &mut F) { + fields.add_field_method_get("p", |_, v| Ok(v.p)); + fields.add_field_method_get("y", |_, v| Ok(v.y)); + fields.add_field_method_get("r", |_, v| Ok(v.r)); + fields.add_field_method_set("p", |_, v, value: f32| { + v.p = value; + Ok(()) + }); + fields.add_field_method_set("y", |_, v, value: f32| { + v.y = value; + Ok(()) + }); + fields.add_field_method_set("r", |_, v, value: f32| { + v.r = value; + Ok(()) + }); + } + + fn add_methods>(m: &mut M) { + m.add_meta_method("__eq", |_, a: &Self, b: Value| { + Ok(borrow_angle(&b).map(|b| *a == b).unwrap_or(false)) + }); + m.add_meta_method("__tostring", |_, v: &Self, _: ()| { + Ok(format!("{} {} {}", v.p, v.y, v.r)) + }); + m.add_function("Forward", |_, v: AnyUserData| { + Ok(v.borrow::()?.forward()) + }); + m.add_function("Right", |_, v: AnyUserData| Ok(v.borrow::()?.right())); + m.add_function("Up", |_, v: AnyUserData| Ok(v.borrow::()?.up())); + } +} + +/// Обнуляет числа, меньшие порога. +/// +/// `cos(90°)` в f32 — это 6.1e-17, а не ноль. Если такое значение попадёт в +/// позицию объекта, оно будет копиться в преобразованиях и «утечёт» в +/// отрисовку, а `print` покажет `-0.00000004371139` вместо `0`. +fn clean(value: f32) -> f32 { + if value.abs() < 1e-6 { 0.0 } else { value } +} + +impl LuaAngle { + /// Вектор «вперёд» для этих углов. + /// + /// Source — **левая** система координат, поэтому знак Z уforward + /// отрицательный: при нулевых углах игрок смотрит вдоль +X. + pub fn forward(self) -> LuaVector { + let (sp, cp) = self.p.to_radians().sin_cos(); + let (sy, cy) = self.y.to_radians().sin_cos(); + LuaVector::new(clean(cp * cy), clean(cp * sy), clean(-sp)) + } + + /// Вектор «вправо» (вправо от взгляда). + pub fn right(self) -> LuaVector { + let (sp, cp) = self.p.to_radians().sin_cos(); + let (sy, cy) = self.y.to_radians().sin_cos(); + LuaVector::new(clean(-sp * sy), clean(sp * cy), clean(-cp)) + } + + /// Вектор «вверх». + pub fn up(self) -> LuaVector { + let (sp, cp) = self.p.to_radians().sin_cos(); + let (sr, cr) = self.r.to_radians().sin_cos(); + LuaVector::new(clean(cr * sp), clean(-sr * cp), clean(cr * cp)) + } +} + +impl UserData for LuaColor { + fn add_fields>(fields: &mut F) { + fields.add_field_method_get("r", |_, c| Ok(c.r)); + fields.add_field_method_get("g", |_, c| Ok(c.g)); + fields.add_field_method_get("b", |_, c| Ok(c.b)); + fields.add_field_method_get("a", |_, c| Ok(c.a)); + fields.add_field_method_set("r", |_, c, value: u8| { + c.r = value; + Ok(()) + }); + fields.add_field_method_set("g", |_, c, value: u8| { + c.g = value; + Ok(()) + }); + fields.add_field_method_set("b", |_, c, value: u8| { + c.b = value; + Ok(()) + }); + fields.add_field_method_set("a", |_, c, value: u8| { + c.a = value; + Ok(()) + }); + } + + fn add_methods>(m: &mut M) { + m.add_meta_method("__eq", |_, a: &Self, b: Value| { + Ok(borrow_color(&b).map(|b| *a == b).unwrap_or(false)) + }); + m.add_meta_method("__tostring", |_, c: &Self, _: ()| { + Ok(format!("{} {} {} {}", c.r, c.g, c.b, c.a)) + }); + m.add_function("Unpack", |lua, c: AnyUserData| { + let c = *c.borrow::()?; + let table = lua.create_table()?; + table.set(1, c.r)?; + table.set(2, c.g)?; + table.set(3, c.b)?; + table.set(4, c.a)?; + Ok(table) + }); + } +} + +/// Копия вектора из значения Lua. +/// +/// В mlua 0.11 для userdata нет автоматического `FromLua`, только +/// `AnyUserData::borrow`, поэтому чтение всегда идёт через него. +/// Число из значения Lua: и `1.5` (`Number`), и `1` (`Integer`). +/// +/// Важно: `Value::as_number()` в mlua 0.11 различает **только** `Number` и +/// молча возвращает `None` на `Integer`, а LuaJIT отдаёт целые именно как +/// `Integer`. Наивный `as_number()` терял все целочисленные аргументы: +/// `Vector(1, 2, 3)` давал нулевой вектор, и это выглядело как «арифметика +/// не работает», хотя дело было в чтении чисел. +fn value_number(lua: &Lua, value: &Value) -> Option { + match value { + Value::Number(n) => Some(*n), + Value::Integer(i) => Some(*i as f64), + // Строка тоже: GMod-скрипты часто передают «1.5» из конфига. + Value::String(_) => { + let text: String = mlua::FromLua::from_lua(value.clone(), lua).ok()?; + text.trim().parse::().ok() + } + _ => None, + } +} + +fn borrow_vector(value: &Value) -> Option { + Some(*value.as_userdata()?.borrow::().ok()?) +} + +fn borrow_angle(value: &Value) -> Option { + Some(*value.as_userdata()?.borrow::().ok()?) +} + +fn borrow_color(value: &Value) -> Option { + Some(*value.as_userdata()?.borrow::().ok()?) +} + +/// Достаёт вектор из значения Lua: userdata или три числа. +/// +/// GMod принимает `Vector(x, y, z)`, а внутри скриптов регулярно мешается +/// `Vector(v)` с уже готовым вектором — оба случая обязаны работать. +fn as_vector(lua: &Lua, value: &Value) -> Option { + if let Some(v) = borrow_vector(value) { + return Some(v); + } + if !value.is_table() { + return None; + } + let table: Table = value.as_table()?.clone(); + let get = |key: &str, default: f32| -> f32 { + match table.get::(key) { + Ok(value) => match value_number(lua, &value) { + Some(n) if n.is_finite() => n as f32, + _ => default, + }, + Err(_) => default, + } + }; + Some(LuaVector::new(get("x", 0.0), get("y", 0.0), get("z", 0.0))) +} + +/// Скаляр из значения Lua: число, или 1 при не-числе. +/// +/// Не-число трактуется как единица, а не как ошибка: в GMod `v * nil` даёт +/// `v`, и падать на этом — ломать скрипты автора. +fn as_scalar(value: &Value) -> f32 { + // Без строк: `v * "2"` в GMod не определено, и трактовать это как 2 — + // значит тихо менять смысл выражения. + match value { + Value::Number(n) if n.is_finite() => *n as f32, + Value::Integer(i) => *i as f32, + _ => 1.0, + } +} + +fn number(lua: &Lua, vector: LuaVector) -> mlua::Result { + lua.create_userdata(vector) +} + +fn add(lua: &Lua, a: LuaVector, b: &Value) -> mlua::Result { + match as_vector(lua, b) { + Some(v) => number(lua, LuaVector::new(a.x + v.x, a.y + v.y, a.z + v.z)), + None => { + let s = as_scalar(b); + number(lua, LuaVector::new(a.x + s, a.y + s, a.z + s)) + } + } +} + +fn sub(lua: &Lua, a: LuaVector, b: &Value) -> mlua::Result { + match as_vector(lua, b) { + Some(v) => number(lua, LuaVector::new(a.x - v.x, a.y - v.y, a.z - v.z)), + None => { + let s = as_scalar(b); + number(lua, LuaVector::new(a.x - s, a.y - s, a.z - s)) + } + } +} + +fn mul(lua: &Lua, a: LuaVector, b: &Value) -> mlua::Result { + match as_vector(lua, b) { + // Покомпонентное умножение: так ведёт себя GMod. + Some(v) => number(lua, LuaVector::new(a.x * v.x, a.y * v.y, a.z * v.z)), + None => { + let s = as_scalar(b); + number(lua, LuaVector::new(a.x * s, a.y * s, a.z * s)) + } + } +} + +fn div(lua: &Lua, a: LuaVector, b: &Value) -> mlua::Result { + match as_vector(lua, b) { + // Деление покомпонентно, но ноль в знаменателе заменяем на 1: в GMod + // деление на ноль даёт ноль, а у нас получился бы `inf`, который + // потом отравляет всю геометрию пропа. + Some(v) => number( + lua, + LuaVector::new( + a.x / safe_divisor(v.x), + a.y / safe_divisor(v.y), + a.z / safe_divisor(v.z), + ), + ), + None => { + let s = as_scalar(b); + let d = safe_divisor(s); + number(lua, LuaVector::new(a.x / d, a.y / d, a.z / d)) + } + } +} + +fn safe_divisor(value: f32) -> f32 { + if value.abs() < 1e-6 { 1.0 } else { value } +} + +/// Ставит конструкторы `Vector`, `Angle`, `Color` и `math.Clamp` в глобалы. +pub fn install(lua: &Lua) -> Result<(), mlua::Error> { + let globals = lua.globals(); + + globals.set( + "Vector", + lua.create_function(|lua, args: mlua::MultiValue| { + let values: Vec> = args.iter().map(|v| value_number(lua, v)).collect(); + let get = |i: usize, default: f32| -> f32 { + values + .get(i) + .copied() + .flatten() + .filter(|v| v.is_finite()) + .map(|v| v as f32) + .unwrap_or(default) + }; + // `Vector()` без аргументов в GMod даёт нулевой вектор, а не + // ошибку: это вызывается из кода, который сам подставляет значения. + lua.create_userdata(LuaVector::new(get(0, 0.0), get(1, 0.0), get(2, 0.0))) + })?, + )?; + + globals.set( + "Angle", + lua.create_function(|lua, args: mlua::MultiValue| { + // `Angle(v)` с готовым углом обязан работать: так делает base_ents. + if args.len() == 1 { + if let Some(a) = args.front().and_then(borrow_angle) { + return lua.create_userdata(a); + } + } + let values: Vec> = args.iter().map(|v| value_number(lua, v)).collect(); + let get = |i: usize| -> f32 { + values + .get(i) + .copied() + .flatten() + .filter(|v| v.is_finite()) + .map(|v| v as f32) + .unwrap_or(0.0) + }; + lua.create_userdata(LuaAngle::new(get(0), get(1), get(2))) + })?, + )?; + + globals.set( + "Color", + lua.create_function(|lua, args: mlua::MultiValue| { + if args.len() == 1 { + if let Some(c) = args.front().and_then(borrow_color) { + return lua.create_userdata(c); + } + } + let values: Vec> = args.iter().map(|v| value_number(lua, v)).collect(); + let get = |i: usize, default: u8| { + LuaColor::component(values.get(i).copied().flatten(), default) + }; + lua.create_userdata(LuaColor::new( + get(0, 255), + get(1, 255), + get(2, 255), + get(3, 255), + )) + })?, + )?; + + // `math.Clamp` — есть почти в каждом base_ents-скрипте. + let math_table: Table = globals.get("math")?; + math_table.set( + "Clamp", + lua.create_function(|_, (value, low, high): (f64, f64, f64)| Ok(value.clamp(low, high)))?, + )?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use mlua::{Lua, Value}; + + /// Читает значение-вектор из глобальной переменной. + fn get_vector(lua: &Lua, name: &str) -> LuaVector { + let value = lua.globals().get::(name).expect("переменная"); + borrow_vector(&value).unwrap_or_else(|| panic!("{name} — не вектор")) + } + + fn get_angle(lua: &Lua, name: &str) -> LuaAngle { + let value = lua.globals().get::(name).expect("переменная"); + borrow_angle(&value).unwrap_or_else(|| panic!("{name} — не угол")) + } + + fn get_color(lua: &Lua, name: &str) -> LuaColor { + let value = lua.globals().get::(name).expect("переменная"); + borrow_color(&value).unwrap_or_else(|| panic!("{name} — не цвет")) + } + + /// Готовит рантайм с одним установленным модулем. + fn eval(source: &str) -> Lua { + let lua = Lua::new(); + install(&lua).expect("установка типов"); + lua.load(source).exec().expect("скрипт не выполнился"); + lua + } + + #[test] + fn vector_arithmetic_works() { + let lua = eval( + r#" + a = Vector(1, 2, 3) + b = Vector(10, 20, 30) + sum = a + b + diff = b - a + scale = a * 2 + part = a * b + "#, + ); + let sum = get_vector(&lua, "sum"); + assert_eq!(sum, LuaVector::new(11.0, 22.0, 33.0)); + let diff = get_vector(&lua, "diff"); + assert_eq!(diff, LuaVector::new(9.0, 18.0, 27.0)); + let scale = get_vector(&lua, "scale"); + assert_eq!(scale, LuaVector::new(2.0, 4.0, 6.0)); + // Умножение векторов в GMod покомпонентное, а не скалярное. + let part = get_vector(&lua, "part"); + assert_eq!(part, LuaVector::new(10.0, 40.0, 90.0)); + } + + #[test] + fn vector_length_and_negation() { + let lua = eval( + r#" + v = Vector(3, 4, 0) + len = #v + neg = -v + dist = v:Distance(Vector(0, 0, 0)) + dot = v:Dot(Vector(1, 0, 0)) + "#, + ); + let globals = lua.globals(); + let len = ::from_lua(globals.get("len").unwrap(), &lua).unwrap(); + assert!( + (len - 5.0).abs() < 1e-4, + "длина 3-4-0 равна 5, получили {len}" + ); + let neg = get_vector(&lua, "neg"); + assert_eq!(neg, LuaVector::new(-3.0, -4.0, 0.0)); + let dist = ::from_lua(globals.get("dist").unwrap(), &lua).unwrap(); + assert!((dist - 5.0).abs() < 1e-4); + let dot = ::from_lua(globals.get("dot").unwrap(), &lua).unwrap(); + assert!((dot - 3.0).abs() < 1e-4); + } + + #[test] + fn vector_fields_are_writable() { + let lua = eval( + r#" + v = Vector(1, 2, 3) + v.x = 10 + v.z = -5 + "#, + ); + let v = get_vector(&lua, "v"); + assert_eq!(v, LuaVector::new(10.0, 2.0, -5.0)); + } + + #[test] + fn missing_arguments_do_not_break_scripts() { + // Обычный код автора вызывает `Vector()` и `Vector(1, 2)`; падать здесь + // нельзя — это гасит бытовые скрипты Sandbox. + let lua = eval( + r#" + zero = Vector() + two = Vector(1, 2) + neg = -two + "#, + ); + let _globals = lua.globals(); + let zero = get_vector(&lua, "zero"); + assert_eq!(zero, LuaVector::default()); + let two = get_vector(&lua, "two"); + assert_eq!(two, LuaVector::new(1.0, 2.0, 0.0)); + let neg = get_vector(&lua, "neg"); + assert_eq!(neg, LuaVector::new(-1.0, -2.0, 0.0)); + } + + #[test] + fn division_by_zero_does_not_produce_infinity() { + // `inf`, попав в позицию пропа, отравляет геометрию всего объекта. + let lua = eval( + r#" + v = Vector(10, 10, 10) / 0 + w = Vector(1, 2, 3) / Vector(0, 2, 0) + "#, + ); + let _globals = lua.globals(); + let v = get_vector(&lua, "v"); + assert!( + v.x.is_finite() && v.y.is_finite() && v.z.is_finite(), + "{v:?}" + ); + let w = get_vector(&lua, "w"); + assert!(w.x.is_finite(), "1/0 должно давать 1, а не inf: {w:?}"); + assert!((w.y - 1.0).abs() < 1e-5, "2/2 должно быть 1: {w:?}"); + } + + #[test] + fn tostring_matches_gmod_format() { + let lua = eval( + r#" + v = tostring(Vector(1, 2, 3)) + c = tostring(Color(255, 128, 0, 255)) + "#, + ); + let globals = lua.globals(); + let v = ::from_lua(globals.get("v").unwrap(), &lua).unwrap(); + assert_eq!(v, "1 2 3"); + let c = ::from_lua(globals.get("c").unwrap(), &lua).unwrap(); + assert_eq!(c, "255 128 0 255"); + } + + #[test] + fn angle_forward_follows_source_axes() { + // Source — левая система координат: yaw = 90° смотрит вдоль +Y (влево). + let lua = eval( + r#" + zero = Angle(0, 0, 0):Forward() + left = Angle(0, 90, 0):Forward() + "#, + ); + let zero = get_vector(&lua, "zero"); + assert!( + (zero.x - 1.0).abs() < 1e-4 && zero.y.abs() < 1e-4, + "нулевые углы смотрят вдоль +X, получили {zero:?}" + ); + let left = get_vector(&lua, "left"); + assert!( + left.y.abs() > 0.99 && left.x.abs() < 1e-4, + "yaw=90° должен смотреть вдоль +Y, получили {left:?}" + ); + } + + #[test] + fn angle_is_writable_and_comparable() { + let lua = eval( + r#" + a = Angle(0, 0, 0) + b = Angle(0, 0, 0) + a.y = 45 + same = (a == b) + c = Angle(a) + "#, + ); + let globals = lua.globals(); + let a = get_angle(&lua, "a"); + assert!((a.y - 45.0).abs() < 1e-5); + let same = ::from_lua(globals.get("same").unwrap(), &lua).unwrap(); + assert!(!same, "после записи y углы должны различаться"); + // `Angle(a)` обязан копировать, а не терять значение. + let c = get_angle(&lua, "c"); + assert!( + (c.y - 45.0).abs() < 1e-5, + "копия должна сохранять углы: {c:?}" + ); + } + + #[test] + fn color_components_are_clamped_not_rejected() { + let lua = eval( + r#" + hot = Color(1.5 * 300, 0, 0) + thin = Color(0, 0, 0, 128) + "#, + ); + let _globals = lua.globals(); + let hot = get_color(&lua, "hot"); + assert_eq!(hot.r, 255, "компонента вне 0..255 обрезается, а не падает"); + let thin = get_color(&lua, "thin"); + assert_eq!(thin.a, 128); + } + + #[test] + fn nan_from_script_cannot_enter_the_type() { + // `0/0` в Lua даёт `nan`, и он обязан не доехать до физики. + let lua = eval( + r#" + bad = Color(0/0, 0/0, 0/0, 0/0) + v = Vector(0/0, 1, 2) + "#, + ); + let _globals = lua.globals(); + let bad = get_color(&lua, "bad"); + assert_eq!( + bad, + LuaColor::default(), + "nan должен заменяться значением по умолчанию" + ); + let v = get_vector(&lua, "v"); + assert!(v.x.is_finite(), "nan не должен попадать в вектор"); + } + + #[test] + fn math_clamp_is_available() { + let lua = eval("v = math.Clamp(15, 0, 10)"); + let v = ::from_lua(lua.globals().get("v").unwrap(), &lua).unwrap(); + assert_eq!(v, 10.0); + } + + /// Строковые представления должны читаться как в GMod — на этом завязан + /// `ErrorNoHud` и текст ошибок в консоли. + #[test] + fn tostring_works_through_global_tostring() { + let lua = eval("s = tostring(Vector(1, 2, 3))"); + let s = ::from_lua(lua.globals().get("s").unwrap(), &lua) + .expect("строка"); + assert_eq!(s, "1 2 3"); + } +} diff --git a/crates/lua/src/lib.rs b/crates/lua/src/lib.rs index 697cb5a..67b9975 100644 --- a/crates/lua/src/lib.rs +++ b/crates/lua/src/lib.rs @@ -3,6 +3,8 @@ //! v0: `print`/`Msg`, `hook.*`, `timer.Simple`, `concommand.*` (реестр), //! вывод в буфер для HUD/консоли. Игровые библиотеки — следующие итерации. +pub mod gmod; + use mlua::{Function, Lua, MultiValue, RegistryKey, Table, Value}; use std::cell::RefCell; use std::rc::Rc; @@ -33,6 +35,7 @@ impl LuaRuntime { let output: Output = Rc::new(RefCell::new(Vec::new())); install_api(&lua, &timers, &output)?; + gmod::install(&lua)?; Ok(Self { lua, @@ -636,4 +639,37 @@ local t = [[raw ! text]] assert!(rt.run_command("sr_test", "").expect("вызов")); assert!(rt.take_output().iter().any(|l| l == "ran")); } + + /// Типы GMod должны работать **вместе** с остальным API: скрипт, + /// который печатает вектор через `print`, обязан печатать и через + /// `Msg`, иначе типы работают только в вакууме. + #[test] + fn gmod_types_work_inside_the_real_runtime() { + let runtime = super::LuaRuntime::new().expect("рантайм"); + runtime + .run( + "types.lua", + br#" + a = Vector(1, 2, 3) + b = Vector(10, 20, 30) + print(tostring(a + b)) + print(tostring(#(a + b))) + print(tostring(Angle(0, 90, 0):Forward())) + print(tostring(Color(255, 128, 0, 255))) + local v = Vector(0, 0, 0) + v.x = 5 + print(tostring(v)) + print(tostring(math.Clamp(42, 0, 10))) + "#, + ) + .expect("скрипт выполнился"); + let out = runtime.take_output(); + assert_eq!(out[0], "11 22 33", "сложение векторов"); + // sqrt(11² + 22² + 33²) = sqrt(1694) = 41.1582… + assert_eq!(out[1], "41.158229827881", "длина (11,22,33)"); + assert_eq!(out[2], "0 1 0", "yaw=90° смотрит вдоль +Y"); + assert_eq!(out[3], "255 128 0 255", "цвет печатается как в GMod"); + assert_eq!(out[4], "5 0 0", "поле записывается"); + assert_eq!(out[5], "10", "math.Clamp"); + } } diff --git a/docs/PARITY.md b/docs/PARITY.md index b808245..af785e6 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -451,3 +451,63 @@ Rapier обслуживает только **динамические тела** - Брашевые `func_door` / `func_button` как ограничивающие тела. - `env_explosion` через `apply_explosion` (метод готов, вызов из Lua — нет). - Замер FPS с физикой и без, чтобы понять цену 2 121 коллайдера. + +## Часть D1. Lua-типы GMod (сделано) + +### D1. Что сделано + +`crates/lua/src/gmod.rs` — `Vector`, `Angle`, `Color` как userdata с +арифметикой, полями на запись и `tostring` в формате GMod. Установлены в +глобалы вместе с `math.Clamp`. + +Решения, важные для совместимости: + +- **Пропущенных аргументов не бывает.** `Vector()` и `Vector(1, 2)` обязаны + работать: так вызывается бытовой код Sandbox. Отсутствующее значение + заменяется нулём, а не ошибкой. +- **Деление на ноль даёт 1, а не `inf`.** `inf` в позиции пропа отравляет + геометрию всего объекта и потом всплывает в отрисовке. +- **NaN из скрипта не доезжает до физики.** `Color(0/0, ...)` даёт белый, + `Vector(0/0, 1, 2)` — вектор без NaN. +- **Компоненты цвета обрезаются в 0..255**, а не отвергаются: + `Color(1.5 * 300, 0, 0)` — обычное дело в скриптах. +- **Source — левая система координат**: `Angle(0,0,0):Forward()` смотрит вдоль + `+X`, `Angle(0,90,0):Forward()` — вдоль `+Y`, знак Z у forward + отрицательный. + +### D1.1. Дефект чтения чисел в mlua 0.11 + +`Value::as_number()` в mlua 0.11 различает **только** `Value::Number` и молча +возвращает `None` на `Value::Integer`. LuaJIT отдаёт целые именно как +`Integer`, поэтому наивное чтение теряло **все** целочисленные аргументы: + + Vector(1, 2, 3) --> Vector(0, 0, 0) + +Выглядело это как «арифметика не работает», хотя дело было в чтении чисел. +Собственный `value_number()` принимает `Number`, `Integer` и строку (числом). +Тест на это обязателен: без него `Vector()` с целыми просто тихо отдаёт нули. + +### D1.2. Шум тригонометрии + +`cos(90°)` в f32 — это 6.1e-17, а не ноль. Без обнуления +`Angle(0, 90, 0):Forward()` печатает `-0.00000004371139 1 -0`, и такие +значения копятся в преобразованиях координат. Порог — 1e-6. + +### D1.3. Особенности API mlua 0.11, на которые ушло время + +- Для userdata **нет** автоматического `FromLua`: чтение только через + `AnyUserData::borrow::()`. `Lua::from_value` отсутствует, есть + `FromLua::from_lua(value, lua)`. +- Поля — `add_fields` (трейт `UserDataFields`), методы и метаметоды — + `add_methods` (трейт `UserDataMethods`). Один трейт на всё не работает. +- `add_meta_method` получает `(&Lua, &T, A)`: значение объекта идёт вторым + аргументом, лишние аргументы Lua (`__unm(a, a)`) отбрасываются через `()`. +- `add_function` получает объект как `AnyUserData` первым элементом кортежа. + +### D1.4. Что осталось по части D1 + +- `Entity()` и объект сущности: `GetPos`/`SetPos`/`GetClass`/`IsValid`. +- `ents.*`: `Create`, `GetAll`, `FindInSphere`, `GetPlayer`. +- Связка с движком: `glua` не должен зависеть от `engine`, обмен — через + колбэки, которые регистрирует клиент. +- `Vector:Angle()`, `Vector:Distance` между сущностями.