Skip to main content

rpfm_extensions/lua/harness/
mod.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Test harness running Lua scripts outside of the game.
12//!
13//! Scripts run in Lua 5.1, the version the games use, together with the real script libraries of the game
14//! (`script/_lib`) and, optionally, the vanilla scripts of a campaign. What the game's C++ side provides is
15//! emulated by `engine.lua`: objects typed after the scripting docs that record every call made on them.
16//!
17//! A test file registers tests with `rpfm.test(name, function)`. Its top-level code runs before the game
18//! boots, so the world it sets up (`rpfm.faction { key = "...", is_human = true }`) exists when the scripts
19//! start. Each test then runs in its own Lua state, after the libraries, the mods of the tested packs and the
20//! first tick have run, so tests can't affect each other.
21//!
22//! Scripts are sandboxed: they can't run programs, load native code or touch files outside of memory.
23
24use getset::{Getters, Setters};
25use mlua::{HookTriggers, Lua, LuaOptions, StdLib, Table, VmState};
26use serde_derive::{Deserialize, Serialize};
27
28use std::cell::Cell;
29use std::collections::HashMap;
30use std::rc::Rc;
31use std::sync::Arc;
32
33use rpfm_lib::error::{RLibError, Result};
34use rpfm_lib::files::{Container, FileType, pack::Pack, RFileDecoded};
35
36use crate::dependencies::Dependencies;
37
38use super::{vanilla_scripts, LuaApi, LuaType};
39
40#[cfg(test)] mod tests;
41
42/// Emulation of the game's engine, and the test API.
43const ENGINE_SCRIPT: &str = include_str!("engine.lua");
44
45/// Script the game runs first when loading a campaign, which loads everything else.
46const CAMPAIGN_BOOT_SCRIPT: &str = "script/campaign_scripted.lua";
47
48/// Script Attila and older games run first when loading a campaign.
49const LEGACY_CAMPAIGN_BOOT_SCRIPT: &str = "lua_scripts/campaign_scripted.lua";
50
51/// Event the game triggers when saving a campaign.
52const SAVING_GAME_EVENT: &str = "SavingGame";
53
54/// Name of the chunk holding the test file, as shown in errors.
55const TEST_FILE_CHUNK: &str = "@test";
56
57/// Instructions a Lua state may run, boot included, before it's considered stuck.
58const INSTRUCTION_LIMIT: u64 = 2_000_000_000;
59
60/// How often, in instructions, the instruction limit is checked.
61const INSTRUCTION_CHECK_INTERVAL: u32 = 100_000;
62
63//---------------------------------------------------------------------------//
64//                              Enum & Structs
65//---------------------------------------------------------------------------//
66
67/// Scripts the harness can load, by path.
68///
69/// Shared behind [`Arc`]s, as every test's Lua state gets its own copy.
70#[derive(Clone, Debug, Default)]
71pub struct LuaScripts {
72
73    /// Path and code of each script, by lowercase path, as file lookups in the game are case-insensitive.
74    scripts: Arc<HashMap<String, (String, String)>>,
75
76    /// Lowercase paths of the scripts coming from the tested packs.
77    pack_paths: Arc<Vec<String>>,
78}
79
80/// Options of a test run.
81#[derive(Clone, Debug, Default, PartialEq, Getters, Setters, Serialize, Deserialize)]
82#[getset(get = "pub", set = "pub")]
83pub struct LuaTestOptions {
84
85    /// Campaign whose vanilla scripts to load, like `main_warhammer`. If `None`, only the script libraries and
86    /// the mods are loaded, which is faster and less affected by engine behavior the harness doesn't emulate.
87    campaign: Option<String>,
88
89    /// Keys of the factions in the game's DB. Scripts looking them up get a faction even if tests didn't set it up.
90    faction_keys: Vec<String>,
91
92    /// Keys of the regions in the game's DB. Scripts looking them up get a region even if tests didn't set it up.
93    region_keys: Vec<String>,
94}
95
96/// Results of running a test file.
97#[derive(Clone, Debug, Default, PartialEq, Getters, Serialize, Deserialize)]
98#[getset(get = "pub")]
99pub struct LuaTestReport {
100
101    /// Result of each test, in the order they were registered.
102    tests: Vec<LuaTestResult>,
103
104    /// Errors raised while booting by scripts that don't come from the tested packs, like vanilla scripts
105    /// reacting to engine behavior the harness doesn't emulate. They don't fail tests.
106    boot_errors: Vec<String>,
107}
108
109/// Values the scripts of a campaign save when the game saves it.
110#[derive(Clone, Debug, Default, PartialEq, Getters, Serialize, Deserialize)]
111#[getset(get = "pub")]
112pub struct LuaSave {
113
114    /// Name and value of each saved value, in the order the scripts saved them.
115    values: Vec<(String, LuaSavedValue)>,
116
117    /// Errors raised by the scripts while booting and saving.
118    errors: Vec<String>,
119}
120
121/// A value saved by the scripts. The game saves numbers as integers, and doesn't save other types.
122#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
123pub enum LuaSavedValue {
124    Bool(bool),
125    Integer(i32),
126    String(String),
127}
128
129/// Result of a test.
130#[derive(Clone, Debug, Default, PartialEq, Getters, Serialize, Deserialize)]
131#[getset(get = "pub")]
132pub struct LuaTestResult {
133
134    /// Name of the test.
135    name: String,
136
137    /// If the test passed.
138    passed: bool,
139
140    /// Why the test failed: its own errors, errors in listeners while it ran, and errors of the tested
141    /// packs' scripts while booting.
142    errors: Vec<String>,
143
144    /// Methods called during the test that the docs don't describe, as `type:method`. Their return values
145    /// are placeholders, so results depending on them may not match the game.
146    unmocked_calls: Vec<String>,
147
148    /// Output of the scripts during the whole run, boot included. Only the latest lines are kept for long runs.
149    log: Vec<String>,
150}
151
152//---------------------------------------------------------------------------//
153//                             Implementations
154//---------------------------------------------------------------------------//
155
156impl LuaScripts {
157
158    /// This function collects the vanilla scripts from the dependencies cache, and the scripts of the tested packs.
159    ///
160    /// # Arguments
161    ///
162    /// * `dependencies` - Dependencies cache with the vanilla files loaded.
163    /// * `packs` - Packs to test. Their scripts replace vanilla scripts with the same path.
164    ///
165    /// # Returns
166    ///
167    /// The scripts.
168    pub fn from_game_and_packs<'a>(dependencies: &Dependencies, packs: impl IntoIterator<Item = &'a Pack>) -> Self {
169        let mut scripts = Self::default();
170        for (path, source) in vanilla_scripts(dependencies) {
171            scripts.add(&path, source, false);
172        }
173
174        for file in packs.into_iter().flat_map(|pack| pack.files_by_type(&[FileType::Text])) {
175            let path = file.path_in_container_raw();
176            if !path.ends_with(".lua") {
177                continue;
178            }
179
180            // Scripts of open packs may be edited in memory, so their decoded text takes priority over their data.
181            let source = match file.decoded() {
182                Ok(RFileDecoded::Text(text)) => Some(text.contents().to_owned()),
183                _ => {
184                    let mut file = file.clone();
185                    file.load().ok().and_then(|_| file.cached().ok().map(|data| String::from_utf8_lossy(data).to_string()))
186                }
187            };
188
189            if let Some(source) = source {
190                scripts.add(path, source, true);
191            }
192        }
193
194        scripts
195    }
196
197    /// This function adds a script. Scripts added later replace earlier ones with the same path.
198    ///
199    /// # Arguments
200    ///
201    /// * `path` - Path of the script, like `script/campaign/mod/my_mod.lua`.
202    /// * `source` - Code of the script.
203    /// * `from_tested_pack` - If the script belongs to one of the packs being tested.
204    pub fn add(&mut self, path: &str, source: String, from_tested_pack: bool) {
205        let path = path.replace('\\', "/");
206        let key = path.to_lowercase();
207        let source = source.strip_prefix('\u{feff}').map(str::to_owned).unwrap_or(source);
208        if from_tested_pack {
209            Arc::make_mut(&mut self.pack_paths).push(key.to_owned());
210        }
211
212        Arc::make_mut(&mut self.scripts).insert(key, (path, source));
213    }
214
215    /// This function returns the path and code of a script.
216    fn get(&self, path: &str) -> Option<&(String, String)> {
217        self.scripts.get(&path.to_lowercase())
218    }
219
220    /// This function checks if there's a script with a path.
221    pub fn contains(&self, path: &str) -> bool {
222        self.get(path).is_some()
223    }
224
225    /// This function checks if the script the game runs first when loading a campaign is there.
226    fn has_campaign_boot_script(&self) -> bool {
227        self.contains(CAMPAIGN_BOOT_SCRIPT) || self.contains(LEGACY_CAMPAIGN_BOOT_SCRIPT)
228    }
229
230    /// This function returns the paths of the scripts directly inside a folder, like the game's `filesystem_lookup`.
231    ///
232    /// # Arguments
233    ///
234    /// * `folder` - Folder to look in, like `script/campaign/mod/`.
235    /// * `filter` - Filter for the file names. Only `*` and `*.extension` are supported.
236    ///
237    /// # Returns
238    ///
239    /// The paths, sorted and separated by commas.
240    fn list(&self, folder: &str, filter: &str) -> String {
241        let folder = folder.trim_matches('/').to_lowercase() + "/";
242        let extension = filter.strip_prefix("*.").filter(|extension| *extension != "*").map(|extension| format!(".{}", extension.to_lowercase()));
243
244        let mut paths = self.scripts.iter()
245            .filter(|(key, _)| key.strip_prefix(&folder).is_some_and(|name| !name.contains('/')))
246            .filter(|(key, _)| extension.as_ref().is_none_or(|extension| key.ends_with(extension)))
247            .map(|(_, (path, _))| path.as_str())
248            .collect::<Vec<_>>();
249
250        paths.sort_unstable();
251        paths.join(",")
252    }
253
254    /// This function checks if an error comes from a script of the tested packs.
255    ///
256    /// Lua errors start with the path of the script that raised them.
257    fn is_pack_error(&self, error: &str) -> bool {
258        let error = error.to_lowercase();
259        self.pack_paths.iter().any(|path| error.contains(path.as_str()))
260    }
261}
262
263/// This function runs the tests of a test file.
264///
265/// # Arguments
266///
267/// * `api` - API of the game, whose docs type the engine objects.
268/// * `scripts` - Scripts of the game and of the tested packs.
269/// * `test_source` - Code of the test file.
270/// * `options` - Options of the run.
271///
272/// # Returns
273///
274/// The result of each test.
275///
276/// # Errors
277///
278/// Returns an error if the game's scripts are missing, if the test file can't be loaded, or if the Lua runtime fails.
279pub fn run_tests(api: &LuaApi, scripts: &LuaScripts, test_source: &str, options: &LuaTestOptions) -> Result<LuaTestReport> {
280    if !scripts.has_campaign_boot_script() {
281        return Err(RLibError::LuaTestGameScriptsNotFound);
282    }
283
284    let mut report = LuaTestReport::default();
285    let mut index = 1;
286
287    // Every test gets a fresh state, so the first one also tells how many tests there are.
288    loop {
289        let lua = new_state(api, scripts, options)?;
290        load_test_file(&lua, test_source)?;
291
292        let rpfm: Table = lua.globals().get("rpfm").map_err(lua_error)?;
293        let tests: Table = rpfm.get("tests").map_err(lua_error)?;
294        if index > tests.raw_len() {
295            break;
296        }
297
298        let boot: mlua::Function = rpfm.get("__boot").map_err(lua_error)?;
299        let boot_error = boot.call::<Option<String>>(options.campaign.as_deref()).map_err(lua_error)?;
300
301        let (pack_errors, other_errors): (Vec<_>, Vec<_>) = string_list(&rpfm, "errors")?.into_iter().partition(|error| scripts.is_pack_error(error));
302        if index == 1 {
303            report.boot_errors = other_errors;
304        }
305
306        let test: Table = tests.get(index).map_err(lua_error)?;
307        let name: String = test.get("name").map_err(lua_error)?;
308
309        let run_test: mlua::Function = rpfm.get("__run_test").map_err(lua_error)?;
310        let (passed, test_error): (bool, Option<String>) = match run_test.call::<(bool, Option<String>)>(index) {
311            Ok(result) => result,
312            Err(error) => (false, Some(error.to_string())),
313        };
314
315        // Without the libraries nothing else works, so that's what every test must report.
316        let mut errors = boot_error.map(|error| vec![format!("The game's script libraries failed to load: {error}")]).unwrap_or_default();
317        errors.extend(pack_errors);
318        errors.extend(test_error);
319        errors.extend(string_list(&rpfm, "errors")?);
320
321        let mut unmocked_calls = rpfm.get::<Table>("unmocked").map_err(lua_error)?
322            .pairs::<String, bool>()
323            .filter_map(|pair| pair.ok().map(|(call, _)| call))
324            .collect::<Vec<_>>();
325        unmocked_calls.sort_unstable();
326
327        report.tests.push(LuaTestResult {
328            name,
329            passed: passed && errors.is_empty(),
330            errors,
331            unmocked_calls,
332            log: string_list(&rpfm, "log")?,
333        });
334
335        index += 1;
336    }
337
338    Ok(report)
339}
340
341/// This function boots a campaign and saves it, like the game does when it builds the campaign's startpos.
342///
343/// # Arguments
344///
345/// * `api` - API of the game, whose docs type the engine objects.
346/// * `scripts` - Scripts of the game and of the packs.
347/// * `options` - Campaign to boot, and the keys of the game's factions and regions.
348/// * `boot_events` - Events triggered after loading the scripts, before saving.
349///
350/// # Returns
351///
352/// The saved values, and the errors raised by the scripts.
353///
354/// # Errors
355///
356/// Returns an error if the game's scripts are missing or fail to load, or if the Lua runtime fails.
357pub fn save_campaign(api: &LuaApi, scripts: &LuaScripts, options: &LuaTestOptions, boot_events: &[&str]) -> Result<LuaSave> {
358    if !scripts.has_campaign_boot_script() {
359        return Err(RLibError::LuaTestGameScriptsNotFound);
360    }
361
362    let lua = new_state(api, scripts, options)?;
363    let rpfm: Table = lua.globals().get("rpfm").map_err(lua_error)?;
364    let boot: mlua::Function = rpfm.get("__boot").map_err(lua_error)?;
365    if let Some(error) = boot.call::<Option<String>>((options.campaign.as_deref(), boot_events.to_vec())).map_err(lua_error)? {
366        return Err(RLibError::LuaRuntimeError(format!("the game's script libraries failed to load: {error}")));
367    }
368
369    let fire: mlua::Function = rpfm.get("fire").map_err(lua_error)?;
370    fire.call::<()>(SAVING_GAME_EVENT).map_err(lua_error)?;
371
372    let mut values = vec![];
373    for entry in rpfm.get::<Table>("saved_values").map_err(lua_error)?.sequence_values::<Table>() {
374        let entry = entry.map_err(lua_error)?;
375        let name: String = entry.get("name").map_err(lua_error)?;
376
377        // The game converts numbers with `lua_tointeger`, which rounds to the nearest integer on its platform.
378        let value = match entry.get::<mlua::Value>("value").map_err(lua_error)? {
379            mlua::Value::Boolean(value) => LuaSavedValue::Bool(value),
380            mlua::Value::Integer(value) => LuaSavedValue::Integer(value as i32),
381            mlua::Value::Number(value) => LuaSavedValue::Integer(value.round_ties_even() as i32),
382            mlua::Value::String(value) => LuaSavedValue::String(String::from_utf8_lossy(&value.as_bytes()).into_owned()),
383            _ => continue,
384        };
385        values.push((name, value));
386    }
387
388    Ok(LuaSave {
389        values,
390        errors: string_list(&rpfm, "errors")?,
391    })
392}
393
394/// This function creates a sandboxed Lua state with the engine emulation loaded.
395fn new_state(api: &LuaApi, scripts: &LuaScripts, options: &LuaTestOptions) -> Result<Lua> {
396
397    // SAFETY: the debug library can break Lua's memory safety guarantees through functions like `debug.setupvalue`
398    // on C functions. The game's scripts need `debug.traceback` and `debug.getinfo`, and they only run in this state.
399    let lua = unsafe { Lua::unsafe_new_with(StdLib::ALL_SAFE | StdLib::DEBUG, LuaOptions::default()) };
400
401    let instructions = Rc::new(Cell::new(0u64));
402    lua.set_hook(HookTriggers::new().every_nth_instruction(INSTRUCTION_CHECK_INTERVAL), move |_, _| {
403        instructions.set(instructions.get() + u64::from(INSTRUCTION_CHECK_INTERVAL));
404        if instructions.get() > INSTRUCTION_LIMIT {
405            return Err(mlua::Error::runtime("the scripts ran for too long, they may be stuck in a loop"));
406        }
407        Ok(VmState::Continue)
408    }).map_err(lua_error)?;
409
410    let globals = lua.globals();
411    let read_scripts = scripts.clone();
412    let read_script = lua.create_function(move |_, path: String| Ok(read_scripts.get(&path).map(|(_, source)| source.to_owned()))).map_err(lua_error)?;
413    let list_scripts_map = scripts.clone();
414    let list_scripts = lua.create_function(move |_, (folder, filter): (String, String)| Ok(list_scripts_map.list(&folder, &filter))).map_err(lua_error)?;
415
416    globals.set("rpfm_read_script", read_script).map_err(lua_error)?;
417    globals.set("rpfm_list_scripts", list_scripts).map_err(lua_error)?;
418    globals.set("rpfm_returns", returns_table(&lua, api)?).map_err(lua_error)?;
419    globals.set("rpfm_accessors", accessors_table(&lua, api)?).map_err(lua_error)?;
420    globals.set("rpfm_faction_keys", key_set(&lua, options.faction_keys())?).map_err(lua_error)?;
421    globals.set("rpfm_region_keys", key_set(&lua, options.region_keys())?).map_err(lua_error)?;
422
423    lua.load(ENGINE_SCRIPT).set_name("@engine").exec().map_err(lua_error)?;
424    Ok(lua)
425}
426
427/// This function runs the top-level code of a test file, which registers its tests and sets up the world.
428fn load_test_file(lua: &Lua, test_source: &str) -> Result<()> {
429    lua.load(test_source).set_name(TEST_FILE_CHUNK).exec().map_err(|error| RLibError::LuaTestFileError(error.to_string()))
430}
431
432/// This function builds the table of return types of documented methods, by owner, then by method.
433///
434/// Only the first returned value is used. Types are passed as their names, with objects resolved to the
435/// game interface of the same name when there is one.
436fn returns_table(lua: &Lua, api: &LuaApi) -> Result<Table> {
437    let owners = lua.create_table().map_err(lua_error)?;
438    for (owner, functions) in api.owners() {
439        let methods = lua.create_table().map_err(lua_error)?;
440        for (name, function) in functions {
441            let return_type = function.returns().first().map_or_else(|| "nil".to_owned(), |returned| type_name(api, returned.lua_type()));
442            methods.set(name.as_str(), return_type).map_err(lua_error)?;
443        }
444        owners.set(owner.as_str(), methods).map_err(lua_error)?;
445    }
446
447    Ok(owners)
448}
449
450/// This function builds the table of return types of context accessors, by event, then by accessor.
451fn accessors_table(lua: &Lua, api: &LuaApi) -> Result<Table> {
452    let events = lua.create_table().map_err(lua_error)?;
453    for (event, accessors) in api.events() {
454        let types = lua.create_table().map_err(lua_error)?;
455        for (name, accessor) in accessors {
456            types.set(name.as_str(), type_name(api, accessor.lua_type())).map_err(lua_error)?;
457        }
458        events.set(event.as_str(), types).map_err(lua_error)?;
459    }
460
461    Ok(events)
462}
463
464/// This function builds a set of keys, as a table with each key set to `true`.
465fn key_set(lua: &Lua, keys: &[String]) -> Result<Table> {
466    let set = lua.create_table_with_capacity(0, keys.len()).map_err(lua_error)?;
467    for key in keys {
468        set.set(key.as_str(), true).map_err(lua_error)?;
469    }
470
471    Ok(set)
472}
473
474/// This function returns the name the engine emulation uses for a type.
475fn type_name(api: &LuaApi, lua_type: &LuaType) -> String {
476    match lua_type {
477        LuaType::Object(object) => {
478            let interface = format!("{}_SCRIPT_INTERFACE", object.to_uppercase());
479            if api.owners().contains_key(&interface) { interface } else { "any".to_owned() }
480        }
481        _ => lua_type.name().to_owned(),
482    }
483}
484
485/// This function reads a list of strings from a field of a table.
486fn string_list(table: &Table, field: &str) -> Result<Vec<String>> {
487    let list: Table = table.get(field).map_err(lua_error)?;
488    list.sequence_values::<String>().collect::<mlua::Result<Vec<_>>>().map_err(lua_error)
489}
490
491/// This function turns a Lua error into an RPFM error.
492fn lua_error(error: mlua::Error) -> RLibError {
493    RLibError::LuaRuntimeError(error.to_string())
494}