Skip to main content

foch_runner/
layout.rs

1//! Pure helpers for assembling a runtime layer and reading its logs.
2//!
3//! Launching a copied `eu4.exe` keeps the original install read-only, but it
4//! reintroduces Windows' `MAX_PATH` limit: the game opens files by
5//! `<runtime dir>\<relative path>`, and anything past 259 characters silently
6//! fails to load. These helpers keep that budget and the log scan testable
7//! without a game.
8
9use foch_test::compile::Bundle;
10
11/// Windows caps a classic path at 260 chars including the terminator.
12pub const MAX_PATH: usize = 260;
13
14/// Whether every game file still fits under `MAX_PATH` when the install is
15/// reached through a runtime directory of `runtime_dir_len` characters.
16/// `longest_relative` is the longest `<dir>\<file>` path below the install,
17/// relative to its root.
18pub fn fits_path_budget(runtime_dir_len: usize, longest_relative: usize) -> bool {
19	// runtime dir + separator + relative path + NUL terminator.
20	runtime_dir_len + longest_relative + 2 <= MAX_PATH
21}
22
23/// The console command file for a session: the bundle's own commands, with
24/// `ai` prepended to disable AI for deterministic results.
25pub fn command_file(bundle: &Bundle, disable_ai: bool) -> String {
26	let base = bundle
27		.files
28		.get("commands.txt")
29		.map(String::as_str)
30		.unwrap_or_default();
31	if disable_ai {
32		format!("ai\n{base}")
33	} else {
34		base.to_string()
35	}
36}
37
38/// A windowed, muted copy of the player's `settings.txt`, so a test launch
39/// never goes fullscreen or plays sound. Keys absent from the template are
40/// left as the engine defaults them.
41pub fn isolated_settings(template: &str) -> String {
42	let mut out = String::with_capacity(template.len());
43	for line in template.split_inclusive('\n') {
44		let trimmed = line.trim_start();
45		let indent = &line[..line.len() - trimmed.len()];
46		let rewritten = if let Some((key, _)) = trimmed.split_once('=') {
47			match key.trim() {
48				"fullScreen" => Some(format!("{indent}fullScreen=no\n")),
49				"borderless" => Some(format!("{indent}borderless=no\n")),
50				"master_volume" | "music_volume" | "sound_fx_volume" | "ambient_volume"
51				| "dev_master_volume" => Some(format!("{indent}{}=0.000000\n", key.trim())),
52				_ => None,
53			}
54		} else {
55			None
56		};
57		out.push_str(&rewritten.unwrap_or_else(|| line.to_string()));
58	}
59	if !out.ends_with('\n') {
60		out.push('\n');
61	}
62	out
63}
64
65/// A `dlc_load.json` enabling exactly the source mod and generated test layer.
66pub fn dlc_load(mod_files: &[&str]) -> String {
67	let mods: Vec<String> = mod_files
68		.iter()
69		.map(|name| format!("\"mod/{name}\""))
70		.collect();
71	format!(
72		"{{\"enabled_mods\":[{}],\"disabled_dlcs\":[]}}",
73		mods.join(",")
74	)
75}
76
77/// Whether the engine log shows an `END` record for every case in the
78/// session, meaning the run is complete and the game can be stopped.
79pub fn all_cases_ended(log: &str, bundle: &Bundle) -> bool {
80	let marker = &bundle.marker_prefix;
81	bundle.cases.iter().all(|&case| {
82		let needle = format!("{} END", bundle.case_prefix(case));
83		log.lines()
84			.any(|line| line.contains(marker.as_str()) && line.trim_end().ends_with(&needle))
85	})
86}
87
88/// Classifies a top-level game-directory entry for the runtime layer: small
89/// loose files are copied so the engine can read and rewrite them freely;
90/// large data directories are linked so nothing is duplicated.
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum LayerAction {
93	Copy,
94	Link,
95	Skip,
96}
97
98/// Foch metadata, old loaders and stale console history never belong in a
99/// fresh runtime layer; everything else loose is copied, directories linked.
100pub fn classify_entry(name: &str, is_dir: bool) -> LayerAction {
101	let lower = name.to_ascii_lowercase();
102	if matches!(
103		lower.as_str(),
104		"plugins" | "foch-runtime" | "foch-player-runtime" | "foch-host"
105	) || foch::plugin::store::is_proxy_name(&lower)
106	{
107		return LayerAction::Skip;
108	}
109	if is_dir {
110		return LayerAction::Link;
111	}
112	let skip = matches!(
113		lower.as_str(),
114		"universal-checksum-patcher.exe" | "console_history.txt" | "userdir.txt"
115	);
116	if skip {
117		LayerAction::Skip
118	} else {
119		LayerAction::Copy
120	}
121}
122
123#[cfg(test)]
124mod tests {
125	use super::*;
126	use foch_test::compile::{Bundle, Launch, Requirements};
127	use foch_test::model::{AiMode, CaseIndex, GameDate, RunId, Tag};
128	use foch_test::plan::SessionId;
129	use std::collections::BTreeMap;
130
131	fn bundle() -> Bundle {
132		let date = GameDate::parse("1444.11.11").unwrap();
133		Bundle {
134			protocol: 2,
135			run_id: RunId::parse("run-1").unwrap(),
136			mod_name: "demo".into(),
137			session: SessionId(0),
138			cases: vec![CaseIndex(0), CaseIndex(2)],
139			marker_prefix: "FOCH_TEST_V2 run-1 abc123".into(),
140			namespace: "foch_test_demo_abc123".into(),
141			test_mod_name: "demo tests [abc123]".into(),
142			files: BTreeMap::from([(
143				"commands.txt".to_string(),
144				"speed 0\nrun start.txt\nspeed 5\n".to_string(),
145			)]),
146			launch: Launch {
147				args: vec!["-start_tag=SWE".into()],
148				ai: AiMode::Off,
149				player: Tag::parse("SWE").unwrap(),
150				start_date: date,
151				end_date: date,
152			},
153			checks: Vec::new(),
154			windows: Vec::new(),
155			requires: Requirements {
156				start_date: date,
157				ai_off: true,
158				shared_session: false,
159			},
160		}
161	}
162
163	#[test]
164	fn path_budget_matches_windows_max_path() {
165		// 127 is EU4's longest relative path; a 131-char runtime dir just fits.
166		assert!(fits_path_budget(131, 127));
167		assert!(!fits_path_budget(132, 127));
168	}
169
170	#[test]
171	fn command_file_prepends_ai_only_when_disabling() {
172		let bundle = bundle();
173		assert_eq!(
174			command_file(&bundle, false),
175			"speed 0\nrun start.txt\nspeed 5\n"
176		);
177		assert_eq!(
178			command_file(&bundle, true),
179			"ai\nspeed 0\nrun start.txt\nspeed 5\n"
180		);
181	}
182
183	#[test]
184	fn settings_are_windowed_and_muted_preserving_other_keys() {
185		let template = "graphics={\n\tfullScreen=yes\n\tborderless=yes\n\tsize={ x=1920 }\n}\nmaster_volume=100.000000\nmusic_volume=50.000000\ndifficulty=\"LUCK_NONE\"\n";
186		let out = isolated_settings(template);
187		assert!(out.contains("\tfullScreen=no\n"));
188		assert!(out.contains("\tborderless=no\n"));
189		assert!(out.contains("master_volume=0.000000\n"));
190		assert!(out.contains("music_volume=0.000000\n"));
191		assert!(
192			out.contains("\tsize={ x=1920 }\n"),
193			"unrelated keys preserved"
194		);
195		assert!(out.contains("difficulty=\"LUCK_NONE\"\n"));
196	}
197
198	#[test]
199	fn all_cases_ended_requires_every_case() {
200		let bundle = bundle();
201		let line = |case: usize, record: &str| {
202			format!(
203				"[x]: EVENT [1444.11.11]:{} c{case} {record}\n",
204				bundle.marker_prefix
205			)
206		};
207		let mut log = String::new();
208		log.push_str(&line(0, "END"));
209		assert!(!all_cases_ended(&log, &bundle), "case 2 has not ended");
210		log.push_str(&line(2, "PASS scope"));
211		assert!(!all_cases_ended(&log, &bundle));
212		log.push_str(&line(2, "END"));
213		assert!(all_cases_ended(&log, &bundle));
214	}
215
216	#[test]
217	fn layer_classification_copies_files_links_dirs_skips_noise() {
218		assert_eq!(classify_entry("eu4.exe", false), LayerAction::Copy);
219		assert_eq!(classify_entry("d3d9.dll", false), LayerAction::Skip);
220		assert_eq!(classify_entry("VERSION.DLL", false), LayerAction::Skip);
221		assert_eq!(classify_entry("Plugins", true), LayerAction::Skip);
222		assert_eq!(classify_entry("steam_api64.dll", false), LayerAction::Copy);
223		assert_eq!(classify_entry("common", true), LayerAction::Link);
224		assert_eq!(
225			classify_entry("console_history.txt", false),
226			LayerAction::Skip
227		);
228		assert_eq!(classify_entry("userdir.txt", false), LayerAction::Skip);
229	}
230
231	#[test]
232	fn dlc_load_enables_source_and_test_mods() {
233		assert_eq!(
234			dlc_load(&["source_mod.mod", "test_mod.mod"]),
235			"{\"enabled_mods\":[\"mod/source_mod.mod\",\"mod/test_mod.mod\"],\"disabled_dlcs\":[]}"
236		);
237	}
238}