1pub 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#[derive(Clone, Debug)]
42pub struct DependencyMod {
43 pub name: String,
44 pub path: PathBuf,
45}
46
47#[derive(Clone, Debug)]
49pub struct Installation {
50 pub game_root: PathBuf,
52 pub runtime_base: PathBuf,
55 pub real_user_dir: Option<PathBuf>,
57}
58
59#[derive(Clone, Debug)]
61pub struct RunOptions {
62 pub timeout: Duration,
63 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
77pub 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#[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 pub profile_untouched: bool,
129 pub launch_detail: String,
131}
132
133pub fn capabilities() -> foch_test::compile::RunnerCapabilities {
136 foch_test::compile::RunnerCapabilities {
137 start_dates: vec![foch_test::model::GameDate::parse("1444.11.11").expect("valid date")],
140 ai_off: true,
143 shared_sessions: false,
145 }
146}
147
148pub 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
164const 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 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 ¤t == original {
205 continue;
206 }
207 untouched = false;
208 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
226pub 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 Ok(outcome)
291}
292
293fn 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
300fn 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
337fn 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 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 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 assert!(is_stale(now - Duration::from_secs(7200), now, max_age));
527 assert!(!is_stale(now - Duration::from_secs(60), now, max_age));
528 assert!(!is_stale(now + Duration::from_secs(60), now, max_age));
530 }
531}