use std::fmt;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LocateError {
#[error("could not determine home/data directory")]
NoHomeDir,
#[error("NMS save directory not found: {0}")]
SaveDirNotFound(PathBuf),
#[error("no account directories found in {0}")]
NoAccountDirs(PathBuf),
#[error("no save files found in {0}")]
NoSaveFiles(PathBuf),
#[error("unsupported platform for NMS save auto-detection")]
UnsupportedPlatform,
#[error(transparent)]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AccountKind {
Steam(u64),
Gog,
Unknown(String),
}
impl fmt::Display for AccountKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Steam(id) => write!(f, "Steam ({id})"),
Self::Gog => write!(f, "GOG"),
Self::Unknown(name) => write!(f, "Unknown ({name})"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountDir {
path: PathBuf,
kind: AccountKind,
}
impl AccountDir {
pub fn path(&self) -> &Path {
&self.path
}
pub fn kind(&self) -> &AccountKind {
&self.kind
}
pub fn name(&self) -> &str {
self.path.file_name().and_then(|n| n.to_str()).unwrap_or("")
}
}
fn parse_account_kind(name: &str) -> AccountKind {
if name == "DefaultUser" {
return AccountKind::Gog;
}
if let Some(id_str) = name.strip_prefix("st_")
&& let Ok(id) = id_str.parse::<u64>()
{
return AccountKind::Steam(id);
}
AccountKind::Unknown(name.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SaveType {
Manual,
Auto,
}
impl fmt::Display for SaveType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Manual => write!(f, "Manual"),
Self::Auto => write!(f, "Auto"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveFile {
path: PathBuf,
slot: u8,
save_type: SaveType,
modified: SystemTime,
}
impl SaveFile {
pub fn path(&self) -> &Path {
&self.path
}
pub fn slot(&self) -> u8 {
self.slot
}
pub fn save_type(&self) -> SaveType {
self.save_type
}
pub fn modified(&self) -> SystemTime {
self.modified
}
pub fn metadata_path(&self) -> PathBuf {
let name = self
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("save.hg");
self.path.with_file_name(format!("mf_{name}"))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveSlot {
slot: u8,
manual: Option<SaveFile>,
auto: Option<SaveFile>,
}
impl SaveSlot {
pub fn slot(&self) -> u8 {
self.slot
}
pub fn manual(&self) -> Option<&SaveFile> {
self.manual.as_ref()
}
pub fn auto(&self) -> Option<&SaveFile> {
self.auto.as_ref()
}
pub fn most_recent(&self) -> Option<&SaveFile> {
match (&self.manual, &self.auto) {
(Some(m), Some(a)) => {
if m.modified >= a.modified {
Some(m)
} else {
Some(a)
}
}
(Some(m), None) => Some(m),
(None, Some(a)) => Some(a),
(None, None) => None,
}
}
}
fn parse_save_filename(name: &str) -> Option<(u8, SaveType)> {
if !name.ends_with(".hg") || name.starts_with("mf_") {
return None;
}
let stem = name.strip_suffix(".hg")?;
if stem == "save" {
return Some((1, SaveType::Manual));
}
let num_str = stem.strip_prefix("save")?;
let file_index: u8 = num_str.parse().ok()?;
if file_index < 2 {
return None;
}
let slot = file_index.div_ceil(2);
let save_type = if file_index.is_multiple_of(2) {
SaveType::Auto
} else {
SaveType::Manual
};
Some((slot, save_type))
}
pub fn nms_save_dir() -> Result<PathBuf, LocateError> {
nms_save_dir_impl()
}
#[cfg(target_os = "macos")]
fn nms_save_dir_impl() -> Result<PathBuf, LocateError> {
let data = dirs::data_dir().ok_or(LocateError::NoHomeDir)?;
Ok(data.join("HelloGames").join("NMS"))
}
#[cfg(target_os = "windows")]
fn nms_save_dir_impl() -> Result<PathBuf, LocateError> {
let data = dirs::data_dir().ok_or(LocateError::NoHomeDir)?;
Ok(data.join("HelloGames").join("NMS"))
}
#[cfg(target_os = "linux")]
fn nms_save_dir_impl() -> Result<PathBuf, LocateError> {
let home = dirs::home_dir().ok_or(LocateError::NoHomeDir)?;
Ok(home.join(".local/share/Steam/steamapps/compatdata/275850/pfx/drive_c/users/steamuser/AppData/Roaming/HelloGames/NMS"))
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
fn nms_save_dir_impl() -> Result<PathBuf, LocateError> {
Err(LocateError::UnsupportedPlatform)
}
pub fn nms_save_dir_checked() -> Result<PathBuf, LocateError> {
let dir = nms_save_dir()?;
if dir.exists() {
Ok(dir)
} else {
Err(LocateError::SaveDirNotFound(dir))
}
}
pub fn list_accounts(save_dir: &Path) -> Result<Vec<AccountDir>, LocateError> {
let mut accounts = Vec::new();
for entry in std::fs::read_dir(save_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let name = match entry.file_name().into_string() {
Ok(n) => n,
Err(_) => continue,
};
let kind = parse_account_kind(&name);
accounts.push(AccountDir {
path: entry.path(),
kind,
});
}
if accounts.is_empty() {
return Err(LocateError::NoAccountDirs(save_dir.to_path_buf()));
}
accounts.sort_by(|a, b| a.name().cmp(b.name()));
Ok(accounts)
}
pub fn list_saves(account_dir: &Path) -> Result<Vec<SaveFile>, LocateError> {
let mut saves = Vec::new();
for entry in std::fs::read_dir(account_dir)? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let name = match entry.file_name().into_string() {
Ok(n) => n,
Err(_) => continue,
};
if let Some((slot, save_type)) = parse_save_filename(&name) {
let modified = entry.metadata()?.modified()?;
saves.push(SaveFile {
path: entry.path(),
slot,
save_type,
modified,
});
}
}
if saves.is_empty() {
return Err(LocateError::NoSaveFiles(account_dir.to_path_buf()));
}
saves.sort_by_key(|save| std::cmp::Reverse(save.modified));
Ok(saves)
}
pub fn group_into_slots(saves: &[SaveFile]) -> Vec<SaveSlot> {
let max_slot = saves.iter().map(|s| s.slot).max().unwrap_or(0);
let mut slots: Vec<SaveSlot> = (1..=max_slot)
.map(|n| SaveSlot {
slot: n,
manual: None,
auto: None,
})
.collect();
for save in saves {
let idx = (save.slot - 1) as usize;
if idx < slots.len() {
match save.save_type {
SaveType::Manual => slots[idx].manual = Some(save.clone()),
SaveType::Auto => slots[idx].auto = Some(save.clone()),
}
}
}
slots.retain(|s| s.manual.is_some() || s.auto.is_some());
slots
}
pub fn find_most_recent_save() -> Result<SaveFile, LocateError> {
let save_dir = nms_save_dir_checked()?;
let accounts = list_accounts(&save_dir)?;
let mut best: Option<SaveFile> = None;
for account in &accounts {
if let Ok(saves) = list_saves(account.path())
&& let Some(newest) = saves.into_iter().next()
{
let dominated = best.as_ref().is_none_or(|b| newest.modified > b.modified);
if dominated {
best = Some(newest);
}
}
}
best.ok_or(LocateError::NoSaveFiles(save_dir))
}
pub fn find_most_recent_save_in(account_dir: &Path) -> Result<SaveFile, LocateError> {
let saves = list_saves(account_dir)?;
Ok(saves.into_iter().next().unwrap())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::thread;
use std::time::Duration;
use tempfile::TempDir;
#[test]
fn parse_save_hg() {
let (slot, st) = parse_save_filename("save.hg").unwrap();
assert_eq!(slot, 1);
assert_eq!(st, SaveType::Manual);
}
#[test]
fn parse_save2_hg() {
let (slot, st) = parse_save_filename("save2.hg").unwrap();
assert_eq!(slot, 1);
assert_eq!(st, SaveType::Auto);
}
#[test]
fn parse_save3_hg() {
let (slot, st) = parse_save_filename("save3.hg").unwrap();
assert_eq!(slot, 2);
assert_eq!(st, SaveType::Manual);
}
#[test]
fn parse_save4_hg() {
let (slot, st) = parse_save_filename("save4.hg").unwrap();
assert_eq!(slot, 2);
assert_eq!(st, SaveType::Auto);
}
#[test]
fn parse_save30_hg() {
let (slot, st) = parse_save_filename("save30.hg").unwrap();
assert_eq!(slot, 15);
assert_eq!(st, SaveType::Auto);
}
#[test]
fn parse_mf_save_rejected() {
assert!(parse_save_filename("mf_save.hg").is_none());
assert!(parse_save_filename("mf_save2.hg").is_none());
}
#[test]
fn parse_nonsave_rejected() {
assert!(parse_save_filename("readme.txt").is_none());
assert!(parse_save_filename("config.hg").is_none());
assert!(parse_save_filename("save.json").is_none());
}
#[test]
fn account_kind_steam() {
match parse_account_kind("st_76561198025707979") {
AccountKind::Steam(id) => assert_eq!(id, 76561198025707979),
other => panic!("expected Steam, got {other:?}"),
}
}
#[test]
fn account_kind_gog() {
assert_eq!(parse_account_kind("DefaultUser"), AccountKind::Gog);
}
#[test]
fn account_kind_unknown() {
match parse_account_kind("some_other_dir") {
AccountKind::Unknown(name) => assert_eq!(name, "some_other_dir"),
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn account_kind_steam_bad_id() {
match parse_account_kind("st_notanumber") {
AccountKind::Unknown(_) => {}
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn nms_save_dir_contains_hellogames_nms() {
let dir = nms_save_dir().unwrap();
let s = dir.to_string_lossy();
assert!(s.contains("HelloGames"), "path missing HelloGames: {s}");
assert!(s.ends_with("NMS"), "path should end with NMS: {s}");
}
#[test]
fn list_accounts_finds_steam_and_gog() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("st_123")).unwrap();
fs::create_dir(tmp.path().join("DefaultUser")).unwrap();
let accounts = list_accounts(tmp.path()).unwrap();
assert_eq!(accounts.len(), 2);
let kinds: Vec<_> = accounts.iter().map(|a| a.kind().clone()).collect();
assert!(kinds.contains(&AccountKind::Gog));
assert!(kinds.contains(&AccountKind::Steam(123)));
}
#[test]
fn list_accounts_skips_files() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("st_123")).unwrap();
fs::write(tmp.path().join("not_a_dir.txt"), b"data").unwrap();
let accounts = list_accounts(tmp.path()).unwrap();
assert_eq!(accounts.len(), 1);
}
#[test]
fn list_accounts_empty_returns_error() {
let tmp = TempDir::new().unwrap();
let err = list_accounts(tmp.path()).unwrap_err();
assert!(matches!(err, LocateError::NoAccountDirs(_)));
}
#[test]
fn list_saves_finds_and_sorts_by_mtime() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("save.hg"), b"old").unwrap();
thread::sleep(Duration::from_millis(50));
fs::write(tmp.path().join("save2.hg"), b"newer").unwrap();
thread::sleep(Duration::from_millis(50));
fs::write(tmp.path().join("save3.hg"), b"newest").unwrap();
let saves = list_saves(tmp.path()).unwrap();
assert_eq!(saves.len(), 3);
assert_eq!(saves[0].slot(), 2); assert_eq!(saves[0].save_type(), SaveType::Manual);
assert_eq!(saves[1].slot(), 1); assert_eq!(saves[1].save_type(), SaveType::Auto);
assert_eq!(saves[2].slot(), 1); assert_eq!(saves[2].save_type(), SaveType::Manual);
}
#[test]
fn list_saves_excludes_metadata() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("save.hg"), b"data").unwrap();
fs::write(tmp.path().join("mf_save.hg"), b"meta").unwrap();
let saves = list_saves(tmp.path()).unwrap();
assert_eq!(saves.len(), 1);
assert_eq!(saves[0].slot(), 1);
}
#[test]
fn list_saves_empty_dir_returns_error() {
let tmp = TempDir::new().unwrap();
let err = list_saves(tmp.path()).unwrap_err();
assert!(matches!(err, LocateError::NoSaveFiles(_)));
}
#[test]
fn group_into_slots_pairs_correctly() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("save.hg"), b"m1").unwrap();
thread::sleep(Duration::from_millis(50));
fs::write(tmp.path().join("save2.hg"), b"a1").unwrap();
thread::sleep(Duration::from_millis(50));
fs::write(tmp.path().join("save3.hg"), b"m2").unwrap();
let saves = list_saves(tmp.path()).unwrap();
let slots = group_into_slots(&saves);
assert_eq!(slots.len(), 2);
assert_eq!(slots[0].slot(), 1);
assert!(slots[0].manual().is_some());
assert!(slots[0].auto().is_some());
assert_eq!(slots[1].slot(), 2);
assert!(slots[1].manual().is_some());
assert!(slots[1].auto().is_none());
}
#[test]
fn find_most_recent_save_in_picks_newest() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("save.hg"), b"old").unwrap();
thread::sleep(Duration::from_millis(50));
fs::write(tmp.path().join("save3.hg"), b"newest").unwrap();
let newest = find_most_recent_save_in(tmp.path()).unwrap();
assert_eq!(newest.slot(), 2);
assert_eq!(newest.save_type(), SaveType::Manual);
}
#[test]
fn save_file_metadata_path() {
let save = SaveFile {
path: PathBuf::from("/tmp/st_123/save3.hg"),
slot: 2,
save_type: SaveType::Manual,
modified: SystemTime::UNIX_EPOCH,
};
assert_eq!(
save.metadata_path(),
PathBuf::from("/tmp/st_123/mf_save3.hg")
);
}
#[test]
fn save_slot_most_recent() {
let older = SaveFile {
path: PathBuf::from("/tmp/save.hg"),
slot: 1,
save_type: SaveType::Manual,
modified: SystemTime::UNIX_EPOCH,
};
let newer = SaveFile {
path: PathBuf::from("/tmp/save2.hg"),
slot: 1,
save_type: SaveType::Auto,
modified: SystemTime::UNIX_EPOCH + Duration::from_secs(100),
};
let slot = SaveSlot {
slot: 1,
manual: Some(older),
auto: Some(newer),
};
let recent = slot.most_recent().unwrap();
assert_eq!(recent.save_type(), SaveType::Auto);
}
#[test]
fn account_kind_display() {
assert_eq!(AccountKind::Steam(12345).to_string(), "Steam (12345)");
assert_eq!(AccountKind::Gog.to_string(), "GOG");
assert_eq!(
AccountKind::Unknown("foo".into()).to_string(),
"Unknown (foo)"
);
}
#[test]
fn save_type_display() {
assert_eq!(SaveType::Manual.to_string(), "Manual");
assert_eq!(SaveType::Auto.to_string(), "Auto");
}
}