This commit is contained in:
2026-08-08 11:56:08 +02:00
commit c2309e3ccf
10 changed files with 1868 additions and 0 deletions

203
src/convert.rs Normal file
View File

@@ -0,0 +1,203 @@
use crate::copy;
use crate::input::ResolvedInput;
use crate::mods;
use crate::password;
use crate::players_db;
use anyhow::{bail, Context, Result};
use std::fs;
use std::path::Path;
pub struct ConvertOptions<'a> {
pub input: &'a Path,
pub output: &'a Path,
pub server_name: &'a str,
pub server_config: Option<&'a Path>,
pub workshop_dir: Option<&'a Path>,
pub password: Option<&'a str>,
pub env_file: Option<&'a Path>,
pub force: bool,
}
pub fn convert(opts: &ConvertOptions<'_>) -> Result<()> {
let resolved = ResolvedInput::resolve(opts.input)?;
let dest_world = opts
.output
.join("Saves")
.join("Multiplayer")
.join(opts.server_name);
if dest_world.exists() && !opts.force {
bail!(
"destination world already exists: {} (use --force to replace)",
dest_world.display()
);
}
eprintln!(
"Converting {} -> {}",
resolved.world_root.display(),
dest_world.display()
);
let copied = copy::copy_world_filtered(&resolved.world_root, &dest_world)?;
eprintln!("Copied {copied} files");
let players_db_path = dest_world.join("players.db");
if players_db_path.is_file() {
let updated = players_db::patch_world_column(&players_db_path, opts.server_name)?;
eprintln!(
"Updated {updated} networkPlayers rows (world -> {})",
opts.server_name
);
} else {
eprintln!("Warning: no players.db found in destination world");
}
if let Some(config_dir) = opts.server_config {
copy_server_config(config_dir, opts.output, &resolved.world_name, opts.server_name)?;
}
recover_mods_into_server_ini(opts, &resolved.world_root)?;
apply_server_password(opts)?;
eprintln!("Done.");
Ok(())
}
fn apply_server_password(opts: &ConvertOptions<'_>) -> Result<()> {
let Some(pw) = password::resolve_password(opts.password, opts.env_file, opts.output)? else {
eprintln!(
"No server password set (pass --password or SERVER_PASSWORD/PASSWORD in .env); leaving Password= unchanged"
);
return Ok(());
};
let ini = opts
.output
.join("Server")
.join(format!("{}.ini", opts.server_name));
if !ini.is_file() {
eprintln!(
"Warning: {} missing — cannot apply Password=",
ini.display()
);
return Ok(());
}
password::patch_ini_key(&ini, "Password", &pw)?;
eprintln!("Patched Password= in {}", ini.display());
Ok(())
}
fn recover_mods_into_server_ini(opts: &ConvertOptions<'_>, world_root: &Path) -> Result<()> {
let mod_ids = mods::extract_mod_ids(world_root)?;
if mod_ids.is_empty() {
eprintln!("No mod IDs found in WorldDictionaryReadable.lua");
return Ok(());
}
eprintln!("Recovered {} mod ID(s) from WorldDictionary", mod_ids.len());
let Some(workshop_dir) = opts.workshop_dir else {
eprintln!(
"Warning: --workshop-dir not set; wrote Mods= only. Pass a Steam workshop content/108600 path to also fill WorkshopItems="
);
let mods_line = mod_ids.join(";");
let ini = opts.output.join("Server").join(format!("{}.ini", opts.server_name));
if ini.is_file() {
// Keep existing WorkshopItems if any
let text = fs::read_to_string(&ini)?;
let workshop = text
.lines()
.find_map(|l| l.strip_prefix("WorkshopItems="))
.unwrap_or("")
.to_string();
mods::patch_server_ini_mods(&ini, &mods_line, &workshop)?;
eprintln!("Patched Mods= in {}", ini.display());
} else {
eprintln!(
"Warning: {} missing — create/start the server once, then re-run with --workshop-dir",
ini.display()
);
}
return Ok(());
};
let (workshop_ids, _) = mods::resolve_workshop_ids(&mod_ids, workshop_dir)?;
let mods_line = mod_ids.join(";");
let workshop_line = workshop_ids.join(";");
let server_dir = opts.output.join("Server");
fs::create_dir_all(&server_dir)?;
let ini = server_dir.join(format!("{}.ini", opts.server_name));
if !ini.is_file() {
bail!(
"server ini not found at {} — start the dedicated server once to generate it, then re-run",
ini.display()
);
}
mods::patch_server_ini_mods(&ini, &mods_line, &workshop_line)?;
eprintln!(
"Patched {} ({} mods, {} workshop items)",
ini.display(),
mod_ids.len(),
workshop_ids.len()
);
Ok(())
}
fn copy_server_config(
config_dir: &Path,
output: &Path,
old_name: &str,
server_name: &str,
) -> Result<()> {
let dest_server = output.join("Server");
fs::create_dir_all(&dest_server)?;
let mappings = [
(format!("{old_name}.ini"), format!("{server_name}.ini")),
(
format!("{old_name}_SandboxVars.lua"),
format!("{server_name}_SandboxVars.lua"),
),
(
format!("{old_name}_spawnpoints.lua"),
format!("{server_name}_spawnpoints.lua"),
),
(
format!("{old_name}_spawnregions.lua"),
format!("{server_name}_spawnregions.lua"),
),
];
let mut copied_any = false;
for (src_name, dest_name) in mappings {
let src = config_dir.join(&src_name);
if src.is_file() {
let dest = dest_server.join(&dest_name);
fs::copy(&src, &dest).with_context(|| {
format!(
"failed to copy server config {} -> {}",
src.display(),
dest.display()
)
})?;
eprintln!("Copied server config: {src_name} -> {dest_name}");
copied_any = true;
}
}
if !copied_any {
eprintln!(
"Warning: no server config files found in {} for world name `{old_name}`",
config_dir.display()
);
}
Ok(())
}

81
src/copy.rs Normal file
View File

@@ -0,0 +1,81 @@
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
const SKIP_FILENAMES: &[&str] = &["thumb.png", "InGameMap.ini", "serverid.dat"];
pub fn copy_world_filtered(source: &Path, dest: &Path) -> Result<u64> {
if dest.exists() {
fs::remove_dir_all(dest)
.with_context(|| format!("failed to remove existing destination: {}", dest.display()))?;
}
fs::create_dir_all(dest)?;
let mut copied = 0u64;
for entry in WalkDir::new(source).follow_links(false) {
let entry = entry?;
let rel = entry
.path()
.strip_prefix(source)
.context("walkdir path outside source root")?;
if rel.as_os_str().is_empty() {
continue;
}
if should_skip(rel) {
continue;
}
let target = dest.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(&target)?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(entry.path(), &target)?;
copied += 1;
}
}
Ok(copied)
}
fn should_skip(rel: &Path) -> bool {
let file_name = match rel.file_name().and_then(|n| n.to_str()) {
Some(name) => name,
None => return false,
};
if SKIP_FILENAMES.contains(&file_name) {
return true;
}
if file_name.ends_with(".db-journal") {
return true;
}
// Syncthing-style atomic-write conflict copies (e.g. foo.bin-1234567890.123-ZwS1g3qi.bin)
if file_name.contains("-ZwS1g3qi") {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skips_conflict_and_journal_files() {
assert!(should_skip(Path::new("erosion.ini-1785960551.276219-ZwS1g3qi.ini")));
assert!(should_skip(Path::new("players.db-journal")));
assert!(should_skip(Path::new("thumb.png")));
assert!(!should_skip(Path::new("players.db")));
assert!(!should_skip(Path::new("map/1000/1056.bin")));
}
}

119
src/input.rs Normal file
View File

@@ -0,0 +1,119 @@
use anyhow::{bail, Context, Result};
use std::fs;
use std::io::copy;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use zip::ZipArchive;
/// Resolved world input: a directory path and optional temp dir holding zip extraction.
pub struct ResolvedInput {
pub world_root: PathBuf,
pub world_name: String,
_temp_dir: Option<TempDir>,
}
impl ResolvedInput {
pub fn resolve(input: &Path) -> Result<Self> {
if is_player_folder(input) {
bail!(
"input looks like a client player cache folder (ends with `_player`): {}",
input.display()
);
}
if input.is_dir() {
let world_name = input
.file_name()
.and_then(|n| n.to_str())
.context("input directory has no name")?
.to_string();
return Ok(Self {
world_root: input.to_path_buf(),
world_name,
_temp_dir: None,
});
}
if input.is_file() && input.extension().is_some_and(|e| e == "zip") {
return resolve_zip(input);
}
bail!(
"input must be a world directory or .zip file: {}",
input.display()
);
}
}
fn is_player_folder(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| name.ends_with("_player"))
}
fn resolve_zip(zip_path: &Path) -> Result<ResolvedInput> {
let file = fs::File::open(zip_path)
.with_context(|| format!("failed to open zip: {}", zip_path.display()))?;
let mut archive = ZipArchive::new(file)
.with_context(|| format!("failed to read zip: {}", zip_path.display()))?;
let temp_dir = tempfile::tempdir().context("failed to create temp directory")?;
let extract_root = temp_dir.path();
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let entry_path = entry
.enclosed_name()
.with_context(|| format!("zip entry {} has an invalid path", entry.name()))?;
let out_path = extract_root.join(entry_path);
if entry.is_dir() {
fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?;
}
let mut out_file = fs::File::create(&out_path)?;
copy(&mut entry, &mut out_file)?;
}
}
let top_level: Vec<_> = fs::read_dir(extract_root)?
.filter_map(|e| e.ok())
.collect();
if top_level.len() != 1 {
bail!(
"expected exactly one top-level folder in zip {}, found {}",
zip_path.display(),
top_level.len()
);
}
let world_root = top_level[0].path();
if !world_root.is_dir() {
bail!(
"expected top-level entry in zip to be a directory: {}",
zip_path.display()
);
}
if is_player_folder(&world_root) {
bail!(
"zip contains a client player cache folder (ends with `_player`): {}",
world_root.display()
);
}
let world_name = world_root
.file_name()
.and_then(|n| n.to_str())
.context("extracted world directory has no name")?
.to_string();
Ok(ResolvedInput {
world_root,
world_name,
_temp_dir: Some(temp_dir),
})
}

64
src/main.rs Normal file
View File

@@ -0,0 +1,64 @@
mod convert;
mod copy;
mod input;
mod mods;
mod password;
mod players_db;
use anyhow::Result;
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "zomboid-converter",
about = "Convert a locally hosted Project Zomboid multiplayer world into a dedicated server save layout"
)]
struct Cli {
/// Hosted world directory or .zip archive (not the `_player` folder)
#[arg(short, long)]
input: PathBuf,
/// Dedicated server-data root (writes Saves/Multiplayer/<server-name>/)
#[arg(short, long)]
output: PathBuf,
/// Target world and config basename (e.g. pzserver)
#[arg(short = 'n', long, default_value = "pzserver")]
server_name: String,
/// Optional directory containing hosted Server/ config files to copy and rename
#[arg(long)]
server_config: Option<PathBuf>,
/// Steam workshop content dir for app 108600 (resolves mod IDs → WorkshopItems)
#[arg(long)]
workshop_dir: Option<PathBuf>,
/// Server join password (Password= in server ini). Overrides .env when set.
#[arg(long)]
password: Option<String>,
/// Path to .env for SERVER_PASSWORD/PASSWORD (default: <output>/../.env)
#[arg(long)]
env_file: Option<PathBuf>,
/// Replace an existing destination world folder
#[arg(long)]
force: bool,
}
fn main() -> Result<()> {
let cli = Cli::parse();
convert::convert(&convert::ConvertOptions {
input: &cli.input,
output: &cli.output,
server_name: &cli.server_name,
server_config: cli.server_config.as_deref(),
workshop_dir: cli.workshop_dir.as_deref(),
password: cli.password.as_deref(),
env_file: cli.env_file.as_deref(),
force: cli.force,
})
}

189
src/mods.rs Normal file
View File

@@ -0,0 +1,189 @@
use anyhow::{Context, Result};
use std::collections::{BTreeSet, HashMap};
use std::fs;
use std::path::Path;
const VANILLA_MOD: &str = "pz-vanilla";
/// Extract unique mod IDs from WorldDictionaryReadable.lua (excludes pz-vanilla).
pub fn extract_mod_ids(world_root: &Path) -> Result<Vec<String>> {
let dict_path = world_root.join("WorldDictionaryReadable.lua");
let text = fs::read_to_string(&dict_path).with_context(|| {
format!(
"failed to read WorldDictionaryReadable.lua at {}",
dict_path.display()
)
})?;
let mut mods = BTreeSet::new();
for line in text.lines() {
if let Some(id) = parse_mod_id_line(line) {
if id != VANILLA_MOD {
mods.insert(id);
}
}
}
// damnlib is a common dependency — load it first when present
let mut ordered: Vec<String> = mods.into_iter().collect();
if let Some(pos) = ordered.iter().position(|m| m == "damnlib") {
let lib = ordered.remove(pos);
ordered.insert(0, lib);
}
Ok(ordered)
}
fn parse_mod_id_line(line: &str) -> Option<String> {
let key = "modID = \"";
let start = line.find(key)? + key.len();
let rest = &line[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
/// Resolve mod folder IDs to Steam Workshop item IDs by scanning a Steam workshop content dir
/// (`.../steamapps/workshop/content/108600`).
pub fn resolve_workshop_ids(
mod_ids: &[String],
workshop_content_dir: &Path,
) -> Result<(Vec<String>, Vec<String>)> {
let catalog = index_workshop_mods(workshop_content_dir)?;
let mut workshop_ids = Vec::new();
let mut missing = Vec::new();
let mut seen = BTreeSet::new();
for mod_id in mod_ids {
match catalog.get(mod_id) {
Some(wid) => {
if seen.insert(wid.clone()) {
workshop_ids.push(wid.clone());
}
}
None => missing.push(mod_id.clone()),
}
}
if !missing.is_empty() {
anyhow::bail!(
"could not resolve Workshop IDs for {} mod(s): {}",
missing.len(),
missing.join(", ")
);
}
Ok((workshop_ids, missing))
}
fn index_workshop_mods(workshop_content_dir: &Path) -> Result<HashMap<String, String>> {
let mut catalog = HashMap::new();
if !workshop_content_dir.is_dir() {
anyhow::bail!(
"workshop content directory not found: {}",
workshop_content_dir.display()
);
}
for entry in fs::read_dir(workshop_content_dir)? {
let entry = entry?;
let name = entry.file_name();
let wid = name.to_string_lossy();
if !entry.file_type()?.is_dir() || !wid.chars().all(|c| c.is_ascii_digit()) {
continue;
}
scan_mod_infos(entry.path().as_path(), &wid, &mut catalog)?;
}
Ok(catalog)
}
fn scan_mod_infos(dir: &Path, workshop_id: &str, catalog: &mut HashMap<String, String>) -> Result<()> {
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
for entry in fs::read_dir(&current)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
stack.push(path);
continue;
}
if entry.file_name() == "mod.info" {
let text = fs::read_to_string(&path)?;
if let Some(mod_id) = parse_mod_info_id(&text) {
catalog.entry(mod_id).or_insert_with(|| workshop_id.to_string());
}
}
}
}
Ok(())
}
fn parse_mod_info_id(text: &str) -> Option<String> {
for line in text.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("id=") {
return Some(rest.trim().to_string());
}
// rare: "id = foo"
if let Some(rest) = line.strip_prefix("id") {
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('=') {
return Some(rest.trim().to_string());
}
}
}
None
}
/// Patch Mods= and WorkshopItems= in an existing server .ini (preserves other settings).
pub fn patch_server_ini_mods(ini_path: &Path, mods: &str, workshop_items: &str) -> Result<()> {
let text = fs::read_to_string(ini_path)
.with_context(|| format!("failed to read {}", ini_path.display()))?;
let mut out = String::with_capacity(text.len() + mods.len() + workshop_items.len());
let mut saw_mods = false;
let mut saw_workshop = false;
for line in text.lines() {
if let Some(rest) = line.strip_prefix("Mods=") {
let _ = rest;
out.push_str("Mods=");
out.push_str(mods);
out.push('\n');
saw_mods = true;
} else if let Some(rest) = line.strip_prefix("WorkshopItems=") {
let _ = rest;
out.push_str("WorkshopItems=");
out.push_str(workshop_items);
out.push('\n');
saw_workshop = true;
} else {
out.push_str(line);
out.push('\n');
}
}
if !saw_mods {
anyhow::bail!("no Mods= line found in {}", ini_path.display());
}
if !saw_workshop {
anyhow::bail!("no WorkshopItems= line found in {}", ini_path.display());
}
fs::write(ini_path, out).with_context(|| format!("failed to write {}", ini_path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_mod_id_line() {
assert_eq!(
parse_mod_id_line(r#" modID = "damnlib","#),
Some("damnlib".into())
);
}
}

131
src/password.rs Normal file
View File

@@ -0,0 +1,131 @@
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
/// Resolve server join password: CLI override wins, else `.env` keys.
pub fn resolve_password(
cli_password: Option<&str>,
env_file: Option<&Path>,
output: &Path,
) -> Result<Option<String>> {
if let Some(pw) = cli_password {
if !pw.is_empty() {
return Ok(Some(pw.to_string()));
}
}
let env_path = match env_file {
Some(p) => Some(p.to_path_buf()),
None => default_env_path(output),
};
let Some(env_path) = env_path else {
return Ok(None);
};
if !env_path.is_file() {
return Ok(None);
}
let vars = parse_env_file(&env_path)?;
for key in ["SERVER_PASSWORD", "PASSWORD"] {
if let Some(val) = vars.get(key) {
if !val.is_empty() {
return Ok(Some(val.clone()));
}
}
}
Ok(None)
}
fn default_env_path(output: &Path) -> Option<PathBuf> {
// output is typically .../server/server-data → sibling .env at .../server/.env
let candidate = output.join("..").join(".env");
let candidate = candidate.canonicalize().ok().filter(|p| p.is_file());
if candidate.is_some() {
return candidate;
}
let candidate = output.join(".env");
if candidate.is_file() {
return Some(candidate);
}
None
}
pub fn parse_env_file(path: &Path) -> Result<HashMap<String, String>> {
let text = fs::read_to_string(path)
.with_context(|| format!("failed to read env file: {}", path.display()))?;
let mut map = HashMap::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let mut value = value.trim().to_string();
if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
value = value[1..value.len() - 1].to_string();
}
map.insert(key.to_string(), value);
}
Ok(map)
}
/// Patch a single KEY=value line in a server .ini (exact key prefix match).
pub fn patch_ini_key(ini_path: &Path, key: &str, value: &str) -> Result<()> {
let text = fs::read_to_string(ini_path)
.with_context(|| format!("failed to read {}", ini_path.display()))?;
let prefix = format!("{key}=");
let mut out = String::with_capacity(text.len() + value.len());
let mut saw = false;
for line in text.lines() {
if line.starts_with(&prefix) {
out.push_str(&prefix);
out.push_str(value);
out.push('\n');
saw = true;
} else {
out.push_str(line);
out.push('\n');
}
}
if !saw {
anyhow::bail!("no {key}= line found in {}", ini_path.display());
}
fs::write(ini_path, out).with_context(|| format!("failed to write {}", ini_path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn parses_env_and_prefers_cli() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "SERVER_PASSWORD=fromenv").unwrap();
let path = f.path();
let vars = parse_env_file(path).unwrap();
assert_eq!(vars.get("SERVER_PASSWORD").unwrap(), "fromenv");
let resolved = resolve_password(Some("clipw"), Some(path), Path::new("/tmp")).unwrap();
assert_eq!(resolved.as_deref(), Some("clipw"));
let resolved = resolve_password(None, Some(path), Path::new("/tmp")).unwrap();
assert_eq!(resolved.as_deref(), Some("fromenv"));
}
}

14
src/players_db.rs Normal file
View File

@@ -0,0 +1,14 @@
use anyhow::{Context, Result};
use rusqlite::Connection;
use std::path::Path;
pub fn patch_world_column(players_db: &Path, server_name: &str) -> Result<usize> {
let conn = Connection::open(players_db)
.with_context(|| format!("failed to open players.db: {}", players_db.display()))?;
let updated = conn
.execute("UPDATE networkPlayers SET world = ?1", [server_name])
.with_context(|| "failed to update networkPlayers.world")?;
Ok(updated)
}