Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions crates/ltk_overlay/src/builder/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ impl OverlayBuilder {
pub(crate) fn collect_all_override_metadata(
&mut self,
game_index: &GameIndex,
) -> Result<HashMap<u64, OverrideMeta>> {
) -> Result<(HashMap<u64, OverrideMeta>, Vec<ModWadReport>)> {
let game_dir = &self.game_dir;
let meta_cache_path = self.state_dir.join("override_meta.bin");
let game_fp = game_index.game_fingerprint();
Expand Down Expand Up @@ -300,6 +300,12 @@ impl OverlayBuilder {
per_mod_results.push(mod_meta);
}

// Build per-mod WAD reports while we still have the un-merged data.
// Reports are load-order independent because each is computed from a
// single mod's metadata only.
let mod_wad_reports =
self.build_mod_wad_reports(&per_mod_results, &fingerprints, game_index);

// Merge in reverse order (last mod first → first mod wins via last-writer-wins)
let mut all_meta: HashMap<u64, OverrideMeta> = HashMap::new();

Expand All @@ -326,7 +332,27 @@ impl OverlayBuilder {
tracing::warn!("Failed to save override meta cache: {}", e);
}

Ok(all_meta)
Ok((all_meta, mod_wad_reports))
}

/// Pair each enabled mod with its un-merged metadata and turn it into a
/// [`ModWadReport`].
///
/// `per_mod_results` and `fingerprints` MUST be parallel to `self.enabled_mods`.
fn build_mod_wad_reports(
&self,
per_mod_results: &[HashMap<u64, OverrideMeta>],
fingerprints: &[Option<u64>],
game_index: &GameIndex,
) -> Vec<ModWadReport> {
self.enabled_mods
.iter()
.zip(per_mod_results.iter())
.zip(fingerprints.iter())
.map(|((enabled_mod, mod_meta), fp)| {
ModWadReport::from_meta(enabled_mod.id.clone(), mod_meta, *fp, game_index)
})
.collect()
}
}

Expand Down
119 changes: 118 additions & 1 deletion crates/ltk_overlay/src/builder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,70 @@ pub struct Conflict {
pub winner: String,
}

/// Per-mod summary of which game WAD files a mod's overrides affect.
///
/// Computed independently for each mod (i.e. before cross-mod merging), so
/// the result is **load-order independent**: it represents the mod's full
/// potential WAD footprint regardless of which other mods are enabled
/// alongside it.
///
/// Reports are produced in two ways:
///
/// 1. As a side effect of [`OverlayBuilder::build`], which captures one report
/// per enabled mod and exposes them via
/// [`take_mod_wad_reports`](OverlayBuilder::take_mod_wad_reports).
/// 2. On demand via [`OverlayBuilder::analyze_single_mod`], which runs the
/// same per-mod analysis without writing any overlay files.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ModWadReport {
/// Mod identifier (matches [`EnabledMod::id`]).
pub mod_id: String,
/// Game-relative WAD paths the mod's overrides land in, sorted and deduplicated.
pub affected_wads: Vec<Utf8PathBuf>,
/// Total number of override entries the mod contributes across all layers.
pub override_count: u32,
/// Content fingerprint of the mod at the time the report was computed,
/// from [`ModContentProvider::content_fingerprint`].
pub content_fingerprint: Option<u64>,
/// Game index fingerprint at the time the report was computed.
pub game_index_fingerprint: u64,
}

impl ModWadReport {
/// Build a report from one mod's collected override metadata.
///
/// For each override hash, the matching set of game WADs is looked up via
/// [`GameIndex::find_wads_with_hash`]. Hashes that don't appear in any
/// game WAD fall back to the per-override `fallback_wad` recorded during
/// metadata collection (i.e. the WAD the mod's directory structure
/// pointed at).
pub(crate) fn from_meta(
mod_id: String,
mod_meta: &HashMap<u64, OverrideMeta>,
content_fingerprint: Option<u64>,
game_index: &GameIndex,
) -> Self {
let mut wads: std::collections::BTreeSet<Utf8PathBuf> = std::collections::BTreeSet::new();
for (path_hash, meta) in mod_meta {
if let Some(wad_paths) = game_index.find_wads_with_hash(*path_hash) {
for wp in wad_paths {
wads.insert(wp.clone());
}
} else if let Some(fallback) = &meta.fallback_wad {
wads.insert(fallback.clone());
}
}

Self {
mod_id,
affected_wads: wads.into_iter().collect(),
override_count: mod_meta.len() as u32,
content_fingerprint,
game_index_fingerprint: game_index.game_fingerprint(),
}
}
}

/// Details about one mod's contribution to a conflicting chunk.
#[derive(Debug, Clone)]
pub struct ModContribution {
Expand Down Expand Up @@ -263,6 +327,9 @@ pub struct OverlayBuilder {
enabled_mods: Vec<EnabledMod>,
blocked_wads: HashSet<String>,
progress_callback: Option<ProgressCallback>,
/// Per-mod WAD reports captured during the most recent successful
/// [`build`](Self::build), drained via [`take_mod_wad_reports`](Self::take_mod_wad_reports).
last_mod_wad_reports: Vec<ModWadReport>,
}

impl OverlayBuilder {
Expand All @@ -284,7 +351,56 @@ impl OverlayBuilder {
enabled_mods: Vec::new(),
blocked_wads: HashSet::new(),
progress_callback: None,
last_mod_wad_reports: Vec::new(),
}
}

/// Drain the per-mod WAD reports captured during the most recent successful
/// [`build`](Self::build).
///
/// Each report describes one mod's full potential WAD footprint, computed
/// independently of load order. Returns an empty vector if no build has
/// run yet, or if the previous build short-circuited (e.g. exact-match skip).
pub fn take_mod_wad_reports(&mut self) -> Vec<ModWadReport> {
std::mem::take(&mut self.last_mod_wad_reports)
}

/// Analyze a single mod's WAD footprint without building or modifying any
/// overlay artifacts.
///
/// Loads (or builds) the [`GameIndex`] from `game_dir` using the cache at
/// `state_dir/game_index.bin`, then runs the same per-mod metadata
/// collection used during a full build and resolves it into a
/// [`ModWadReport`]. Safe to call concurrently with [`build`](Self::build)
/// because it neither writes overlay state nor takes any locks held by
/// the build pipeline.
pub fn analyze_single_mod(
game_dir: &Utf8Path,
state_dir: &Utf8Path,
enabled_mod: &mut EnabledMod,
) -> Result<ModWadReport> {
let data_final_dir = game_dir.join("DATA").join("FINAL");
if !data_final_dir.as_std_path().exists() {
return Err(format!(
"League path does not contain Game/DATA/FINAL. Game dir: '{}'",
game_dir
)
.into());
}

std::fs::create_dir_all(state_dir.as_std_path())?;
let cache_path = state_dir.join("game_index.bin");
let game_index = GameIndex::load_or_build(game_dir, &cache_path)?;

let fingerprint = enabled_mod.cache_fingerprint();
let mod_meta = metadata::collect_single_mod_metadata(enabled_mod, &game_index, game_dir)?;

Ok(ModWadReport::from_meta(
enabled_mod.id.clone(),
&mod_meta,
fingerprint,
&game_index,
))
}

/// Register a progress callback.
Expand Down Expand Up @@ -418,7 +534,8 @@ impl OverlayBuilder {

self.emit_progress(OverlayProgress::stage(OverlayStage::CollectingOverrides));

let all_meta = self.collect_all_override_metadata(&game_index)?;
let (all_meta, mod_wad_reports) = self.collect_all_override_metadata(&game_index)?;
self.last_mod_wad_reports = mod_wad_reports;

let mut wad_hash_sets = self.distribute_override_hashes(&all_meta, &game_index);

Expand Down
3 changes: 2 additions & 1 deletion crates/ltk_overlay/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ pub mod wad_builder;

// Re-export main public API.
pub use builder::{
EnabledMod, OverlayBuildResult, OverlayBuilder, OverlayProgress, OverlayStage, BASE_LAYER_NAME,
EnabledMod, ModWadReport, OverlayBuildResult, OverlayBuilder, OverlayProgress, OverlayStage,
BASE_LAYER_NAME,
};
pub use content::{FsModContent, ModContentProvider};
pub use error::{Error, Result};
Expand Down