fix(core): shader configs are persisted across launches

This commit is contained in:
LynithDev 2026-07-25 18:15:47 +02:00
parent b2672c8712
commit 76c4a03942
2 changed files with 250 additions and 11 deletions

View file

@ -490,7 +490,7 @@ pub(crate) async fn finalize_session(
if !dedicated {
crate::game::import_manual_content(&state.services, cluster, cwd).await;
if let Err(err) = crate::game::clear_shared_content(cwd).await {
if let Err(err) = crate::game::clear_shared_content(&state.services, cluster, cwd).await {
tracing::warn!(cluster_id, error = %err, "failed to clear shared content on exit");
}
crate::game::unlink_cluster_logs(cwd).await;

View file

@ -1,13 +1,14 @@
use std::collections::HashSet;
use std::fs::FileType;
use std::path::Path;
use oneclient_db::dao::artifact as artifact_dao;
use crate::LauncherResult;
use crate::clusters::Cluster;
use crate::packages::PackageStore;
use crate::packages::domain::ContentType;
use crate::packages::store::{artifact_absolute_path, link_or_copy};
use crate::packages::{LinkedArtifactInfo, PackageStore};
use crate::state::LauncherServices;
const REDIRECTED_DIRS: [&str; 2] = ["logs", "crash-reports"];
@ -30,14 +31,22 @@ pub async fn sync_shared_content(
import_manual_content(services, cluster, game_dir).await;
let linked = PackageStore::list_linked_artifacts(cluster.id, services).await?;
for content_type in SWAP_TYPES {
let dir = game_dir.join(content_type.folder_name());
let stash = cluster.dir()?.join(content_type.folder_name());
polyio::create_dir_all(&dir).await.ok();
clear_content_files(&dir).await;
let managed = managed_names(&linked, content_type);
// Anything still here belongs to whoever played last (or crashed last)
// — take it into this cluster rather than deleting it.
stash_content_files(&dir, &stash, &managed).await;
ensure_note(&dir, content_type).await;
restore_stashed(&stash, &dir, &managed).await;
}
let linked = PackageStore::list_linked_artifacts(cluster.id, services).await?;
for link in linked {
if !link.enabled || !SWAP_TYPES.contains(&link.content_type) {
continue;
@ -156,14 +165,33 @@ pub async fn write_allowed_symlinks(game_dir: &Path) -> LauncherResult<()> {
const EMPTY_NOTE_NAME: &str = "WHY_NOTHING_HERE.txt";
async fn clear_content_files(dir: &Path) {
/// The file names the package store owns inside a content folder. Everything
/// else in there was put there by the game or the user.
fn managed_names(linked: &[LinkedArtifactInfo], content_type: ContentType) -> HashSet<String> {
linked
.iter()
.filter(|link| link.content_type == content_type)
.map(|link| link.cluster_file_name.clone())
.collect()
}
/// Empty a shared content folder without losing anything the game wrote into
/// it.
///
/// Managed content is dropped — the cluster folder already holds an equivalent
/// link into the artifact cache. Everything else (a shaderpack's settings
/// sidecar, an unzipped pack, a stray config) is *moved* into the cluster's own
/// copy of the folder, and [`restore_stashed`] links it back on the next
/// launch. That is what keeps shader configs attached to the cluster they were
/// tuned in instead of being wiped by the next launch of any cluster.
async fn stash_content_files(dir: &Path, stash: &Path, managed: &HashSet<String>) {
let Ok(mut entries) = polyio::read_dir(dir).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else {
continue;
};
if name == EMPTY_NOTE_NAME || name.starts_with('.') {
@ -174,14 +202,99 @@ async fn clear_content_files(dir: &Path) {
continue;
};
if !file_type.is_file() && !file_type.is_symlink() {
// Ours: either a link we made this launch, or a name the store tracks
// (on Windows `symlink_file` hard-links, so ours is not always a
// symlink). Either way the cluster folder has it covered.
if file_type.is_symlink() || managed.contains(&name) {
remove_entry(&path, file_type).await;
continue;
}
polyio::remove_file(&path).await.ok();
let dest = stash.join(&name);
if let Err(err) = move_entry(&path, &dest).await {
tracing::warn!(
file = %name,
error = %err,
"failed to stash shared content into cluster; leaving it in place"
);
}
}
}
/// Link the cluster's stashed leftovers back into the shared folder so the
/// game finds them where it left them. The game writes straight through the
/// link, so edits land in the cluster folder even if we never get to run on
/// exit.
async fn restore_stashed(stash: &Path, dir: &Path, managed: &HashSet<String>) {
let Ok(mut entries) = polyio::read_dir(stash).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else {
continue;
};
if name == EMPTY_NOTE_NAME || name.starts_with('.') || managed.contains(&name) {
continue;
}
let Ok(file_type) = entry.file_type().await else {
continue;
};
// A link into the artifact cache — managed content is linked from the
// database further down, not from here.
if file_type.is_symlink() {
continue;
}
let dest = dir.join(&name);
let result = if file_type.is_dir() {
polyio::symlink_dir(&path, &dest).await.map_err(Into::into)
} else {
link_or_copy(&path, &dest).await
};
if let Err(err) = result {
tracing::warn!(
file = %name,
error = %err,
"failed to restore stashed content into shared dir"
);
}
}
}
async fn remove_entry(path: &Path, file_type: FileType) {
if file_type.is_dir() {
polyio::remove_dir_all(path).await.ok();
} else if polyio::remove_file(path).await.is_err() {
// A Windows junction has to go through `remove_dir`.
polyio::remove_symlink_dir(path).await.ok();
}
}
async fn move_entry(src: &Path, dest: &Path) -> LauncherResult<()> {
if let Some(parent) = dest.parent() {
polyio::create_dir_all(parent).await.ok();
}
// A stash left from an earlier session is always older than what the game
// just wrote, so it loses.
if let Ok(meta) = polyio::symlink_metadata(dest).await {
remove_entry(dest, meta.file_type()).await;
}
if polyio::rename(src, dest).await.is_ok() {
return Ok(());
}
// `rename` cannot cross devices; fall back to a copy for plain files.
polyio::copy(src, dest).await?;
polyio::remove_file(src).await.ok();
Ok(())
}
async fn ensure_note(dir: &Path, content_type: ContentType) {
let note = dir.join(EMPTY_NOTE_NAME);
@ -206,12 +319,24 @@ async fn ensure_note(dir: &Path, content_type: ContentType) {
.ok();
}
#[tracing::instrument(level = "debug")]
pub async fn clear_shared_content(game_dir: &Path) -> LauncherResult<()> {
#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")]
pub async fn clear_shared_content(
services: &LauncherServices,
cluster: &Cluster,
game_dir: &Path,
) -> LauncherResult<()> {
// Run after `import_manual_content`, so anything the user dropped in is
// already a tracked artifact by now and gets dropped rather than stashed.
let linked = PackageStore::list_linked_artifacts(cluster.id, services)
.await
.unwrap_or_default();
for content_type in SWAP_TYPES {
let dir = game_dir.join(content_type.folder_name());
let stash = cluster.dir()?.join(content_type.folder_name());
polyio::create_dir_all(&dir).await.ok();
clear_content_files(&dir).await;
stash_content_files(&dir, &stash, &managed_names(&linked, content_type)).await;
ensure_note(&dir, content_type).await;
}
Ok(())
@ -329,3 +454,117 @@ async fn sync_fabric_dep_overrides(cluster: &Cluster, game_dir: &Path) -> Launch
Ok(())
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use super::*;
fn tmp_root(tag: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("oneclient_shd_{}_{tag}_{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn managed(names: &[&str]) -> HashSet<String> {
names.iter().map(|n| (*n).to_string()).collect()
}
/// A shaderpack's settings sidecar has to survive a launch/exit cycle, and
/// end up in the cluster rather than the shared dir.
#[tokio::test]
async fn shader_settings_survive_a_session() {
let root = tmp_root("shader_settings");
let shared = root.join("shared").join("shaderpacks");
let stash = root.join("cluster").join("shaderpacks");
polyio::create_dir_all(&shared).await.unwrap();
let names = managed(&["bsl.zip"]);
// Play: the pack is linked in, the game writes its settings next to it.
polyio::write(shared.join("bsl.zip"), b"pack".as_slice())
.await
.unwrap();
polyio::write(shared.join("bsl.zip.txt"), b"BLOOM=off".as_slice())
.await
.unwrap();
// Exit.
stash_content_files(&shared, &stash, &names).await;
assert!(!shared.join("bsl.zip").exists(), "managed pack left behind");
assert!(!shared.join("bsl.zip.txt").exists(), "sidecar left behind");
assert_eq!(
polyio::read_to_string(stash.join("bsl.zip.txt")).await.unwrap(),
"BLOOM=off"
);
// Next launch.
restore_stashed(&stash, &shared, &names).await;
assert_eq!(
polyio::read_to_string(shared.join("bsl.zip.txt")).await.unwrap(),
"BLOOM=off"
);
// The game edits its settings through the link we restored.
polyio::write(stash.join("bsl.zip.txt"), b"BLOOM=on".as_slice())
.await
.unwrap();
assert_eq!(
polyio::read_to_string(shared.join("bsl.zip.txt")).await.unwrap(),
"BLOOM=on"
);
std::fs::remove_dir_all(&root).ok();
}
/// An unzipped pack is a directory; it moves out and comes back as a link.
#[tokio::test]
async fn unpacked_dirs_move_into_the_cluster() {
let root = tmp_root("unpacked");
let shared = root.join("shared").join("shaderpacks");
let stash = root.join("cluster").join("shaderpacks");
polyio::create_dir_all(shared.join("Loose/shaders")).await.unwrap();
polyio::write(shared.join("Loose/shaders/final.fsh"), b"void main".as_slice())
.await
.unwrap();
stash_content_files(&shared, &stash, &HashSet::new()).await;
assert!(!shared.join("Loose").exists());
assert!(stash.join("Loose/shaders/final.fsh").exists());
restore_stashed(&stash, &shared, &HashSet::new()).await;
assert!(shared.join("Loose/shaders/final.fsh").exists());
// Only the link goes, never the stashed original.
stash_content_files(&shared, &stash, &HashSet::new()).await;
assert!(!shared.join("Loose").exists());
assert!(stash.join("Loose/shaders/final.fsh").exists());
std::fs::remove_dir_all(&root).ok();
}
/// The note explaining the empty folder is ours, and stays put.
#[tokio::test]
async fn note_is_never_stashed() {
let root = tmp_root("note");
let shared = root.join("shared").join("mods");
let stash = root.join("cluster").join("mods");
polyio::create_dir_all(&shared).await.unwrap();
polyio::write(shared.join(EMPTY_NOTE_NAME), b"hi".as_slice())
.await
.unwrap();
stash_content_files(&shared, &stash, &HashSet::new()).await;
assert!(shared.join(EMPTY_NOTE_NAME).exists());
assert!(!stash.join(EMPTY_NOTE_NAME).exists());
std::fs::remove_dir_all(&root).ok();
}
}