From 7b08f54bb61da45a1edc85d21faa7ad2d4b7f6da Mon Sep 17 00:00:00 2001 From: Joshua Goins Date: Mon, 28 Apr 2025 22:58:36 -0400 Subject: [PATCH] Add initial files --- .gitignore | 1 + Cargo.lock | 198 +++++++++++++++++++++++++++++++++++ Cargo.toml | 18 ++++ src/execlookup.rs | 63 ++++++++++++ src/existing_dirs.rs | 239 +++++++++++++++++++++++++++++++++++++++++++ src/installer.rs | 148 +++++++++++++++++++++++++++ src/lib.rs | 9 ++ 7 files changed, 676 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/execlookup.rs create mode 100644 src/existing_dirs.rs create mode 100644 src/installer.rs create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9be4ce7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,198 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-expr" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d4ba6e40bd1184518716a6e1a781bf9160e286d219ccdb8ab2612e74cfe4789" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miscel" +version = "0.1.0" +dependencies = [ + "system-deps", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d23aaf9f331227789a99e8de4c91bf46703add012bdfd45fdecdfb2975a005" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "toml" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + +[[package]] +name = "winnow" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb8234a863ea0e8cd7284fcdd4f145233eb00fee02bbdd9861aec44e6477bc5" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4fa4f3f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "miscel" +version = "0.1.0" +edition = "2024" + +[build-dependencies] +system-deps = "7" + +[package.metadata.system-deps] +libunshield = { version = "1.4", feature = "game_install" } + +[features] +default = [] + +# enables game installation support using unshield (only supported on Linux and macOS) +game_install = [] + +[dependencies] diff --git a/src/execlookup.rs b/src/execlookup.rs new file mode 100644 index 0000000..b0fdb8d --- /dev/null +++ b/src/execlookup.rs @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2024 Joshua Goins +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::fs; + +fn from_u16(from: &mut [u16]) -> &[u8] { + #[cfg(target_endian = "little")] + from.iter_mut().for_each(|word| *word = word.to_be()); + + let ptr: *const u8 = from.as_ptr().cast(); + let len = from.len().checked_mul(2).unwrap(); + + unsafe { std::slice::from_raw_parts(ptr, len) } +} + +fn find_needle(installer_file: &[u8], needle: &str) -> Option { + let mut needle: Vec = needle.encode_utf16().collect(); + let bytes = from_u16(&mut needle); + + let mut position = installer_file + .windows(bytes.len()) + .position(|window| window == bytes)?; + + let parse_char_at_position = |position: usize| { + let upper = installer_file[position]; + let lower = installer_file[position + 1]; + + let result = char::decode_utf16([((upper as u16) << 8) | lower as u16]) + .map(|r| r.map_err(|e| e.unpaired_surrogate())) + .collect::>(); + + result[0] + }; + + let mut string: String = String::new(); + + let mut last_char = parse_char_at_position(position); + while last_char.is_ok() && last_char.unwrap() != '\0' { + string.push(last_char.unwrap()); + + position += 2; + last_char = parse_char_at_position(position); + } + + Some(string) +} + +/// Extract the frontier URL from ffxivlauncher.exe +pub fn extract_frontier_url(launcher_path: &str) -> Option { + let installer_file = fs::read(launcher_path).unwrap(); + + // New Frontier URL format + if let Some(url) = find_needle(&installer_file, "https://launcher.finalfantasyxiv.com") { + return Some(url); + } + + // Old Frontier URL format + if let Some(url) = find_needle(&installer_file, "https://frontier.ffxiv.com") { + return Some(url); + } + + None +} diff --git a/src/existing_dirs.rs b/src/existing_dirs.rs new file mode 100644 index 0000000..8aeacab --- /dev/null +++ b/src/existing_dirs.rs @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: 2024 Joshua Goins +// SPDX-License-Identifier: GPL-3.0-or-later + +// Rust deprecating this is stupid, I don't want to use a crate here +#[allow(deprecated)] +use std::env::home_dir; + +use std::fs; +use std::fs::read_dir; +use std::path::PathBuf; + +/// Where the existing installation came from +#[derive(Clone, Copy)] +#[repr(C)] +pub enum ExistingInstallType { + /// Installed via the official launcher + OfficialLauncher, + /// Installed via XIVQuickLauncher + XIVQuickLauncher, + /// Installed via XIVLauncherCore + XIVLauncherCore, + /// Installed via XIVOnMac + XIVOnMac, + /// Installed via Astra + Astra, +} + +/// An existing install location on disk +pub struct ExistingGameDirectory { + /// The application where this installation was from + pub install_type: ExistingInstallType, + /// The path to the "main folder" where "game" and "boot" sits + pub path: String, +} + +/// Finds existing installations on disk. Will only return locations that actually have files in them, and a really basic check to see if the data is valid. +pub fn find_existing_game_dirs() -> Vec { + let mut install_dirs = Vec::new(); + + match std::env::consts::OS { + "linux" => { + // Official install (Wine) + install_dirs.push(ExistingGameDirectory { + install_type: ExistingInstallType::OfficialLauncher, + path: from_home_dir(".wine/drive_c/Program Files (x86)/SquareEnix/FINAL FANTASY XIV - A Realm Reborn") + }); + + // Official install (Steam) + install_dirs.push(ExistingGameDirectory { + install_type: ExistingInstallType::OfficialLauncher, + path: from_home_dir( + ".steam/steam/steamapps/common/FINAL FANTASY XIV - A Realm Reborn", + ), + }); + + // XIVLauncherCore location + install_dirs.push(ExistingGameDirectory { + install_type: ExistingInstallType::XIVLauncherCore, + path: from_home_dir(".xlcore/ffxiv"), + }); + + // Astra location. But we have to iterate through each UUID. + if let Ok(entries) = read_dir(from_home_dir(".local/share/astra/game/")) { + entries + .flatten() + .flat_map(|entry| { + let Ok(meta) = entry.metadata() else { + return vec![]; + }; + if meta.is_dir() { + return vec![entry.path()]; + } + vec![] + }) + .for_each(|path| { + install_dirs.push(ExistingGameDirectory { + install_type: ExistingInstallType::Astra, + path: path.into_os_string().into_string().unwrap(), + }) + }); + } + } + "macos" => { + // Official Launcher (macOS) + install_dirs.push(ExistingGameDirectory { + install_type: ExistingInstallType::OfficialLauncher, + path: from_home_dir("Library/Application Support/FINAL FANTASY XIV ONLINE/Bottles/published_Final_Fantasy/drive_c/Program Files (x86)/SquareEnix/FINAL FANTASY XIV - A Realm Reborn") + }); + + // TODO: add XIV on Mac + } + "windows" => { + // Official install (Wine) + install_dirs.push(ExistingGameDirectory { + install_type: ExistingInstallType::OfficialLauncher, + path: "C:\\Program Files (x86)\\SquareEnix\\FINAL FANTASY XIV - A Realm Reborn" + .parse() + .unwrap(), + }); + + // TODO: Add Astra + } + &_ => {} + } + + install_dirs + .into_iter() + .filter(|dir| is_valid_game_dir(&dir.path)) + .collect() +} + +/// An existing user directory +pub struct ExistingUserDirectory { + /// The application where this directory was from + pub install_type: ExistingInstallType, + /// The path to the user folder + pub path: String, +} + +/// Finds existing user folders on disk. Will only return locations that actually have files in them, and a really basic check to see if the data is valid. +pub fn find_existing_user_dirs() -> Vec { + let mut user_dirs = Vec::new(); + #[allow(deprecated)] // We still want std::env::home_dir + let Some(_) = home_dir() else { + return user_dirs; + }; + + match std::env::consts::OS { + "linux" => { + // Official install (Wine) + user_dirs.push(ExistingUserDirectory { + install_type: ExistingInstallType::OfficialLauncher, + path: from_home_dir("Documents/My Games/FINAL FANTASY XIV - A Realm Reborn"), + }); + + // XIVLauncherCore location + user_dirs.push(ExistingUserDirectory { + install_type: ExistingInstallType::XIVLauncherCore, + path: from_home_dir(".xlcore/ffxivConfig"), + }); + + // Astra location. But we have to iterate through each UUID. + if let Ok(entries) = read_dir(from_home_dir(".local/share/astra/user/")) { + entries + .flatten() + .flat_map(|entry| { + let Ok(meta) = entry.metadata() else { + return vec![]; + }; + if meta.is_dir() { + return vec![entry.path()]; + } + vec![] + }) + .for_each(|path| { + user_dirs.push(ExistingUserDirectory { + install_type: ExistingInstallType::Astra, + path: path.into_os_string().into_string().unwrap(), + }) + }); + } + } + "macos" => { + // Official install (Wine) + user_dirs.push(ExistingUserDirectory { + install_type: ExistingInstallType::OfficialLauncher, + path: from_home_dir("Documents/My Games/FINAL FANTASY XIV - A Realm Reborn"), + }) + + // TODO: Add XIV on Mac? + } + "windows" => { + // Official install + user_dirs.push(ExistingUserDirectory { + install_type: ExistingInstallType::OfficialLauncher, + path: from_home_dir("Documents/My Games/FINAL FANTASY XIV - A Realm Reborn"), + }) + + // TODO: Add Astra + } + &_ => {} + } + + user_dirs + .into_iter() + .filter(|dir| is_valid_user_dir(&dir.path)) + .collect() +} + +fn from_home_dir(path: &'static str) -> String { + #[allow(deprecated)] // We still want std::env::home_dir + let mut new_path = home_dir().unwrap(); + new_path.push(path); + new_path.into_os_string().into_string().unwrap() +} + +fn is_valid_game_dir(path: &String) -> bool { + let mut d = PathBuf::from(path); + + // Check for the dir itself + if fs::metadata(d.as_path()).is_err() { + return false; + } + + // Check for "game" + d.push("game"); + + if fs::metadata(d.as_path()).is_err() { + return false; + } + + // Check for "boot" + d.pop(); + d.push("boot"); + + if fs::metadata(d.as_path()).is_err() { + return false; + } + + true +} + +fn is_valid_user_dir(path: &String) -> bool { + let mut d = PathBuf::from(path); + + // Check for the dir itself + if fs::metadata(d.as_path()).is_err() { + return false; + } + + // Check for "FFXIV.cfg" + d.push("FFXIV.cfg"); + + if fs::metadata(d.as_path()).is_err() { + return false; + } + + true +} diff --git a/src/installer.rs b/src/installer.rs new file mode 100644 index 0000000..908d75a --- /dev/null +++ b/src/installer.rs @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2023 Joshua Goins +// SPDX-License-Identifier: GPL-3.0-or-later + +use std::ffi::{CStr, CString, NulError}; +use std::fs; +use std::fs::File; +use std::io::Write; +use std::os::raw::c_char; + +const FILES_TO_EXTRACT: [&str; 3] = ["data1.cab", "data1.hdr", "data2.cab"]; + +const BOOT_COMPONENT_FILES: [&str; 18] = [ + "cef_license.txt", + "FFXIV.ico", + "ffxivboot.exe", + "ffxivboot.ver", + "ffxivboot64.exe", + "ffxivconfig.exe", + "ffxivconfig64.exe", + "ffxivlauncher.exe", + "ffxivlauncher64.exe", + "ffxivsysinfo.exe", + "ffxivsysinfo64.exe", + "ffxivupdater.exe", + "ffxivupdater64.exe", + "FFXIV_sysinfo.ico", + "icudt.dll", + "libcef.dll", + "license.txt", + "locales/reserved.txt", +]; + +const GAME_COMPONENT_FILES: [&str; 1] = ["ffxivgame.ver"]; + +#[repr(C)] +struct Unshield { + _private: [u8; 0], +} + +unsafe extern "C" { + fn unshield_open(filename: *const c_char) -> *mut Unshield; + fn unshield_close(unshield: *mut Unshield); + + fn unshield_set_log_level(level: i32); + + fn unshield_file_count(unshield: *mut Unshield) -> i32; + fn unshield_file_name(unshield: *mut Unshield, index: i32) -> *const c_char; + fn unshield_file_save(unshield: *mut Unshield, index: i32, filename: *const c_char) -> bool; +} + +pub enum InstallError { + IOFailure, + FFIFailure, +} + +impl From for InstallError { + fn from(_: std::io::Error) -> Self { + InstallError::IOFailure + } +} + +impl From for InstallError { + fn from(_: NulError) -> Self { + InstallError::FFIFailure + } +} + +/// Installs the game from the provided retail installer. +pub fn install_game(installer_path: &str, game_directory: &str) -> Result<(), InstallError> { + let installer_file = fs::read(installer_path).unwrap(); + + let mut last_position = 0; + let mut last_filename = ""; + for filename in FILES_TO_EXTRACT { + let needle = format!("Disk1\\{}", filename); + + let position = installer_file + .windows(needle.len()) + .position(|window| window == needle.as_str().as_bytes()); + if position == None { + break; + } + + let position = position.unwrap(); + + if last_position != 0 { + let mut temp_dir = std::env::temp_dir(); + temp_dir.push(last_filename); + + let mut new_file = File::create(temp_dir).unwrap(); + + if last_filename == "data1.hdr" { + new_file.write_all(&installer_file[last_position + 30..position - 42])?; + } else { + new_file.write_all(&installer_file[last_position + 33..position - 42])?; + } + } + + last_position = position; + last_filename = filename; + } + + let mut temp_dir = std::env::temp_dir(); + temp_dir.push(last_filename); + + let mut new_file = File::create(temp_dir).unwrap(); + + new_file.write_all(&installer_file[last_position + 33..installer_file.len() - 42])?; + + fs::create_dir_all(format!("{game_directory}/boot"))?; + fs::create_dir_all(format!("{game_directory}/game"))?; + + // set unshield to shut up + unsafe { unshield_set_log_level(0) }; + + let mut temp_dir = std::env::temp_dir(); + temp_dir.push("data1.cab"); + let temp_dir_string = CString::new(temp_dir.to_str().unwrap())?; + + let unshield = unsafe { unshield_open(temp_dir_string.as_ptr()) }; + let file_count = unsafe { unshield_file_count(unshield) }; + + for i in 0..file_count { + let filename = unsafe { CStr::from_ptr(unshield_file_name(unshield, i)).to_string_lossy() }; + + for boot_name in BOOT_COMPONENT_FILES { + if boot_name == filename { + let save_filename = format!("{game_directory}/boot/{boot_name}"); + let save_filename_c = CString::new(save_filename)?; + unsafe { unshield_file_save(unshield, i, save_filename_c.as_ptr()) }; + } + } + + for game_name in GAME_COMPONENT_FILES { + if game_name == filename { + let save_filename = format!("{game_directory}/game/{game_name}"); + let save_filename_c = CString::new(save_filename)?; + unsafe { unshield_file_save(unshield, i, save_filename_c.as_ptr()) }; + } + } + } + + unsafe { + unshield_close(unshield); + } + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..71144ae --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +/// Find existing installation directories +pub mod existing_dirs; + +/// Reading data from executables +pub mod execlookup; + +/// Initializing a new retail game install from the official retail installer. No execution required! +#[cfg(feature = "game_install")] +pub mod installer;