1pub 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#[derive(Clone, Debug)]
39pub struct DependencyMod {
40 pub name: String,
41 pub path: PathBuf,
42}
43
44#[derive(Clone, Debug)]
46pub struct Installation {
47 pub game_root: PathBuf,
49 pub runtime_base: PathBuf,
52 pub real_user_dir: Option<PathBuf>,
54}
55
56#[derive(Clone, Debug)]
58pub struct RunOptions {
59 pub timeout: Duration,
60 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
74pub 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#[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 pub profile_untouched: bool,
111 pub launch_detail: String,
113}
114
115pub fn capabilities() -> foch_test::compile::RunnerCapabilities {
118 foch_test::compile::RunnerCapabilities {
119 start_dates: vec![foch_test::model::GameDate::parse("1444.11.11").expect("valid date")],
122 ai_off: true,
125 shared_sessions: false,
127 }
128}
129
130pub 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
146const 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 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 ¤t == original {
187 continue;
188 }
189 untouched = false;
190 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
208pub 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 Ok(outcome)
273}
274
275fn 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
282fn 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
319fn 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 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 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
384struct 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 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 fn drop(&mut self) {
452 platform::remove_layer(&self.directory);
453 }
454}
455
456fn 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 assert!(is_stale(now - Duration::from_secs(7200), now, max_age));
487 assert!(!is_stale(now - Duration::from_secs(60), now, max_age));
488 assert!(!is_stale(now + Duration::from_secs(60), now, max_age));
490 }
491}