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