pub mod layout;
mod platform;
pub mod runtime;
use runtime::RuntimeLayer;
use foch::game::eu4::Eu4;
use foch::game::eu4::base::snapshot::resolve_game_root;
use foch::input::Config;
use foch_test::RunnerExit;
use foch_test::compile::Bundle;
use foch_test::judge::Timing;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant, SystemTime};
#[derive(Clone, Debug)]
pub struct DependencyMod {
pub name: String,
pub path: PathBuf,
}
#[derive(Clone, Debug)]
pub struct Installation {
pub game_root: PathBuf,
pub runtime_base: PathBuf,
pub real_user_dir: Option<PathBuf>,
}
#[derive(Clone, Debug)]
pub struct RunOptions {
pub timeout: Duration,
pub cancel: Arc<AtomicBool>,
}
impl Default for RunOptions {
fn default() -> Self {
Self {
timeout: Duration::from_secs(300),
cancel: Arc::new(AtomicBool::new(false)),
}
}
}
pub fn sweep_runtime_base(runtime_base: &Path, max_age: Duration) {
let now = SystemTime::now();
let Ok(entries) = fs::read_dir(runtime_base) else {
return;
};
for entry in entries.flatten() {
let Ok(metadata) = entry.metadata() else {
continue;
};
if metadata.is_dir()
&& ordinary_runtime_marker(&entry.path().join("foch-runtime"))
&& fs::symlink_metadata(entry.path().join("foch-player-runtime"))
.is_err_and(|error| error.kind() == io::ErrorKind::NotFound)
&& metadata
.modified()
.is_ok_and(|modified| is_stale(modified, now, max_age))
{
platform::remove_layer(&entry.path());
}
}
}
fn ordinary_runtime_marker(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok_and(|metadata| {
let linked = metadata.file_type().is_symlink();
#[cfg(windows)]
let linked = {
use std::os::windows::fs::MetadataExt;
linked || metadata.file_attributes() & 0x400 != 0
};
metadata.is_file() && !linked
})
}
fn is_stale(modified: SystemTime, now: SystemTime, max_age: Duration) -> bool {
now.duration_since(modified).is_ok_and(|age| age > max_age)
}
#[derive(Clone, Debug)]
pub struct SessionOutcome {
pub game_log: Option<String>,
pub error_log: Option<String>,
pub exit: RunnerExit,
pub timing: Timing,
pub profile_untouched: bool,
pub launch_detail: String,
}
pub fn capabilities() -> foch_test::compile::RunnerCapabilities {
foch_test::compile::RunnerCapabilities {
start_dates: vec![foch_test::model::GameDate::parse("1444.11.11").expect("valid date")],
ai_off: true,
shared_sessions: false,
}
}
pub fn locate_game(config: &Config, explicit: Option<&Path>) -> Result<PathBuf, String> {
if let Some(path) = explicit {
return if path.is_dir() {
Ok(path.to_path_buf())
} else {
Err(format!("game path {} is not a directory", path.display()))
};
}
resolve_game_root(config, &Eu4)
.ok_or_else(|| {
"could not locate the EU4 install; pass --game-path or set game_path.eu4 / the Steam path".into()
})
}
const GUARDED_FILES: &[&str] = &[
"settings.txt",
"pdx_settings.txt",
"dlc_load.json",
"game_data.json",
];
#[derive(Debug)]
struct ProfileGuard {
directory: PathBuf,
before: BTreeMap<&'static str, Option<Vec<u8>>>,
backups: BTreeMap<&'static str, Vec<u8>>,
}
impl ProfileGuard {
fn capture(directory: &Path) -> Self {
let mut before = BTreeMap::new();
let mut backups = BTreeMap::new();
for &name in GUARDED_FILES {
let content = fs::read(directory.join(name)).ok();
if let Some(bytes) = &content {
backups.insert(name, bytes.clone());
}
before.insert(name, content);
}
Self {
directory: directory.to_path_buf(),
before,
backups,
}
}
fn verify_and_restore(&self) -> bool {
let mut untouched = true;
for (&name, original) in &self.before {
let path = self.directory.join(name);
let current = fs::read(&path).ok();
if ¤t == original {
continue;
}
untouched = false;
let restored = match self.backups.get(name) {
Some(bytes) => fs::write(&path, bytes),
None => match fs::remove_file(&path) {
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
other => other,
},
};
if let Err(error) = restored {
eprintln!("warning: could not restore {}: {error}", path.display());
}
}
untouched
}
}
pub fn run_session(
bundle: &Bundle,
source_mod: &Path,
dependencies: &[DependencyMod],
session_dir: &Path,
installation: &Installation,
options: &RunOptions,
) -> io::Result<SessionOutcome> {
let runtime = assemble_runtime(bundle, &installation.game_root, &installation.runtime_base)?;
if let Some(message) = runtime.budget_error() {
return Err(io::Error::other(message));
}
let user_dir = session_dir.join("userdir");
let real_settings = installation
.real_user_dir
.as_ref()
.map(|dir| dir.join("settings.txt"));
assemble_profile(
bundle,
source_mod,
dependencies,
&user_dir,
real_settings.as_deref(),
)?;
let guard = installation
.real_user_dir
.as_ref()
.filter(|dir| dir.is_dir())
.map(|dir| ProfileGuard::capture(dir));
let launch = platform::LaunchSpec {
exe: runtime.directory.join("eu4.exe"),
working_dir: runtime.directory.clone(),
args: launch_args(bundle, &user_dir),
};
let log = user_dir.join("logs").join("game.log");
let error_log = user_dir.join("logs").join("error.log");
let started = Instant::now();
let mut process = platform::spawn(&launch)?;
let launch_detail = launch.detail();
let exit = watch(&mut process, bundle, &log, options.timeout, &options.cancel);
let timing = Timing {
wall_ms: started.elapsed().as_millis() as u64,
..Timing::default()
};
let profile_untouched = guard
.as_ref()
.map(ProfileGuard::verify_and_restore)
.unwrap_or(true);
let outcome = SessionOutcome {
game_log: fs::read_to_string(&log).ok(),
error_log: fs::read_to_string(&error_log).ok(),
exit,
timing,
profile_untouched,
launch_detail,
};
Ok(outcome)
}
fn launch_args(bundle: &Bundle, user_dir: &Path) -> Vec<OsString> {
let mut args = vec![OsString::from(format!("-userdir={}", user_dir.display()))];
args.extend(bundle.launch.args.iter().map(OsString::from));
args
}
fn watch(
process: &mut platform::GameProcess,
bundle: &Bundle,
log: &Path,
timeout: Duration,
cancel: &AtomicBool,
) -> RunnerExit {
let started = Instant::now();
loop {
if cancel.load(Ordering::Relaxed) {
process.stop();
return RunnerExit::Cancelled;
}
if let Some(code) = process.exit_code() {
return if code == 0 {
RunnerExit::Success
} else {
RunnerExit::Failed { code: Some(code) }
};
}
if let Ok(text) = fs::read_to_string(log)
&& layout::all_cases_ended(&text, bundle)
{
process.stop();
return RunnerExit::Success;
}
if started.elapsed() >= timeout {
process.stop();
return RunnerExit::TimedOut {
seconds: timeout.as_secs(),
};
}
std::thread::sleep(Duration::from_millis(200));
}
}
fn assemble_profile(
bundle: &Bundle,
source_mod: &Path,
dependencies: &[DependencyMod],
user_dir: &Path,
real_settings: Option<&Path>,
) -> io::Result<()> {
let mod_dir = user_dir.join("mod");
fs::create_dir_all(&mod_dir)?;
let test_mod = user_dir.join("test_mod");
for (relative, content) in &bundle.files {
let Some(rest) = relative.strip_prefix("test_mod/") else {
continue;
};
let destination = test_mod.join(rest);
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
fs::write(destination, content)?;
}
let mut enabled = Vec::new();
for (index, dependency) in dependencies.iter().enumerate() {
let file = format!("dep_{index:03}.mod");
write_descriptor(&mod_dir.join(&file), &dependency.name, &dependency.path)?;
enabled.push(file);
}
write_descriptor(
&mod_dir.join("source_mod.mod"),
"Foch source mod",
source_mod,
)?;
enabled.push("source_mod.mod".to_string());
write_descriptor(
&mod_dir.join("test_mod.mod"),
&bundle.test_mod_name,
&test_mod,
)?;
enabled.push("test_mod.mod".to_string());
let enabled: Vec<&str> = enabled.iter().map(String::as_str).collect();
fs::write(user_dir.join("dlc_load.json"), layout::dlc_load(&enabled))?;
if let Some(path) = real_settings
&& let Ok(template) = fs::read_to_string(path)
{
fs::write(
user_dir.join("settings.txt"),
layout::isolated_settings(&template),
)?;
}
Ok(())
}
fn write_descriptor(path: &Path, name: &str, mod_path: &Path) -> io::Result<()> {
let normalized = mod_path.to_string_lossy().replace('\\', "/");
fs::write(path, format!("name=\"{name}\"\npath=\"{normalized}\"\n"))
}
fn assemble_runtime(
bundle: &Bundle,
game_root: &Path,
runtime_base: &Path,
) -> io::Result<RuntimeLayer> {
let runtime = RuntimeLayer::prepare(game_root, runtime_base, &bundle.namespace)?;
foch::plugin::store::replace_package_file(
&runtime.directory.join("commands.txt"),
layout::command_file(bundle, bundle.requires.ai_off).as_bytes(),
)?;
for (relative, content) in &bundle.files {
if relative.ends_with("_start.txt") && !relative.contains('/') {
foch::plugin::store::replace_package_file(
&runtime.directory.join(relative),
content.as_bytes(),
)?;
}
}
Ok(runtime)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(any(unix, windows))]
#[test]
fn sweeping_requires_an_ordinary_runtime_marker_and_preserves_other_contents() {
let scratch = tempfile::tempdir().unwrap();
let base = scratch.path().join("runtimes");
let source = scratch.path().join("game");
fs::create_dir(&source).unwrap();
fs::create_dir(&base).unwrap();
let external = scratch.path().join("external-marker");
fs::write(&external, b"external bytes").unwrap();
for name in ["linked", "dangling", "directory-marker", "unmarked"] {
let directory = base.join(name);
fs::create_dir(&directory).unwrap();
fs::write(directory.join("unrelated"), b"keep these bytes").unwrap();
let marker = directory.join("foch-runtime");
match name {
"linked" | "dangling" => {
let target = if name == "linked" {
external.clone()
} else {
scratch.path().join("missing")
};
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &marker).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&target, &marker).unwrap();
}
"directory-marker" => fs::create_dir(&marker).unwrap(),
_ => {}
}
}
let owned = RuntimeLayer::prepare(&source, &base, "owned").unwrap();
sweep_runtime_base(&base, Duration::ZERO);
assert!(!owned.directory.exists());
for name in ["linked", "dangling", "directory-marker", "unmarked"] {
assert_eq!(
fs::read(base.join(name).join("unrelated"))
.unwrap_or_else(|error| panic!("swept {name}: {error}")),
b"keep these bytes",
"swept {name}"
);
}
assert_eq!(fs::read(&external).unwrap(), b"external bytes");
assert_eq!(fs::read_dir(&source).unwrap().count(), 0);
}
#[test]
fn sweeping_preserves_every_existing_player_marker_entry() {
let scratch = tempfile::tempdir().unwrap();
let source = scratch.path().join("game");
let base = scratch.path().join("runtimes");
fs::create_dir(&source).unwrap();
let external = scratch.path().join("external-marker");
fs::write(&external, b"external bytes").unwrap();
for name in ["file", "directory", "link", "dangling", "absent"] {
let mut layer = RuntimeLayer::prepare(&source, &base, name).unwrap();
let directory = layer.retain().unwrap();
fs::write(directory.join("keep"), b"player bytes").unwrap();
let marker = directory.join("foch-player-runtime");
if name != "file" {
fs::remove_file(&marker).unwrap();
}
match name {
"directory" => fs::create_dir(&marker).unwrap(),
"link" | "dangling" => {
let target = if name == "link" {
external.clone()
} else {
scratch.path().join("missing")
};
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &marker).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&target, &marker).unwrap();
}
_ => {}
}
sweep_runtime_base(&base, Duration::ZERO);
if name == "absent" {
assert!(!directory.exists(), "absent marker prevented sweeping");
} else {
assert_eq!(
fs::read(directory.join("keep"))
.unwrap_or_else(|error| panic!("swept {name} player: {error}")),
b"player bytes"
);
platform::remove_layer(&directory);
}
drop(layer);
}
assert_eq!(fs::read(&external).unwrap(), b"external bytes");
assert_eq!(fs::read_dir(&source).unwrap().count(), 0);
}
#[test]
fn stale_layers_are_older_than_the_grace_period() {
let now = SystemTime::now();
let max_age = Duration::from_secs(3600);
assert!(is_stale(now - Duration::from_secs(7200), now, max_age));
assert!(!is_stale(now - Duration::from_secs(60), now, max_age));
assert!(!is_stale(now + Duration::from_secs(60), now, max_age));
}
}