mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Plugin SDK per realm core, Dungeon Clear bundle, reliable incremental builds
- The host records its own ABI fingerprint (.coapl); the manager reads it from worldserver.exe instead of hardcoded fingerprints (solver, provenance, analyzer). - SDK store per realm: <realm>/.manager/sdk/<16 hex of fingerprint>/, chosen by the host fingerprint; install_realm_sdk refuses an SDK of another core. - Dungeon Clear is built into the SQUID DLL (bundle); persistent build cache. - Refuse layouts whose header paths exceed Ninja's Windows path limit (such headers counted as changed and every build became a full rebuild). - Compile jobs limited by free RAM; full build log kept in build.log; failures without an MSVC diagnostic report the failed step and the log tail. Bundle test: full build 1425 s, after a one-file change 9.7 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a56bc7cf2
commit
e8ac3151a3
7 files changed
+649
-178
No files matched your search
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+464
-111
@@ -77,8 +77,87 @@ pub struct BuildResult {
|
||||
pub verification_status: Option<VerificationStatus>,
|
||||
}
|
||||
|
||||
/// Detects available MSVC toolchain, CMake, Ninja, and CoA Plugin SDK.
|
||||
/// Where a realm keeps the plugin SDKs of its cores: `<realm>/.manager/sdk/<first 16 hex of the host
|
||||
/// fingerprint>/`. 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<String> {
|
||||
.ok_or_else(|| Error::Invalid(format!("{} has no valid abiFingerprint", path.display())))
|
||||
}
|
||||
|
||||
/// The fingerprint in a plugin DLL's ".coapl" section ("COAPLUGIN1|<id>|<fingerprint>"), 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<String> {
|
||||
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<PathBuf>) -> 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<String> {
|
||||
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<String> {
|
||||
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<Str
|
||||
}
|
||||
|
||||
/// Parses MSVC compiler and linker error messages from stdout/stderr.
|
||||
/// Ninja records headers relative to the build directory and stats `<build dir>\<relative path>` 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<PathBuf> {
|
||||
let mut best: Option<PathBuf> = 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 `<build dir>\<path of target relative to build dir>`, as Ninja forms it.
|
||||
fn ninja_stat_path_len(build_dir: &Path, target: &Path) -> usize {
|
||||
let norm = |p: &Path| -> Vec<String> {
|
||||
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::<Vec<_>>().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<BuildErrorDiagnostic> {
|
||||
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<Box<dyn Fn(BuildProgressEvent) + Send>>,
|
||||
) -> Result<BuildResult> {
|
||||
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<Box<dyn Fn(BuildProgressEvent) + Send>>,
|
||||
) -> Result<BuildResult> {
|
||||
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<PathBuf> = 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/<n>/. 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<PathBuf> = 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::<Vec<_>>().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 <cmath> 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<PathBuf> = 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<PathBuf> = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<PluginsRegistry> {
|
||||
let _ = cleanup_stale_staging(server);
|
||||
|
||||
@@ -46,6 +46,53 @@ fn rva_to_offset(rva: u32, sections: &[(u32, u32, u32, u32)]) -> Option<usize> {
|
||||
}
|
||||
|
||||
/// Parse PE headers and extract all exported symbols and metadata directly from binary bytes
|
||||
/// The ABI fingerprint in an image's ".coapl" section ("COAPLUGIN1|<id>|<fingerprint>"), 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
["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<PeExportEntry>, Option<String>)> {
|
||||
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<PeExportEntry
|
||||
sections.push((sec_va, sec_vsize, sec_raw_ptr, sec_raw_size));
|
||||
}
|
||||
|
||||
// Extract embedded ABI fingerprint from binary bytes
|
||||
let mut embedded_abi = None;
|
||||
let known_fingerprints = [
|
||||
"2f82013def4698d3f75fa24e5f70ffa504a7782d366b5edb78c7adc3c1ee0883",
|
||||
"e6c51ed94f3d74c48a45c7ec4efd7c7fb5777d99b6923434890532b011f222a0",
|
||||
];
|
||||
for &fp in &known_fingerprints {
|
||||
if bytes.windows(64).any(|w| w == fp.as_bytes()) {
|
||||
embedded_abi = Some(fp.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The ABI fingerprint the image records in its ".coapl" section (plugins and plugin hosts).
|
||||
let embedded_abi = coapl_fingerprint(bytes);
|
||||
|
||||
// Parse Export Table
|
||||
let mut export_entries = Vec::new();
|
||||
@@ -290,3 +327,30 @@ pub fn verify_worldserver_provenance(
|
||||
notes,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coapl_fingerprint_reads_plugins_and_rejects_other_images() {
|
||||
// A plugin built against sdk-v03 carries that SDK's fingerprint.
|
||||
let dll = Path::new("C:/games/coa-work/plugin-spike/plugins/probe-missing/companions-38c0220/build/companions_38c0220.dll");
|
||||
if dll.is_file() {
|
||||
assert_eq!(
|
||||
file_abi_fingerprint(dll).as_deref(),
|
||||
Some("2f82013def4698d3f75fa24e5f70ffa504a7782d366b5edb78c7adc3c1ee0883")
|
||||
);
|
||||
}
|
||||
// Not a PE file, a PE without the section, truncated input: no fingerprint, no panic.
|
||||
assert_eq!(coapl_fingerprint(b"not a pe file"), None);
|
||||
assert_eq!(coapl_fingerprint(&[]), None);
|
||||
let notepad = Path::new("C:/Windows/System32/notepad.exe");
|
||||
if let Ok(bytes) = fs::read(notepad) {
|
||||
assert_eq!(coapl_fingerprint(&bytes), None);
|
||||
for cut in [0x40, 0x200, 0x400, bytes.len() / 2] {
|
||||
let _ = coapl_fingerprint(&bytes[..cut.min(bytes.len())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-11
@@ -2955,8 +2955,13 @@ async fn get_public_module_catalog(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn preview_toolchain_status() -> coa_core::build_engine::ToolchainStatus {
|
||||
coa_core::build_engine::detect_toolchain()
|
||||
fn preview_toolchain_status(
|
||||
state: State<'_, AppState>,
|
||||
realm_id: Option<String>,
|
||||
) -> 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<PathBuf> {
|
||||
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<String>,
|
||||
) -> std::result::Result<String, UiError> {
|
||||
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<coa_core::solver::SolverVerdict, UiError> {
|
||||
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![],
|
||||
|
||||
+2
-1
@@ -844,7 +844,8 @@ export const api = {
|
||||
consoleRisk: (command: string) => invoke<"normal" | "dangerous">("console_risk", { command }),
|
||||
consoleCommand: (id: string, command: string, confirmed: boolean) => invoke<string>("console_command", { id, command, confirmed }),
|
||||
previewPreset: (id: string, scope: Scope, preset: string) => invoke<PresetPreview>("preview_preset", { id, scope, preset }),
|
||||
previewToolchainStatus: () => invoke<ToolchainStatus>("preview_toolchain_status"),
|
||||
previewToolchainStatus: (realmId?: string) =>
|
||||
invoke<ToolchainStatus>("preview_toolchain_status", { realmId: realmId ?? null }),
|
||||
previewRegisterTestRealm: (path?: string) => invoke<ServerSummary>("preview_register_test_realm", { path: path ?? null }),
|
||||
previewBuildModule: (realmId: string, sourceUrlOrPath: string) =>
|
||||
invoke<BuildResult>("preview_build_module", { realmId, sourceUrlOrPath }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user