Skip to main content

foch_runner/
lib.rs

1//! Runs Foch test bundles against a real EU4 install, in isolation.
2//!
3//! The install stays read-only: the runner builds a short-path *runtime
4//! layer* that copies the executable and loose files and links the large data
5//! directories, then launches the copied `eu4.exe` with a throwaway user
6//! directory holding only the source mod and the generated test layer. It
7//! never launches through Steam and never writes the player's own profile.
8//!
9//! The player's real EU4 user directory is kept intact by backing up the few
10//! files the game rewrites and restoring them if their contents change (the
11//! game may still touch their modification time). A stronger in-process
12//! redirect was tried and dropped; see `notes/research/eu4-headless-testing.md`
13//! and the `CreateFileW`-redirect route noted there. The caller turns a
14//! [`SessionOutcome`] into a `foch_test::RunArtifacts` for judging; this crate
15//! performs the I/O that `foch-test` deliberately does not.
16
17pub mod layout;
18mod platform;
19
20use foch::game::eu4::Eu4;
21use foch::game::eu4::base::snapshot::resolve_game_root;
22use foch::input::Config;
23use foch_test::RunnerExit;
24use foch_test::compile::Bundle;
25use foch_test::judge::Timing;
26use std::collections::BTreeMap;
27use std::ffi::OsString;
28use std::fs;
29use std::io;
30use std::path::{Path, PathBuf};
31use std::sync::Arc;
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::time::{Duration, Instant, SystemTime};
34
35/// A mod the mod under test declares a dependency on, resolved to its
36/// installed directory. Loaded read-only before the source mod so the mod's
37/// content that relies on it resolves.
38#[derive(Clone, Debug)]
39pub struct DependencyMod {
40	pub name: String,
41	pub path: PathBuf,
42}
43
44/// The game installation to launch and where to stage the throwaway runtime.
45#[derive(Clone, Debug)]
46pub struct Installation {
47	/// EU4 install root (read-only). Resolve with [`locate_game`].
48	pub game_root: PathBuf,
49	/// Short-path directory under which per-session runtime layers are built,
50	/// kept short so game files stay within `MAX_PATH`.
51	pub runtime_base: PathBuf,
52	/// The player's real EU4 user directory to guard, if known.
53	pub real_user_dir: Option<PathBuf>,
54}
55
56/// Everything that varies per run.
57#[derive(Clone, Debug)]
58pub struct RunOptions {
59	pub timeout: Duration,
60	/// Set from outside (e.g. a Ctrl-C handler) to stop the current launch. The
61	/// game is stopped gracefully and the runtime layer is still torn down.
62	pub cancel: Arc<AtomicBool>,
63}
64
65impl Default for RunOptions {
66	fn default() -> Self {
67		Self {
68			timeout: Duration::from_secs(300),
69			cancel: Arc::new(AtomicBool::new(false)),
70		}
71	}
72}
73
74/// Remove runtime layers left under `runtime_base` by earlier runs that exited
75/// without their [`RuntimeLayer`] drop (a crash or a hard kill). Only entries
76/// older than `max_age` are removed, so a concurrent run's fresh layer is never
77/// touched. Junctions are unlinked rather than followed, so linked game data is
78/// never deleted. Best-effort: inaccessible entries are left alone.
79pub fn sweep_runtime_base(runtime_base: &Path, max_age: Duration) {
80	let now = SystemTime::now();
81	let Ok(entries) = fs::read_dir(runtime_base) else {
82		return;
83	};
84	for entry in entries.flatten() {
85		let Ok(metadata) = entry.metadata() else {
86			continue;
87		};
88		if metadata.is_dir()
89			&& metadata
90				.modified()
91				.is_ok_and(|modified| is_stale(modified, now, max_age))
92		{
93			platform::remove_layer(&entry.path());
94		}
95	}
96}
97
98fn is_stale(modified: SystemTime, now: SystemTime, max_age: Duration) -> bool {
99	now.duration_since(modified).is_ok_and(|age| age > max_age)
100}
101
102/// What one launch produced, ready to turn into a `foch_test::RunArtifacts`.
103#[derive(Clone, Debug)]
104pub struct SessionOutcome {
105	pub game_log: Option<String>,
106	pub error_log: Option<String>,
107	pub exit: RunnerExit,
108	pub timing: Timing,
109	/// The real user directory's guarded files were unchanged.
110	pub profile_untouched: bool,
111	/// Human-readable account of how the game was launched.
112	pub launch_detail: String,
113}
114
115/// What this built-in runner can provide, as verified against a real game.
116/// Cases needing anything else are refused before launch.
117pub fn capabilities() -> foch_test::compile::RunnerCapabilities {
118	foch_test::compile::RunnerCapabilities {
119		// Launching with `-start_tag` begins at EU4's default start; other
120		// dates are not yet reachable. Only 1444.11.11 is verified.
121		start_dates: vec![foch_test::model::GameDate::parse("1444.11.11").expect("valid date")],
122		// The `ai` console command disables AI (verified: AI actions drop to
123		// zero), giving deterministic runs.
124		ai_off: true,
125		// Several cases in one launch are not yet verified in a real game.
126		shared_sessions: false,
127	}
128}
129
130/// Resolve the EU4 install from Foch configuration and Steam, honoring an
131/// explicit override first.
132pub fn locate_game(config: &Config, explicit: Option<&Path>) -> Result<PathBuf, String> {
133	if let Some(path) = explicit {
134		return if path.is_dir() {
135			Ok(path.to_path_buf())
136		} else {
137			Err(format!("game path {} is not a directory", path.display()))
138		};
139	}
140	resolve_game_root(config, &Eu4)
141		.ok_or_else(|| {
142			"could not locate the EU4 install; pass --game-path or set game_path.eu4 / the Steam path".into()
143		})
144}
145
146/// Files in the real user directory the game may rewrite; guarded by content.
147const GUARDED_FILES: &[&str] = &[
148	"settings.txt",
149	"pdx_settings.txt",
150	"dlc_load.json",
151	"game_data.json",
152];
153
154#[derive(Debug)]
155struct ProfileGuard {
156	directory: PathBuf,
157	before: BTreeMap<&'static str, Option<Vec<u8>>>,
158	backups: BTreeMap<&'static str, Vec<u8>>,
159}
160
161impl ProfileGuard {
162	fn capture(directory: &Path) -> Self {
163		let mut before = BTreeMap::new();
164		let mut backups = BTreeMap::new();
165		for &name in GUARDED_FILES {
166			let content = fs::read(directory.join(name)).ok();
167			if let Some(bytes) = &content {
168				backups.insert(name, bytes.clone());
169			}
170			before.insert(name, content);
171		}
172		Self {
173			directory: directory.to_path_buf(),
174			before,
175			backups,
176		}
177	}
178
179	/// Returns whether the guarded files are unchanged, restoring any that the
180	/// game rewrote with different contents.
181	fn verify_and_restore(&self) -> bool {
182		let mut untouched = true;
183		for (&name, original) in &self.before {
184			let path = self.directory.join(name);
185			let current = fs::read(&path).ok();
186			if &current == original {
187				continue;
188			}
189			untouched = false;
190			// Restore a file that existed before; delete one the game newly
191			// created. A failure here leaves the real profile changed, so the
192			// session stays flagged (untouched = false).
193			let restored = match self.backups.get(name) {
194				Some(bytes) => fs::write(&path, bytes),
195				None => match fs::remove_file(&path) {
196					Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
197					other => other,
198				},
199			};
200			if let Err(error) = restored {
201				eprintln!("warning: could not restore {}: {error}", path.display());
202			}
203		}
204		untouched
205	}
206}
207
208/// Build the runtime layer, launch the game, wait for every case to finish (or
209/// time out), collect the logs, and tear the layer down. `session_dir` is where
210/// the throwaway profile and logs live; `source_mod` is the mod under test.
211pub fn run_session(
212	bundle: &Bundle,
213	source_mod: &Path,
214	dependencies: &[DependencyMod],
215	session_dir: &Path,
216	installation: &Installation,
217	options: &RunOptions,
218) -> io::Result<SessionOutcome> {
219	let runtime = RuntimeLayer::build(bundle, &installation.game_root, &installation.runtime_base)?;
220	if let Some(message) = runtime.budget_error() {
221		return Err(io::Error::other(message));
222	}
223	let user_dir = session_dir.join("userdir");
224	let real_settings = installation
225		.real_user_dir
226		.as_ref()
227		.map(|dir| dir.join("settings.txt"));
228	assemble_profile(
229		bundle,
230		source_mod,
231		dependencies,
232		&user_dir,
233		real_settings.as_deref(),
234	)?;
235
236	let guard = installation
237		.real_user_dir
238		.as_ref()
239		.filter(|dir| dir.is_dir())
240		.map(|dir| ProfileGuard::capture(dir));
241
242	let launch = platform::LaunchSpec {
243		exe: runtime.directory.join("eu4.exe"),
244		working_dir: runtime.directory.clone(),
245		args: launch_args(bundle, &user_dir),
246	};
247	let log = user_dir.join("logs").join("game.log");
248	let error_log = user_dir.join("logs").join("error.log");
249
250	let started = Instant::now();
251	let mut process = platform::spawn(&launch)?;
252	let launch_detail = launch.detail();
253	let exit = watch(&mut process, bundle, &log, options.timeout, &options.cancel);
254	let timing = Timing {
255		wall_ms: started.elapsed().as_millis() as u64,
256		..Timing::default()
257	};
258
259	let profile_untouched = guard
260		.as_ref()
261		.map(ProfileGuard::verify_and_restore)
262		.unwrap_or(true);
263	let outcome = SessionOutcome {
264		game_log: fs::read_to_string(&log).ok(),
265		error_log: fs::read_to_string(&error_log).ok(),
266		exit,
267		timing,
268		profile_untouched,
269		launch_detail,
270	};
271	// `runtime` is dropped here, which removes the layer.
272	Ok(outcome)
273}
274
275/// Launch args: the throwaway user directory plus the bundle's own args.
276fn launch_args(bundle: &Bundle, user_dir: &Path) -> Vec<OsString> {
277	let mut args = vec![OsString::from(format!("-userdir={}", user_dir.display()))];
278	args.extend(bundle.launch.args.iter().map(OsString::from));
279	args
280}
281
282/// Wait until the game logs an `END` for every case, exits, or times out.
283fn watch(
284	process: &mut platform::GameProcess,
285	bundle: &Bundle,
286	log: &Path,
287	timeout: Duration,
288	cancel: &AtomicBool,
289) -> RunnerExit {
290	let started = Instant::now();
291	loop {
292		if cancel.load(Ordering::Relaxed) {
293			process.stop();
294			return RunnerExit::Cancelled;
295		}
296		if let Some(code) = process.exit_code() {
297			return if code == 0 {
298				RunnerExit::Success
299			} else {
300				RunnerExit::Failed { code: Some(code) }
301			};
302		}
303		if let Ok(text) = fs::read_to_string(log)
304			&& layout::all_cases_ended(&text, bundle)
305		{
306			process.stop();
307			return RunnerExit::Success;
308		}
309		if started.elapsed() >= timeout {
310			process.stop();
311			return RunnerExit::TimedOut {
312				seconds: timeout.as_secs(),
313			};
314		}
315		std::thread::sleep(Duration::from_millis(200));
316	}
317}
318
319/// Write the throwaway profile inside `user_dir`: the generated test layer
320/// (written from the bundle's own files), descriptors enabling the source mod
321/// and that layer, a `dlc_load.json`, and a windowed/muted settings file.
322fn assemble_profile(
323	bundle: &Bundle,
324	source_mod: &Path,
325	dependencies: &[DependencyMod],
326	user_dir: &Path,
327	real_settings: Option<&Path>,
328) -> io::Result<()> {
329	let mod_dir = user_dir.join("mod");
330	fs::create_dir_all(&mod_dir)?;
331	// The runner owns this copy of the test layer, so it never aliases a copy
332	// the CLI may also have written beside the bundle.
333	let test_mod = user_dir.join("test_mod");
334	for (relative, content) in &bundle.files {
335		let Some(rest) = relative.strip_prefix("test_mod/") else {
336			continue;
337		};
338		let destination = test_mod.join(rest);
339		if let Some(parent) = destination.parent() {
340			fs::create_dir_all(parent)?;
341		}
342		fs::write(destination, content)?;
343	}
344
345	// Enable declared dependencies first, then the source mod, then the test
346	// layer last so its events win any tie.
347	let mut enabled = Vec::new();
348	for (index, dependency) in dependencies.iter().enumerate() {
349		let file = format!("dep_{index:03}.mod");
350		write_descriptor(&mod_dir.join(&file), &dependency.name, &dependency.path)?;
351		enabled.push(file);
352	}
353	write_descriptor(
354		&mod_dir.join("source_mod.mod"),
355		"Foch source mod",
356		source_mod,
357	)?;
358	enabled.push("source_mod.mod".to_string());
359	write_descriptor(
360		&mod_dir.join("test_mod.mod"),
361		&bundle.test_mod_name,
362		&test_mod,
363	)?;
364	enabled.push("test_mod.mod".to_string());
365	let enabled: Vec<&str> = enabled.iter().map(String::as_str).collect();
366	fs::write(user_dir.join("dlc_load.json"), layout::dlc_load(&enabled))?;
367
368	if let Some(path) = real_settings
369		&& let Ok(template) = fs::read_to_string(path)
370	{
371		fs::write(
372			user_dir.join("settings.txt"),
373			layout::isolated_settings(&template),
374		)?;
375	}
376	Ok(())
377}
378
379fn write_descriptor(path: &Path, name: &str, mod_path: &Path) -> io::Result<()> {
380	let normalized = mod_path.to_string_lossy().replace('\\', "/");
381	fs::write(path, format!("name=\"{name}\"\npath=\"{normalized}\"\n"))
382}
383
384/// A per-session copy of the install: loose files copied, data directories
385/// linked, laid out on a short path.
386struct RuntimeLayer {
387	directory: PathBuf,
388	longest_relative: usize,
389}
390
391impl RuntimeLayer {
392	fn build(bundle: &Bundle, game_root: &Path, runtime_base: &Path) -> io::Result<Self> {
393		let directory = runtime_base.join(&bundle.namespace);
394		fs::create_dir_all(&directory)?;
395		let mut longest_relative = 0;
396		for entry in fs::read_dir(game_root)? {
397			let entry = entry?;
398			let name = entry.file_name();
399			let name = name.to_string_lossy();
400			let is_dir = entry.file_type()?.is_dir();
401			match layout::classify_entry(&name, is_dir) {
402				layout::LayerAction::Copy => {
403					fs::copy(entry.path(), directory.join(&*name))?;
404				}
405				layout::LayerAction::Link => {
406					longest_relative =
407						longest_relative.max(deepest_relative(&entry.path(), name.len()));
408					platform::link_dir(&entry.path(), &directory.join(&*name))?;
409				}
410				layout::LayerAction::Skip => {}
411			}
412		}
413		fs::write(directory.join("userdir.txt"), "")?;
414		fs::write(
415			directory.join("steam_appid.txt"),
416			Eu4::STEAM_APP_ID.to_string(),
417		)?;
418		fs::write(
419			directory.join("commands.txt"),
420			layout::command_file(bundle, bundle.requires.ai_off),
421		)?;
422		// The start file is referenced by `run <name>`, resolved against the
423		// game's working directory, so it lives beside the executable.
424		for (relative, content) in &bundle.files {
425			if relative.ends_with("_start.txt") && !relative.contains('/') {
426				fs::write(directory.join(relative), content)?;
427			}
428		}
429		Ok(Self {
430			directory,
431			longest_relative,
432		})
433	}
434
435	fn budget_error(&self) -> Option<String> {
436		if layout::fits_path_budget(self.directory.as_os_str().len(), self.longest_relative) {
437			None
438		} else {
439			Some(format!(
440				"runtime layer path {} is too long for the game's files (deepest relative {} chars); choose a shorter runtime base",
441				self.directory.display(),
442				self.longest_relative
443			))
444		}
445	}
446}
447
448impl Drop for RuntimeLayer {
449	/// Tear the layer down on any exit from `run_session`, including early
450	/// returns, so a failed launch never leaves junctions or copies behind.
451	fn drop(&mut self) {
452		platform::remove_layer(&self.directory);
453	}
454}
455
456/// Deepest `<dir>/<file>` length under `root`, relative to the install root
457/// (so it already counts `dir_name` as its first component).
458fn deepest_relative(root: &Path, dir_name_len: usize) -> usize {
459	fn walk(path: &Path, prefix: usize) -> usize {
460		let mut deepest = prefix;
461		if let Ok(entries) = fs::read_dir(path) {
462			for entry in entries.flatten() {
463				let name_len = entry.file_name().to_string_lossy().chars().count();
464				let child = prefix + 1 + name_len;
465				deepest = deepest.max(if entry.path().is_dir() {
466					walk(&entry.path(), child)
467				} else {
468					child
469				});
470			}
471		}
472		deepest
473	}
474	walk(root, dir_name_len)
475}
476
477#[cfg(test)]
478mod tests {
479	use super::*;
480
481	#[test]
482	fn stale_layers_are_older_than_the_grace_period() {
483		let now = SystemTime::now();
484		let max_age = Duration::from_secs(3600);
485		// A layer touched two hours ago is stale; one from a minute ago is not.
486		assert!(is_stale(now - Duration::from_secs(7200), now, max_age));
487		assert!(!is_stale(now - Duration::from_secs(60), now, max_age));
488		// A modification time in the future (clock skew) is never stale.
489		assert!(!is_stale(now + Duration::from_secs(60), now, max_age));
490	}
491}