use std::{
collections::{BTreeMap, BTreeSet},
fs,
io::Write,
path::{Path, PathBuf},
process::Command,
};
use chrono::Local;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use crate::{
config::RepoConfig,
console::{Console, ProgressFormat, ProgressReporter},
error::{DoomError, Result},
paths::{is_within, normalize_path, RepoContext},
};
const DEFAULT_INJECTOR_ITEM_ID: i64 = 7475;
const DEFAULT_INJECTOR_DOWNLOAD_API: &str =
"https://gamebanana.com/apiv7/Tool/{item_id}?_csvProperties=_aFiles";
const STREAM_CHUNK_SIZE: usize = 1024 * 1024;
const INJECTOR_NAME: &str = "EternalModInjector.bat";
const INJECTOR_SETTINGS_NAME: &str = "EternalModInjector Settings.txt";
const INJECTOR_WORKDIR_GUARD_NOTE: &str =
"REM xylex-doom: resolve relative paths from this batch file's folder.";
const GAME_EXE_NAME: &str = "DOOMEternalx64vk.exe";
const SANDBOX_EXE_NAME: &str = "DOOMSandBox64vk.exe";
const STEAM_APP_ID: &str = "782330";
const BLANG_REDIRECT_CONTAINER: &str = "gameresources_patch3";
const PATCH_MANIFEST_STEAM_KEY: &str = "8B031F6A24C5C4F3950130C57EF660E9";
const PATCH_MANIFEST_BETHESDA_KEY: &str = "2C05001C1BF0134D1B17D4D5C4D784C0";
const STEAM_GAME_EXE_MD5: &str = "baa2815a445bfdc8784c7bd62f78d291";
const STEAM_GAME_EXE_PATCHED_MD5: &str = "6d12e4973061ccd3bba8463e5777cd2d";
const GOG_GAME_EXE_MD5: &str = "2779176f10354f0d5b6399bbaf31325d";
const GOG_GAME_EXE_PATCHED_MD5: &str = "6434df9ef87702604b537f941e4765bb";
const SANDBOX_EXE_MD5: &str = "361bb11d4860b7de68a27202b998bdb6";
const SANDBOX_EXE_PATCHED_MD5: &str = "07742801e2fed29062a59d0d41b3edb1";
#[derive(Debug, Clone)]
pub struct InstallRequest {
pub game_root: PathBuf,
pub mod_zip: PathBuf,
pub backup_root: PathBuf,
pub install_tools: bool,
pub injector_zip: Option<PathBuf>,
pub tool_cache_root: PathBuf,
pub injector_item_id: i64,
pub skip_injector: bool,
pub wait_for_injector: bool,
pub dry_run: bool,
}
#[derive(Debug, Clone)]
pub struct RestoreRequest {
pub manifest_path: PathBuf,
pub game_root_override: Option<PathBuf>,
pub skip_injector: bool,
pub wait_for_injector: bool,
pub dry_run: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GamePlatform {
Steam,
Gog,
Xbox,
}
#[derive(Debug, Clone)]
pub struct RepairRequest {
pub game_root: PathBuf,
pub dry_run: bool,
}
#[derive(Debug, Clone)]
pub struct CleanRequest {
pub game_root: PathBuf,
pub reset_backups: bool,
pub skip_injector: bool,
pub dry_run: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BackupRecord {
original_path: String,
backup_path: String,
r#type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BackupManifest {
action: String,
created_at: String,
game_root: String,
mods_dir: String,
injector_path: String,
mod_zip_source: String,
installed_entries: Vec<String>,
backups: Vec<BackupRecord>,
tool_install: Option<ToolInstallRecord>,
notes: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ToolInstallRecord {
source: serde_json::Value,
archive_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstalledModRecord {
pub file_name: String,
pub path: String,
pub name: String,
pub version: String,
pub enabled: bool,
pub installed_at: Option<String>,
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InstalledModRegistry {
version: u32,
mods: Vec<InstalledModRecord>,
}
#[derive(Debug, Clone, Default)]
struct ModZipMetadata {
name: Option<String>,
version: Option<String>,
}
struct InjectorToolInstallRequest<'a> {
game_root: &'a Path,
backup_dir: &'a Path,
backup_records: &'a mut Vec<BackupRecord>,
installed_entries: &'a mut Vec<String>,
injector_zip: Option<&'a Path>,
tool_cache_root: &'a Path,
injector_item_id: i64,
dry_run: bool,
}
#[derive(Debug, Clone)]
struct InjectorToolLayout {
game_root: PathBuf,
base_root: PathBuf,
settings_path: PathBuf,
load_mods_exe: PathBuf,
rehash_exe: PathBuf,
patch_manifest_exe: PathBuf,
eternal_patcher_exe: PathBuf,
game_exe: PathBuf,
sandbox_exe: PathBuf,
}
#[derive(Debug, Clone, Default)]
struct InjectorConfiguration {
values: BTreeMap<String, String>,
resource_lines: BTreeSet<String>,
}
pub fn default_injector_item_id() -> i64 {
DEFAULT_INJECTOR_ITEM_ID
}
impl InjectorConfiguration {
fn load(path: &Path) -> Result<Self> {
if !path.is_file() {
return Err(DoomError::message(format!(
"Missing injector settings file: {}",
path.display()
)));
}
let mut config = Self::default();
for line in fs::read_to_string(path)?.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some((key, value)) = trimmed
.strip_prefix(':')
.and_then(|value| value.split_once('='))
{
config
.values
.insert(key.trim().to_string(), value.trim().to_string());
continue;
}
config.resource_lines.insert(trimmed.to_string());
}
Ok(config)
}
fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let ordered_keys = [
"ASSET_VERSION",
"AUTO_LAUNCH_GAME",
"AUTO_UPDATE",
"COMPRESS_TEXTURES",
"DISABLE_MULTITHREADING",
"GAME_PARAMETERS",
"HAS_CHECKED_RESOURCES",
"HAS_READ_FIRST_TIME",
"ONLINE_SAFE",
"RESET_BACKUPS",
"SLOW",
"VERBOSE",
];
let mut lines = Vec::new();
for key in ordered_keys {
if let Some(value) = self.values.get(key) {
lines.push(format!(":{key}={value}"));
}
}
for (key, value) in &self.values {
if ordered_keys.contains(&key.as_str()) {
continue;
}
lines.push(format!(":{key}={value}"));
}
lines.push(String::new());
lines.extend(self.resource_lines.iter().cloned());
lines.push(String::new());
fs::write(path, lines.join("\n"))?;
Ok(())
}
fn flag_enabled(&self, key: &str) -> bool {
self.values
.get(key)
.map(|value| value == "1")
.unwrap_or(false)
}
fn has_checked_resources(&self) -> bool {
self.flag_enabled("HAS_CHECKED_RESOURCES")
}
fn set_has_checked_resources(&mut self, enabled: bool) {
self.values.insert(
"HAS_CHECKED_RESOURCES".to_string(),
if enabled { "1" } else { "0" }.to_string(),
);
}
fn auto_launch_game(&self) -> bool {
self.values
.get("AUTO_LAUNCH_GAME")
.map(|value| value != "0")
.unwrap_or(true)
}
fn game_parameters(&self) -> Option<&str> {
self.values
.get("GAME_PARAMETERS")
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn build_load_mods_args(&self) -> Vec<String> {
let mut args = vec![
".".to_string(),
"--redirectBlangContainer".to_string(),
BLANG_REDIRECT_CONTAINER.to_string(),
];
if self.flag_enabled("COMPRESS_TEXTURES") {
args.push("--compress-textures".to_string());
}
if self.flag_enabled("DISABLE_MULTITHREADING") {
args.push("--disable-multithreading".to_string());
}
if self.flag_enabled("ONLINE_SAFE") {
args.push("--online-safe".to_string());
}
if self.flag_enabled("SLOW") {
args.push("--slow".to_string());
}
if self.flag_enabled("VERBOSE") {
args.push("--verbose".to_string());
}
args
}
fn is_resource_backed_up(&self, key: &str) -> bool {
self.resource_lines.contains(&format!("{key}.backup"))
}
fn is_resource_modified(&self, modified_line: &str) -> bool {
self.resource_lines.contains(modified_line)
}
fn mark_resource_backed_up(&mut self, key: &str) {
self.resource_lines.insert(format!("{key}.backup"));
}
fn mark_resource_modified(&mut self, modified_line: &str) {
self.resource_lines.insert(modified_line.to_string());
}
fn clear_resource_modified(&mut self, modified_line: &str) {
self.resource_lines.remove(modified_line);
}
}
pub fn discover_game_root(repo: &RepoContext) -> Option<PathBuf> {
let env_candidates = ["DOOM_ETERNAL_ROOT", "DOOM_ETERNAL_DIR", "DOOM_ROOT"];
for variable in env_candidates {
if let Some(raw_candidate) = std::env::var_os(variable) {
let candidate = PathBuf::from(raw_candidate);
if candidate.exists() {
if let Some(resolved) = try_resolve_game_root(&candidate) {
return Some(resolved);
}
}
}
}
let config = RepoConfig::load_or_default(repo);
for candidate in config.game_root_candidates(repo) {
if candidate.exists() {
if let Some(resolved) = try_resolve_game_root(&candidate) {
return Some(resolved);
}
}
}
None
}
pub fn resolve_game_root(
game_root: Option<&Path>,
repo: &RepoContext,
console: &Console,
) -> Result<PathBuf> {
if game_root.is_none() || game_root.is_some_and(|path| path.as_os_str().is_empty()) {
if let Some(discovered) = discover_game_root(repo) {
return Ok(discovered);
}
let suggestions = RepoConfig::load_or_default(repo)
.game_root_candidates(repo)
.into_iter()
.map(|candidate| format!("- {}", candidate.display()))
.collect::<Vec<_>>()
.join("\n");
return Err(DoomError::message(format!(
"Could not auto-detect the DOOM Eternal install root.\nPass --game-root explicitly or set steam.libraryRoots in config.yaml. Candidates:\n{suggestions}"
)));
}
let candidate = game_root.expect("checked above");
if !candidate.exists() {
return Err(DoomError::message(format!(
"Missing DOOM Eternal path: {}",
candidate.display()
)));
}
let resolved = try_resolve_game_root(candidate).ok_or_else(|| {
DoomError::message(format!(
"Expected a DOOM Eternal install root containing {INJECTOR_NAME} or {GAME_EXE_NAME}. Got: {}",
console.shorten_path(candidate)
))
})?;
Ok(resolved)
}
pub async fn download_injector_zip_to_cache(
console: &Console,
tool_cache_root: &Path,
injector_zip: Option<&Path>,
item_id: i64,
dry_run: bool,
) -> Result<PathBuf> {
let (archive_path, _) =
obtain_injector_zip(console, injector_zip, tool_cache_root, item_id, dry_run).await?;
Ok(archive_path)
}
pub fn launch_installed_injector(
console: &Console,
game_root: &Path,
wait_for_injector: bool,
dry_run: bool,
) -> Result<()> {
launch_injector(console, game_root, wait_for_injector, dry_run)
}
pub async fn install_mod(console: &Console, request: InstallRequest) -> Result<PathBuf> {
if !request.mod_zip.is_file() {
return Err(DoomError::message(format!(
"Missing mod zip: {}",
console.shorten_path(&request.mod_zip)
)));
}
let mod_metadata = read_mod_zip_metadata(&request.mod_zip)?;
let mods_dir = request.game_root.join("Mods");
let installed_mod_path = mods_dir.join(
request
.mod_zip
.file_name()
.map(|value| value.to_os_string())
.ok_or_else(|| {
DoomError::message(format!(
"Mod zip has no file name: {}",
request.mod_zip.display()
))
})?,
);
let backup_dir = make_backup_dir(&request.backup_root);
let manifest_path = backup_dir.join("backup-manifest.json");
console.log_info(format!(
"Installing mod zip {} into {}",
console.format_path(&request.mod_zip),
console.format_path(&mods_dir)
));
if request.dry_run {
console.log_dry_run(format!(
"ensure mods dir {}",
console.format_path(&mods_dir)
));
} else {
fs::create_dir_all(&mods_dir)?;
}
let mod_zip_name = request
.mod_zip
.file_name()
.map(|value| value.to_string_lossy().to_string())
.ok_or_else(|| {
DoomError::message(format!(
"Mod zip has no file name: {}",
request.mod_zip.display()
))
})?;
let mut backup_records = backup_existing_entries(
console,
&mods_dir,
&mod_zip_name,
&backup_dir,
request.dry_run,
)?;
if backup_records.is_empty() {
console.log_info("No existing Xylex mod payload needed a backup.");
} else {
console.log_info(format!(
"Backed up {} before install.",
backup_records.len()
));
}
let mut installed_entries = Vec::new();
let tool_install = if request.install_tools {
Some(
install_injector_tools(
console,
InjectorToolInstallRequest {
game_root: &request.game_root,
backup_dir: &backup_dir,
backup_records: &mut backup_records,
installed_entries: &mut installed_entries,
injector_zip: request.injector_zip.as_deref(),
tool_cache_root: &request.tool_cache_root,
injector_item_id: request.injector_item_id,
dry_run: request.dry_run,
},
)
.await?,
)
} else {
console.log_info("Skipping injector tool install.");
None
};
if request.dry_run {
console.log_dry_run(format!(
"copy {} -> {}",
console.format_path(&request.mod_zip),
console.format_path(&installed_mod_path)
));
} else {
fs::copy(&request.mod_zip, &installed_mod_path)?;
upsert_installed_mod_record(
&mods_dir,
InstalledModRecord {
file_name: mod_zip_name.clone(),
path: installed_mod_path.display().to_string(),
name: mod_metadata
.name
.unwrap_or_else(|| mod_name_from_zip_file_name(&mod_zip_name)),
version: mod_metadata
.version
.unwrap_or_else(|| "0.0.0-local".to_string()),
enabled: true,
installed_at: Some(Local::now().to_rfc3339()),
source: Some(request.mod_zip.display().to_string()),
},
)?;
console.log_info(format!(
"Copied mod zip into Mods/: {}",
console.format_path(&installed_mod_path)
));
}
installed_entries.push(installed_mod_path.display().to_string());
let manifest = BackupManifest {
action: "install".to_string(),
created_at: Local::now().to_rfc3339(),
game_root: request.game_root.display().to_string(),
mods_dir: mods_dir.display().to_string(),
injector_path: request.game_root.join(INJECTOR_NAME).display().to_string(),
mod_zip_source: request.mod_zip.display().to_string(),
installed_entries,
backups: backup_records,
tool_install,
notes: "This manifest tracks the previous mod payload in DOOM Eternal's Mods folder and any EternalModInjector files that were overwritten. EternalModInjector manages the game's own backup/restore flow.".to_string(),
};
console.log_info(format!(
"Writing backup manifest: {}",
console.format_path(&manifest_path)
));
write_manifest(console, &manifest_path, &manifest, request.dry_run)?;
if request.skip_injector {
console.log_success(format!(
"Skipped {} launch.",
console.format_tool_name(INJECTOR_NAME)
));
} else {
if !request.wait_for_injector {
console.log_info(
"Running the injector toolchain inline from the Rust CLI. Use `tools launch injector` for the legacy batch window.",
);
}
run_native_injector(console, &request.game_root, request.dry_run)?;
console.log_success("Native injector flow completed.");
}
Ok(manifest_path)
}
pub fn resolve_restore_manifest(
manifest: Option<&Path>,
backup_root: &Path,
console: &Console,
) -> Result<PathBuf> {
if let Some(manifest) = manifest {
if !manifest.as_os_str().is_empty() {
let candidate = manifest.to_path_buf();
if !candidate.is_file() {
return Err(DoomError::message(format!(
"Missing backup manifest: {}",
console.shorten_path(&candidate)
)));
}
return Ok(candidate);
}
}
let mut manifests = fs::read_dir(backup_root)?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path().join("backup-manifest.json"))
.filter(|path| path.is_file())
.collect::<Vec<_>>();
manifests.sort();
manifests.pop().ok_or_else(|| {
DoomError::message(format!(
"No backup manifests found under {}",
console.shorten_path(backup_root)
))
})
}
pub fn restore_mod(console: &Console, repo: &RepoContext, request: RestoreRequest) -> Result<()> {
let manifest = load_backup_manifest(&request.manifest_path, console)?;
let manifest_game_root = PathBuf::from(&manifest.game_root);
let game_root = request
.game_root_override
.clone()
.map(Ok)
.unwrap_or_else(|| resolve_game_root(Some(&manifest_game_root), repo, console))?;
let mods_dir = game_root.join("Mods");
if !is_within(&mods_dir, &game_root) {
return Err(DoomError::message(format!(
"Resolved Mods folder is outside the game root: {}",
console.shorten_path(&mods_dir)
)));
}
let backed_up_targets = manifest
.backups
.iter()
.map(|record| record.original_path.clone())
.collect::<std::collections::HashSet<_>>();
for installed_entry in &manifest.installed_entries {
if backed_up_targets.contains(installed_entry) {
continue;
}
let installed_path = PathBuf::from(installed_entry);
if !is_within(&installed_path, &game_root) {
return Err(DoomError::message(format!(
"Refusing to delete path outside the game root: {}",
console.shorten_path(&installed_path)
)));
}
if request.dry_run {
console.log_dry_run(format!(
"remove installed entry {}",
console.format_path(&installed_path)
));
} else {
remove_entry(&installed_path)?;
}
}
if !request.dry_run {
prune_installed_mod_registry(&mods_dir)?;
}
for record in &manifest.backups {
let backup_path = PathBuf::from(&record.backup_path);
let original_path = PathBuf::from(&record.original_path);
if !backup_path.exists() {
return Err(DoomError::message(format!(
"Missing backup entry: {}",
console.shorten_path(&backup_path)
)));
}
if !is_within(&original_path, &game_root) {
return Err(DoomError::message(format!(
"Refusing to restore path outside the game root: {}",
console.shorten_path(&original_path)
)));
}
if request.dry_run {
console.log_dry_run(format!(
"restore {} -> {}",
console.format_path(&backup_path),
console.format_path(&original_path)
));
} else {
remove_entry(&original_path)?;
copy_entry(&backup_path, &original_path)?;
}
}
if request.skip_injector {
console.log_success(format!(
"Skipped {} launch.",
console.format_tool_name(INJECTOR_NAME)
));
} else {
if !request.wait_for_injector {
console.log_info(
"Running the injector toolchain inline from the Rust CLI. Use `tools launch injector` for the legacy batch window.",
);
}
run_native_injector(console, &game_root, request.dry_run)?;
console.log_success("Native injector flow completed.");
}
Ok(())
}
pub fn detect_game_platform(game_root: &Path) -> GamePlatform {
if game_root.join("steam_api64.dll").is_file() {
GamePlatform::Steam
} else if game_root.join("Galaxy64.dll").is_file() {
GamePlatform::Gog
} else {
GamePlatform::Xbox
}
}
pub fn repair_game_installation(console: &Console, request: RepairRequest) -> Result<()> {
let platform = detect_game_platform(&request.game_root);
console.log_info(format!(
"Repairing DOOM Eternal through {} ({})",
platform_label(platform),
console.shorten_path(&request.game_root)
));
match platform {
GamePlatform::Steam => launch_steam_validate(console, request.dry_run),
GamePlatform::Gog => {
console.log_info(
"Open GOG Galaxy, right-click DOOM Eternal, choose Manage installation > Verify / Repair, and wait for it to finish.",
);
Ok(())
}
GamePlatform::Xbox => {
console.log_info(
"Repair DOOM Eternal through the Xbox App or Microsoft Store. If that is unavailable, reinstall the game.",
);
Ok(())
}
}
}
pub fn clean_game_installation(console: &Console, request: CleanRequest) -> Result<()> {
let layout = resolve_injector_layout(console, &request.game_root)?;
let removed_mods = clear_mods_folder(console, &request.game_root, request.dry_run)?;
if !request.dry_run {
write_installed_mod_registry(
&request.game_root.join("Mods"),
InstalledModRegistry {
version: 1,
mods: Vec::new(),
},
)?;
}
console.log_info(format!(
"Removed {removed_mods} mod payload entr{} from Mods/.",
if removed_mods == 1 { "y" } else { "ies" }
));
let mut config = InjectorConfiguration::load(&layout.settings_path)?;
restore_native_modified_resources(console, &layout, &mut config, request.dry_run)?;
delete_eternal_mod_streamdb(console, &layout.base_root, request.dry_run)?;
if request.reset_backups {
reset_injector_backups(console, &layout, &mut config, request.dry_run)?;
console.log_info(
"Cleared EternalModInjector backup files and resource tracking. Run repair first when backups were stale.",
);
}
if request.skip_injector {
console.log_success("Skipped injector rehash and manifest patch.");
return Ok(());
}
ensure_native_resource_hash_offsets(console, &layout, &mut config, request.dry_run)?;
console.log_info("Patching manifest back to vanilla... (DEternal_patchManifest)");
patch_native_manifest(console, &layout, request.dry_run)?;
console.log_success("DOOM Eternal mod state cleaned.");
Ok(())
}
pub fn try_resolve_game_root(path: &Path) -> Option<PathBuf> {
let resolved = normalize_path(path).ok()?;
if resolved.join(INJECTOR_NAME).is_file() || resolved.join(GAME_EXE_NAME).is_file() {
return Some(resolved);
}
if resolved
.file_name()
.map(|value| value.to_string_lossy().eq_ignore_ascii_case("base"))
.unwrap_or(false)
&& (resolved.parent()?.join(INJECTOR_NAME).is_file()
|| resolved.parent()?.join(GAME_EXE_NAME).is_file())
{
return resolved.parent().map(Path::to_path_buf);
}
None
}
fn make_backup_dir(backup_root: &Path) -> PathBuf {
let timestamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
let mut backup_dir = backup_root.join(×tamp);
let mut suffix = 1;
while backup_dir.exists() {
backup_dir = backup_root.join(format!("{timestamp}-{suffix}"));
suffix += 1;
}
backup_dir
}
fn ensure_unique_backup_record(
records: &mut Vec<BackupRecord>,
original_path: &Path,
backup_path: &Path,
entry_type: &str,
) {
let original_as_str = original_path.display().to_string();
if records
.iter()
.any(|record| record.original_path == original_as_str)
{
return;
}
records.push(BackupRecord {
original_path: original_as_str,
backup_path: backup_path.display().to_string(),
r#type: entry_type.to_string(),
});
}
fn backup_existing_entries(
console: &Console,
mods_dir: &Path,
mod_zip_name: &str,
backup_dir: &Path,
dry_run: bool,
) -> Result<Vec<BackupRecord>> {
let mut candidates = vec![
mods_dir.join(mod_zip_name),
mods_dir.join(Path::new(mod_zip_name).file_stem().unwrap_or_default()),
];
if mods_dir.is_dir() {
for entry in fs::read_dir(mods_dir)? {
let entry = entry?;
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
let lower_name = file_name.to_ascii_lowercase();
if lower_name.starts_with("xylex")
&& (path.is_dir() || lower_name.ends_with(".zip"))
&& !candidates.iter().any(|candidate| candidate == &path)
{
candidates.push(path);
}
}
}
let mut records = Vec::new();
let backup_mods_dir = backup_dir.join("mods");
for candidate in candidates {
if !candidate.exists() {
continue;
}
let relative_name = candidate
.file_name()
.map(|value| value.to_os_string())
.ok_or_else(|| {
DoomError::message(format!(
"Backup candidate has no file name: {}",
candidate.display()
))
})?;
let backup_path = backup_mods_dir.join(relative_name);
ensure_unique_backup_record(
&mut records,
&candidate,
&backup_path,
if candidate.is_dir() { "dir" } else { "file" },
);
if dry_run {
console.log_dry_run(format!(
"backup {} -> {}",
console.format_path(&candidate),
console.format_path(&backup_path)
));
} else {
copy_entry(&candidate, &backup_path)?;
}
}
Ok(records)
}
fn copy_entry(source: &Path, destination: &Path) -> Result<()> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
if source.is_dir() {
if destination.exists() {
fs::remove_dir_all(destination)?;
}
copy_dir_recursive(source, destination)?;
return Ok(());
}
fs::copy(source, destination)?;
Ok(())
}
fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> {
fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let child_source = entry.path();
let child_destination = destination.join(entry.file_name());
if child_source.is_dir() {
copy_dir_recursive(&child_source, &child_destination)?;
} else {
if let Some(parent) = child_destination.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&child_source, &child_destination)?;
}
}
Ok(())
}
fn remove_entry(path: &Path) -> Result<()> {
if !path.exists() {
return Ok(());
}
if path.is_dir() {
fs::remove_dir_all(path)?;
} else {
fs::remove_file(path)?;
}
Ok(())
}
fn write_manifest(
console: &Console,
manifest_path: &Path,
payload: &BackupManifest,
dry_run: bool,
) -> Result<()> {
if dry_run {
console.log_dry_run(format!(
"write manifest {}",
console.format_path(manifest_path)
));
return Ok(());
}
if let Some(parent) = manifest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(manifest_path, serde_json::to_string_pretty(payload)?)?;
Ok(())
}
async fn fetch_injector_metadata(item_id: i64) -> Result<serde_json::Value> {
let api_url = DEFAULT_INJECTOR_DOWNLOAD_API.replace("{item_id}", &item_id.to_string());
let client = browserish_client()?;
let payload = client
.get(api_url)
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?;
let files = payload
.get("_aFiles")
.and_then(|value| value.as_array())
.ok_or_else(|| {
DoomError::message(format!(
"GameBanana item {item_id} did not expose any downloadable files."
))
})?;
let newest = files
.iter()
.max_by_key(|item| {
(
item.get("_tsDateAdded")
.and_then(|value| value.as_i64())
.unwrap_or_default(),
item.get("_idRow")
.and_then(|value| value.as_i64())
.unwrap_or_default(),
)
})
.ok_or_else(|| {
DoomError::message(format!(
"GameBanana item {item_id} did not expose any downloadable files."
))
})?;
Ok(newest.clone())
}
fn verify_md5(console: &Console, file_path: &Path, expected_md5: &str, label: &str) -> Result<()> {
let total_size = file_path
.metadata()
.map(|metadata| metadata.len())
.unwrap_or(0);
let mut reporter = ProgressReporter::new(console, label, total_size, ProgressFormat::Bytes);
reporter.update(0, true);
let mut handle = fs::File::open(file_path)?;
let actual_md5 = compute_md5_from_reader(&mut handle, |bytes_read| {
reporter.update(bytes_read, false);
})?;
reporter.finish();
if !actual_md5.eq_ignore_ascii_case(expected_md5) {
return Err(DoomError::message(format!(
"Downloaded injector zip checksum mismatch for {}: expected {}, got {}",
console.shorten_path(file_path),
expected_md5,
actual_md5
)));
}
Ok(())
}
fn compute_md5(path: &Path) -> Result<String> {
let mut handle = fs::File::open(path)?;
compute_md5_from_reader(&mut handle, |_| {})
}
fn compute_md5_from_reader(
handle: &mut fs::File,
mut on_progress: impl FnMut(u64),
) -> Result<String> {
let mut digest = md5::Context::new();
let mut bytes_read = 0_u64;
loop {
let mut buffer = vec![0_u8; STREAM_CHUNK_SIZE];
let read = std::io::Read::read(&mut *handle, &mut buffer)?;
if read == 0 {
break;
}
digest.consume(&buffer[..read]);
bytes_read += read as u64;
on_progress(bytes_read);
}
Ok(format!("{:x}", digest.compute()))
}
async fn obtain_injector_zip(
console: &Console,
injector_zip: Option<&Path>,
tool_cache_root: &Path,
item_id: i64,
dry_run: bool,
) -> Result<(PathBuf, serde_json::Value)> {
if let Some(injector_zip) = injector_zip {
if !injector_zip.is_file() {
return Err(DoomError::message(format!(
"Missing injector zip: {}",
console.shorten_path(injector_zip)
)));
}
console.log_info(format!(
"Using local injector zip: {}",
console.format_path(injector_zip)
));
return Ok((
injector_zip.to_path_buf(),
serde_json::json!({ "source": "local", "path": injector_zip }),
));
}
console.log_info(format!(
"Fetching {} metadata from {}...",
console.format_tool_name("EternalModInjector"),
console.ansi(&format!("GameBanana item {item_id}"), &["\u{1b}[1m"])
));
let metadata = fetch_injector_metadata(item_id).await?;
let download_url = metadata
.get("_sDownloadUrl")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
DoomError::message(format!(
"GameBanana item {item_id} is missing download metadata."
))
})?;
let file_name = metadata
.get("_sFile")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
DoomError::message(format!(
"GameBanana item {item_id} is missing download metadata."
))
})?;
let expected_md5 = metadata
.get("_sMd5Checksum")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty());
let cache_file = tool_cache_root.join(file_name);
if cache_file.is_file() {
console.log_info(format!(
"Using cached injector zip: {}",
console.format_path(&cache_file)
));
if let Some(expected_md5) = expected_md5 {
if verify_md5(
console,
&cache_file,
expected_md5,
"Verifying cached injector zip",
)
.is_ok()
{
return Ok((cache_file, metadata));
}
console.log_info("Cached injector zip checksum mismatch. Re-downloading it.");
let _ = fs::remove_file(&cache_file);
} else {
return Ok((cache_file, metadata));
}
}
if dry_run {
console.log_dry_run(format!(
"download injector tools {download_url} -> {}",
console.format_path(&cache_file)
));
return Ok((cache_file, metadata));
}
fs::create_dir_all(tool_cache_root)?;
console.log_info(format!(
"Downloading injector zip to cache: {}",
console.format_path(&cache_file)
));
download_to_path(
console,
download_url,
&cache_file,
"Downloading injector zip",
)
.await?;
if let Some(expected_md5) = expected_md5 {
verify_md5(
console,
&cache_file,
expected_md5,
"Verifying downloaded injector zip",
)?;
}
Ok((cache_file, metadata))
}
fn iter_archive_files(archive_path: &Path) -> Result<Vec<(String, bool)>> {
let file = fs::File::open(archive_path)?;
let mut archive = zip::ZipArchive::new(file)?;
let mut members = Vec::new();
for index in 0..archive.len() {
let item = archive.by_index(index)?;
let member_path = PathBuf::from(item.name());
if item.name().is_empty()
|| member_path.is_absolute()
|| member_path
.components()
.any(|component| component.as_os_str() == "..")
{
return Err(DoomError::message(format!(
"Unsafe path in injector archive: {}",
item.name()
)));
}
members.push((item.name().to_string(), item.is_dir()));
}
Ok(members)
}
async fn install_injector_tools(
console: &Console,
request: InjectorToolInstallRequest<'_>,
) -> Result<ToolInstallRecord> {
let (archive_path, metadata) = obtain_injector_zip(
console,
request.injector_zip,
request.tool_cache_root,
request.injector_item_id,
request.dry_run,
)
.await?;
let backup_tools_dir = request.backup_dir.join("tools");
console.log_info(format!(
"Preparing injector tool install from {}",
console.format_path(&archive_path)
));
if request.dry_run {
let member_descriptions = if archive_path.exists() {
iter_archive_files(&archive_path)?
} else {
Vec::new()
};
for (member_name, is_dir) in member_descriptions {
let target_path = request.game_root.join(Path::new(&member_name));
if is_dir {
console.log_dry_run(format!(
"ensure tool dir {}",
console.format_path(&target_path)
));
} else {
console.log_dry_run(format!(
"install tool file {} -> {}",
console.ansi(&member_name, &["\u{1b}[1m"]),
console.format_path(&target_path)
));
request
.installed_entries
.push(target_path.display().to_string());
}
}
return Ok(ToolInstallRecord {
source: metadata,
archive_path: archive_path.display().to_string(),
});
}
let temp_dir = tempfile::TempDir::new()?;
let file = fs::File::open(&archive_path)?;
let mut archive = zip::ZipArchive::new(file)?;
let file_members = (0..archive.len())
.filter_map(|index| {
archive
.by_index(index)
.ok()
.map(|entry| (!entry.is_dir(), entry.name().to_string()))
})
.filter(|(is_file, _)| *is_file)
.collect::<Vec<_>>();
let mut extract_reporter = ProgressReporter::new(
console,
"Extracting injector archive",
file_members.len() as u64,
ProgressFormat::FileCount,
);
extract_reporter.update(0, true);
for index in 0..archive.len() {
let mut member = archive.by_index(index)?;
let member_path = PathBuf::from(member.name());
if member.name().is_empty()
|| member_path.is_absolute()
|| member_path
.components()
.any(|component| component.as_os_str() == "..")
{
return Err(DoomError::message(format!(
"Unsafe path in injector archive: {}",
member.name()
)));
}
let destination = temp_dir.path().join(&member_path);
if member.is_dir() {
fs::create_dir_all(&destination)?;
continue;
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
let mut output = fs::File::create(&destination)?;
std::io::copy(&mut member, &mut output)?;
output.flush()?;
extract_reporter.advance(1);
}
extract_reporter.finish();
let source_files = walkdir::WalkDir::new(temp_dir.path())
.into_iter()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_file())
.map(|entry| entry.path().to_path_buf())
.collect::<Vec<_>>();
let mut install_reporter = ProgressReporter::new(
console,
"Installing injector tool files",
source_files.len() as u64,
ProgressFormat::FileCount,
);
install_reporter.update(0, true);
for source_file in source_files {
let relative_path = source_file.strip_prefix(temp_dir.path())?;
let target_path = request.game_root.join(relative_path);
if !is_within(&target_path, request.game_root) {
return Err(DoomError::message(format!(
"Refusing to write tool file outside game root: {}",
console.shorten_path(&target_path)
)));
}
if target_path.exists() {
let backup_path = backup_tools_dir.join(relative_path);
ensure_unique_backup_record(request.backup_records, &target_path, &backup_path, "file");
if !backup_path.exists() {
copy_entry(&target_path, &backup_path)?;
}
}
copy_entry(&source_file, &target_path)?;
if target_path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case(INJECTOR_NAME))
{
ensure_injector_batch_workdir_guard(&target_path)?;
}
request
.installed_entries
.push(target_path.display().to_string());
install_reporter.advance(1);
}
install_reporter.finish();
fs::create_dir_all(request.game_root.join("Mods"))?;
Ok(ToolInstallRecord {
source: metadata,
archive_path: archive_path.display().to_string(),
})
}
fn run_native_injector(console: &Console, game_root: &Path, dry_run: bool) -> Result<()> {
let layout = resolve_injector_layout(console, game_root)?;
let mut config = InjectorConfiguration::load(&layout.settings_path)?;
console.log_info("Restoring modified files...");
restore_native_modified_resources(console, &layout, &mut config, dry_run)?;
delete_eternal_mod_streamdb(console, &layout.base_root, dry_run)?;
ensure_native_resource_hash_offsets(console, &layout, &mut config, dry_run)?;
let has_mods = mods_exist(&layout.game_root)?;
console.log_info("Checking for mods... (DEternal_loadMods)");
if !has_mods {
console.log_info("No mods were found in Mods/. Keeping the restored vanilla resources.");
patch_native_manifest(console, &layout, dry_run)?;
launch_game_from_native_injector(console, &layout, &config, dry_run)?;
return Ok(());
}
patch_native_executables(console, &layout, dry_run)?;
let mut resource_paths =
collect_native_mod_target_resources(console, &layout, &config, dry_run)?;
let meta_resources = layout.base_root.join("meta.resources");
if !resource_paths.iter().any(|path| path == &meta_resources) {
resource_paths.push(meta_resources);
}
backup_and_mark_native_resources(console, &mut config, &resource_paths, dry_run)?;
delete_eternal_mod_streamdb(console, &layout.base_root, dry_run)?;
if !dry_run {
config.save(&layout.settings_path)?;
}
console.log_info("Loading mods... (DEternal_loadMods)");
run_command_and_relay_output(
&layout.load_mods_exe,
&config.build_load_mods_args(),
&layout.game_root,
dry_run,
)?;
console.log_info("Rehashing resource hashes... (idRehash)");
run_command_and_relay_output(&layout.rehash_exe, &[], &layout.base_root, dry_run)?;
console.log_info("Patching manifest... (DEternal_patchManifest)");
patch_native_manifest(console, &layout, dry_run)?;
launch_game_from_native_injector(console, &layout, &config, dry_run)?;
Ok(())
}
fn resolve_injector_layout(console: &Console, game_root: &Path) -> Result<InjectorToolLayout> {
let base_root = game_root.join("base");
let layout = InjectorToolLayout {
game_root: game_root.to_path_buf(),
base_root: base_root.clone(),
settings_path: game_root.join(INJECTOR_SETTINGS_NAME),
load_mods_exe: base_root.join("DEternal_loadMods.exe"),
rehash_exe: base_root.join("idRehash.exe"),
patch_manifest_exe: base_root.join("DEternal_patchManifest.exe"),
eternal_patcher_exe: base_root.join("EternalPatcher.exe"),
game_exe: game_root.join(GAME_EXE_NAME),
sandbox_exe: game_root.join("doomSandBox").join(SANDBOX_EXE_NAME),
};
for required in [
&layout.settings_path,
&layout.load_mods_exe,
&layout.rehash_exe,
&layout.patch_manifest_exe,
&layout.eternal_patcher_exe,
&layout.base_root.join("EternalPatcher.def"),
&layout.base_root.join("EternalPatcher.exe.config"),
] {
if !required.is_file() {
return Err(DoomError::message(format!(
"Missing injector runtime file: {}. Run install with --install-tools first.",
console.shorten_path(required)
)));
}
}
if !layout.base_root.join("meta.resources").is_file() {
return Err(DoomError::message(format!(
"Missing game resource file: {}",
console.shorten_path(layout.base_root.join("meta.resources"))
)));
}
if !layout.base_root.join("warehouse.resources").is_file() {
return Err(DoomError::message(format!(
"Missing game resource file: {}",
console.shorten_path(layout.base_root.join("warehouse.resources"))
)));
}
Ok(layout)
}
fn restore_native_modified_resources(
console: &Console,
layout: &InjectorToolLayout,
config: &mut InjectorConfiguration,
dry_run: bool,
) -> Result<()> {
let modified_lines = config
.resource_lines
.iter()
.filter(|line| line.ends_with(".resources"))
.cloned()
.collect::<Vec<_>>();
let mut changed = false;
for modified_line in modified_lines {
if !config.is_resource_modified(&modified_line) {
continue;
}
let resource_path = layout.base_root.join(&modified_line);
let key = resource_config_key(&resource_path);
if !config.is_resource_backed_up(&key) {
return Err(DoomError::message(format!(
"Injector settings mark {} as modified, but no backup entry exists.",
modified_line
)));
}
let backup_path = backup_path_for_resource(&resource_path)?;
if !backup_path.is_file() {
return Err(DoomError::message(format!(
"Missing injector backup file: {}",
console.shorten_path(&backup_path)
)));
}
if dry_run {
console.log_dry_run(format!(
"restore {} -> {}",
console.format_path(&backup_path),
console.format_path(&resource_path)
));
} else {
fs::copy(&backup_path, &resource_path)?;
config.clear_resource_modified(&modified_line);
changed = true;
}
}
if changed {
config.save(&layout.settings_path)?;
}
Ok(())
}
fn ensure_native_resource_hash_offsets(
console: &Console,
layout: &InjectorToolLayout,
config: &mut InjectorConfiguration,
dry_run: bool,
) -> Result<()> {
if config.has_checked_resources() {
return Ok(());
}
console.log_info("Getting vanilla resource hash offsets... (idRehash)");
run_command_and_relay_output(
&layout.rehash_exe,
&["--getoffsets".to_string()],
&layout.base_root,
dry_run,
)?;
if !dry_run {
config.set_has_checked_resources(true);
config.save(&layout.settings_path)?;
}
Ok(())
}
fn patch_native_executables(
console: &Console,
layout: &InjectorToolLayout,
dry_run: bool,
) -> Result<()> {
match detect_game_platform(&layout.game_root) {
GamePlatform::Steam => patch_executable_with_eternal_patcher(
console,
layout,
&layout.game_exe,
GAME_EXE_NAME,
STEAM_GAME_EXE_MD5,
STEAM_GAME_EXE_PATCHED_MD5,
dry_run,
)?,
GamePlatform::Gog => patch_executable_with_eternal_patcher(
console,
layout,
&layout.game_exe,
GAME_EXE_NAME,
GOG_GAME_EXE_MD5,
GOG_GAME_EXE_PATCHED_MD5,
dry_run,
)?,
GamePlatform::Xbox => {
console.log_info("Skipping game EXE patch hash validation for Xbox App layout.");
}
}
if layout.sandbox_exe.is_file() {
patch_executable_with_eternal_patcher(
console,
layout,
&layout.sandbox_exe,
SANDBOX_EXE_NAME,
SANDBOX_EXE_MD5,
SANDBOX_EXE_PATCHED_MD5,
dry_run,
)?;
}
Ok(())
}
fn patch_executable_with_eternal_patcher(
console: &Console,
layout: &InjectorToolLayout,
executable_path: &Path,
executable_label: &str,
vanilla_md5: &str,
patched_md5: &str,
dry_run: bool,
) -> Result<()> {
if !executable_path.is_file() {
return Err(DoomError::message(format!(
"Missing executable for EternalPatcher: {}",
console.shorten_path(executable_path)
)));
}
if dry_run {
console.log_dry_run(format!(
"run {} --patch {}",
console.format_path(&layout.eternal_patcher_exe),
console.format_path(executable_path)
));
return Ok(());
}
let current_md5 = compute_md5(executable_path)?;
if current_md5.eq_ignore_ascii_case(patched_md5) {
console.log_info(format!("{executable_label} is already patched."));
return Ok(());
}
if !current_md5.eq_ignore_ascii_case(vanilla_md5) {
return Err(DoomError::message(format!(
"{} has an unexpected checksum: {}. Verify/repair DOOM Eternal, then run clean --reset-backups before installing mods again.",
console.shorten_path(executable_path),
current_md5
)));
}
let backup_path = executable_path.with_file_name(format!("{executable_label}.backup"));
if !backup_path.is_file() {
console.log_info(format!("Backing up {executable_label}..."));
fs::copy(executable_path, &backup_path)?;
}
console.log_info(format!("Patching {executable_label}... (EternalPatcher)"));
let patch_target = eternal_patcher_target(layout, executable_path);
run_command_and_relay_output(
&layout.eternal_patcher_exe,
&["--patch".to_string(), patch_target],
&layout.base_root,
dry_run,
)?;
let patched_actual_md5 = compute_md5(executable_path)?;
if !patched_actual_md5.eq_ignore_ascii_case(patched_md5) {
return Err(DoomError::message(format!(
"EternalPatcher did not produce the expected {} checksum. Try running {} manually and patching {}.",
executable_label,
console.shorten_path(&layout.eternal_patcher_exe),
console.shorten_path(executable_path)
)));
}
Ok(())
}
fn eternal_patcher_target(layout: &InjectorToolLayout, executable_path: &Path) -> String {
if executable_path == layout.game_exe {
return format!("..\\{GAME_EXE_NAME}");
}
if executable_path == layout.sandbox_exe {
return format!("..\\doomSandBox\\{SANDBOX_EXE_NAME}");
}
executable_path.display().to_string()
}
fn collect_native_mod_target_resources(
console: &Console,
layout: &InjectorToolLayout,
config: &InjectorConfiguration,
dry_run: bool,
) -> Result<Vec<PathBuf>> {
let mut args = config.build_load_mods_args();
args.push("--list-res".to_string());
let output =
run_command_capture_stdout(&layout.load_mods_exe, &args, &layout.game_root, dry_run)?;
if dry_run && output.trim().is_empty() {
return Ok(vec![layout.base_root.join("warehouse.resources")]);
}
output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| resolve_reported_resource_path(console, &layout.game_root, line))
.collect()
}
fn resolve_reported_resource_path(
console: &Console,
game_root: &Path,
reported_path: &str,
) -> Result<PathBuf> {
let normalized = reported_path.replace('/', "\\");
let candidate = if normalized.starts_with(".\\") {
game_root.join(normalized.trim_start_matches(".\\"))
} else {
let path = PathBuf::from(&normalized);
if path.is_absolute() {
path
} else {
game_root.join(path)
}
};
if !is_within(&candidate, game_root) {
return Err(DoomError::message(format!(
"Injector reported a resource outside the game root: {}",
console.shorten_path(&candidate)
)));
}
Ok(candidate)
}
fn backup_and_mark_native_resources(
console: &Console,
config: &mut InjectorConfiguration,
resource_paths: &[PathBuf],
dry_run: bool,
) -> Result<()> {
let mut logged_header = false;
for resource_path in resource_paths {
if !resource_path.is_file() {
return Err(DoomError::message(format!(
"Missing game resource file to back up: {}",
console.shorten_path(resource_path)
)));
}
let key = resource_config_key(resource_path);
let modified_line = resource_path
.file_name()
.map(|value| value.to_string_lossy().to_string())
.ok_or_else(|| {
DoomError::message(format!(
"Resource path has no file name: {}",
resource_path.display()
))
})?;
if !config.is_resource_backed_up(&key) {
let backup_path = backup_path_for_resource(resource_path)?;
if !logged_header {
console.log_info("Backing up game resource files...");
logged_header = true;
}
if dry_run {
console.log_dry_run(format!(
"backup {} -> {}",
console.format_path(resource_path),
console.format_path(&backup_path)
));
} else {
fs::copy(resource_path, &backup_path)?;
config.mark_resource_backed_up(&key);
}
}
if !dry_run {
config.mark_resource_modified(&modified_line);
}
}
Ok(())
}
fn delete_eternal_mod_streamdb(console: &Console, base_root: &Path, dry_run: bool) -> Result<()> {
let stream_db = base_root.join("EternalMod.streamdb");
if !stream_db.exists() {
return Ok(());
}
if dry_run {
console.log_dry_run(format!("remove {}", console.format_path(&stream_db)));
} else {
fs::remove_file(&stream_db)?;
}
Ok(())
}
fn patch_native_manifest(
console: &Console,
layout: &InjectorToolLayout,
dry_run: bool,
) -> Result<()> {
if dry_run {
console.log_dry_run(format!(
"run {} {}",
console.format_path(&layout.patch_manifest_exe),
PATCH_MANIFEST_STEAM_KEY
));
return Ok(());
}
let steam_args = vec![PATCH_MANIFEST_STEAM_KEY.to_string()];
let bethesda_args = vec![PATCH_MANIFEST_BETHESDA_KEY.to_string()];
let output = Command::new(&layout.patch_manifest_exe)
.args(&steam_args)
.current_dir(&layout.base_root)
.output()?;
relay_command_output(&output);
if output.status.success() {
return Ok(());
}
let fallback = Command::new(&layout.patch_manifest_exe)
.args(&bethesda_args)
.current_dir(&layout.base_root)
.output()?;
relay_command_output(&fallback);
if fallback.status.success() {
return Ok(());
}
Err(DoomError::message(format!(
"DEternal_patchManifest failed with Steam key exit {} and Bethesda key exit {}",
output.status.code().unwrap_or(-1),
fallback.status.code().unwrap_or(-1)
)))
}
fn launch_game_from_native_injector(
console: &Console,
layout: &InjectorToolLayout,
config: &InjectorConfiguration,
dry_run: bool,
) -> Result<()> {
if !config.auto_launch_game() {
console.log_info("Automatic game launch is disabled in EternalModInjector Settings.txt.");
return Ok(());
}
let game_parameters = config
.game_parameters()
.unwrap_or_default()
.trim()
.to_string();
if dry_run {
console.log_dry_run(format!(
"launch DOOM Eternal from {}",
console.format_path(&layout.game_root)
));
return Ok(());
}
if layout.game_root.join("steam_api64.dll").is_file() {
let steam_target = if game_parameters.is_empty() {
format!("steam://run/{STEAM_APP_ID}")
} else {
format!("steam://run/{STEAM_APP_ID}//{game_parameters}")
};
Command::new("cmd")
.args(["/c", "start", "", &steam_target])
.spawn()?;
console.log_success("DOOM Eternal has been launched through Steam.");
return Ok(());
}
if !layout.game_exe.is_file() {
return Err(DoomError::message(format!(
"Missing game executable: {}",
console.shorten_path(&layout.game_exe)
)));
}
let mut command = Command::new("cmd");
command.args(["/c", "start", "", GAME_EXE_NAME]);
if !game_parameters.is_empty() {
command.arg(game_parameters);
}
command.current_dir(&layout.game_root).spawn()?;
console.log_success("DOOM Eternal has been launched.");
Ok(())
}
fn platform_label(platform: GamePlatform) -> &'static str {
match platform {
GamePlatform::Steam => "Steam",
GamePlatform::Gog => "GOG Galaxy",
GamePlatform::Xbox => "Xbox App",
}
}
fn launch_steam_validate(console: &Console, dry_run: bool) -> Result<()> {
let target = format!("steam://validate/{STEAM_APP_ID}");
if dry_run {
console.log_dry_run(format!(
"launch Steam verify integrity for app {STEAM_APP_ID}"
));
return Ok(());
}
Command::new("cmd")
.args(["/c", "start", "", &target])
.spawn()?;
console.log_success(
"Steam verify/repair started for DOOM Eternal. Wait for it to finish before running clean --reset-backups.",
);
Ok(())
}
fn clear_mods_folder(console: &Console, game_root: &Path, dry_run: bool) -> Result<u64> {
let mods_dir = game_root.join("Mods");
if !mods_dir.is_dir() {
return Ok(0);
}
let mut removed = 0_u64;
for entry in fs::read_dir(&mods_dir)? {
let entry = entry?;
let path = entry.path();
if dry_run {
console.log_dry_run(format!("remove {}", console.format_path(&path)));
} else {
remove_entry(&path)?;
}
removed += 1;
}
Ok(removed)
}
fn reset_injector_backups(
console: &Console,
layout: &InjectorToolLayout,
config: &mut InjectorConfiguration,
dry_run: bool,
) -> Result<()> {
let backup_files = collect_injector_backup_files(&layout.base_root);
for backup_path in backup_files {
if dry_run {
console.log_dry_run(format!("remove {}", console.format_path(&backup_path)));
} else {
remove_entry(&backup_path)?;
}
}
config.resource_lines.clear();
config.set_has_checked_resources(false);
config
.values
.insert("RESET_BACKUPS".to_string(), "0".to_string());
if !dry_run {
config.save(&layout.settings_path)?;
}
Ok(())
}
fn collect_injector_backup_files(base_root: &Path) -> Vec<PathBuf> {
WalkDir::new(base_root)
.into_iter()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_file())
.map(|entry| entry.into_path())
.filter(|path| {
path.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case("backup"))
})
.collect()
}
fn mods_exist(game_root: &Path) -> Result<bool> {
let mods_dir = game_root.join("Mods");
if !mods_dir.is_dir() {
return Ok(false);
}
for entry in fs::read_dir(mods_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() || path.is_file() {
return Ok(true);
}
}
Ok(false)
}
pub fn list_installed_mods(game_root: &Path) -> Result<Vec<InstalledModRecord>> {
let mods_dir = game_root.join("Mods");
if !mods_dir.is_dir() {
return Ok(Vec::new());
}
let registry = read_installed_mod_registry(&mods_dir);
let mut records = Vec::new();
for entry in fs::read_dir(&mods_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file()
|| !path
.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case("zip"))
{
continue;
}
let file_name = path
.file_name()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string());
let registry_record = registry
.mods
.iter()
.find(|record| record.file_name.eq_ignore_ascii_case(&file_name));
let metadata = read_mod_zip_metadata(&path).unwrap_or_default();
records.push(InstalledModRecord {
file_name: file_name.clone(),
path: path.display().to_string(),
name: registry_record
.map(|record| record.name.clone())
.or(metadata.name)
.unwrap_or_else(|| mod_name_from_zip_file_name(&file_name)),
version: registry_record
.map(|record| record.version.clone())
.or(metadata.version)
.unwrap_or_else(|| "unknown".to_string()),
enabled: true,
installed_at: registry_record.and_then(|record| record.installed_at.clone()),
source: registry_record.and_then(|record| record.source.clone()),
});
}
records.sort_by(|left, right| left.file_name.cmp(&right.file_name));
Ok(records)
}
fn registry_path(mods_dir: &Path) -> PathBuf {
mods_dir.join(".xylex-mods.json")
}
fn read_installed_mod_registry(mods_dir: &Path) -> InstalledModRegistry {
let path = registry_path(mods_dir);
fs::read_to_string(path)
.ok()
.and_then(|payload| serde_json::from_str::<InstalledModRegistry>(&payload).ok())
.unwrap_or(InstalledModRegistry {
version: 1,
mods: Vec::new(),
})
}
fn write_installed_mod_registry(mods_dir: &Path, registry: InstalledModRegistry) -> Result<()> {
fs::create_dir_all(mods_dir)?;
fs::write(
registry_path(mods_dir),
serde_json::to_string_pretty(®istry)?,
)?;
Ok(())
}
fn upsert_installed_mod_record(mods_dir: &Path, record: InstalledModRecord) -> Result<()> {
let mut registry = read_installed_mod_registry(mods_dir);
registry.mods.retain(|existing| {
!existing
.file_name
.eq_ignore_ascii_case(record.file_name.as_str())
});
registry.mods.push(record);
registry
.mods
.sort_by(|left, right| left.file_name.cmp(&right.file_name));
prune_missing_records(mods_dir, &mut registry);
write_installed_mod_registry(mods_dir, registry)
}
fn prune_installed_mod_registry(mods_dir: &Path) -> Result<()> {
let mut registry = read_installed_mod_registry(mods_dir);
prune_missing_records(mods_dir, &mut registry);
write_installed_mod_registry(mods_dir, registry)
}
fn prune_missing_records(mods_dir: &Path, registry: &mut InstalledModRegistry) {
registry
.mods
.retain(|record| mods_dir.join(&record.file_name).exists());
}
fn read_mod_zip_metadata(zip_path: &Path) -> Result<ModZipMetadata> {
let file = fs::File::open(zip_path)?;
let mut archive = zip::ZipArchive::new(file)?;
let metadata_index = if archive.by_name("EternalMod.json").is_ok() {
Some(None)
} else {
let mut found = None;
for index in 0..archive.len() {
let entry = archive.by_index(index)?;
if entry
.name()
.replace('\\', "/")
.ends_with("/EternalMod.json")
{
found = Some(index);
break;
}
}
found.map(Some)
};
let mut metadata_entry = match metadata_index {
Some(None) => archive.by_name("EternalMod.json")?,
Some(Some(index)) => archive.by_index(index)?,
None => {
return Ok(ModZipMetadata {
name: None,
version: None,
})
}
};
let mut payload = String::new();
std::io::Read::read_to_string(&mut metadata_entry, &mut payload)?;
let value = serde_json::from_str::<serde_json::Value>(&payload)?;
Ok(ModZipMetadata {
name: value
.get("name")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
version: value
.get("version")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
})
}
fn mod_name_from_zip_file_name(file_name: &str) -> String {
file_name
.strip_suffix(".zip")
.unwrap_or(file_name)
.replace(['_', '-'], " ")
}
fn run_command_capture_stdout(
executable: &Path,
args: &[String],
current_dir: &Path,
dry_run: bool,
) -> Result<String> {
if dry_run {
return Ok(String::new());
}
let output = Command::new(executable)
.args(args)
.current_dir(current_dir)
.output()?;
if !output.status.success() {
relay_command_output(&output);
return Err(DoomError::message(format!(
"{} exited with {}",
executable
.file_name()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_else(|| executable.display().to_string()),
output.status.code().unwrap_or(-1)
)));
}
Ok(decode_command_text(&output.stdout))
}
fn run_command_and_relay_output(
executable: &Path,
args: &[String],
current_dir: &Path,
dry_run: bool,
) -> Result<()> {
if dry_run {
return Ok(());
}
let output = Command::new(executable)
.args(args)
.current_dir(current_dir)
.output()?;
relay_command_output(&output);
if !output.status.success() {
return Err(DoomError::message(format!(
"{} exited with {}",
executable
.file_name()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_else(|| executable.display().to_string()),
output.status.code().unwrap_or(-1)
)));
}
Ok(())
}
fn relay_command_output(output: &std::process::Output) {
for line in decode_command_text(&output.stdout).lines() {
println!("{line}");
}
for line in decode_command_text(&output.stderr).lines() {
eprintln!("{line}");
}
}
fn decode_command_text(bytes: &[u8]) -> String {
if bytes.is_empty() {
return String::new();
}
let looks_like_utf16le = bytes.len() >= 2
&& bytes.chunks_exact(2).filter(|chunk| chunk[1] == 0).count() * 2 >= bytes.len() / 2;
if looks_like_utf16le {
let units = bytes
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect::<Vec<_>>();
return String::from_utf16_lossy(&units)
.trim_start_matches('\u{feff}')
.to_string();
}
String::from_utf8_lossy(bytes).to_string()
}
fn backup_path_for_resource(resource_path: &Path) -> Result<PathBuf> {
let file_name = resource_path
.file_name()
.map(|value| value.to_string_lossy().to_string())
.ok_or_else(|| {
DoomError::message(format!(
"Resource path has no file name: {}",
resource_path.display()
))
})?;
Ok(resource_path.with_file_name(format!("{file_name}.backup")))
}
fn resource_config_key(resource_path: &Path) -> String {
let stem = resource_path
.file_stem()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_else(|| "resource".to_string());
let parent = resource_path
.parent()
.map(|value| {
value
.to_string_lossy()
.replace('/', "\\")
.to_ascii_lowercase()
})
.unwrap_or_default();
if parent.ends_with("\\game\\dlc\\hub") {
format!("dlc_{stem}")
} else {
stem
}
}
fn launch_injector(
console: &Console,
game_root: &Path,
wait_for_injector: bool,
dry_run: bool,
) -> Result<()> {
let injector_path = game_root.join(INJECTOR_NAME);
if !injector_path.is_file() {
return Err(DoomError::message(format!(
"Missing {INJECTOR_NAME} in {}. Run install with --install-tools or place EternalModInjector in the game root first.",
console.shorten_path(game_root)
)));
}
if dry_run {
let mode = if wait_for_injector { "wait" } else { "detach" };
console.log_dry_run(format!(
"launch {} ({mode})",
console.format_path(&injector_path)
));
return Ok(());
}
if ensure_injector_batch_workdir_guard(&injector_path)? {
console.log_info(format!(
"Patched {} to resolve relative paths from the game root.",
console.format_tool_name(INJECTOR_NAME)
));
}
if wait_for_injector {
console.log_info(format!(
"Launching {} and waiting for it to exit...",
console.format_tool_name(INJECTOR_NAME)
));
let status = Command::new("cmd")
.args(["/c", INJECTOR_NAME])
.current_dir(game_root)
.status()?;
if !status.success() {
return Err(DoomError::message(format!(
"{INJECTOR_NAME} exited with {}",
status.code().unwrap_or(-1)
)));
}
return Ok(());
}
console.log_info(format!(
"Launching {} in a separate window...",
console.format_tool_name(INJECTOR_NAME)
));
Command::new("cmd")
.args(["/c", "start", "", INJECTOR_NAME])
.current_dir(game_root)
.spawn()?;
Ok(())
}
fn ensure_injector_batch_workdir_guard(injector_path: &Path) -> Result<bool> {
let content = fs::read_to_string(injector_path)?;
let lower_content = content.to_ascii_lowercase();
if lower_content.contains("pushd \"%~dp0\"") || lower_content.contains("cd /d \"%~dp0\"") {
return Ok(false);
}
let line_ending = if content.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let guard =
format!("{INJECTOR_WORKDIR_GUARD_NOTE}{line_ending}PUSHD \"%~dp0\" >NUL{line_ending}");
let updated = if let Some(rest) = content.strip_prefix("@ECHO OFF\r\n") {
format!("@ECHO OFF\r\n{guard}{rest}")
} else if let Some(rest) = content.strip_prefix("@ECHO OFF\n") {
format!("@ECHO OFF\n{guard}{rest}")
} else {
format!("{guard}{content}")
};
fs::write(injector_path, updated)?;
Ok(true)
}
fn load_backup_manifest(path: &Path, console: &Console) -> Result<BackupManifest> {
let payload = serde_json::from_str::<BackupManifest>(&fs::read_to_string(path)?)?;
if payload.backups.is_empty() && payload.installed_entries.is_empty() {
return Err(DoomError::message(format!(
"Backup manifest is missing backups or installed entries: {}",
console.shorten_path(path)
)));
}
Ok(payload)
}
fn browserish_client() -> Result<Client> {
Ok(Client::builder()
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
.build()?)
}
async fn download_to_path(
console: &Console,
url: &str,
destination: &Path,
label: &str,
) -> Result<()> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
let client = browserish_client()?;
let mut response = client.get(url).send().await?.error_for_status()?;
let total_size = response.content_length().unwrap_or(0);
let mut reporter = ProgressReporter::new(console, label, total_size, ProgressFormat::Bytes);
reporter.update(0, true);
let mut file = fs::File::create(destination)?;
let mut bytes_written = 0_u64;
while let Some(chunk) = response.chunk().await? {
file.write_all(&chunk)?;
bytes_written += chunk.len() as u64;
reporter.update(bytes_written, false);
}
file.flush()?;
reporter.finish();
Ok(())
}
#[cfg(test)]
mod tests {
use std::{fs, path::PathBuf};
use tempfile::TempDir;
use super::{
collect_injector_backup_files, compute_md5, decode_command_text, detect_game_platform,
ensure_injector_batch_workdir_guard, eternal_patcher_target, resource_config_key,
try_resolve_game_root, GamePlatform, InjectorConfiguration, InjectorToolLayout,
GAME_EXE_NAME, INJECTOR_NAME, INJECTOR_SETTINGS_NAME, SANDBOX_EXE_NAME,
};
#[test]
fn resolves_direct_game_root() {
let temp_dir = TempDir::new().expect("tempdir");
let game_root = temp_dir.path().join("DOOMEternal");
fs::create_dir_all(&game_root).expect("game root");
fs::write(game_root.join(GAME_EXE_NAME), b"exe").expect("game exe");
let resolved = try_resolve_game_root(&game_root);
assert_eq!(resolved, Some(PathBuf::from(&game_root)));
}
#[test]
fn resolves_base_subfolder_back_to_game_root() {
let temp_dir = TempDir::new().expect("tempdir");
let game_root = temp_dir.path().join("DOOMEternal");
let base = game_root.join("base");
fs::create_dir_all(&base).expect("base");
fs::write(game_root.join(INJECTOR_NAME), b"bat").expect("injector");
let resolved = try_resolve_game_root(&base);
assert_eq!(resolved, Some(PathBuf::from(&game_root)));
}
#[test]
fn injector_configuration_round_trips_resource_state() {
let temp_dir = TempDir::new().expect("tempdir");
let settings_path = temp_dir.path().join(INJECTOR_SETTINGS_NAME);
fs::write(
&settings_path,
[
":AUTO_LAUNCH_GAME=1",
":HAS_CHECKED_RESOURCES=0",
"",
"meta.backup",
"meta.resources",
"",
]
.join("\n"),
)
.expect("write settings");
let mut config = InjectorConfiguration::load(&settings_path).expect("load settings");
assert!(config.auto_launch_game(), "auto launch should parse");
assert!(
!config.has_checked_resources(),
"has checked resources should parse"
);
assert!(
config.is_resource_backed_up("meta"),
"backup marker should parse"
);
assert!(
config.is_resource_modified("meta.resources"),
"modified marker should parse"
);
config.set_has_checked_resources(true);
config.clear_resource_modified("meta.resources");
config.save(&settings_path).expect("save settings");
let reloaded = InjectorConfiguration::load(&settings_path).expect("reload settings");
assert!(
reloaded.has_checked_resources(),
"updated hash offset flag should persist"
);
assert!(
!reloaded.is_resource_modified("meta.resources"),
"cleared modified flag should persist"
);
assert!(
reloaded.is_resource_backed_up("meta"),
"backup flag should remain after save"
);
}
#[test]
fn resource_config_key_matches_dlc_hub_convention() {
let path = PathBuf::from("C:/Games/DOOMEternal/base/game/dlc/hub/hub.resources");
assert_eq!(resource_config_key(&path), "dlc_hub");
let normal_path = PathBuf::from("C:/Games/DOOMEternal/base/warehouse.resources");
assert_eq!(resource_config_key(&normal_path), "warehouse");
}
#[test]
fn decode_command_text_handles_utf16le_console_output() {
let encoded = b"H\0e\0l\0l\0o\0 \0R\0u\0s\0t\0";
assert_eq!(decode_command_text(encoded), "Hello Rust");
}
#[test]
fn compute_md5_hashes_files_for_executable_patch_checks() {
let temp_dir = TempDir::new().expect("tempdir");
let path = temp_dir.path().join("sample.bin");
fs::write(&path, b"abc").expect("write sample");
assert_eq!(
compute_md5(&path).expect("hash sample"),
"900150983cd24fb0d6963f7d28e17f72"
);
}
#[test]
fn eternal_patcher_targets_match_batch_relative_paths() {
let game_root = PathBuf::from("C:/Games/DOOMEternal");
let base_root = game_root.join("base");
let layout = InjectorToolLayout {
game_root: game_root.clone(),
base_root: base_root.clone(),
settings_path: game_root.join(INJECTOR_SETTINGS_NAME),
load_mods_exe: base_root.join("DEternal_loadMods.exe"),
rehash_exe: base_root.join("idRehash.exe"),
patch_manifest_exe: base_root.join("DEternal_patchManifest.exe"),
eternal_patcher_exe: base_root.join("EternalPatcher.exe"),
game_exe: game_root.join(GAME_EXE_NAME),
sandbox_exe: game_root.join("doomSandBox").join(SANDBOX_EXE_NAME),
};
assert_eq!(
eternal_patcher_target(&layout, &layout.game_exe),
"..\\DOOMEternalx64vk.exe"
);
assert_eq!(
eternal_patcher_target(&layout, &layout.sandbox_exe),
"..\\doomSandBox\\DOOMSandBox64vk.exe"
);
}
#[test]
fn detect_game_platform_reads_launcher_dlls() {
let temp_dir = TempDir::new().expect("tempdir");
let game_root = temp_dir.path().join("DOOMEternal");
fs::create_dir_all(&game_root).expect("game root");
fs::write(game_root.join("steam_api64.dll"), b"steam").expect("steam dll");
assert_eq!(detect_game_platform(&game_root), GamePlatform::Steam);
fs::remove_file(game_root.join("steam_api64.dll")).expect("remove steam dll");
fs::write(game_root.join("Galaxy64.dll"), b"gog").expect("gog dll");
assert_eq!(detect_game_platform(&game_root), GamePlatform::Gog);
fs::remove_file(game_root.join("Galaxy64.dll")).expect("remove gog dll");
assert_eq!(detect_game_platform(&game_root), GamePlatform::Xbox);
}
#[test]
fn collect_injector_backup_files_finds_resources_backups() {
let temp_dir = TempDir::new().expect("tempdir");
let base_root = temp_dir.path().join("base");
let nested = base_root.join("game").join("dlc");
fs::create_dir_all(&nested).expect("nested dir");
fs::write(base_root.join("meta.resources.backup"), b"meta").expect("meta backup");
fs::write(nested.join("hub.resources.backup"), b"hub").expect("hub backup");
fs::write(base_root.join("notes.txt"), b"ignore").expect("ignore file");
let backups = collect_injector_backup_files(&base_root);
assert_eq!(backups.len(), 2);
assert!(backups
.iter()
.any(|path| path.ends_with("meta.resources.backup")));
assert!(backups
.iter()
.any(|path| path.ends_with("hub.resources.backup")));
}
#[test]
fn injector_batch_workdir_guard_is_inserted_after_echo_off() {
let temp_dir = TempDir::new().expect("tempdir");
let injector_path = temp_dir.path().join(INJECTOR_NAME);
fs::write(
&injector_path,
"@ECHO OFF\r\n\r\nSETLOCAL ENABLEEXTENSIONS\r\n",
)
.expect("write injector");
assert!(
ensure_injector_batch_workdir_guard(&injector_path).expect("patch injector"),
"first patch should update the batch"
);
let patched = fs::read_to_string(&injector_path).expect("read patched injector");
assert!(
patched.starts_with("@ECHO OFF\r\nREM xylex-doom:"),
"guard should be inserted immediately after @ECHO OFF"
);
assert!(patched.contains("PUSHD \"%~dp0\" >NUL"));
assert!(
!ensure_injector_batch_workdir_guard(&injector_path).expect("patch injector again"),
"second patch should be a no-op"
);
}
}