diff --git a/crates/coa-core/src/analyzer.rs b/crates/coa-core/src/analyzer.rs index 15970c2..f165c80 100644 --- a/crates/coa-core/src/analyzer.rs +++ b/crates/coa-core/src/analyzer.rs @@ -1107,11 +1107,40 @@ pub fn analyze_github_repository(url_str: &str, cache_base_dir: Option<&Path>) - report.commit = resolved_commit; report.tree_digest = Some(tree_digest); report.cache_hit = Some(cache_hit); - report.sdk_fingerprint = Some(SdkExportContract::v03().abi_fingerprint); + report.sdk_fingerprint = crate::build_engine::detect_toolchain().sdk_abi_fingerprint; Ok(report) } +/// A plugin loads only on a host with the fingerprint it was built with. Compare the realm's host with the +/// SDK this manager builds against (the one `build_engine::detect_toolchain` finds). +fn push_realm_abi_finding(report: &mut CompatibilityReport, realm_root: &Path, host_recorded_abi: bool, realm_fp: &str, export_count: usize) { + let sdk_fp = crate::build_engine::detect_toolchain_for(Some(realm_root)).sdk_abi_fingerprint; + let explanation = match (&sdk_fp, host_recorded_abi) { + (_, false) => format!( + "The realm's worldserver.exe records no plugin ABI (no .coapl section): it predates plugin hosts or is not one. Export-list digest: {}.", + realm_fp + ), + (None, true) => format!("No CoA Plugin SDK is available to build for this realm's host ABI '{}'.", realm_fp), + (Some(sdk), true) if sdk != realm_fp => format!( + "The realm's host ABI '{}' differs from the available SDK '{}': a plugin built with this SDK would be rejected by this realm.", + realm_fp, sdk + ), + _ => return, + }; + report.findings.push(CompatibilityFinding { + rule_id: "REALM_ABI_MISMATCH".into(), + severity: FindingSeverity::Warning, + confidence: FindingConfidence::High, + file: "worldserver.exe".into(), + line: 0, + explanation, + recommended_action: "Build with the SDK released with this realm's core.".into(), + evidence: format!("Realm worldserver exports: {}", export_count), + is_auto_fixable: false, + }); +} + /// Analyzes a local repository bound to a specific realm installation. /// Validates the realm's worldserver host binary, extracts its embedded PE ABI identity, /// and audits the repository in the context of that realm's ABI contract. @@ -1149,6 +1178,7 @@ pub fn analyze_repository_for_realm( } }; + let host_recorded_abi = embedded_abi.is_some(); let realm_sdk_fingerprint = embedded_abi.unwrap_or_else(|| { let mut hasher = Sha256::new(); for exp in &exports { @@ -1161,24 +1191,7 @@ pub fn analyze_repository_for_realm( let mut report = analyze_repository(repo_path)?; report.sdk_fingerprint = Some(realm_sdk_fingerprint.clone()); - let v03_fingerprint = SdkExportContract::v03().abi_fingerprint; - if realm_sdk_fingerprint != v03_fingerprint { - report.findings.push(CompatibilityFinding { - rule_id: "REALM_ABI_MISMATCH".into(), - severity: FindingSeverity::Warning, - confidence: FindingConfidence::High, - file: "worldserver.exe".into(), - line: 0, - explanation: format!( - "Realm host ABI fingerprint '{}' differs from reference SDK v0.3 '{}'. Dynamic symbols may require re-qualification.", - realm_sdk_fingerprint, - v03_fingerprint - ), - recommended_action: "Ensure plugin is compiled against this realm's specific worldserver.lib import library.".into(), - evidence: format!("Realm worldserver exports: {}, reference v0.3 exports: 4360", exports.len()), - is_auto_fixable: false, - }); - } + push_realm_abi_finding(&mut report, realm_root, host_recorded_abi, &realm_sdk_fingerprint, exports.len()); Ok(report) } @@ -1220,6 +1233,7 @@ pub fn analyze_github_module_for_realm( } }; + let host_recorded_abi = embedded_abi.is_some(); let realm_sdk_fingerprint = embedded_abi.unwrap_or_else(|| { let mut hasher = Sha256::new(); for exp in &exports { @@ -1248,24 +1262,7 @@ pub fn analyze_github_module_for_realm( report.reasons.push(format!("Blocked by CoA policy: {}", reason)); } - let v03_fingerprint = SdkExportContract::v03().abi_fingerprint; - if realm_sdk_fingerprint != v03_fingerprint { - report.findings.push(CompatibilityFinding { - rule_id: "REALM_ABI_MISMATCH".into(), - severity: FindingSeverity::Warning, - confidence: FindingConfidence::High, - file: "worldserver.exe".into(), - line: 0, - explanation: format!( - "Realm host ABI fingerprint '{}' differs from reference SDK v0.3 '{}'. Dynamic symbols may require re-qualification.", - realm_sdk_fingerprint, - v03_fingerprint - ), - recommended_action: "Ensure plugin is compiled against this realm's specific worldserver.lib import library.".into(), - evidence: format!("Realm worldserver exports: {}, reference v0.3 exports: 4360", exports.len()), - is_auto_fixable: false, - }); - } + push_realm_abi_finding(&mut report, realm_root, host_recorded_abi, &realm_sdk_fingerprint, exports.len()); Ok(report) } @@ -2434,15 +2431,22 @@ mod tests { if server_v03.join("worldserver.exe").is_file() && test_module.exists() { let res_v03 = analyze_repository_for_realm(test_module, server_v03).expect("v03 realm analysis failed"); - assert_eq!(res_v03.sdk_fingerprint.as_deref(), Some("2f82013def4698d3f75fa24e5f70ffa504a7782d366b5edb78c7adc3c1ee0883")); - assert!(!res_v03.findings.iter().any(|f| f.rule_id == "REALM_ABI_MISMATCH")); + // The fingerprint is the one the host records, and the SDK in use was exported from that host. + let host_fp = crate::provenance::realm_host_fingerprint(server_v03); + assert!(host_fp.is_some(), "server-v03 host records no .coapl fingerprint"); + assert_eq!(res_v03.sdk_fingerprint, host_fp); + if crate::build_engine::detect_toolchain_for(Some(server_v03)).sdk_abi_fingerprint == host_fp { + assert!(!res_v03.findings.iter().any(|f| f.rule_id == "REALM_ABI_MISMATCH")); + } assert_eq!(res_v03.multi_dimensional_status.as_ref().map(|s| s.security_status.as_str()), Some("REVIEWED_THIRD_PARTY")); } if server_legacy.join("worldserver.exe").is_file() && test_module.exists() { let res_legacy = analyze_repository_for_realm(test_module, server_legacy).expect("legacy realm analysis failed"); - assert_eq!(res_legacy.sdk_fingerprint.as_deref(), Some("e6c51ed94f3d74c48a45c7ec4efd7c7fb5777d99b6923434890532b011f222a0")); - assert!(res_legacy.findings.iter().any(|f| f.rule_id == "REALM_ABI_MISMATCH")); + // A host without a recorded ABI is always reported. + if crate::provenance::realm_host_fingerprint(server_legacy).is_none() { + assert!(res_legacy.findings.iter().any(|f| f.rule_id == "REALM_ABI_MISMATCH")); + } } } diff --git a/crates/coa-core/src/build_engine.rs b/crates/coa-core/src/build_engine.rs index db5b16f..b819233 100644 --- a/crates/coa-core/src/build_engine.rs +++ b/crates/coa-core/src/build_engine.rs @@ -77,8 +77,87 @@ pub struct BuildResult { pub verification_status: Option, } -/// Detects available MSVC toolchain, CMake, Ninja, and CoA Plugin SDK. +/// Where a realm keeps the plugin SDKs of its cores: `/.manager/sdk//`. The full fingerprint is checked against the SDK's sdk.json; the short name keeps header +/// paths in Ninja's dependency log under Windows' 260-character limit (beyond it Ninja cannot stat a +/// header, treats it as changed and recompiles everything on every build). +pub fn realm_sdk_store(realm_root: &Path) -> PathBuf { + realm_root.join(".manager").join("sdk") +} + +/// The SDK directory of one host fingerprint inside the realm's store. +pub fn realm_sdk_dir(realm_root: &Path, host_fp: &str) -> PathBuf { + realm_sdk_store(realm_root).join(&host_fp[..host_fp.len().min(16)]) +} + +/// The cmake directory of the SDK that matches the realm's installed worldserver.exe: a plugin built with +/// any other SDK would be rejected by this host. None when the host records no ABI or no SDK for it is +/// installed. +pub fn realm_sdk_cmake_dir(realm_root: &Path) -> Option { + let host_fp = crate::provenance::realm_host_fingerprint(realm_root)?; + let cmake = realm_sdk_dir(realm_root, &host_fp).join("cmake"); + let ok = cmake.join("CoAPluginSDKConfig.cmake").is_file() + && sdk_abi_fingerprint(&cmake).ok().as_deref() == Some(host_fp.as_str()); + ok.then_some(cmake) +} + +/// Installs an SDK (a directory with sdk.json, cmake/, include/, lib/) for the realm's current core. The SDK +/// must have been exported from that core: its fingerprint must equal the one worldserver.exe records. +pub fn install_realm_sdk(realm_root: &Path, sdk_root: &Path) -> Result { + let host_fp = crate::provenance::realm_host_fingerprint(realm_root).ok_or_else(|| { + Error::Invalid(format!( + "[SDK_HOST_UNKNOWN] The worldserver.exe of '{}' records no plugin ABI; it is not a plugin host.", + realm_root.display() + )) + })?; + let sdk_fp = sdk_abi_fingerprint(&sdk_root.join("cmake"))?; + if sdk_fp != host_fp { + return Err(Error::Invalid(format!( + "[SDK_HOST_MISMATCH] SDK '{}' is for ABI {}, the realm's core is {}.", + sdk_root.display(), sdk_fp, host_fp + ))); + } + let store = realm_sdk_store(realm_root); + let dest = realm_sdk_dir(realm_root, &host_fp); + if realm_sdk_cmake_dir(realm_root).is_some() { + return Ok(dest); + } + fs::create_dir_all(&store)?; + let staging = store.join(format!(".incoming-{}", &host_fp[..host_fp.len().min(16)])); + if staging.exists() { + fs::remove_dir_all(&staging)?; + } + copy_dir_all(sdk_root, &staging)?; + if dest.exists() { + fs::remove_dir_all(&dest)?; + } + fs::rename(&staging, &dest)?; + Ok(dest) +} + +fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> { + fs::create_dir_all(dest)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let target = dest.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir_all(&entry.path(), &target)?; + } else { + fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} + +/// Detects the MSVC toolchain, CMake and Ninja, without a realm (so without an SDK unless +/// `COA_PLUGIN_SDK_DIR` names one). pub fn detect_toolchain() -> ToolchainStatus { + detect_toolchain_for(None) +} + +/// Detects the MSVC toolchain, CMake, Ninja, and the CoA Plugin SDK for `realm_root`'s core. +/// `COA_PLUGIN_SDK_DIR` overrides the SDK (development only; the build still checks it against the host). +pub fn detect_toolchain_for(realm_root: Option<&Path>) -> ToolchainStatus { let vcvars_candidates = [ "C:\\Program Files\\Microsoft Visual Studio\\18\\Insiders\\VC\\Auxiliary\\Build\\vcvars64.bat", "C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Auxiliary\\Build\\vcvars64.bat", @@ -102,12 +181,8 @@ pub fn detect_toolchain() -> ToolchainStatus { .or_else(|| ninja_candidates.iter().find(|p| Path::new(p).is_file()).map(|s| s.to_string())) .or_else(|| which_cmd("ninja.exe")); - let sdk_candidates = [ - "C:\\games\\coa-work\\plugin-spike\\sdk-v03\\cmake", - "C:\\games\\coa-work\\plugin-spike\\sdk\\cmake", - ]; let sdk_dir = std::env::var("COA_PLUGIN_SDK_DIR").ok().filter(|p| Path::new(p).join("CoAPluginSDKConfig.cmake").is_file()) - .or_else(|| sdk_candidates.iter().find(|p| Path::new(p).join("CoAPluginSDKConfig.cmake").is_file()).map(|s| s.to_string())); + .or_else(|| realm_root.and_then(realm_sdk_cmake_dir).map(|p| p.to_string_lossy().into_owned())); let msvc_available = vcvars64_path.is_some(); let cmake_available = cmake_path.is_some(); @@ -116,13 +191,13 @@ pub fn detect_toolchain() -> ToolchainStatus { let ready = msvc_available && cmake_available && ninja_available && sdk_available; let diagnostic_message = if ready { - "MSVC, CMake, Ninja, and CoA Plugin SDK v0.3 are ready.".into() + "MSVC, CMake, Ninja, and the CoA Plugin SDK for this core are ready.".into() } else { let mut missing = Vec::new(); if !msvc_available { missing.push("MSVC vcvars64.bat"); } if !cmake_available { missing.push("CMake"); } if !ninja_available { missing.push("Ninja"); } - if !sdk_available { missing.push("CoA Plugin SDK (CoAPluginSDKConfig.cmake)"); } + if !sdk_available { missing.push("CoA Plugin SDK for this realm's core"); } format!("Build toolchain incomplete. Missing: {}", missing.join(", ")) }; @@ -168,34 +243,80 @@ fn sdk_abi_fingerprint(sdk_cmake_dir: &Path) -> Result { .ok_or_else(|| Error::Invalid(format!("{} has no valid abiFingerprint", path.display()))) } -/// The fingerprint in a plugin DLL's ".coapl" section ("COAPLUGIN1||"), read from the -/// file without loading it. None when the image has no such section or it is malformed. -fn plugin_section_fingerprint(bytes: &[u8]) -> Option { - let u16_at = |o: usize| bytes.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]])); - let u32_at = |o: usize| bytes.get(o..o + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); - let pe = u32_at(0x3C)? as usize; - if u32_at(pe)? != 0x0000_4550 { - return None; +/// Copies `src` to `dest` only when the content differs, so an unchanged file keeps its timestamp and +/// Ninja does not rebuild it. +fn sync_file(src: &Path, dest: &Path) -> Result<()> { + let data = fs::read(src)?; + write_if_changed(dest, &data) +} + +fn write_if_changed(path: &Path, data: &[u8]) -> Result<()> { + if fs::read(path).map(|old| old == data).unwrap_or(false) { + return Ok(()); } - let sections = u16_at(pe + 6)? as usize; - let first = pe + 24 + u16_at(pe + 20)? as usize; - for i in 0..sections.min(96) { - let at = first.checked_add(i * 40)?; - let name = bytes.get(at..at + 8)?; - if !name.starts_with(b".coapl") { - continue; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, data)?; + Ok(()) +} + +/// Deletes staged sources that no longer exist upstream (renamed or removed files). +fn remove_unstaged(dir: &Path, keep: &std::collections::HashSet) -> Result<()> { + for entry in fs::read_dir(dir)?.flatten() { + let path = entry.path(); + if path.is_dir() { + remove_unstaged(&path, keep)?; + } else if !keep.contains(&path) { + fs::remove_file(&path)?; } - let size = u32_at(at + 8)?.min(u32_at(at + 16)?) as usize; - let raw = u32_at(at + 20)? as usize; - let data = bytes.get(raw..raw.checked_add(size)?)?; - let text = std::str::from_utf8(&data[..data.iter().position(|&c| c == 0).unwrap_or(data.len())]).ok()?; - let mut parts = text.split('|'); - return match (parts.next(), parts.next(), parts.next(), parts.next()) { - (Some("COAPLUGIN1"), Some(_), Some(fp), None) if fp.len() == 64 => Some(fp.to_owned()), - _ => None, - }; } - None + Ok(()) +} + +/// The plugin id an analyzer-generated adapter declares. +fn adapter_plugin_id(adapter_source: &str) -> Option { + let marker = "COA_PLUGIN_DEFINE(\""; + let start = adapter_source.find(marker)? + marker.len(); + let end = adapter_source[start..].find('"')?; + Some(adapter_source[start..start + end].to_string()) +} + +/// Adapter for modules whose shutdown work runs in hooks, and for bundles. A faulted plugin's hooks are +/// suppressed but its lifecycle phases still run, so the bot modules save and log out their bots in +/// onStopping as well (running it twice is harmless: the second pass finds no bot in the world). +/// SQUID's world-thread queue is cleared in onDrainBeforeDbClose: its static destructor otherwise logs +/// through the core's already destroyed Log at process exit (0xC0000005). +fn lifecycle_adapter(module_id: &str, plugin_id: &str, loader: &str, bundled_loaders: &[String]) -> Option { + let (includes, stopping, drain): (&[&str], &str, &str) = match module_id { + "companions" => (&["BotMgr.h"], "if (BotMgr::BotsEnabled())\n sBotMgr->LogoutAllBots();", ""), + "playerbots" => ( + &["RandomPlayerbotMgr.h", "PlayerbotWorldThreadProcessor.h", "PlayerbotCommandServer.h"], + "sRandomPlayerbotMgr.LogoutAllBots();", + "PlayerbotWorldThreadProcessor::instance().ClearQueue();\n PlayerbotCommandServer::instance().Stop();", + ), + _ if !bundled_loaders.is_empty() => (&[], "", ""), + _ => return None, + }; + let loaders: Vec<&str> = std::iter::once(loader).chain(bundled_loaders.iter().map(String::as_str)).collect(); + let mut out = String::from("#include \"CoaPluginApi.h\"\n"); + for h in includes { + out += &format!("#include \"{}\"\n", h); + } + out += "\n"; + for l in &loaders { + out += &format!("void {}();\n", l); + } + out += "\nnamespace\n{\n bool Register()\n {\n"; + for l in &loaders { + out += &format!(" {}();\n", l); + } + out += " return true;\n }\n\n bool Startup()\n {\n return true;\n }\n\n"; + out += &format!(" void Stopping()\n {{\n {}\n }}\n\n", stopping); + out += &format!(" bool Drain()\n {{\n {}\n return true;\n }}\n\n", drain); + out += " void Finalize()\n {\n }\n}\n\n"; + out += &format!("COA_PLUGIN_DEFINE(\"{}\", \"ext\", Register, Startup, Stopping, Drain, Finalize)\n", plugin_id); + Some(out) } /// Computes SHA-256 of file bytes. @@ -353,6 +474,94 @@ pub fn resolve_module_identity(source_dir: &Path) -> (String, String, Option\` with the +/// classic Win32 API, which fails past 260 characters unless long paths are enabled. A header it cannot +/// stat counts as changed, so every build silently becomes a full rebuild. Refuse such a layout up front. +const NINJA_PATH_LIMIT: usize = 250; + +fn check_dependency_path_lengths(build_dir: &Path, sdk_cmake_dir: &Path) -> Result<()> { + let sdk_root = sdk_cmake_dir.parent().unwrap_or(sdk_cmake_dir); + let include = sdk_root.join("include"); + let longest = longest_relative_file(&include); + let Some(longest) = longest else { return Ok(()) }; + let header = include.join(&longest); + let worst = ninja_stat_path_len(build_dir, &header); + if worst > NINJA_PATH_LIMIT { + return Err(Error::Invalid(format!( + "[BUILD_PATH_TOO_LONG] Header paths would reach {} characters ({}), over the Windows limit Ninja can handle ({}). Move the server to a shorter folder (for example C:/CoA) or enable Windows long paths.", + worst, header.display(), NINJA_PATH_LIMIT + ))); + } + Ok(()) +} + +/// The longest file path below `root`, relative to it. +fn longest_relative_file(root: &Path) -> Option { + let mut best: Option = None; + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = fs::read_dir(&dir) else { continue }; + for e in entries.flatten() { + let path = e.path(); + if e.file_type().map(|t| t.is_dir()).unwrap_or(false) { + stack.push(path); + } else if let Ok(rel) = path.strip_prefix(root) { + if best.as_ref().map_or(true, |b| rel.as_os_str().len() > b.as_os_str().len()) { + best = Some(rel.to_path_buf()); + } + } + } + } + best +} + +/// Length of `\`, as Ninja forms it. +fn ninja_stat_path_len(build_dir: &Path, target: &Path) -> usize { + let norm = |p: &Path| -> Vec { + p.components().map(|c| c.as_os_str().to_string_lossy().to_lowercase()).collect() + }; + let (b, t) = (norm(build_dir), norm(target)); + let common = b.iter().zip(t.iter()).take_while(|(x, y)| x == y).count(); + if common == 0 { + return target.as_os_str().len(); // another drive: Ninja keeps the absolute path + } + let ups = b.len() - common; + let rest: usize = target.components().skip(common).map(|c| c.as_os_str().len() + 1).sum(); + build_dir.as_os_str().len() + 1 + ups * 3 + rest.saturating_sub(1) +} + +/// Parallel compile jobs: one per core, but no more than free RAM allows (a SQUID translation unit with +/// /bigobj takes up to ~2.5 GB in cl.exe; running out of memory kills the build without an MSVC error). +fn build_jobs() -> u32 { + let hw = crate::population::hardware(); + let by_ram = (hw.free_ram_gb / 2.5).floor() as u32; + hw.cores.min(by_ram).clamp(1, 16) +} + +/// A failure without a recognised MSVC diagnostic (out of memory, a crashed tool, a CMake error): the +/// step that failed and the end of the log, so the user never sees an empty error list. +fn unparsed_failure(exit_code: i32, logs: &[String], log_path: &Path) -> BuildErrorDiagnostic { + let step = match exit_code { + 101 => "Visual Studio environment (vcvars64) could not be loaded", + 102 => "CMake configuration failed", + 103 => "Compilation or linking failed", + _ => "Build runner failed", + }; + let tail: Vec<&str> = logs.iter().map(String::as_str) + .filter(|l| !l.contains("Note: including file") && !l.trim().is_empty()) + .rev().take(30).collect::>().into_iter().rev().collect(); + BuildErrorDiagnostic { + file: log_path.to_string_lossy().into_owned(), + line: 0, + error_code: format!("BUILD_EXIT_{}", exit_code), + message: format!("{} (exit code {}). Last output: +{}", step, exit_code, tail.join(" +")), + explanation: None, + resolution: Some(format!("Full log: {}", log_path.display())), + } +} + fn parse_msvc_errors(logs: &[String]) -> Vec { let mut diagnostics = Vec::new(); for line in logs { @@ -444,6 +653,23 @@ pub fn build_module_from_source( realm_root: &Path, custom_staging_dir: Option<&Path>, progress_callback: Option>, +) -> Result { + build_module_with_bundle(module_source_dir, &[], realm_root, custom_staging_dir, progress_callback) +} + +/// Builds `module_source_dir` into one plugin DLL, together with `bundled` source trees that must live +/// in the same image (a module that derives from another module's C++ classes, such as Dungeon Clear +/// on SQUID). Each bundled tree's loader is called from the plugin's registration. +/// +/// The staging and build directories persist between builds (per realm and module): sources are +/// synchronised file by file, unchanged files keep their timestamps, and Ninja recompiles only what +/// changed. Updating a bundled module therefore recompiles only its files and relinks the DLL. +pub fn build_module_with_bundle( + module_source_dir: &Path, + bundled: &[PathBuf], + realm_root: &Path, + custom_staging_dir: Option<&Path>, + progress_callback: Option>, ) -> Result { let _lock = BUILD_LOCK.try_lock().map_err(|_| { Error::Invalid("Another module compilation is already in progress. Concurrent builds are restricted.".into()) @@ -464,10 +690,20 @@ pub fn build_module_from_source( }; emit_progress("PREPARING", 5, "Checking toolchain and build environment...", None); - let toolchain = detect_toolchain(); + let toolchain = detect_toolchain_for(Some(realm_root)); if !toolchain.ready { return Err(Error::Invalid(format!("[TOOLCHAIN_ERROR] {}", toolchain.diagnostic_message))); } + // A plugin loads only on a host with the fingerprint it was built with: refuse to build one this + // realm would reject (possible only with a COA_PLUGIN_SDK_DIR override). + if let Some(host_fp) = crate::provenance::realm_host_fingerprint(realm_root) { + if toolchain.sdk_abi_fingerprint.as_deref() != Some(host_fp.as_str()) { + return Err(Error::Invalid(format!( + "[SDK_HOST_MISMATCH] The SDK is for ABI {}, this realm's core is {}.", + toolchain.sdk_abi_fingerprint.as_deref().unwrap_or("unknown"), host_fp + ))); + } + } let vcvars64 = toolchain.vcvars64_path.unwrap(); let cmake = toolchain.cmake_path.unwrap(); @@ -506,31 +742,32 @@ pub fn build_module_from_source( emit_progress("CONFIGURING", 30, "Generating controlled CMake recipe and staging sources...", None); - // 2. Prepare staging directory - let default_staging = std::env::temp_dir().join("coa_csm_preview_builds").join(&module_id); - let staging_dir = custom_staging_dir.map(PathBuf::from).unwrap_or(default_staging); - if staging_dir.exists() { - let _ = fs::remove_dir_all(&staging_dir); + // Every other loader the plugin must call: one per bundled source tree. + let mut bundled_loaders = Vec::new(); + for tree in bundled { + match generate_adapter(tree)? { + AdapterGenerationResult::Success { canonical_loader, .. } => bundled_loaders.push(canonical_loader), + _ => { + return Err(Error::Invalid(format!( + "[NO_LOADER_FOUND] Bundled sources at '{}' have no single Add*Scripts loader.", + tree.display() + ))) + } + } } + let plugin_id = adapter_plugin_id(&adapter_source).unwrap_or_else(|| module_id.clone()); + let adapter_source = lifecycle_adapter(&module_id, &plugin_id, &canonical_loader, &bundled_loaders) + .unwrap_or(adapter_source); + + // 2. Prepare the persistent staging directory + let default_staging = realm_root.join(".manager").join("build-cache").join(&module_id); + let staging_dir = custom_staging_dir.map(PathBuf::from).unwrap_or(default_staging); fs::create_dir_all(&staging_dir)?; let staging_src = staging_dir.join("src"); fs::create_dir_all(&staging_src)?; let build_output_dir = staging_dir.join("build"); fs::create_dir_all(&build_output_dir)?; - // Write adapter - let adapter_path = staging_src.join("adapter.cpp"); - fs::write(&adapter_path, &adapter_source)?; - - // Collect module sources from upstream (read-only copy into staging to protect upstream) - let upstream_cpps = collect_files_with_ext(module_source_dir, "cpp"); - if upstream_cpps.is_empty() { - return Err(Error::Invalid(format!( - "No C++ (.cpp) source files found in '{}'", - module_source_dir.display() - ))); - } - // Helper to strip verbatim extended path prefix (\\?\) so CMake and MSVC can parse include dirs let clean_path = |p: &Path| -> String { let s = p.to_string_lossy(); @@ -538,64 +775,61 @@ pub fn build_module_from_source( stripped.replace('\\', "/") }; - let mut staged_cpp_paths = Vec::new(); - staged_cpp_paths.push(clean_path(&adapter_path)); + let adapter_path = staging_src.join("adapter.cpp"); + write_if_changed(&adapter_path, adapter_source.as_bytes())?; + let mut staged: std::collections::HashSet = std::collections::HashSet::new(); + staged.insert(adapter_path.clone()); - // Every source of the module is built as it is: a symbol the module needs from the core must be - // exported by worldserver.exe, never re-implemented (or stubbed) inside the plugin. - for cpp in &upstream_cpps { - let rel = cpp.strip_prefix(module_source_dir).unwrap_or_else(|_| Path::new(cpp.file_name().unwrap())); + let mut staged_cpp_paths = vec![clean_path(&adapter_path)]; + let mut include_dirs = vec![clean_path(&staging_src)]; - let dest = staging_src.join(rel); - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent)?; - } - fs::copy(cpp, &dest)?; - - staged_cpp_paths.push(clean_path(&dest)); + // Primary tree at the staging root, each bundled tree under bundle//. Every source is built as + // it is: a symbol a module needs from the core must be exported by worldserver.exe, never + // re-implemented (or stubbed) inside the plugin. + let mut trees: Vec<(PathBuf, PathBuf)> = vec![(module_source_dir.to_path_buf(), staging_src.clone())]; + for (n, root) in bundled.iter().enumerate() { + // A bundled repository is built from its src/ (its tests and tools stay out). + let tree = if root.join("src").is_dir() { root.join("src") } else { root.clone() }; + trees.push((tree, staging_src.join("bundle").join(n.to_string()))); } - - - // Also copy all headers (.h and .hpp) preserving directory structure - let upstream_headers = collect_files_with_ext(module_source_dir, "h"); - for h in &upstream_headers { - let rel = h.strip_prefix(module_source_dir).unwrap_or_else(|_| Path::new(h.file_name().unwrap())); - let dest = staging_src.join(rel); - if let Some(parent) = dest.parent() { - let _ = fs::create_dir_all(parent); + let mut source_count = 0; + for (tree, dest_root) in &trees { + let cpps = collect_files_with_ext(tree, "cpp"); + if cpps.is_empty() { + return Err(Error::Invalid(format!("No C++ (.cpp) source files found in '{}'", tree.display()))); } - let _ = fs::copy(h, &dest); - } - let upstream_hpps = collect_files_with_ext(module_source_dir, "hpp"); - for h in &upstream_hpps { - let rel = h.strip_prefix(module_source_dir).unwrap_or_else(|_| Path::new(h.file_name().unwrap())); - let dest = staging_src.join(rel); - if let Some(parent) = dest.parent() { - let _ = fs::create_dir_all(parent); - } - let _ = fs::copy(h, &dest); - } - - // Collect include directories from upstream and staging - let mut include_dirs = Vec::new(); - include_dirs.push(clean_path(&staging_src)); - include_dirs.push(clean_path(module_source_dir)); - - for h in upstream_headers.iter().chain(upstream_hpps.iter()) { - if let Some(parent) = h.parent() { - let p_clean = clean_path(parent); - if !include_dirs.contains(&p_clean) { - include_dirs.push(p_clean); + source_count += cpps.len(); + let headers: Vec = collect_files_with_ext(tree, "h").into_iter() + .chain(collect_files_with_ext(tree, "hpp")).collect(); + for file in cpps.iter().chain(headers.iter()) { + let rel = file.strip_prefix(tree).unwrap_or_else(|_| Path::new(file.file_name().unwrap())); + let dest = dest_root.join(rel); + sync_file(file, &dest)?; + staged.insert(dest.clone()); + if file.extension().map(|e| e.eq_ignore_ascii_case("cpp")).unwrap_or(false) { + staged_cpp_paths.push(clean_path(&dest)); } - if let Ok(rel) = parent.strip_prefix(module_source_dir) { - let staged_parent = staging_src.join(rel); - let sp_clean = clean_path(&staged_parent); - if !include_dirs.contains(&sp_clean) { - include_dirs.push(sp_clean); + } + include_dirs.push(clean_path(tree)); + for h in &headers { + if let Some(parent) = h.parent() { + let mut dirs = vec![parent.to_path_buf()]; + if let Ok(rel) = parent.strip_prefix(tree) { + dirs.push(dest_root.join(rel)); + } + for dir in dirs { + let d = clean_path(&dir); + if !include_dirs.contains(&d) { + include_dirs.push(d); + } } } } } + remove_unstaged(&staging_src, &staged)?; + check_dependency_path_lengths(&build_output_dir, Path::new(&sdk_dir))?; + emit_progress("CONFIGURING", 35, &format!( + "Staged {} source files ({} tree(s)); unchanged files are not recompiled.", source_count, trees.len()), None); // 3. Write controlled CMakeLists.txt (eliminating D9025 /Zi vs /Z7 conflict) let sources_block = staged_cpp_paths.iter().map(|s| format!(" \"{}\"", s)).collect::>().join("\n"); @@ -624,11 +858,13 @@ target_compile_definitions({safe_target_name} PRIVATE NOMINMAX ) if(MSVC) - target_compile_options({safe_target_name} PRIVATE /bigobj) + # /D_USE_MATH_DEFINES= (empty, identical to Define.h's): M_PI for sources that include before + # any core header (Dungeon Clear documents why). + target_compile_options({safe_target_name} PRIVATE /bigobj /D_USE_MATH_DEFINES=) endif() "#, safe_target_name = safe_target_name, cmake_sdk_path = cmake_sdk_path, sources_block = sources_block, includes_block = includes_block); - fs::write(staging_dir.join("CMakeLists.txt"), &controlled_cmake)?; + write_if_changed(&staging_dir.join("CMakeLists.txt"), controlled_cmake.as_bytes())?; // 4. Create batch runner executing MSVC + CMake + Ninja with clean English UTF-8 output let ninja_dir = Path::new(&ninja).parent().unwrap_or(Path::new("")).to_string_lossy(); @@ -641,9 +877,9 @@ if errorlevel 1 exit /b 101 set "PATH={ninja_dir};%PATH%" "{cmake}" -G Ninja -S "{staging_dir}" -B "{build_dir}" -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCoAPluginSDK_DIR="{sdk_dir}" if errorlevel 1 exit /b 102 -"{cmake}" --build "{build_dir}" -- -j8 +"{cmake}" --build "{build_dir}" -- -j{jobs} if errorlevel 1 exit /b 103 -"#, vcvars64 = vcvars64, ninja_dir = ninja_dir, cmake = cmake, staging_dir = clean_path(&staging_dir), build_dir = clean_path(&build_output_dir), sdk_dir = clean_path(Path::new(&sdk_dir))); +"#, jobs = build_jobs(), vcvars64 = vcvars64, ninja_dir = ninja_dir, cmake = cmake, staging_dir = clean_path(&staging_dir), build_dir = clean_path(&build_output_dir), sdk_dir = clean_path(Path::new(&sdk_dir))); fs::write(&runner_bat, &runner_content)?; @@ -680,7 +916,13 @@ if errorlevel 1 exit /b 103 emit_progress("COMPILING", 65, "Compiler output", Some(line)); } - let errors = parse_msvc_errors(&all_logs); + // The full log stays next to the build, so a failure can always be read afterwards. + let _ = fs::write(staging_dir.join("build.log"), all_logs.join(" +")); + let mut errors = parse_msvc_errors(&all_logs); + if exit_code != 0 && errors.is_empty() { + errors.push(unparsed_failure(exit_code, &all_logs, &staging_dir.join("build.log"))); + } let expected_dll_path = build_output_dir.join(format!("{}.dll", safe_target_name)); if exit_code != 0 || !expected_dll_path.is_file() { @@ -735,7 +977,7 @@ if errorlevel 1 exit /b 103 // The fingerprint the DLL was really built with (its .coapl section) must equal the SDK's; the host // checks the same value against its own before loading the plugin. let host_abi_fingerprint = sdk_abi_fingerprint(Path::new(&sdk_dir))?; - let dll_fingerprint = plugin_section_fingerprint(&dll_bytes).ok_or_else(|| { + let dll_fingerprint = crate::provenance::coapl_fingerprint(&dll_bytes).ok_or_else(|| { Error::Invalid("Generated DLL has no .coapl plugin section (not built with the CoA Plugin SDK)".into()) })?; if dll_fingerprint != host_abi_fingerprint { @@ -756,8 +998,9 @@ if errorlevel 1 exit /b 103 let target_dll_path = store_dir.join(&target_dll_name); fs::copy(&expected_dll_path, &target_dll_path)?; - // Copy configs if any - let conf_files = collect_files_with_ext(module_source_dir, "dist"); + // Copy configs if any (the bundled modules' too) + let package_roots: Vec<&Path> = std::iter::once(module_source_dir).chain(bundled.iter().map(PathBuf::as_path)).collect(); + let conf_files: Vec = package_roots.iter().flat_map(|r| collect_files_with_ext(r, "dist")).collect(); let mut packaged_confs = Vec::new(); for c in conf_files { let c_name = c.file_name().unwrap().to_string_lossy().into_owned(); @@ -767,7 +1010,7 @@ if errorlevel 1 exit /b 103 } // Copy SQL if any - let sql_files = collect_files_with_ext(module_source_dir, "sql"); + let sql_files: Vec = package_roots.iter().flat_map(|r| collect_files_with_ext(r, "sql")).collect(); let mut packaged_sqls = Vec::new(); for s in sql_files { let s_name = s.file_name().unwrap().to_string_lossy().into_owned(); @@ -850,7 +1093,7 @@ mod tests { #[test] fn test_detect_toolchain() { - let tc = detect_toolchain(); + let tc = detect_toolchain_for(Some(Path::new("C:/games/coa-work/plugin-spike/server-v03"))); assert!(tc.msvc_available, "MSVC vcvars64.bat should be detected"); assert!(tc.cmake_available, "CMake should be detected"); assert!(tc.ninja_available, "Ninja should be detected"); @@ -858,6 +1101,57 @@ mod tests { assert!(tc.ready, "Complete toolchain should be ready"); } + #[test] + fn realm_sdk_is_chosen_by_the_host_fingerprint() { + let host = Path::new("C:/games/coa-work/plugin-spike/server-v03/worldserver.exe"); + let Some(host_fp) = crate::provenance::file_abi_fingerprint(host) else { return }; + let tmp = tempfile::tempdir().unwrap(); + let realm = tmp.path().join("realm"); + fs::create_dir_all(realm.join("Core")).unwrap(); + fs::copy(host, realm.join("Core/worldserver.exe")).unwrap(); + let fake_sdk = |name: &str, fp: &str| { + let dir = tmp.path().join(name); + fs::create_dir_all(dir.join("cmake")).unwrap(); + fs::write(dir.join("cmake/CoAPluginSDKConfig.cmake"), "# sdk").unwrap(); + fs::write(dir.join("sdk.json"), format!("{{\"abiFingerprint\":\"{}\"}}", fp)).unwrap(); + dir + }; + assert!(realm_sdk_cmake_dir(&realm).is_none()); + + // An SDK of another core is refused and changes nothing. + let other = fake_sdk("other", &"0".repeat(64)); + let err = install_realm_sdk(&realm, &other).unwrap_err().to_string(); + assert!(err.contains("SDK_HOST_MISMATCH"), "{err}"); + assert!(realm_sdk_cmake_dir(&realm).is_none()); + + // The SDK of this core is installed under its fingerprint and found by the toolchain. + let right = fake_sdk("right", &host_fp); + let dest = install_realm_sdk(&realm, &right).unwrap(); + assert_eq!(dest, realm_sdk_store(&realm).join(&host_fp[..16])); + assert_eq!(realm_sdk_cmake_dir(&realm), Some(dest.join("cmake"))); + if std::env::var_os("COA_PLUGIN_SDK_DIR").is_none() { + let tc = detect_toolchain_for(Some(&realm)); + assert_eq!(tc.sdk_abi_fingerprint.as_deref(), Some(host_fp.as_str())); + assert!(detect_toolchain().sdk_dir.is_none(), "no SDK without a realm"); + } + } + + #[test] + fn ninja_path_length_counts_the_walk_up_from_the_build_dir() { + let build = Path::new("C:/Users/u/AppData/Local/Temp/st/build"); + let header = Path::new("C:/games/realm/.manager/sdk/0123456789abcdef/include/a.h"); + // "C:/Users/u/AppData/Local/Temp/st/build" + "/" + "../" * 7 + "games/realm/.manager/sdk/0123456789abcdef/include/a.h" + let expected = build.as_os_str().len() + 1 + 7 * 3 + "games/realm/.manager/sdk/0123456789abcdef/include/a.h".len(); + assert_eq!(ninja_stat_path_len(build, header), expected); + let tmp = tempfile::tempdir().unwrap(); + let sdk = tmp.path().join("sdk"); + fs::create_dir_all(sdk.join("cmake")).unwrap(); + fs::create_dir_all(sdk.join("include").join("x".repeat(200))).unwrap(); + fs::write(sdk.join("include").join("x".repeat(200)).join("y.h"), "").unwrap(); + let err = check_dependency_path_lengths(&tmp.path().join("b"), &sdk.join("cmake")).unwrap_err().to_string(); + assert!(err.contains("BUILD_PATH_TOO_LONG"), "{err}"); + } + #[test] fn test_sanitize_module_name() { assert_eq!(sanitize_module_name("mod-congrats-on-level"), "mod_congrats_on_level"); @@ -994,4 +1288,63 @@ mod tests { assert!(dll_p.is_file(), "mod-playerbots DLL must exist"); let _ = fs::remove_dir_all(&staging); } + + /// Dungeon Clear derives from SQUID's C++ classes, so it is bundled into the SQUID DLL. Its update + /// must not rebuild SQUID: the second build after a one-file change has to be a small fraction of + /// the first. + #[test] + fn test_controlled_build_squid_with_dungeon_clear_bundle_incremental() { + let _turn = real_build_turn(); + let squid = Path::new("C:/Users/Dion/AppData/Local/CSMModulesPreview/github_cache/github/Zyth45/mod-playerbots/3f916d45a4b55ce919bf69fee4a4ec75be12cffe"); + let dc_lab = Path::new("C:/games/coa-work/module-compat-lab/mod-dungeon-clear/src"); + let realm_root = Path::new("C:/games/coa-work/plugin-spike/server-v03"); + if !squid.exists() || !dc_lab.exists() || !realm_root.join("worldserver.exe").is_file() { + return; + } + + // A private copy of Dungeon Clear, so the test can change a file. + let dc = std::env::temp_dir().join("test_dc_src"); + let _ = fs::remove_dir_all(&dc); + copy_tree(dc_lab, &dc); + let staging = std::env::temp_dir().join("tsdc"); + let _ = fs::remove_dir_all(&staging); + + let started = Instant::now(); + let first = build_module_with_bundle(squid, &[dc.clone()], realm_root, Some(&staging), None) + .unwrap_or_else(|e| panic!("bundle build failed: {}", e)); + let full = started.elapsed(); + assert!(first.success, "bundle build must succeed: {:?}", first.errors); + let adapter = fs::read_to_string(staging.join("src").join("adapter.cpp")).unwrap(); + assert!(adapter.contains("Addmod_playerbotsScripts();") && adapter.contains("Addmod_dungeon_clearScripts();"), + "adapter must register both modules:\n{}", adapter); + assert!(adapter.contains("ClearQueue()"), "SQUID's queue must be cleared in onDrainBeforeDbClose"); + + let loader = dc.join("mod_dungeon_clear_loader.cpp"); + let mut text = fs::read_to_string(&loader).unwrap(); + text.push_str("\n// incremental build test\n"); + fs::write(&loader, text).unwrap(); + + let started = Instant::now(); + let second = build_module_with_bundle(squid, &[dc.clone()], realm_root, Some(&staging), None) + .unwrap_or_else(|e| panic!("incremental bundle build failed: {}", e)); + let incremental = started.elapsed(); + assert!(second.success, "incremental build must succeed: {:?}", second.errors); + eprintln!("bundle build: full {:?}, after a one-file change {:?}", full, incremental); + assert!(incremental * 5 < full, "one-file change rebuilt too much: full {:?}, incremental {:?}", full, incremental); + + let _ = fs::remove_dir_all(&staging); + let _ = fs::remove_dir_all(&dc); + } + + fn copy_tree(from: &Path, to: &Path) { + fs::create_dir_all(to).unwrap(); + for entry in fs::read_dir(from).unwrap().flatten() { + let target = to.join(entry.file_name()); + if entry.path().is_dir() { + copy_tree(&entry.path(), &target); + } else { + fs::copy(entry.path(), &target).unwrap(); + } + } + } } diff --git a/crates/coa-core/src/plugins.rs b/crates/coa-core/src/plugins.rs index f1b76e1..7862c58 100644 --- a/crates/coa-core/src/plugins.rs +++ b/crates/coa-core/src/plugins.rs @@ -176,6 +176,18 @@ pub fn cleanup_stale_staging(server: &Path) -> Result<()> { Ok(()) } +/// The ABI fingerprint an installed plugin's DLL was built with (from its .coapl section). +pub fn installed_plugin_fingerprint(server: &Path, id: &str) -> Option { + let reg = load_registry(server).ok()?; + let entry = reg.plugins.get(id)?; + let dir = meta_dir(server).join(&entry.store_path); + fs::read_dir(&dir).ok()? + .flatten() + .map(|e| e.path()) + .find(|p| p.extension().map(|x| x.eq_ignore_ascii_case("dll")).unwrap_or(false)) + .and_then(|dll| crate::provenance::file_abi_fingerprint(&dll)) +} + /// Load `.manager/plugins.json` directly without running recovery check. pub fn load_registry_direct(server: &Path) -> Result { let _ = cleanup_stale_staging(server); diff --git a/crates/coa-core/src/provenance.rs b/crates/coa-core/src/provenance.rs index a3b7866..af599b7 100644 --- a/crates/coa-core/src/provenance.rs +++ b/crates/coa-core/src/provenance.rs @@ -46,6 +46,53 @@ fn rva_to_offset(rva: u32, sections: &[(u32, u32, u32, u32)]) -> Option { } /// Parse PE headers and extract all exported symbols and metadata directly from binary bytes +/// The ABI fingerprint in an image's ".coapl" section ("COAPLUGIN1||"), read from the file +/// without loading it. Plugin DLLs carry the fingerprint of the SDK they were built with; a plugin host +/// worldserver.exe carries its own (id "worldserver"). None when there is no such section. +pub fn coapl_fingerprint(bytes: &[u8]) -> Option { + let u16_at = |o: usize| bytes.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]])); + let u32_at = |o: usize| bytes.get(o..o + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + let pe = u32_at(0x3C)? as usize; + if u32_at(pe)? != 0x0000_4550 { + return None; + } + let sections = u16_at(pe + 6)? as usize; + let first = pe + 24 + u16_at(pe + 20)? as usize; + for i in 0..sections.min(96) { + let at = first.checked_add(i * 40)?; + if !bytes.get(at..at + 8)?.starts_with(b".coapl") { + continue; + } + let size = u32_at(at + 8)?.min(u32_at(at + 16)?) as usize; + let raw = u32_at(at + 20)? as usize; + let data = bytes.get(raw..raw.checked_add(size)?)?; + let text = std::str::from_utf8(&data[..data.iter().position(|&c| c == 0).unwrap_or(data.len())]).ok()?; + let mut parts = text.split('|'); + return match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some("COAPLUGIN1"), Some(_), Some(fp), None) if fp.len() == 64 && fp.bytes().all(|c| c.is_ascii_hexdigit()) => { + Some(fp.to_owned()) + } + _ => None, + }; + } + None +} + +/// `coapl_fingerprint` of a file on disk. +pub fn file_abi_fingerprint(path: &Path) -> Option { + fs::read(path).ok().and_then(|b| coapl_fingerprint(&b)) +} + +/// The plugin ABI the realm's worldserver.exe accepts. None for a server without a plugin host (or one +/// built before hosts recorded their fingerprint). +pub fn realm_host_fingerprint(realm_root: &Path) -> Option { + ["Core/worldserver.exe", "worldserver.exe"] + .iter() + .map(|rel| realm_root.join(rel)) + .find(|p| p.is_file()) + .and_then(|p| file_abi_fingerprint(&p)) +} + pub fn parse_pe_exports(bytes: &[u8]) -> Result<(String, bool, Vec, Option)> { if bytes.len() < 64 { return Err(Error::Invalid("File too small to be a PE image".into())); @@ -113,18 +160,8 @@ pub fn parse_pe_exports(bytes: &[u8]) -> Result<(String, bool, Vec coa_core::build_engine::ToolchainStatus { - coa_core::build_engine::detect_toolchain() +fn preview_toolchain_status( + state: State<'_, AppState>, + realm_id: Option, +) -> coa_core::build_engine::ToolchainStatus { + // The SDK depends on the realm's core: without a realm only the compiler tools are reported. + let realm_root = realm_id.and_then(|id| path_of(&state, &id).ok()); + coa_core::build_engine::detect_toolchain_for(realm_root.as_deref()) } #[tauri::command] @@ -3049,12 +3054,44 @@ async fn preview_build_module( } }; - coa_core::build_engine::build_module_from_source(&source_dir, &realm_root, None, Some(progress_cb)) + // Dungeon Clear derives from SQUID's C++ classes, so both are built into the SQUID DLL: building + // either one builds that DLL, with Dungeon Clear in it when it is installed in this realm. The + // build cache keeps SQUID's objects, so a Dungeon Clear update recompiles only its own files. + let (module_id, _, _) = coa_core::build_engine::resolve_module_identity(&source_dir); + let dungeon_clear_installed = { + let dir = coa_core::plugins::modules_config_dir(&realm_root); + dir.join("mod_dungeon_clear.conf").is_file() || dir.join("mod_dungeon_clear.conf.dist").is_file() + }; + let (primary, bundled) = match module_id.as_str() { + "dungeon-clear" => { + let squid = cached_catalog_tree(&cache_dir, "playerbots").ok_or_else(|| { + coa_core::Error::Invalid("Dungeon Clear is built into SQUID's Playerbots: install SQUID's Playerbots first.".into()) + })?; + (squid, vec![source_dir]) + } + "playerbots" if dungeon_clear_installed => { + (source_dir, cached_catalog_tree(&cache_dir, "dungeon-clear").into_iter().collect()) + } + _ => (source_dir, Vec::new()), + }; + + coa_core::build_engine::build_module_with_bundle(&primary, &bundled, &realm_root, None, Some(progress_cb)) .map_err(Into::into) }) .await } +/// The newest cached source tree of a catalog module (fetched earlier by the module intake). +fn cached_catalog_tree(cache_dir: &std::path::Path, catalog_id: &str) -> Option { + let entry = coa_core::modules::catalog().into_iter().find(|e| e.id == catalog_id)?; + let (owner, repo, _) = coa_core::analyzer::parse_and_validate_github_url(&entry.repo).ok()?; + std::fs::read_dir(cache_dir.join("github").join(owner).join(repo)).ok()? + .flatten() + .filter(|e| e.path().join(".coa_tree_digest").is_file()) + .max_by_key(|e| e.metadata().and_then(|m| m.modified()).ok()) + .map(|e| e.path()) +} + #[tauri::command] async fn preview_install_module( state: State<'_, AppState>, @@ -3127,7 +3164,9 @@ async fn preview_export_diagnostics( realm_id: Option, ) -> std::result::Result { let dir = data_dir(); - let tc = coa_core::build_engine::detect_toolchain(); + let tc = coa_core::build_engine::detect_toolchain_for( + realm_id.as_deref().and_then(|id| path_of(&state, id).ok()).as_deref(), + ); let export_path = dir.join("diagnostics_report.json"); let mut realms = Vec::new(); @@ -3204,6 +3243,8 @@ async fn preview_check_updates( ) -> std::result::Result { let realm_root = path_of(&state, &realm_id)?; blocking(move || { + // What the installed worldserver.exe accepts, read from the binary (its .coapl section). + let installed_host_fp = coa_core::provenance::realm_host_fingerprint(&realm_root); let candidate_lock_path = std::env::var("COA_PREVIEW_LOCK_PATH") .map(std::path::PathBuf::from) .ok() @@ -3212,10 +3253,6 @@ async fn preview_check_updates( if p1.is_file() { return Some(p1); } let p2 = realm_root.join(".manager").join("candidate-lock.json"); if p2.is_file() { return Some(p2); } - let p3 = std::path::PathBuf::from(r"C:\games\coa-server-build\updates\csm-preview-components.json"); - if p3.is_file() { return Some(p3); } - let p4 = std::path::PathBuf::from(r"C:\games\coa-server-build\candidate-lock.json"); - if p4.is_file() { return Some(p4); } None }); let candidate_host = if let Some(lock_path) = candidate_lock_path { @@ -3234,7 +3271,7 @@ async fn preview_check_updates( coa_core::solver::HostManifest { version: "0.7.1-preview".into(), commit: "unknown".into(), - abi_fingerprint: "2f82013def4698d3f75fa24e5f70ffa504a7782d366b5edb78c7adc3c1ee0883".into(), + abi_fingerprint: installed_host_fp.clone().unwrap_or_default(), sdk_contract_version: "0.3.2".into(), schema_version: "2026_10_28_00".into(), } @@ -3243,7 +3280,7 @@ async fn preview_check_updates( let installed_host = coa_core::solver::HostManifest { version: "0.7.0".into(), commit: "installed_commit".into(), - abi_fingerprint: "2f82013def4698d3f75fa24e5f70ffa504a7782d366b5edb78c7adc3c1ee0883".into(), + abi_fingerprint: installed_host_fp.clone().unwrap_or_default(), sdk_contract_version: "0.3.2".into(), schema_version: "2026_10_28_00".into(), }; @@ -3256,7 +3293,7 @@ async fn preview_check_updates( id: m.id.clone(), name: m.name, version: m.version.unwrap_or_else(|| "1.0.0".into()), - required_abi_fingerprint: Some("2f82013def4698d3f75fa24e5f70ffa504a7782d366b5edb78c7adc3c1ee0883".into()), + required_abi_fingerprint: coa_core::plugins::installed_plugin_fingerprint(&realm_root, &m.id), required_sdk_contract: Some("0.3.2".into()), min_host_version: Some("0.7.0".into()), dependencies: vec![], diff --git a/src/lib/api.ts b/src/lib/api.ts index 490f7d8..8aabac5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -844,7 +844,8 @@ export const api = { consoleRisk: (command: string) => invoke<"normal" | "dangerous">("console_risk", { command }), consoleCommand: (id: string, command: string, confirmed: boolean) => invoke("console_command", { id, command, confirmed }), previewPreset: (id: string, scope: Scope, preset: string) => invoke("preview_preset", { id, scope, preset }), - previewToolchainStatus: () => invoke("preview_toolchain_status"), + previewToolchainStatus: (realmId?: string) => + invoke("preview_toolchain_status", { realmId: realmId ?? null }), previewRegisterTestRealm: (path?: string) => invoke("preview_register_test_realm", { path: path ?? null }), previewBuildModule: (realmId: string, sourceUrlOrPath: string) => invoke("preview_build_module", { realmId, sourceUrlOrPath }), diff --git a/src/screens/ModulesPage.tsx b/src/screens/ModulesPage.tsx index 95af2b6..bc9e715 100644 --- a/src/screens/ModulesPage.tsx +++ b/src/screens/ModulesPage.tsx @@ -673,12 +673,12 @@ export function ModulesPage({ const loadToolchain = useCallback(async () => { try { - const tc = await api.previewToolchainStatus(); + const tc = await api.previewToolchainStatus(serverId); setToolchain(tc); } catch (e) { console.error("Failed to load toolchain status", e); } - }, []); + }, [serverId]); const loadInstalledPlugins = useCallback(async () => { try {