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/// The launcher, checksum patcher 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	if is_dir {
102		return LayerAction::Link;
103	}
104	let lower = name.to_ascii_lowercase();
105	let skip = matches!(
106		lower.as_str(),
107		"universal-checksum-patcher.exe" | "console_history.txt" | "userdir.txt"
108	);
109	if skip {
110		LayerAction::Skip
111	} else {
112		LayerAction::Copy
113	}
114}
115
116#[cfg(test)]
117mod tests {
118	use super::*;
119	use foch_test::compile::{Bundle, Launch, Requirements};
120	use foch_test::model::{AiMode, CaseIndex, GameDate, RunId, Tag};
121	use foch_test::plan::SessionId;
122	use std::collections::BTreeMap;
123
124	fn bundle() -> Bundle {
125		let date = GameDate::parse("1444.11.11").unwrap();
126		Bundle {
127			protocol: 2,
128			run_id: RunId::parse("run-1").unwrap(),
129			mod_name: "demo".into(),
130			session: SessionId(0),
131			cases: vec![CaseIndex(0), CaseIndex(2)],
132			marker_prefix: "FOCH_TEST_V2 run-1 abc123".into(),
133			namespace: "foch_test_demo_abc123".into(),
134			test_mod_name: "demo tests [abc123]".into(),
135			files: BTreeMap::from([(
136				"commands.txt".to_string(),
137				"speed 0\nrun start.txt\nspeed 5\n".to_string(),
138			)]),
139			launch: Launch {
140				args: vec!["-start_tag=SWE".into()],
141				ai: AiMode::Off,
142				player: Tag::parse("SWE").unwrap(),
143				start_date: date,
144				end_date: date,
145			},
146			checks: Vec::new(),
147			windows: Vec::new(),
148			requires: Requirements {
149				start_date: date,
150				ai_off: true,
151				shared_session: false,
152			},
153		}
154	}
155
156	#[test]
157	fn path_budget_matches_windows_max_path() {
158		// 127 is EU4's longest relative path; a 131-char runtime dir just fits.
159		assert!(fits_path_budget(131, 127));
160		assert!(!fits_path_budget(132, 127));
161	}
162
163	#[test]
164	fn command_file_prepends_ai_only_when_disabling() {
165		let bundle = bundle();
166		assert_eq!(
167			command_file(&bundle, false),
168			"speed 0\nrun start.txt\nspeed 5\n"
169		);
170		assert_eq!(
171			command_file(&bundle, true),
172			"ai\nspeed 0\nrun start.txt\nspeed 5\n"
173		);
174	}
175
176	#[test]
177	fn settings_are_windowed_and_muted_preserving_other_keys() {
178		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";
179		let out = isolated_settings(template);
180		assert!(out.contains("\tfullScreen=no\n"));
181		assert!(out.contains("\tborderless=no\n"));
182		assert!(out.contains("master_volume=0.000000\n"));
183		assert!(out.contains("music_volume=0.000000\n"));
184		assert!(
185			out.contains("\tsize={ x=1920 }\n"),
186			"unrelated keys preserved"
187		);
188		assert!(out.contains("difficulty=\"LUCK_NONE\"\n"));
189	}
190
191	#[test]
192	fn all_cases_ended_requires_every_case() {
193		let bundle = bundle();
194		let line = |case: usize, record: &str| {
195			format!(
196				"[x]: EVENT [1444.11.11]:{} c{case} {record}\n",
197				bundle.marker_prefix
198			)
199		};
200		let mut log = String::new();
201		log.push_str(&line(0, "END"));
202		assert!(!all_cases_ended(&log, &bundle), "case 2 has not ended");
203		log.push_str(&line(2, "PASS scope"));
204		assert!(!all_cases_ended(&log, &bundle));
205		log.push_str(&line(2, "END"));
206		assert!(all_cases_ended(&log, &bundle));
207	}
208
209	#[test]
210	fn layer_classification_copies_files_links_dirs_skips_noise() {
211		assert_eq!(classify_entry("eu4.exe", false), LayerAction::Copy);
212		assert_eq!(classify_entry("d3d9.dll", false), LayerAction::Copy);
213		assert_eq!(classify_entry("common", true), LayerAction::Link);
214		assert_eq!(
215			classify_entry("console_history.txt", false),
216			LayerAction::Skip
217		);
218		assert_eq!(classify_entry("userdir.txt", false), LayerAction::Skip);
219	}
220
221	#[test]
222	fn dlc_load_enables_source_and_test_mods() {
223		assert_eq!(
224			dlc_load(&["source_mod.mod", "test_mod.mod"]),
225			"{\"enabled_mods\":[\"mod/source_mod.mod\",\"mod/test_mod.mod\"],\"disabled_dlcs\":[]}"
226		);
227	}
228}