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::{BTreeMap, 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/// Name of the chunk holding the test file, as shown in errors.
49const TEST_FILE_CHUNK: &str = "@test";
50
51/// Instructions a Lua state may run, boot included, before it's considered stuck.
52const INSTRUCTION_LIMIT: u64 = 2_000_000_000;
53
54/// How often, in instructions, the instruction limit is checked.
55const INSTRUCTION_CHECK_INTERVAL: u32 = 100_000;
56
57//---------------------------------------------------------------------------//
58//                              Enum & Structs
59//---------------------------------------------------------------------------//
60
61/// Scripts the harness can load, by path.
62///
63/// Shared behind [`Arc`]s, as every test's Lua state gets its own copy.
64#[derive(Clone, Debug, Default)]
65pub struct LuaScripts {
66
67    /// Path and code of each script, by lowercase path, as file lookups in the game are case-insensitive.
68    scripts: Arc<HashMap<String, (String, String)>>,
69
70    /// Lowercase paths of the scripts coming from the tested packs.
71    pack_paths: Arc<Vec<String>>,
72}
73
74/// Options of a test run.
75#[derive(Clone, Debug, Default, PartialEq, Getters, Setters, Serialize, Deserialize)]
76#[getset(get = "pub", set = "pub")]
77pub struct LuaTestOptions {
78
79    /// Campaign whose vanilla scripts to load, like `main_warhammer`. If `None`, only the script libraries and
80    /// the mods are loaded, which is faster and less affected by engine behavior the harness doesn't emulate.
81    campaign: Option<String>,
82
83    /// Keys of the factions in the game's DB. Scripts looking them up get a faction even if tests didn't set it up.
84    faction_keys: Vec<String>,
85
86    /// Keys of the regions in the game's DB. Scripts looking them up get a region even if tests didn't set it up.
87    region_keys: Vec<String>,
88}
89
90/// Results of running a test file.
91#[derive(Clone, Debug, Default, PartialEq, Getters, Serialize, Deserialize)]
92#[getset(get = "pub")]
93pub struct LuaTestReport {
94
95    /// Result of each test, in the order they were registered.
96    tests: Vec<LuaTestResult>,
97
98    /// Errors raised while booting by scripts that don't come from the tested packs, like vanilla scripts
99    /// reacting to engine behavior the harness doesn't emulate. They don't fail tests.
100    boot_errors: Vec<String>,
101}
102
103/// Result of a test.
104#[derive(Clone, Debug, Default, PartialEq, Getters, Serialize, Deserialize)]
105#[getset(get = "pub")]
106pub struct LuaTestResult {
107
108    /// Name of the test.
109    name: String,
110
111    /// If the test passed.
112    passed: bool,
113
114    /// Why the test failed: its own errors, errors in listeners while it ran, and errors of the tested
115    /// packs' scripts while booting.
116    errors: Vec<String>,
117
118    /// Methods called during the test that the docs don't describe, as `type:method`. Their return values
119    /// are placeholders, so results depending on them may not match the game.
120    unmocked_calls: Vec<String>,
121
122    /// Output of the scripts during the whole run, boot included. Only the latest lines are kept for long runs.
123    log: Vec<String>,
124}
125
126//---------------------------------------------------------------------------//
127//                             Implementations
128//---------------------------------------------------------------------------//
129
130impl LuaScripts {
131
132    /// This function collects the vanilla scripts from the dependencies cache, and the scripts of the tested packs.
133    ///
134    /// # Arguments
135    ///
136    /// * `dependencies` - Dependencies cache with the vanilla files loaded.
137    /// * `packs` - Packs to test. Their scripts replace vanilla scripts with the same path.
138    ///
139    /// # Returns
140    ///
141    /// The scripts.
142    pub fn from_game_and_packs(dependencies: &Dependencies, packs: &BTreeMap<String, Pack>) -> Self {
143        let mut scripts = Self::default();
144        for (path, source) in vanilla_scripts(dependencies) {
145            scripts.add(&path, source, false);
146        }
147
148        for file in packs.values().flat_map(|pack| pack.files_by_type(&[FileType::Text])) {
149            let path = file.path_in_container_raw();
150            if !path.ends_with(".lua") {
151                continue;
152            }
153
154            // Scripts of open packs may be edited in memory, so their decoded text takes priority over their data.
155            let source = match file.decoded() {
156                Ok(RFileDecoded::Text(text)) => Some(text.contents().to_owned()),
157                _ => {
158                    let mut file = file.clone();
159                    file.load().ok().and_then(|_| file.cached().ok().map(|data| String::from_utf8_lossy(data).to_string()))
160                }
161            };
162
163            if let Some(source) = source {
164                scripts.add(path, source, true);
165            }
166        }
167
168        scripts
169    }
170
171    /// This function adds a script. Scripts added later replace earlier ones with the same path.
172    ///
173    /// # Arguments
174    ///
175    /// * `path` - Path of the script, like `script/campaign/mod/my_mod.lua`.
176    /// * `source` - Code of the script.
177    /// * `from_tested_pack` - If the script belongs to one of the packs being tested.
178    pub fn add(&mut self, path: &str, source: String, from_tested_pack: bool) {
179        let path = path.replace('\\', "/");
180        let key = path.to_lowercase();
181        let source = source.strip_prefix('\u{feff}').map(str::to_owned).unwrap_or(source);
182        if from_tested_pack {
183            Arc::make_mut(&mut self.pack_paths).push(key.to_owned());
184        }
185
186        Arc::make_mut(&mut self.scripts).insert(key, (path, source));
187    }
188
189    /// This function returns the path and code of a script.
190    fn get(&self, path: &str) -> Option<&(String, String)> {
191        self.scripts.get(&path.to_lowercase())
192    }
193
194    /// This function returns the paths of the scripts directly inside a folder, like the game's `filesystem_lookup`.
195    ///
196    /// # Arguments
197    ///
198    /// * `folder` - Folder to look in, like `script/campaign/mod/`.
199    /// * `filter` - Filter for the file names. Only `*` and `*.extension` are supported.
200    ///
201    /// # Returns
202    ///
203    /// The paths, sorted and separated by commas.
204    fn list(&self, folder: &str, filter: &str) -> String {
205        let folder = folder.trim_matches('/').to_lowercase() + "/";
206        let extension = filter.strip_prefix("*.").filter(|extension| *extension != "*").map(|extension| format!(".{}", extension.to_lowercase()));
207
208        let mut paths = self.scripts.iter()
209            .filter(|(key, _)| key.strip_prefix(&folder).is_some_and(|name| !name.contains('/')))
210            .filter(|(key, _)| extension.as_ref().is_none_or(|extension| key.ends_with(extension)))
211            .map(|(_, (path, _))| path.as_str())
212            .collect::<Vec<_>>();
213
214        paths.sort_unstable();
215        paths.join(",")
216    }
217
218    /// This function checks if an error comes from a script of the tested packs.
219    ///
220    /// Lua errors start with the path of the script that raised them.
221    fn is_pack_error(&self, error: &str) -> bool {
222        let error = error.to_lowercase();
223        self.pack_paths.iter().any(|path| error.contains(path.as_str()))
224    }
225}
226
227/// This function runs the tests of a test file.
228///
229/// # Arguments
230///
231/// * `api` - API of the game, whose docs type the engine objects.
232/// * `scripts` - Scripts of the game and of the tested packs.
233/// * `test_source` - Code of the test file.
234/// * `options` - Options of the run.
235///
236/// # Returns
237///
238/// The result of each test.
239///
240/// # Errors
241///
242/// Returns an error if the game's scripts are missing, if the test file can't be loaded, or if the Lua runtime fails.
243pub fn run_tests(api: &LuaApi, scripts: &LuaScripts, test_source: &str, options: &LuaTestOptions) -> Result<LuaTestReport> {
244    if scripts.get(CAMPAIGN_BOOT_SCRIPT).is_none() {
245        return Err(RLibError::LuaTestGameScriptsNotFound);
246    }
247
248    let mut report = LuaTestReport::default();
249    let mut index = 1;
250
251    // Every test gets a fresh state, so the first one also tells how many tests there are.
252    loop {
253        let lua = new_state(api, scripts, options)?;
254        load_test_file(&lua, test_source)?;
255
256        let rpfm: Table = lua.globals().get("rpfm").map_err(lua_error)?;
257        let tests: Table = rpfm.get("tests").map_err(lua_error)?;
258        if index > tests.raw_len() {
259            break;
260        }
261
262        let boot: mlua::Function = rpfm.get("__boot").map_err(lua_error)?;
263        let boot_error = boot.call::<Option<String>>(options.campaign.as_deref()).map_err(lua_error)?;
264
265        let (pack_errors, other_errors): (Vec<_>, Vec<_>) = string_list(&rpfm, "errors")?.into_iter().partition(|error| scripts.is_pack_error(error));
266        if index == 1 {
267            report.boot_errors = other_errors;
268        }
269
270        let test: Table = tests.get(index).map_err(lua_error)?;
271        let name: String = test.get("name").map_err(lua_error)?;
272
273        let run_test: mlua::Function = rpfm.get("__run_test").map_err(lua_error)?;
274        let (passed, test_error): (bool, Option<String>) = match run_test.call::<(bool, Option<String>)>(index) {
275            Ok(result) => result,
276            Err(error) => (false, Some(error.to_string())),
277        };
278
279        // Without the libraries nothing else works, so that's what every test must report.
280        let mut errors = boot_error.map(|error| vec![format!("The game's script libraries failed to load: {error}")]).unwrap_or_default();
281        errors.extend(pack_errors);
282        errors.extend(test_error);
283        errors.extend(string_list(&rpfm, "errors")?);
284
285        let mut unmocked_calls = rpfm.get::<Table>("unmocked").map_err(lua_error)?
286            .pairs::<String, bool>()
287            .filter_map(|pair| pair.ok().map(|(call, _)| call))
288            .collect::<Vec<_>>();
289        unmocked_calls.sort_unstable();
290
291        report.tests.push(LuaTestResult {
292            name,
293            passed: passed && errors.is_empty(),
294            errors,
295            unmocked_calls,
296            log: string_list(&rpfm, "log")?,
297        });
298
299        index += 1;
300    }
301
302    Ok(report)
303}
304
305/// This function creates a sandboxed Lua state with the engine emulation loaded.
306fn new_state(api: &LuaApi, scripts: &LuaScripts, options: &LuaTestOptions) -> Result<Lua> {
307
308    // SAFETY: the debug library can break Lua's memory safety guarantees through functions like `debug.setupvalue`
309    // on C functions. The game's scripts need `debug.traceback` and `debug.getinfo`, and they only run in this state.
310    let lua = unsafe { Lua::unsafe_new_with(StdLib::ALL_SAFE | StdLib::DEBUG, LuaOptions::default()) };
311
312    let instructions = Rc::new(Cell::new(0u64));
313    lua.set_hook(HookTriggers::new().every_nth_instruction(INSTRUCTION_CHECK_INTERVAL), move |_, _| {
314        instructions.set(instructions.get() + u64::from(INSTRUCTION_CHECK_INTERVAL));
315        if instructions.get() > INSTRUCTION_LIMIT {
316            return Err(mlua::Error::runtime("the scripts ran for too long, they may be stuck in a loop"));
317        }
318        Ok(VmState::Continue)
319    }).map_err(lua_error)?;
320
321    let globals = lua.globals();
322    let read_scripts = scripts.clone();
323    let read_script = lua.create_function(move |_, path: String| Ok(read_scripts.get(&path).map(|(_, source)| source.to_owned()))).map_err(lua_error)?;
324    let list_scripts_map = scripts.clone();
325    let list_scripts = lua.create_function(move |_, (folder, filter): (String, String)| Ok(list_scripts_map.list(&folder, &filter))).map_err(lua_error)?;
326
327    globals.set("rpfm_read_script", read_script).map_err(lua_error)?;
328    globals.set("rpfm_list_scripts", list_scripts).map_err(lua_error)?;
329    globals.set("rpfm_returns", returns_table(&lua, api)?).map_err(lua_error)?;
330    globals.set("rpfm_accessors", accessors_table(&lua, api)?).map_err(lua_error)?;
331    globals.set("rpfm_faction_keys", key_set(&lua, options.faction_keys())?).map_err(lua_error)?;
332    globals.set("rpfm_region_keys", key_set(&lua, options.region_keys())?).map_err(lua_error)?;
333
334    lua.load(ENGINE_SCRIPT).set_name("@engine").exec().map_err(lua_error)?;
335    Ok(lua)
336}
337
338/// This function runs the top-level code of a test file, which registers its tests and sets up the world.
339fn load_test_file(lua: &Lua, test_source: &str) -> Result<()> {
340    lua.load(test_source).set_name(TEST_FILE_CHUNK).exec().map_err(|error| RLibError::LuaTestFileError(error.to_string()))
341}
342
343/// This function builds the table of return types of documented methods, by owner, then by method.
344///
345/// Only the first returned value is used. Types are passed as their names, with objects resolved to the
346/// game interface of the same name when there is one.
347fn returns_table(lua: &Lua, api: &LuaApi) -> Result<Table> {
348    let owners = lua.create_table().map_err(lua_error)?;
349    for (owner, functions) in api.owners() {
350        let methods = lua.create_table().map_err(lua_error)?;
351        for (name, function) in functions {
352            let return_type = function.returns().first().map_or_else(|| "nil".to_owned(), |returned| type_name(api, returned.lua_type()));
353            methods.set(name.as_str(), return_type).map_err(lua_error)?;
354        }
355        owners.set(owner.as_str(), methods).map_err(lua_error)?;
356    }
357
358    Ok(owners)
359}
360
361/// This function builds the table of return types of context accessors, by event, then by accessor.
362fn accessors_table(lua: &Lua, api: &LuaApi) -> Result<Table> {
363    let events = lua.create_table().map_err(lua_error)?;
364    for (event, accessors) in api.events() {
365        let types = lua.create_table().map_err(lua_error)?;
366        for (name, accessor) in accessors {
367            types.set(name.as_str(), type_name(api, accessor.lua_type())).map_err(lua_error)?;
368        }
369        events.set(event.as_str(), types).map_err(lua_error)?;
370    }
371
372    Ok(events)
373}
374
375/// This function builds a set of keys, as a table with each key set to `true`.
376fn key_set(lua: &Lua, keys: &[String]) -> Result<Table> {
377    let set = lua.create_table_with_capacity(0, keys.len()).map_err(lua_error)?;
378    for key in keys {
379        set.set(key.as_str(), true).map_err(lua_error)?;
380    }
381
382    Ok(set)
383}
384
385/// This function returns the name the engine emulation uses for a type.
386fn type_name(api: &LuaApi, lua_type: &LuaType) -> String {
387    match lua_type {
388        LuaType::Object(object) => {
389            let interface = format!("{}_SCRIPT_INTERFACE", object.to_uppercase());
390            if api.owners().contains_key(&interface) { interface } else { "any".to_owned() }
391        }
392        _ => lua_type.name().to_owned(),
393    }
394}
395
396/// This function reads a list of strings from a field of a table.
397fn string_list(table: &Table, field: &str) -> Result<Vec<String>> {
398    let list: Table = table.get(field).map_err(lua_error)?;
399    list.sequence_values::<String>().collect::<mlua::Result<Vec<_>>>().map_err(lua_error)
400}
401
402/// This function turns a Lua error into an RPFM error.
403fn lua_error(error: mlua::Error) -> RLibError {
404    RLibError::LuaRuntimeError(error.to_string())
405}