From 97c03ec87cfee505bb0ebb1803813e1c2e654786 Mon Sep 17 00:00:00 2001 From: Alex Mondello Date: Sun, 14 Jun 2026 01:02:16 -0600 Subject: [PATCH 01/14] feat(diagnostics): Phase 1 baseline launch observability Add a stable launch diagnostic surface (schema_version 1) that reports the resolved pipeline, runtime profile, wine binary, prefix, artifact sources with sha256 hashes, staged DLL hashes, and shader cache directories. Missing required artifacts now produce a structured failure instead of a silent fallback. Thread LaunchTiming checkpoints through prepare_pipeline (pipeline resolution, recipe build, runtime validation, DLL staging, prefix deploy) and persist atomically per-bottle. Add Steam library and full-scan timing persistence. Record sha256 + source sha256 in the injections manifest so staged-vs-source drift is observable. New routes (no existing behavior changed): GET /diagnostics/launch?appid=&pipeline= GET /diagnostics/launch/timing?appid= M9/M10/M11 artifact paths and launch behavior are untouched. Tests: 513 passed, 0 failed (was 502; +11 new). clippy + fmt clean. --- app/src-rust/Cargo.lock | 66 +++ app/src-rust/Cargo.toml | 1 + app/src-rust/src/bottles.rs | 5 +- app/src-rust/src/diagnostics.rs | 615 ++++++++++++++++++++++++ app/src-rust/src/main.rs | 55 ++- app/src-rust/src/mtsp/launcher.rs | 26 + docs/optimization-roadmap/PR-SUMMARY.md | 64 +++ 7 files changed, 830 insertions(+), 2 deletions(-) create mode 100644 app/src-rust/src/diagnostics.rs create mode 100644 docs/optimization-roadmap/PR-SUMMARY.md diff --git a/app/src-rust/Cargo.lock b/app/src-rust/Cargo.lock index 1659fc9f..912546c5 100644 --- a/app/src-rust/Cargo.lock +++ b/app/src-rust/Cargo.lock @@ -26,6 +26,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -106,6 +115,15 @@ dependencies = [ "url", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -115,6 +133,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctrlc" version = "3.5.2" @@ -135,6 +163,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -238,6 +276,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -509,6 +557,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2", "tiny_http", "toml", "ureq", @@ -775,6 +824,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -959,6 +1019,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/app/src-rust/Cargo.toml b/app/src-rust/Cargo.toml index c42625ea..2b51c781 100644 --- a/app/src-rust/Cargo.toml +++ b/app/src-rust/Cargo.toml @@ -16,3 +16,4 @@ rusqlite = { version = "0.40", features = ["bundled"] } ctrlc = "3" libc = "0.2" zip = { version = "8", default-features = false, features = ["deflate"] } +sha2 = "0.10" diff --git a/app/src-rust/src/bottles.rs b/app/src-rust/src/bottles.rs index d1ef550f..d59b7fd0 100644 --- a/app/src-rust/src/bottles.rs +++ b/app/src-rust/src/bottles.rs @@ -3010,7 +3010,10 @@ fn runtime_profile_for_pipeline(pipeline: crate::mtsp::engine::PipelineId) -> Ru } } -fn runtime_profile_for_app_pipeline(appid: u32, pipeline: crate::mtsp::engine::PipelineId) -> RuntimeProfile { +pub(crate) fn runtime_profile_for_app_pipeline( + appid: u32, + pipeline: crate::mtsp::engine::PipelineId, +) -> RuntimeProfile { if pipeline == crate::mtsp::engine::PipelineId::FnaArm64 { return match crate::mtsp::launcher::find_fna_profile(appid).mono_arch { crate::mtsp::launcher::MonoArch::X86 => RuntimeProfile::FnaX86, diff --git a/app/src-rust/src/diagnostics.rs b/app/src-rust/src/diagnostics.rs new file mode 100644 index 00000000..9329b92f --- /dev/null +++ b/app/src-rust/src/diagnostics.rs @@ -0,0 +1,615 @@ +//! Phase 1: Baseline launch observability. +//! +//! This module provides a single, stable diagnostic surface that answers +//! "what runtime did this game actually launch with?" without changing any +//! launch or graphics behavior. It reports the resolved pipeline, runtime +//! profile, Wine binary, prefix, artifact sources (with content hashes), +//! staged DLL hashes, and shader cache directories. +//! +//! Two concerns live here: +//! +//! * [`build_launch_diagnostic`] produces a stable JSON snapshot of the launch +//! environment for a given appid. Missing required artifacts produce a +//! structured failure instead of a silent fallback. +//! * [`LaunchTiming`] records named checkpoints around the launch preparation +//! stages (pipeline resolution, DLL staging, bridge checks, process spawn, +//! log path creation) and the Steam library / bottle manifest scan. The last +//! recorded timing for a bottle is persisted next to its logs so performance +//! deltas can be compared between PRs. + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +/// Schema version for the diagnostic JSON. Bumped only when the field shape +/// changes in a backwards-incompatible way. Performance deltas are compared +/// across PRs by matching `schema_version` and the named timing checkpoints. +pub const DIAGNOSTIC_SCHEMA_VERSION: u32 = 1; + +/// Name of the persisted latest-launch timing file inside a bottle's log dir. +const TIMING_LATEST_NAME: &str = "launch-timing-latest.json"; + +/// SHA-256 of a file's contents, returned as lowercase hex. +/// Returns `None` if the file cannot be read. This is intentional: the +/// diagnostic reports presence/absence explicitly rather than panicking. +pub fn file_sha256(path: &Path) -> Option { + let bytes = fs::read(path).ok()?; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let digest = hasher.finalize(); + Some(format!("{:x}", digest)) +} + +/// A small checkpoint recorder for launch preparation timing. +/// +/// Created with [`LaunchTiming::start`], advanced with [`LaunchTiming::mark`], +/// and serialized with [`LaunchTiming::to_json`]. Callers that persist timing +/// use [`LaunchTiming::record_for_bottle`]. +#[derive(Debug, Clone)] +pub struct LaunchTiming { + started_at: Instant, + started_unix: u64, + checkpoints: Vec<(String, Duration)>, +} + +impl LaunchTiming { + pub fn start() -> Self { + let started_unix = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + Self { started_at: Instant::now(), started_unix, checkpoints: Vec::new() } + } + + /// Record a named checkpoint with the elapsed time since `start`. + /// Repeated names are allowed and keep their insertion order so a stage + /// with multiple sub-steps can be observed. + pub fn mark(&mut self, name: &str) { + let elapsed = self.started_at.elapsed(); + self.checkpoints.push((name.to_string(), elapsed)); + } + + /// Total elapsed time since `start`, regardless of how many marks exist. + pub fn total(&self) -> Duration { + self.started_at.elapsed() + } + + /// Serialize to the stable JSON shape consumed by the diagnostic route. + pub fn to_json(&self) -> Value { + let checkpoints: Vec = self + .checkpoints + .iter() + .map(|(name, elapsed)| { + json!({ + "name": name, + "elapsed_ms": elapsed.as_millis() as u64, + "elapsed_us": elapsed.as_micros() as u64, + }) + }) + .collect(); + json!({ + "started_at_unix": self.started_unix, + "total_ms": self.total().as_millis() as u64, + "checkpoints": checkpoints, + }) + } + + /// Persist this timing as the latest launch timing for a bottle. + /// + /// `bottle_id` is the appid-scoped bottle id (e.g. `steam_105600`). The + /// file is written atomically (write-to-temp then rename) so a concurrent + /// reader never sees a partial document. + pub fn record_for_bottle(&self, home: &Path, bottle_id: &str) { + let Some(log_dir) = bottle_log_dir(home, bottle_id) else { + return; + }; + if fs::create_dir_all(&log_dir).is_err() { + return; + } + let final_path = log_dir.join(TIMING_LATEST_NAME); + let tmp_path = log_dir.join(format!("{}.tmp", TIMING_LATEST_NAME)); + let payload = serde_json::to_string(&self.to_json()).unwrap_or_else(|_| "{}".into()); + if fs::write(&tmp_path, payload).is_ok() { + let _ = fs::rename(&tmp_path, &final_path); + } + } +} + +impl Default for LaunchTiming { + fn default() -> Self { + Self::start() + } +} + +/// Read the most recently persisted launch timing for a bottle, if any. +pub fn latest_launch_timing(home: &Path, bottle_id: &str) -> Option { + let log_dir = bottle_log_dir(home, bottle_id)?; + let raw = fs::read_to_string(log_dir.join(TIMING_LATEST_NAME)).ok()?; + serde_json::from_str(&raw).ok() +} + +/// Persist a named scan timing (e.g. "steam_library", "scan_all", +/// "bottle_manifest") as the latest of its kind. Scan timings are written +/// under `~/.metalsharp/logs/` and are not bottle-specific so that Steam +/// library refresh and manifest-write costs can be compared across PRs. +pub fn record_scan_timing(home: &Path, kind: &str, timing: &LaunchTiming) { + if kind.is_empty() { + return; + } + let logs_dir = crate::platform::metalsharp_home_dir_for(home).join("logs"); + if fs::create_dir_all(&logs_dir).is_err() { + return; + } + let final_path = logs_dir.join(format!("scan-timing-{}-latest.json", kind)); + let tmp_path = logs_dir.join(format!("scan-timing-{}.tmp", kind)); + let payload = serde_json::to_string(&timing.to_json()).unwrap_or_else(|_| "{}".into()); + if fs::write(&tmp_path, payload).is_ok() { + let _ = fs::rename(&tmp_path, &final_path); + } +} + +/// Read the most recently persisted scan timing for a kind, if any. +pub fn latest_scan_timing(home: &Path, kind: &str) -> Option { + let logs_dir = crate::platform::metalsharp_home_dir_for(home).join("logs"); + let raw = fs::read_to_string(logs_dir.join(format!("scan-timing-{}-latest.json", kind))).ok()?; + serde_json::from_str(&raw).ok() +} + +fn bottle_log_dir(home: &Path, bottle_id: &str) -> Option { + if bottle_id.is_empty() { + return None; + } + Some(crate::platform::metalsharp_home_dir_for(home).join("bottles").join(bottle_id).join("logs")) +} + +/// Resolve the on-disk shader cache directories that a pipeline would use for +/// an appid, including the legacy shared DXMT-Metal family aliases. This +/// mirrors [`crate::mtsp::shader_cache`] lookup families so the diagnostic +/// reports the same roots the runtime consults. +pub fn shader_cache_dirs(home: &Path, pipeline: crate::mtsp::engine::PipelineId, appid: u32) -> Vec { + use crate::mtsp::engine::PipelineId; + + let cache_root = crate::platform::metalsharp_home_dir_for(home).join("shader-cache"); + let appid_str = appid.to_string(); + + let subdirs: &[&str] = match pipeline { + PipelineId::M9 => &["m9", "dxmt-metal"], + PipelineId::M10 => &["m10", "dxmt-metal"], + PipelineId::M11 => &["m11", "dxmt-metal"], + PipelineId::M12 => &["m12", "dxmt-metal12"], + PipelineId::M13 => &["m13", "dxmt-metal12"], + _ => &[], + }; + + subdirs.iter().map(|sub| cache_root.join(sub).join(&appid_str)).collect() +} + +/// Build the stable launch diagnostic JSON for an appid and an optional +/// requested pipeline. +/// +/// This walks the same resolution path as `prepare_pipeline` / +/// `handle_steam_runtime_doctor`: it resolves the pipeline, resolves the +/// game directory, resolves the deploy list from the pipeline node, and +/// reports each artifact source with presence and content hash. Required +/// artifacts that are missing produce a structured failure (`ok: false`) +/// rather than a silent fallback. +pub fn build_launch_diagnostic(appid: u32, requested: Option) -> Value { + let home = match dirs::home_dir() { + Some(h) => h, + None => { + return json!({ + "ok": false, + "schema_version": DIAGNOSTIC_SCHEMA_VERSION, + "error": "home directory could not be resolved", + "appid": appid, + }); + }, + }; + let ms_home = crate::platform::metalsharp_home_dir_for(&home); + + let pipeline = crate::bottles::resolve_steam_pipeline_for_request(appid, requested); + let node = crate::mtsp::engine::get_pipeline(pipeline); + let profile = crate::bottles::runtime_profile_for_app_pipeline(appid, pipeline); + let dual = crate::scan::resolve_dual_game_dir(appid); + let prefix = ms_home.join("prefix-steam"); + + let wine_root = ms_home.join("runtime").join("wine"); + let wine_binary = crate::platform::runtime_wine_binary(&wine_root); + let wine_library_env = crate::platform::runtime_library_env(&wine_root) + .map(|(key, value)| json!({ "key": key, "value": value })) + .unwrap_or_else(|| json!({ "present": false })); + + // Artifact sources: every deploy_dll in the pipeline node, resolved against + // the runtime wine root. Optional stubs (nvapi/nvngx/atidxx) are tolerated + // as missing without causing a structured failure. + let mut artifact_sources: Vec = Vec::new(); + let mut missing_required: Vec = Vec::new(); + for deploy in &node.deploy_dlls { + let source_path = wine_root.join(deploy.source_subpath).join(deploy.filename); + let present = source_path.exists(); + let is_optional_stub = deploy.filename.starts_with("nvapi") + || deploy.filename.starts_with("nvngx") + || deploy.filename.starts_with("atidxx"); + let sha = if present { file_sha256(&source_path) } else { None }; + let size = if present { fs::metadata(&source_path).ok().map(|m| m.len()) } else { None }; + + artifact_sources.push(json!({ + "source_subpath": deploy.source_subpath, + "filename": deploy.filename, + "dest_filename": deploy.dest_filename, + "source_path": source_path.to_string_lossy(), + "present": present, + "optional": is_optional_stub, + "sha256": sha, + "size_bytes": size, + })); + + if !present && !is_optional_stub { + missing_required.push(json!({ + "filename": deploy.filename, + "source_subpath": deploy.source_subpath, + "source_path": source_path.to_string_lossy(), + })); + } + } + + // Staged DLL hashes: read the most recent injections manifest written by + // deploy_recipe_dlls into /.metalsharp/injections.json, and + // report the current sha256 of each staged destination. + let staged_dll_hashes = staged_dll_hashes_for(dual.wine_dir.as_deref()); + + // Cache directories for this pipeline+appid. + let cache_dirs: Vec = shader_cache_dirs(&home, pipeline, appid) + .into_iter() + .map(|dir| { + let exists = dir.exists(); + let entry_count = if exists { fs::read_dir(&dir).ok().map(|rd| rd.count() as u64).unwrap_or(0) } else { 0 }; + json!({ + "path": dir.to_string_lossy(), + "exists": exists, + "entry_count": entry_count, + }) + }) + .collect(); + + let bundle_hash = bundle_hash_for(&ms_home); + + let generated_at_unix = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + + if !missing_required.is_empty() { + return json!({ + "ok": false, + "schema_version": DIAGNOSTIC_SCHEMA_VERSION, + "metalsharp_version": env!("CARGO_PKG_VERSION"), + "generated_at_unix": generated_at_unix, + "appid": appid, + "pipeline": pipeline, + "pipeline_name": node.name, + "runtime_profile": profile, + "error": "required runtime artifacts are missing", + "missing_artifacts": missing_required, + "artifact_sources": artifact_sources, + "wine_binary_path": wine_binary.to_string_lossy(), + "wine_binary_exists": wine_binary.exists(), + "wine_library_env": wine_library_env, + "prefix_path": prefix.to_string_lossy(), + "prefix_exists": prefix.exists(), + "game_install_path": dual.wine_dir.as_ref().map(|p| p.to_string_lossy().to_string()), + "bundle_hash": bundle_hash, + "cache_directories": cache_dirs, + }); + } + + json!({ + "ok": true, + "schema_version": DIAGNOSTIC_SCHEMA_VERSION, + "metalsharp_version": env!("CARGO_PKG_VERSION"), + "generated_at_unix": generated_at_unix, + "appid": appid, + "pipeline": pipeline, + "pipeline_name": node.name, + "backend": node.backend, + "graphics_backend": node.graphics_backend, + "runtime_profile": profile, + "wine_binary_path": wine_binary.to_string_lossy(), + "wine_binary_exists": wine_binary.exists(), + "wine_library_env": wine_library_env, + "prefix_path": prefix.to_string_lossy(), + "prefix_exists": prefix.exists(), + "game_install_path": dual.wine_dir.as_ref().map(|p| p.to_string_lossy().to_string()), + "bundle_hash": bundle_hash, + "artifact_sources": artifact_sources, + "staged_dll_hashes": staged_dll_hashes, + "cache_directories": cache_dirs, + }) +} + +/// Read `/.metalsharp/injections.json` (written by +/// `deploy_recipe_dlls`) and report the current sha256 of each staged DLL +/// destination. Returns an empty array if the game dir or manifest is absent. +fn staged_dll_hashes_for(game_dir: Option<&Path>) -> Vec { + let Some(game_dir) = game_dir else { + return Vec::new(); + }; + let manifest_path = game_dir.join(".metalsharp").join("injections.json"); + let Ok(raw) = fs::read_to_string(&manifest_path) else { + return Vec::new(); + }; + let Ok(manifest) = serde_json::from_str::(&raw) else { + return Vec::new(); + }; + let Some(dlls) = manifest.get("dlls").and_then(|v| v.as_array()) else { + return Vec::new(); + }; + + let manifest_pipeline = manifest.get("pipeline").cloned(); + let manifest_pipeline_name = manifest.get("pipeline_name").cloned(); + let manifest_updated_at = manifest.get("updated_at_unix").cloned(); + + dlls.iter() + .filter_map(|dll| { + let filename = dll.get("filename").and_then(|v| v.as_str())?; + let dest = dll.get("dest_path").and_then(|v| v.as_str()).map(PathBuf::from)?; + let source_path = dll.get("source_path").and_then(|v| v.as_str()).map(PathBuf::from); + let present = dest.exists(); + let sha = if present { file_sha256(&dest) } else { None }; + let source_sha = source_path.and_then(|p| if p.exists() { file_sha256(&p) } else { None }); + let matches_source = match (sha.as_ref(), source_sha.as_ref()) { + (Some(a), Some(b)) => Some(a == b), + _ => None, + }; + Some(json!({ + "filename": filename, + "dest_path": dest.to_string_lossy(), + "present": present, + "sha256": sha, + "source_sha256": source_sha, + "matches_source": matches_source, + "manifest_pipeline": manifest_pipeline, + "manifest_pipeline_name": manifest_pipeline_name, + "manifest_updated_at_unix": manifest_updated_at, + })) + }) + .collect() +} + +/// Best-effort runtime bundle hash. MetalSharp records an installed bundle +/// identity under the runtime root when present; if it is absent we report +/// `null` rather than inventing one, so the diagnostic never lies about +/// provenance. +fn bundle_hash_for(ms_home: &Path) -> Option { + let candidates = [ + ms_home.join("runtime").join("bundle-hash.txt"), + ms_home.join("runtime").join("wine").join("bundle-hash.txt"), + ms_home.join("bundle-hash.txt"), + ]; + for candidate in candidates { + if let Ok(raw) = fs::read_to_string(&candidate) { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn file_sha256_matches_known_value() { + let dir = std::env::temp_dir().join("ms-diag-sha-test"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("blob.bin"); + fs::write(&path, b"abc").unwrap(); + // Known SHA-256 of "abc" + let known = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + assert_eq!(file_sha256(&path).as_deref(), Some(known)); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn file_sha256_returns_none_for_missing_file() { + let path = std::env::temp_dir().join("ms-diag-sha-missing-nope.bin"); + let _ = fs::remove_file(&path); + assert_eq!(file_sha256(&path), None); + } + + #[test] + fn launch_timing_records_named_checkpoints_in_order() { + let mut t = LaunchTiming::start(); + t.mark("pipeline_resolution"); + t.mark("dll_staging"); + t.mark("wine_spawn"); + let value = t.to_json(); + let names: Vec = value + .get("checkpoints") + .unwrap() + .as_array() + .unwrap() + .iter() + .map(|c| c.get("name").unwrap().as_str().unwrap().to_string()) + .collect(); + assert_eq!(names, vec!["pipeline_resolution", "dll_staging", "wine_spawn"]); + assert!(value.get("total_ms").unwrap().as_u64().is_some()); + } + + #[test] + fn launch_timing_total_grows_with_marks() { + let mut t = LaunchTiming::start(); + let before = t.total(); + // Spin briefly so elapsed is nonzero. + while t.total() == before {} + t.mark("wait"); + assert!(t.total() >= before); + } + + #[test] + fn shader_cache_dirs_include_dxmt_metal_family_for_legacy_pipelines() { + let home = std::env::temp_dir().join("ms-diag-cache-test"); + let _ = fs::remove_dir_all(&home); + let dirs = shader_cache_dirs(&home, crate::mtsp::engine::PipelineId::M11, 42); + let names: Vec = dirs.iter().map(|d| d.to_string_lossy().to_string()).collect(); + assert!(names.iter().any(|n| n.contains("shader-cache/m11/42")), "got {:?}", names); + assert!(names.iter().any(|n| n.contains("shader-cache/dxmt-metal/42")), "got {:?}", names); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn shader_cache_dirs_use_m12_isolated_family() { + let home = std::env::temp_dir().join("ms-diag-cache-m12"); + let _ = fs::remove_dir_all(&home); + let dirs = shader_cache_dirs(&home, crate::mtsp::engine::PipelineId::M12, 7); + let names: Vec = dirs.iter().map(|d| d.to_string_lossy().to_string()).collect(); + assert!(names.iter().any(|n| n.contains("shader-cache/m12/7")), "got {:?}", names); + assert!(names.iter().any(|n| n.contains("shader-cache/dxmt-metal12/7")), "got {:?}", names); + // M12 must NOT share the dxmt-metal legacy family. + assert!(!names.iter().any(|n| n.contains("shader-cache/dxmt-metal/")), "got {:?}", names); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn build_launch_diagnostic_contains_required_fields_for_known_appid() { + // Celeste (504230) resolves to a stable pipeline regardless of host + // state. The diagnostic must report the pipeline id, runtime profile, + // prefix path, wine binary, and artifact_sources shape. Missing + // artifacts on a clean test host are reported as a structured failure. + let report = build_launch_diagnostic(504230, None); + assert_eq!(report.get("schema_version").and_then(|v| v.as_u64()), Some(DIAGNOSTIC_SCHEMA_VERSION as u64)); + assert_eq!(report.get("metalsharp_version").and_then(|v| v.as_str()), Some(env!("CARGO_PKG_VERSION"))); + assert_eq!(report.get("appid").and_then(|v| v.as_u64()), Some(504230)); + // pipeline is serialized as snake_case and must be present either way. + assert!(report.get("pipeline").is_some(), "diagnostic must report pipeline id"); + assert!(report.get("pipeline_name").is_some(), "diagnostic must report pipeline name"); + assert!(report.get("runtime_profile").is_some(), "diagnostic must report runtime profile"); + assert!(report.get("prefix_path").is_some(), "diagnostic must report prefix path"); + assert!(report.get("wine_binary_path").is_some(), "diagnostic must report wine binary path"); + assert!(report.get("artifact_sources").unwrap().is_array(), "diagnostic must report artifact sources"); + } + + #[test] + fn build_launch_diagnostic_reports_structured_failure_when_artifacts_missing() { + // Point METALSHARP_HOME at an empty dir so no runtime artifacts exist, + // then request M12 which requires d3d12.dll etc. The diagnostic must + // report ok=false with a missing_artifacts array, not a silent ok=true. + let tmp = std::env::temp_dir().join("ms-diag-empty-home"); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + let prev = std::env::var("METALSHARP_HOME").ok(); + std::env::set_var("METALSHARP_HOME", &tmp); + + let report = build_launch_diagnostic(999999, Some(crate::mtsp::engine::PipelineId::M12)); + + // Restore env ASAP even if assertions fail. + match prev { + Some(v) => std::env::set_var("METALSHARP_HOME", v), + None => std::env::remove_var("METALSHARP_HOME"), + } + + // 999999 is not a known game, so it resolves through the fallback. + // If it happens to resolve to M12, we get a structured failure. If it + // resolves to a non-DXMT route, there are no required deploy_dlls and + // ok=true is valid. Either way, the shape must be valid: when ok=false, + // missing_artifacts MUST be a non-empty array. + let ok = report.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); + if !ok { + let missing = report.get("missing_artifacts").and_then(|v| v.as_array()).unwrap(); + assert!(!missing.is_empty(), "ok=false must include missing_artifacts: {}", report); + assert!( + report.get("error").and_then(|v| v.as_str()).unwrap_or("").contains("missing"), + "ok=false must explain the failure: {}", + report + ); + } + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn latest_launch_timing_round_trips_through_disk() { + let home = std::env::temp_dir().join("ms-diag-timing-rt"); + let _ = fs::remove_dir_all(&home); + fs::create_dir_all(&home).unwrap(); + let mut t = LaunchTiming::start(); + t.mark("pipeline_resolution"); + t.mark("dll_staging"); + t.record_for_bottle(&home, "steam_504230"); + + let read_back = latest_launch_timing(&home, "steam_504230").expect("timing should persist"); + let names: Vec = read_back + .get("checkpoints") + .unwrap() + .as_array() + .unwrap() + .iter() + .map(|c| c.get("name").unwrap().as_str().unwrap().to_string()) + .collect(); + assert_eq!(names, vec!["pipeline_resolution", "dll_staging"]); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn scan_timing_round_trips_through_disk() { + let home = std::env::temp_dir().join("ms-diag-scan-rt"); + let _ = fs::remove_dir_all(&home); + fs::create_dir_all(&home).unwrap(); + let mut t = LaunchTiming::start(); + t.mark("library_load_start"); + t.mark("library_load_done"); + record_scan_timing(&home, "steam_library", &t); + let read_back = latest_scan_timing(&home, "steam_library").expect("scan timing should persist"); + let names: Vec = read_back + .get("checkpoints") + .unwrap() + .as_array() + .unwrap() + .iter() + .map(|c| c.get("name").unwrap().as_str().unwrap().to_string()) + .collect(); + assert_eq!(names, vec!["library_load_start", "library_load_done"]); + let _ = fs::remove_dir_all(&home); + } + + #[test] + fn staged_dll_hashes_reads_injections_manifest_and_hashes_destinations() { + let dir = std::env::temp_dir().join("ms-diag-staged"); + let _ = fs::remove_dir_all(&dir); + let injection_dir = dir.join(".metalsharp"); + fs::create_dir_all(&injection_dir).unwrap(); + + // Stage a fake DLL at the destination and record it in the manifest. + let dest = dir.join("d3d12.dll"); + fs::write(&dest, b"fake-d3d12").unwrap(); + let source = std::env::temp_dir().join("ms-diag-staged-src.bin"); + fs::write(&source, b"fake-d3d12").unwrap(); + + let manifest = json!({ + "appid": 504230, + "pipeline": "m12", + "pipeline_name": "M12", + "updated_at_unix": 1700000000u64, + "dlls": [{ + "filename": "d3d12.dll", + "source_path": source.to_string_lossy(), + "dest_path": dest.to_string_lossy(), + }], + }); + fs::write(injection_dir.join("injections.json"), serde_json::to_string_pretty(&manifest).unwrap()).unwrap(); + + let hashes = staged_dll_hashes_for(Some(&dir)); + assert_eq!(hashes.len(), 1); + let entry = &hashes[0]; + assert_eq!(entry.get("filename").and_then(|v| v.as_str()), Some("d3d12.dll")); + assert_eq!(entry.get("present").and_then(|v| v.as_bool()), Some(true)); + assert!(entry.get("sha256").and_then(|v| v.as_str()).is_some()); + assert_eq!( + entry.get("matches_source").and_then(|v| v.as_bool()), + Some(true), + "staged DLL hash must match the recorded source hash" + ); + let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_file(&source); + } +} diff --git a/app/src-rust/src/main.rs b/app/src-rust/src/main.rs index 09c1585a..8bd05c1f 100644 --- a/app/src-rust/src/main.rs +++ b/app/src-rust/src/main.rs @@ -20,6 +20,7 @@ mod anticheat; mod bottles; mod d3d12_runtime_doctor; +mod diagnostics; mod installer; mod kernel_translation; mod launch; @@ -307,16 +308,29 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { } }, (Method::Get, "/scan") => { + let mut timing = diagnostics::LaunchTiming::start(); app_log("Scanning for installed games..."); - match scan::scan_all() { + timing.mark("scan_start"); + let result = scan::scan_all(); + timing.mark("scan_all_done"); + if let Some(home) = dirs::home_dir() { + diagnostics::record_scan_timing(&home, "scan_all", &timing); + } + match result { Ok(result) => resp(200, result), Err(e) => resp(500, json!({"ok": false, "error": e.to_string()})), } }, (Method::Get, "/steam/status") => resp(200, steam::status()), (Method::Get, "/steam/library") => { + let mut timing = diagnostics::LaunchTiming::start(); app_log("Loading Steam library..."); + timing.mark("library_load_start"); let result = steam::library(); + timing.mark("library_load_done"); + if let Some(home) = dirs::home_dir() { + diagnostics::record_scan_timing(&home, "steam_library", &timing); + } app_log(&format!("Loaded {} games", result.get("total").and_then(|t| t.as_u64()).unwrap_or(0))); resp(200, result) }, @@ -991,6 +1005,45 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { let body = read_body(req); resp(200, d3d12_runtime_doctor::handle_steam_d3d12_runtime_doctor(&body)) }, + // Phase 1: baseline launch observability. Stable JSON diagnostic that + // reports the resolved pipeline, runtime profile, wine path, prefix, + // artifact sources (with content hashes), staged DLL hashes, and cache + // directories for an appid. No launch behavior changes. + (Method::Get, "/diagnostics/launch") => { + let url_str = req.url().to_string(); + let appid: u32 = url_str + .split("appid=") + .nth(1) + .and_then(|v| v.split('&').next()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let requested_pipeline = url_str + .split("pipeline=") + .nth(1) + .and_then(|v| v.split('&').next()) + .and_then(crate::mtsp::engine::PipelineId::from_str_flexible); + resp(200, diagnostics::build_launch_diagnostic(appid, requested_pipeline)) + }, + (Method::Get, "/diagnostics/launch/timing") => { + let url_str = req.url().to_string(); + let appid: u32 = url_str + .split("appid=") + .nth(1) + .and_then(|v| v.split('&').next()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let home = dirs::home_dir().unwrap_or_default(); + let bottle_id = format!("steam_{}", appid); + match diagnostics::latest_launch_timing(&home, &bottle_id) { + Some(timing) => { + resp(200, json!({ "ok": true, "appid": appid, "bottle_id": bottle_id, "timing": timing })) + }, + None => resp( + 200, + json!({ "ok": false, "appid": appid, "bottle_id": bottle_id, "error": "no launch timing recorded for this bottle yet" }), + ), + } + }, (Method::Post, "/steam/compatdata") => { let body = read_body(req); resp(200, bottles::handle_steam_compatdata(&body)) diff --git a/app/src-rust/src/mtsp/launcher.rs b/app/src-rust/src/mtsp/launcher.rs index d19d08aa..a0a9f82c 100644 --- a/app/src-rust/src/mtsp/launcher.rs +++ b/app/src-rust/src/mtsp/launcher.rs @@ -444,22 +444,41 @@ pub fn launch_auto(appid: u32) -> Result<(u32, &'static str), Box Result> { + let mut timing = crate::diagnostics::LaunchTiming::start(); + timing.mark("pipeline_resolution_start"); let pipeline_id = super::rules::resolve_pipeline(appid); let node = get_pipeline(pipeline_id); + timing.mark("pipeline_resolution_done"); prepare_start_protected_game_for_pipeline(appid, pipeline_id); + timing.mark("start_protected_game_prepare_done"); let recipe = super::recipe::build_launch_recipe(appid, node)?; + timing.mark("recipe_build_done"); let deployed_sources: Vec = { validate_recipe_runtime(&recipe)?; + timing.mark("recipe_runtime_validate_done"); if let Some(game_dir) = recipe.game_dir.as_ref() { cleanup_legacy_injections(game_dir)?; + timing.mark("legacy_injection_cleanup_done"); } let sources = recipe.dlls.iter().map(|dll| dll.source_subpath.clone()).collect(); deploy_recipe_dlls(&recipe)?; + timing.mark("dll_staging_done"); let home = dirs::home_dir().ok_or("no home dir")?; let prefix = crate::platform::metalsharp_home_dir_for(&home).join("prefix-steam"); deploy_prefix_route_dlls(&recipe, &prefix)?; + timing.mark("prefix_route_dll_deploy_done"); sources }; + timing.mark("prepare_complete"); + + // Persist launch timing so performance deltas can be compared between PRs + // via GET /diagnostics/launch/timing?appid=... + let home_for_timing = dirs::home_dir(); + if let Some(home) = home_for_timing { + timing.record_for_bottle(&home, &format!("steam_{}", appid)); + } + + let timing_json = timing.to_json(); Ok(serde_json::json!({ "ok": true, @@ -469,6 +488,7 @@ pub fn prepare_pipeline(appid: u32) -> Result Result<(), Bo } std::fs::copy(&deploy.source_path, &deploy.dest_path)?; + let staged_sha256 = crate::diagnostics::file_sha256(&deploy.dest_path); + let source_sha256 = + if staged_sha256.is_some() { crate::diagnostics::file_sha256(&deploy.source_path) } else { None }; manifest_dlls.push(serde_json::json!({ "filename": deploy.filename, "source_path": deploy.source_path, "dest_path": deploy.dest_path, "backup_path": if backup_path.exists() { Some(backup_path) } else { None }, + "sha256": staged_sha256, + "source_sha256": source_sha256, + "matches_source": matches!((&staged_sha256, &source_sha256), (Some(a), Some(b)) if a == b), })); } diff --git a/docs/optimization-roadmap/PR-SUMMARY.md b/docs/optimization-roadmap/PR-SUMMARY.md new file mode 100644 index 00000000..24445624 --- /dev/null +++ b/docs/optimization-roadmap/PR-SUMMARY.md @@ -0,0 +1,64 @@ +# Phased Optimization Roadmap — Draft PR Summary + +Branch: `codex/phased-optimization-roadmap` +Base: `main` (3f2f5349, v0.46.8) +Roadmap: `MetalSharp-Phased-Optimization-Roadmap.md` + +This draft PR implements the full 9-phase MetalSharp optimization roadmap as +one body of work. Each phase is a separate commit with its own proof gate +(`cargo fmt --check`, `cargo clippy -D warnings`, `cargo test` all green) and +keeps M9/M10/M11 launch behavior and artifact paths untouched. + +Baseline before any work: **502 tests passed, 0 failed.** + +## Phase 1: Baseline Observability ✅ + +**Purpose:** make future optimization measurable before changing behavior. + +**What landed:** +- New `app/src-rust/src/diagnostics.rs` module — a single stable launch + diagnostic surface (`schema_version: 1`) that reports: + - `metalsharp_version`, generated timestamp, appid + - resolved `pipeline`, `pipeline_name`, `backend`, `graphics_backend` + - `runtime_profile` + - `wine_binary_path` + existence, `wine_library_env` + - `prefix_path` + existence, `game_install_path` + - `bundle_hash` (best-effort, `null` when absent rather than fabricated) + - `artifact_sources[]` — every pipeline deploy DLL with resolved source + path, presence, sha256, size, and optional-stub flag + - `staged_dll_hashes[]` — current sha256 of each staged destination read + from `/.metalsharp/injections.json`, plus the recorded source + hash and a `matches_source` boolean + - `cache_directories[]` — shader cache roots per pipeline (M9/M10/M11 share + the `dxmt-metal` family; M12/M13 use the isolated `dxmt-metal12` family) +- Missing required runtime artifacts produce a **structured failure** + (`ok: false`, `missing_artifacts[]`, explanatory `error`) instead of a + silent fallback. Optional stubs (nvapi/nvngx/atidxx) are tolerated. +- `LaunchTiming` checkpoint recorder threaded through `prepare_pipeline` + (pipeline resolution, recipe build, runtime validation, legacy-injection + cleanup, DLL staging, prefix-route deploy, complete) and persisted + atomically to `/logs/launch-timing-latest.json`. +- Scan timing for Steam library refresh and full library scan, persisted to + `~/.metalsharp/logs/scan-timing--latest.json`. +- `deploy_recipe_dlls` now records `sha256`, `source_sha256`, and + `matches_source` per staged DLL in `injections.json`. +- Two new HTTP routes (no behavior change to existing routes): + - `GET /diagnostics/launch?appid=&pipeline=` → live diagnostic JSON + - `GET /diagnostics/launch/timing?appid=` → latest persisted launch timing + +**New tests (11):** known appid diagnostic fields, structured failure on +missing artifacts, sha256 known-vector, sha256-of-missing, timing checkpoint +ordering, timing monotonicity, shader cache family isolation (M11 shares +legacy, M12 isolated), timing round-trip, scan-timing round-trip, staged DLL +hash matching source. + +**Proof:** +``` +cargo fmt --all -- --check # clean +cargo clippy --all-targets -- -D warnings # clean +cargo test # 513 passed, 0 failed +``` + +**Boundary check:** no M9/M10/M11 artifact path or launch behavior changed. +The only additions are diagnostic outputs, timing instrumentation, and +content hashes in the existing injection manifest. From f28ae63f23cb826705b9ba3a67328c210b3fdf9f Mon Sep 17 00:00:00 2001 From: Alex Mondello Date: Sun, 14 Jun 2026 01:15:00 -0600 Subject: [PATCH 02/14] feat(bottles): Phase 2 runtime and bottle route contract hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a declarative Steam route contract (SteamRouteContract) that codifies, per pipeline, the runtime profile, steam identity mode, launch route, requires_wine, shared Steam prefix binding, prefix-idle wait policy, compat tool name, and appid-scoped bottle id template. The contract is derived from the same primitives the runtime uses so it cannot drift from launch behavior. Add passive-refresh preservation tests for M11 and M12 (the M9 case already existed), a data-driven route-contract test covering M9/M10/M11/M12/M13/ FnaArm64/WineBare/D3DMetal, and a deploy_steam_appid staging test. Add a migration preserve/skip report (migrate::MigrationReport) that records every preserved and skipped category with a reason during preserve/restore, persisted atomically to logs/migration-report-latest.json. This is purely observational — it does not change what is preserved. New routes: GET /bottles/route-contracts, GET /update/migrate/report. No new test mutates the process-global METALSHARP_HOME; all new diagnostics and migration tests use explicit-home (_for) variants for parallel safety. Tests: 521 passed, 0 failed (3 consecutive runs). clippy + fmt clean. M9/M10/M11 launch behavior and artifact paths unchanged. --- app/src-rust/src/bottles.rs | 232 +++++++++++++ app/src-rust/src/diagnostics.rs | 70 ++-- app/src-rust/src/main.rs | 6 + app/src-rust/src/migrate.rs | 415 +++++++++++++++++++++--- app/src-rust/src/mtsp/launcher.rs | 32 ++ docs/optimization-roadmap/PR-SUMMARY.md | 56 ++++ 6 files changed, 738 insertions(+), 73 deletions(-) diff --git a/app/src-rust/src/bottles.rs b/app/src-rust/src/bottles.rs index d59b7fd0..fb0191cc 100644 --- a/app/src-rust/src/bottles.rs +++ b/app/src-rust/src/bottles.rs @@ -825,6 +825,66 @@ fn write_bottle_manifest_atomic(manifest_path: &Path, data: &[u8]) -> Result<(), Ok(()) } +/// Phase 2: declarative Steam route contract for a pipeline. +/// +/// This codifies what every first-class Steam game route promises so that a +/// passive refresh, a compatdata write, or a bottle metadata change cannot +/// silently downgrade or erase a saved route. The contract is derived from +/// the same primitives the runtime uses (`steam_pipeline_defaults_offline`, +/// `runtime_profile_for_pipeline`, `pipeline_preference_id`, and the pipeline +/// node's `requires_wine` flag) so it can never drift from launch behavior. +#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] +pub struct SteamRouteContract { + pub pipeline: &'static str, + pub runtime_profile: RuntimeProfile, + pub steam_identity_mode: &'static str, + pub launch_route: &'static str, + pub requires_wine: bool, + pub binds_to_shared_steam_prefix: bool, + pub waits_for_prefix_idle: bool, + pub compat_tool_name: &'static str, + pub bottle_id_template: &'static str, +} + +/// The route contract for a single pipeline. +pub fn steam_route_contract_for(pipeline: crate::mtsp::engine::PipelineId) -> SteamRouteContract { + let offline = steam_pipeline_defaults_offline(pipeline); + let requires_wine = crate::mtsp::engine::get_pipeline(pipeline).requires_wine; + SteamRouteContract { + pipeline: pipeline_preference_id(pipeline), + runtime_profile: runtime_profile_for_pipeline(pipeline), + steam_identity_mode: if offline { "offline_steam_emulation" } else { "wine_steam_background" }, + launch_route: if offline { "/steam/launch-offline" } else { "/steam/launch-game" }, + // Steam game bottles bind to the shared Steam launch prefix and never + // block the launcher on prefix idle completion (only installer bottles + // do). This is the contract that `should_wait_for_prefix_idle` + // enforces for steam game bottles. + requires_wine, + binds_to_shared_steam_prefix: requires_wine, + waits_for_prefix_idle: false, + compat_tool_name: "MetalSharp", + bottle_id_template: "steam_{appid}", + } +} + +/// The full route-contract table covering every protected and first-class +/// Steam game lane. M9/M10/M11 are protected compatibility lanes; M12/M13, +/// FnaArm64, WineBare, and D3DMetal cover the remaining route families the +/// contract must exercise. +pub fn steam_route_contracts() -> Vec { + use crate::mtsp::engine::PipelineId::*; + vec![ + steam_route_contract_for(M9), + steam_route_contract_for(M10), + steam_route_contract_for(M11), + steam_route_contract_for(M12), + steam_route_contract_for(M13), + steam_route_contract_for(FnaArm64), + steam_route_contract_for(WineBare), + steam_route_contract_for(D3DMetal), + ] +} + pub fn list_bottles() -> Result, Box> { let root = bottles_root(); if !root.exists() { @@ -5596,6 +5656,178 @@ mod tests { ); } + #[test] + fn passive_steam_refresh_preserves_saved_m11_pipeline() { + // A saved M11 route must survive a passive refresh that would otherwise + // resolve to M12. This is the M11 counterpart to the M9 preservation + // rule and protects the protected D3D11 compatibility lane. + let manifest = BottleManifest { + id: steam_game_bottle_id(17300), + name: "M11 Title".into(), + custom_name: None, + bottle_type: BottleType::Steam, + steam_app_id: Some(17300), + prefix_path: steam_launch_prefix().to_string_lossy().to_string(), + arch: BottleArch::Wow64, + runtime_profile: RuntimeProfile::M11, + preferred_pipeline: Some("m11".into()), + installed_components: default_components_for(RuntimeProfile::M11), + source_installer_path: None, + installer_kind: None, + game_install_path: None, + runtime_assets: Vec::new(), + installed_app_detections: Vec::new(), + health: BottleHealth::Ready, + last_launch_log: None, + last_launch_pid: None, + last_launch_status: None, + last_launch_finished_at: None, + created_at: "0".into(), + updated_at: "0".into(), + }; + + assert_eq!( + effective_pipeline_for_bottle_refresh(Some(&manifest), crate::mtsp::engine::PipelineId::M12, true), + crate::mtsp::engine::PipelineId::M11, + "passive refresh must not downgrade a saved M11 route to M12" + ); + // An active (explicit) request still wins. + assert_eq!( + effective_pipeline_for_bottle_refresh(Some(&manifest), crate::mtsp::engine::PipelineId::M12, false), + crate::mtsp::engine::PipelineId::M12 + ); + } + + #[test] + fn passive_steam_refresh_preserves_saved_m12_pipeline() { + // A saved M12 route must survive a passive refresh that would otherwise + // fall back to M11 or M9. The isolated M12 lane cannot be silently + // erased by a background library refresh. + let manifest = BottleManifest { + id: steam_game_bottle_id(2379780), + name: "M12 Title".into(), + custom_name: None, + bottle_type: BottleType::Steam, + steam_app_id: Some(2379780), + prefix_path: steam_launch_prefix().to_string_lossy().to_string(), + arch: BottleArch::Win64, + runtime_profile: RuntimeProfile::M12, + preferred_pipeline: Some("m12".into()), + installed_components: default_components_for(RuntimeProfile::M12), + source_installer_path: None, + installer_kind: None, + game_install_path: None, + runtime_assets: Vec::new(), + installed_app_detections: Vec::new(), + health: BottleHealth::Ready, + last_launch_log: None, + last_launch_pid: None, + last_launch_status: None, + last_launch_finished_at: None, + created_at: "0".into(), + updated_at: "0".into(), + }; + + assert_eq!( + effective_pipeline_for_bottle_refresh(Some(&manifest), crate::mtsp::engine::PipelineId::M11, true), + crate::mtsp::engine::PipelineId::M12, + "passive refresh must not downgrade a saved M12 route to M11" + ); + assert_eq!( + effective_pipeline_for_bottle_refresh(Some(&manifest), crate::mtsp::engine::PipelineId::M9, true), + crate::mtsp::engine::PipelineId::M12, + "passive refresh must not downgrade a saved M12 route to M9" + ); + } + + #[test] + fn steam_route_contract_table_matches_compatdata_records() { + // The declarative route contract must match the actual compatdata + // record produced for each first-class Steam game lane. If this test + // fails, a pipeline's identity mode, launch route, or bottle scoping + // has drifted from the codified contract. + for contract in steam_route_contracts() { + let pipeline = crate::mtsp::engine::PipelineId::from_str_flexible(contract.pipeline) + .expect("contract pipeline id must parse"); + let appid = 620u32; + let manifest = BottleManifest { + id: steam_game_bottle_id(appid), + name: format!("{} contract probe", contract.pipeline), + custom_name: None, + bottle_type: BottleType::Steam, + steam_app_id: Some(appid), + prefix_path: steam_launch_prefix().to_string_lossy().to_string(), + arch: BottleArch::Win64, + runtime_profile: contract.runtime_profile, + preferred_pipeline: Some(contract.pipeline.to_string()), + installed_components: default_components_for(contract.runtime_profile), + source_installer_path: None, + installer_kind: None, + game_install_path: Some("/games/probe".into()), + runtime_assets: Vec::new(), + installed_app_detections: Vec::new(), + health: BottleHealth::Ready, + last_launch_log: None, + last_launch_pid: None, + last_launch_status: None, + last_launch_finished_at: None, + created_at: timestamp_secs(), + updated_at: timestamp_secs(), + }; + + let record = steam_compatdata_record(&manifest, pipeline); + + assert_eq!(record.appid, appid, "appid scoping for {}", contract.pipeline); + assert_eq!(record.bottle_id, steam_game_bottle_id(appid), "bottle id template for {}", contract.pipeline); + assert_eq!(record.launch_pipeline, contract.pipeline, "launch_pipeline for {}", contract.pipeline); + assert_eq!( + record.steam_identity_mode, contract.steam_identity_mode, + "steam_identity_mode for {}", + contract.pipeline + ); + assert_eq!( + record.compat_tool_name, contract.compat_tool_name, + "compat_tool_name for {}", + contract.pipeline + ); + assert!( + record.launch_command_template.contains(contract.launch_route), + "launch route for {}: {}", + contract.pipeline, + record.launch_command_template + ); + } + } + + #[test] + fn steam_route_contract_table_covers_all_required_lanes() { + // The contract table is the protected-lane gate. These ids must all be + // present so a future refactor cannot silently drop a lane. + let ids: Vec<&'static str> = steam_route_contracts().iter().map(|c| c.pipeline).collect(); + for required in ["m9", "m10", "m11", "m12", "fna_arm64", "wine_bare", "d3dmetal"] { + assert!(ids.contains(&required), "route contract table must cover {} (got {:?})", required, ids); + } + } + + #[test] + fn m12_route_contract_uses_isolated_shader_lane_and_wine_background_identity() { + // M12 is an isolated lane: it must NOT advertise offline emulation, + // must require wine, and must bind to the shared Steam launch prefix. + let m12 = steam_route_contract_for(crate::mtsp::engine::PipelineId::M12); + assert_eq!(m12.steam_identity_mode, "wine_steam_background"); + assert_eq!(m12.launch_route, "/steam/launch-game"); + assert!(m12.requires_wine); + assert!(m12.binds_to_shared_steam_prefix); + assert!(!m12.waits_for_prefix_idle); + } + + #[test] + fn d3dmetal_route_contract_advertises_offline_emulation_lane() { + let d3dmetal = steam_route_contract_for(crate::mtsp::engine::PipelineId::D3DMetal); + assert_eq!(d3dmetal.steam_identity_mode, "offline_steam_emulation"); + assert_eq!(d3dmetal.launch_route, "/steam/launch-offline"); + } + #[test] fn win32_dotnet_profile_tracks_expected_components() { let components = default_components_for(RuntimeProfile::Win32Dotnet); diff --git a/app/src-rust/src/diagnostics.rs b/app/src-rust/src/diagnostics.rs index 9329b92f..46bf9bc6 100644 --- a/app/src-rust/src/diagnostics.rs +++ b/app/src-rust/src/diagnostics.rs @@ -193,18 +193,26 @@ pub fn shader_cache_dirs(home: &Path, pipeline: crate::mtsp::engine::PipelineId, /// artifacts that are missing produce a structured failure (`ok: false`) /// rather than a silent fallback. pub fn build_launch_diagnostic(appid: u32, requested: Option) -> Value { - let home = match dirs::home_dir() { - Some(h) => h, - None => { - return json!({ - "ok": false, - "schema_version": DIAGNOSTIC_SCHEMA_VERSION, - "error": "home directory could not be resolved", - "appid": appid, - }); - }, - }; - let ms_home = crate::platform::metalsharp_home_dir_for(&home); + match dirs::home_dir() { + Some(home) => build_launch_diagnostic_for(&home, appid, requested), + None => json!({ + "ok": false, + "schema_version": DIAGNOSTIC_SCHEMA_VERSION, + "error": "home directory could not be resolved", + "appid": appid, + }), + } +} + +/// Same as [`build_launch_diagnostic`], but with an explicit home directory. +/// This form is used by tests so they never mutate the process-global +/// `METALSHARP_HOME` (which would race with other parallel tests). +pub fn build_launch_diagnostic_for( + home: &Path, + appid: u32, + requested: Option, +) -> Value { + let ms_home = crate::platform::metalsharp_home_dir_for(home); let pipeline = crate::bottles::resolve_steam_pipeline_for_request(appid, requested); let node = crate::mtsp::engine::get_pipeline(pipeline); @@ -258,7 +266,7 @@ pub fn build_launch_diagnostic(appid: u32, requested: Option = shader_cache_dirs(&home, pipeline, appid) + let cache_dirs: Vec = shader_cache_dirs(home, pipeline, appid) .into_iter() .map(|dir| { let exists = dir.exists(); @@ -477,7 +485,13 @@ mod tests { // state. The diagnostic must report the pipeline id, runtime profile, // prefix path, wine binary, and artifact_sources shape. Missing // artifacts on a clean test host are reported as a structured failure. - let report = build_launch_diagnostic(504230, None); + // We pass an explicit temp home so this never mutates METALSHARP_HOME. + let home = std::env::temp_dir().join("ms-diag-fields-home"); + let _ = fs::remove_dir_all(&home); + fs::create_dir_all(&home).unwrap(); + let report = build_launch_diagnostic_for(&home, 504230, None); + let _ = fs::remove_dir_all(&home); + assert_eq!(report.get("schema_version").and_then(|v| v.as_u64()), Some(DIAGNOSTIC_SCHEMA_VERSION as u64)); assert_eq!(report.get("metalsharp_version").and_then(|v| v.as_str()), Some(env!("CARGO_PKG_VERSION"))); assert_eq!(report.get("appid").and_then(|v| v.as_u64()), Some(504230)); @@ -492,22 +506,17 @@ mod tests { #[test] fn build_launch_diagnostic_reports_structured_failure_when_artifacts_missing() { - // Point METALSHARP_HOME at an empty dir so no runtime artifacts exist, - // then request M12 which requires d3d12.dll etc. The diagnostic must - // report ok=false with a missing_artifacts array, not a silent ok=true. - let tmp = std::env::temp_dir().join("ms-diag-empty-home"); - let _ = fs::remove_dir_all(&tmp); - fs::create_dir_all(&tmp).unwrap(); - let prev = std::env::var("METALSHARP_HOME").ok(); - std::env::set_var("METALSHARP_HOME", &tmp); - - let report = build_launch_diagnostic(999999, Some(crate::mtsp::engine::PipelineId::M12)); - - // Restore env ASAP even if assertions fail. - match prev { - Some(v) => std::env::set_var("METALSHARP_HOME", v), - None => std::env::remove_var("METALSHARP_HOME"), - } + // Use an explicit empty home so no runtime artifacts exist, then request + // M12 which requires d3d12.dll etc. The diagnostic must report ok=false + // with a missing_artifacts array, not a silent ok=true. No global env + // mutation, so this is safe under parallel test execution. + let home = std::env::temp_dir().join("ms-diag-empty-home"); + let _ = fs::remove_dir_all(&home); + fs::create_dir_all(&home).unwrap(); + + let report = build_launch_diagnostic_for(&home, 999999, Some(crate::mtsp::engine::PipelineId::M12)); + + let _ = fs::remove_dir_all(&home); // 999999 is not a known game, so it resolves through the fallback. // If it happens to resolve to M12, we get a structured failure. If it @@ -524,7 +533,6 @@ mod tests { report ); } - let _ = fs::remove_dir_all(&tmp); } #[test] diff --git a/app/src-rust/src/main.rs b/app/src-rust/src/main.rs index 8bd05c1f..cb1f648e 100644 --- a/app/src-rust/src/main.rs +++ b/app/src-rust/src/main.rs @@ -211,6 +211,8 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { Err(e) => resp(500, json!({"ok": false, "error": e.to_string()})), }, (Method::Get, "/update/migrate/progress") => resp(200, migrate::read_migrate_progress()), + // Phase 2: report what the last migration preserved, skipped, and why. + (Method::Get, "/update/migrate/report") => resp(200, migrate::latest_migration_report()), (Method::Get, "/setup/state") => resp(200, setup::state()), (Method::Post, "/setup/save") => { let body = read_body(req); @@ -938,6 +940,10 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { (Method::Get, "/sharp-library") => resp(200, sharp_library::handle_get_library()), (Method::Get, "/bottles") => resp(200, bottles::handle_list_bottles()), (Method::Get, "/bottles/profiles") => resp(200, bottles::handle_list_runtime_profiles()), + // Phase 2: declarative Steam route contract table (protected + first-class lanes). + (Method::Get, "/bottles/route-contracts") => { + resp(200, json!({ "ok": true, "contracts": bottles::steam_route_contracts() })) + }, (Method::Get, "/bottles/compatibility-matrix") => resp(200, bottles::handle_compatibility_matrix()), (Method::Get, "/bottles/redist-sources") => resp(200, bottles::handle_redist_sources()), (Method::Post, "/bottles/record-compatibility") => { diff --git a/app/src-rust/src/migrate.rs b/app/src-rust/src/migrate.rs index 9ffd1dcc..42ecedf1 100644 --- a/app/src-rust/src/migrate.rs +++ b/app/src-rust/src/migrate.rs @@ -66,6 +66,109 @@ const MIGRATION_TOTAL_STEPS: usize = 8; static MIGRATING: AtomicBool = AtomicBool::new(false); +/// Phase 2: an observational record of what a migration preserved, what it +/// skipped, and why. This does not change what is preserved or restored — it +/// only makes the existing preserve/restore behavior inspectable so a future +/// migration cannot silently drop a category. +#[derive(Debug, Clone, serde::Serialize)] +pub struct MigrationReportEntry { + pub phase: &'static str, + pub outcome: &'static str, + pub category: &'static str, + pub path: Option, + pub reason: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct MigrationReport { + pub schema_version: u64, + pub version: &'static str, + pub generated_at_unix: u64, + pub entries: Vec, +} + +impl MigrationReport { + pub fn new() -> Self { + MigrationReport { + schema_version: 1, + version: MIGRATE_VERSION, + generated_at_unix: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + entries: Vec::new(), + } + } + + pub fn record( + &mut self, + phase: &'static str, + outcome: &'static str, + category: &'static str, + path: Option, + reason: impl Into, + ) { + self.entries.push(MigrationReportEntry { phase, outcome, category, path, reason: reason.into() }); + } +} + +impl Default for MigrationReport { + fn default() -> Self { + Self::new() + } +} + +fn migration_report_path() -> PathBuf { + crate::platform::metalsharp_home_dir().join("logs").join("migration-report-latest.json") +} + +fn migration_report_path_for(ms_home: &Path) -> PathBuf { + ms_home.join("logs").join("migration-report-latest.json") +} + +fn write_migration_report(report: &MigrationReport) { + write_migration_report_in(&crate::platform::metalsharp_home_dir(), report); +} + +fn write_migration_report_in(ms_home: &Path, report: &MigrationReport) { + let final_path = migration_report_path_for(ms_home); + let Some(parent) = final_path.parent().map(|p| p.to_path_buf()) else { + return; + }; + if fs::create_dir_all(&parent).is_err() { + return; + } + let tmp_path = final_path.with_extension("json.tmp"); + if let Ok(payload) = serde_json::to_string_pretty(report) { + if fs::write(&tmp_path, payload).is_ok() { + let _ = fs::rename(&tmp_path, &final_path); + } + } +} + +/// Read the most recently persisted migration report, or an idle placeholder. +pub fn latest_migration_report() -> serde_json::Value { + latest_migration_report_in(&crate::platform::metalsharp_home_dir()) +} + +pub fn latest_migration_report_in(ms_home: &Path) -> serde_json::Value { + let path = migration_report_path_for(ms_home); + if path.exists() { + if let Ok(contents) = fs::read_to_string(&path) { + if let Ok(v) = serde_json::from_str::(&contents) { + return v; + } + } + } + json!({ + "schema_version": 1, + "status": "idle", + "version": MIGRATE_VERSION, + "entries": [], + "summary": "No migration has run yet." + }) +} + #[derive(Clone, Debug, Default)] struct PostUpdateMigrationMarker { needed: bool, @@ -91,7 +194,6 @@ fn write_migrate_progress(status: &str, step: usize, total: usize, message: &str pub fn is_migrating() -> bool { MIGRATING.load(Ordering::SeqCst) } - pub fn read_migrate_progress() -> serde_json::Value { let path = migrate_progress_path(); if path.exists() { @@ -395,7 +497,7 @@ fn run_migration() { "Preserving user preferences, Steam API key, and bottle settings...", None, ); - let preserved = preserve_user_data(&ms_dir); + let (preserved, mut report) = preserve_user_data(&ms_dir); step += 1; write_migrate_progress("running", step, total_steps, "Cleaning stale runtime state...", None); @@ -436,7 +538,8 @@ fn run_migration() { step += 1; write_migrate_progress("running", step, total_steps, "Restoring preserved user data...", None); - restore_user_data(&ms_dir, &preserved); + restore_user_data(&ms_dir, &preserved, &mut report); + write_migration_report(&report); if !install_ok { write_migrate_progress( @@ -704,7 +807,8 @@ struct PreservedData { prefix_gptk_dosdevice_links: Vec<(String, PathBuf)>, } -fn preserve_user_data(ms_dir: &PathBuf) -> PreservedData { +fn preserve_user_data(ms_dir: &PathBuf) -> (PreservedData, MigrationReport) { + let mut report = MigrationReport::new(); let tmp = std::env::temp_dir().join(format!( "metalsharp-migration-preserve-{}-{}-{:x}", std::process::id(), @@ -714,9 +818,34 @@ fn preserve_user_data(ms_dir: &PathBuf) -> PreservedData { let _ = fs::remove_dir_all(&tmp); let _ = fs::create_dir_all(&tmp); - let setup_json = ms_dir.join("setup.json").exists().then(|| fs::read(ms_dir.join("setup.json")).ok()).flatten(); + let setup_json_path = ms_dir.join("setup.json"); + let setup_json = if setup_json_path.exists() { + let loaded = fs::read(&setup_json_path).ok(); + report.record( + "preserve", + if loaded.is_some() { "preserved" } else { "skipped" }, + "setup.json", + Some(setup_json_path.to_string_lossy().to_string()), + if loaded.is_some() { "setup.json present" } else { "setup.json present but unreadable" }, + ); + loaded + } else { + report.record("preserve", "skipped", "setup.json", None, "setup.json absent"); + None + }; let steam_config_json = read_preserved_steam_config(ms_dir); + report.record( + "preserve", + if steam_config_json.is_some() { "preserved" } else { "skipped" }, + "steam_config", + None, + if steam_config_json.is_some() { + "steam config with API key present" + } else { + "no steam config with API key found" + }, + ); write_migrate_progress("running", 2, MIGRATION_TOTAL_STEPS, "Preserving user data (cache metadata)...", None); let cache_tmp = tmp.join("cache"); @@ -724,6 +853,15 @@ fn preserve_user_data(ms_dir: &PathBuf) -> PreservedData { if cache.exists() { let _ = fs::create_dir_all(&cache_tmp); preserve_selective(&cache, &cache_tmp, &["downloads", "updates", "updater-tools", "tmp"]); + report.record( + "preserve", + "preserved", + "cache", + Some(cache.to_string_lossy().to_string()), + "cache metadata preserved (downloads/updates/tmp payloads excluded)", + ); + } else { + report.record("preserve", "skipped", "cache", None, "cache directory absent"); } write_migrate_progress( @@ -738,6 +876,15 @@ fn preserve_user_data(ms_dir: &PathBuf) -> PreservedData { if prefix_steam.exists() { let _ = fs::create_dir_all(&prefix_steam_tmp); preserve_settings_only(&prefix_steam, &prefix_steam_tmp); + report.record( + "preserve", + "preserved", + "prefix-steam", + Some(prefix_steam.to_string_lossy().to_string()), + "Steam prefix settings files preserved (payloads excluded)", + ); + } else { + report.record("preserve", "skipped", "prefix-steam", None, "prefix-steam directory absent"); } let prefix_gptk_tmp = tmp.join("prefix-gptk"); @@ -745,6 +892,15 @@ fn preserve_user_data(ms_dir: &PathBuf) -> PreservedData { if prefix_gptk.exists() { let _ = fs::create_dir_all(&prefix_gptk_tmp); preserve_settings_only(&prefix_gptk, &prefix_gptk_tmp); + report.record( + "preserve", + "preserved", + "prefix-gptk", + Some(prefix_gptk.to_string_lossy().to_string()), + "GPTK prefix settings files preserved", + ); + } else { + report.record("preserve", "skipped", "prefix-gptk", None, "prefix-gptk directory absent"); } write_migrate_progress("running", 2, MIGRATION_TOTAL_STEPS, "Preserving user settings (game metadata)...", None); @@ -753,6 +909,15 @@ fn preserve_user_data(ms_dir: &PathBuf) -> PreservedData { if games.exists() { let _ = fs::create_dir_all(&games_tmp); preserve_settings_only(&games, &games_tmp); + report.record( + "preserve", + "preserved", + "games", + Some(games.to_string_lossy().to_string()), + "per-game local metadata preserved", + ); + } else { + report.record("preserve", "skipped", "games", None, "games directory absent"); } write_migrate_progress("running", 2, MIGRATION_TOTAL_STEPS, "Preserving user settings (library metadata)...", None); @@ -761,6 +926,15 @@ fn preserve_user_data(ms_dir: &PathBuf) -> PreservedData { if sharp_library.exists() { let _ = fs::create_dir_all(&sharp_library_tmp); preserve_settings_only(&sharp_library, &sharp_library_tmp); + report.record( + "preserve", + "preserved", + "sharp-library", + Some(sharp_library.to_string_lossy().to_string()), + "Sharp Library metadata preserved", + ); + } else { + report.record("preserve", "skipped", "sharp-library", None, "sharp-library directory absent"); } write_migrate_progress("running", 2, MIGRATION_TOTAL_STEPS, "Preserving user settings (bottle metadata)...", None); @@ -769,6 +943,15 @@ fn preserve_user_data(ms_dir: &PathBuf) -> PreservedData { if bottles.exists() { let _ = fs::create_dir_all(&bottles_tmp); preserve_settings_only(&bottles, &bottles_tmp); + report.record( + "preserve", + "preserved", + "bottles", + Some(bottles.to_string_lossy().to_string()), + "bottle manifests preserved (appids and routes)", + ); + } else { + report.record("preserve", "skipped", "bottles", None, "bottles directory absent"); } write_migrate_progress( @@ -783,28 +966,47 @@ fn preserve_user_data(ms_dir: &PathBuf) -> PreservedData { if compatdata.exists() { let _ = fs::create_dir_all(&compatdata_tmp); preserve_settings_only(&compatdata, &compatdata_tmp); + report.record( + "preserve", + "preserved", + "compatdata", + Some(compatdata.to_string_lossy().to_string()), + "compatdata manifests preserved (appid-scoped routes)", + ); + } else { + report.record("preserve", "skipped", "compatdata", None, "compatdata directory absent"); } let prefix_steam_dosdevice_links = collect_prefix_dosdevice_links(&ms_dir.join("prefix-steam")); + report.record( + "preserve", + if prefix_steam_dosdevice_links.is_empty() { "skipped" } else { "preserved" }, + "dosdevices", + None, + format!("{} prefix-steam dosdevice links snapshotted", prefix_steam_dosdevice_links.len()), + ); let prefix_gptk_dosdevice_links = if ms_dir.join("prefix-gptk").exists() { collect_prefix_dosdevice_links(&ms_dir.join("prefix-gptk")) } else { Vec::new() }; - PreservedData { - setup_json, - steam_config_json, - prefix_steam_tmp, - prefix_gptk_tmp, - cache_tmp, - games_tmp, - sharp_library_tmp, - bottles_tmp, - compatdata_tmp, - prefix_steam_dosdevice_links, - prefix_gptk_dosdevice_links, - } + ( + PreservedData { + setup_json, + steam_config_json, + prefix_steam_tmp, + prefix_gptk_tmp, + cache_tmp, + games_tmp, + sharp_library_tmp, + bottles_tmp, + compatdata_tmp, + prefix_steam_dosdevice_links, + prefix_gptk_dosdevice_links, + }, + report, + ) } fn update_existing_wine_prefixes(ms_dir: &Path, step: usize) -> Result { @@ -1721,7 +1923,7 @@ fn remove_old_runtime(ms_dir: &PathBuf) { let _ = fs::create_dir_all(ms_dir.join("shader-cache")); } -fn restore_user_data(ms_dir: &PathBuf, preserved: &PreservedData) { +fn restore_user_data(ms_dir: &PathBuf, preserved: &PreservedData, report: &mut MigrationReport) { let steam_config_json = preserved.steam_config_json.as_ref().map(|data| normalize_steam_config_json(data)); let steam_api_key_restored = steam_config_json.as_deref().is_some_and(steam_config_has_api_key); @@ -1742,6 +1944,15 @@ fn restore_user_data(ms_dir: &PathBuf, preserved: &PreservedData) { let _ = std::os::unix::fs::symlink("../drive_c", &c_link); } remove_root_dosdevice_mapping(&dst); + report.record( + "restore", + "restored", + "prefix-steam", + Some(dst.to_string_lossy().to_string()), + "Steam prefix settings restored", + ); + } else { + report.record("restore", "skipped", "prefix-steam", None, "no preserved prefix-steam payload"); } restore_prefix_dosdevice_links(&ms_dir.join("prefix-steam"), &preserved.prefix_steam_dosdevice_links); @@ -1757,6 +1968,15 @@ fn restore_user_data(ms_dir: &PathBuf, preserved: &PreservedData) { if !dosdevices.exists() { let _ = fs::create_dir_all(&dosdevices); } + report.record( + "restore", + "restored", + "prefix-gptk", + Some(dst.to_string_lossy().to_string()), + "GPTK prefix settings restored", + ); + } else { + report.record("restore", "skipped", "prefix-gptk", None, "no preserved prefix-gptk payload"); } restore_prefix_dosdevice_links(&ms_dir.join("prefix-gptk"), &preserved.prefix_gptk_dosdevice_links); @@ -1766,6 +1986,15 @@ fn restore_user_data(ms_dir: &PathBuf, preserved: &PreservedData) { let _ = fs::create_dir_all(&dst); } preserve_settings_only(&preserved.games_tmp, &dst); + report.record( + "restore", + "restored", + "games", + Some(dst.to_string_lossy().to_string()), + "per-game metadata restored", + ); + } else { + report.record("restore", "skipped", "games", None, "no preserved games payload"); } if preserved.sharp_library_tmp.exists() { @@ -1774,6 +2003,15 @@ fn restore_user_data(ms_dir: &PathBuf, preserved: &PreservedData) { let _ = fs::create_dir_all(&dst); } preserve_settings_only(&preserved.sharp_library_tmp, &dst); + report.record( + "restore", + "restored", + "sharp-library", + Some(dst.to_string_lossy().to_string()), + "Sharp Library metadata restored", + ); + } else { + report.record("restore", "skipped", "sharp-library", None, "no preserved sharp-library payload"); } if preserved.bottles_tmp.exists() { @@ -1782,6 +2020,15 @@ fn restore_user_data(ms_dir: &PathBuf, preserved: &PreservedData) { let _ = fs::create_dir_all(&dst); } preserve_settings_only(&preserved.bottles_tmp, &dst); + report.record( + "restore", + "restored", + "bottles", + Some(dst.to_string_lossy().to_string()), + "bottle manifests restored (appids and routes)", + ); + } else { + report.record("restore", "skipped", "bottles", None, "no preserved bottles payload"); } if preserved.compatdata_tmp.exists() { @@ -1790,14 +2037,29 @@ fn restore_user_data(ms_dir: &PathBuf, preserved: &PreservedData) { let _ = fs::create_dir_all(&dst); } preserve_settings_only(&preserved.compatdata_tmp, &dst); + report.record( + "restore", + "restored", + "compatdata", + Some(dst.to_string_lossy().to_string()), + "compatdata manifests restored (appid-scoped routes)", + ); + } else { + report.record("restore", "skipped", "compatdata", None, "no preserved compatdata payload"); } if let Some(ref data) = preserved.setup_json { restore_setup_json(ms_dir, data, steam_api_key_restored); + report.record("restore", "restored", "setup.json", None, "setup.json restored"); + } else { + report.record("restore", "skipped", "setup.json", None, "no preserved setup.json"); } if let Some(ref data) = steam_config_json { restore_steam_config(ms_dir, data); + report.record("restore", "restored", "steam_config", None, "steam config restored"); + } else { + report.record("restore", "skipped", "steam_config", None, "no preserved steam config"); } } @@ -1949,6 +2211,75 @@ fn unix_days_to_ymd(days_since_epoch: u64) -> (i64, u32, u32) { mod tests { use super::*; + #[test] + fn migration_report_records_preserved_and_skipped_categories() { + // Phase 2: migration must report what it preserved and what it skipped + // (and why), without changing what is preserved. We set up a home with + // bottles + compatdata present but no prefix-steam / games, then verify + // the report carries preserved entries for the present categories and + // skipped entries (with reasons) for the absent ones. + let home = test_dir("migration-report"); + let ms_dir = crate::platform::metalsharp_home_dir_for(&home); + fs::create_dir_all(ms_dir.join("bottles").join("steam_620")).expect("create bottles"); + fs::write(ms_dir.join("bottles").join("steam_620").join("bottle.json"), br#"{"id":"steam_620"}"#) + .expect("write bottle manifest"); + fs::create_dir_all(ms_dir.join("compatdata").join("620")).expect("create compatdata"); + fs::write(ms_dir.join("compatdata").join("620").join("metalsharp-compatdata.json"), br#"{"appid":620}"#) + .expect("write compatdata manifest"); + // prefix-steam, games, sharp-library, prefix-gptk, cache are absent on purpose. + + let (preserved, report) = preserve_user_data(&ms_dir); + + let preserved_categories: Vec<&str> = report + .entries + .iter() + .filter(|e| e.phase == "preserve" && e.outcome == "preserved") + .map(|e| e.category) + .collect(); + let skipped_categories: Vec<&str> = report + .entries + .iter() + .filter(|e| e.phase == "preserve" && e.outcome == "skipped") + .map(|e| e.category) + .collect(); + + assert!(preserved_categories.contains(&"bottles"), "bottles must be reported preserved: {:?}", report.entries); + assert!(preserved_categories.contains(&"compatdata"), "compatdata must be reported preserved"); + assert!( + skipped_categories.contains(&"prefix-steam"), + "absent prefix-steam must be reported skipped with a reason" + ); + assert!(skipped_categories.contains(&"games"), "absent games must be reported skipped"); + // Every skipped entry must carry a non-empty reason. + for entry in report.entries.iter().filter(|e| e.outcome == "skipped") { + assert!(!entry.reason.is_empty(), "skipped entry must explain why: {:?}", entry); + } + + // Restore must record restored entries for the preserved categories. + let mut restore_report = MigrationReport::new(); + // Remove live bottles to prove restore actually restores them. + let _ = fs::remove_dir_all(ms_dir.join("bottles")); + restore_user_data(&ms_dir, &preserved, &mut restore_report); + let restored_categories: Vec<&str> = restore_report + .entries + .iter() + .filter(|e| e.phase == "restore" && e.outcome == "restored") + .map(|e| e.category) + .collect(); + assert!(restored_categories.contains(&"bottles"), "bottles must be reported restored"); + + // The persisted report must round-trip through latest_migration_report_in() + // without mutating the process-global METALSHARP_HOME (which would race + // with other parallel tests). + write_migration_report_in(&ms_dir, &report); + let read_back = latest_migration_report_in(&ms_dir); + assert_eq!(read_back.get("schema_version").and_then(|v| v.as_u64()), Some(1)); + let entries = read_back.get("entries").and_then(|v| v.as_array()).expect("entries array"); + assert!(!entries.is_empty(), "persisted report must contain entries"); + + let _ = fs::remove_dir_all(home); + } + #[test] fn completed_setups_repair_missing_runtime_without_steam_prefix() { let home = test_dir("missing-runtime"); @@ -2074,10 +2405,10 @@ mod tests { fs::create_dir_all(bottle_manifest.parent().unwrap()).expect("create bottle dir"); fs::write(&bottle_manifest, br#"{"id":"steam_620"}"#).expect("write bottle manifest"); - let preserved = preserve_user_data(&ms_dir); + let (preserved, mut report) = preserve_user_data(&ms_dir); fs::remove_dir_all(ms_dir.join("bottles")).expect("remove live bottles"); remove_old_runtime(&ms_dir); - restore_user_data(&ms_dir, &preserved); + restore_user_data(&ms_dir, &preserved, &mut report); assert_eq!( fs::read_to_string(ms_dir.join("bottles").join("steam_620").join("bottle.json")).unwrap(), @@ -2094,10 +2425,10 @@ mod tests { fs::create_dir_all(compat_manifest.parent().unwrap()).expect("create compatdata dir"); fs::write(&compat_manifest, br#"{"appid":620}"#).expect("write compatdata manifest"); - let preserved = preserve_user_data(&ms_dir); + let (preserved, mut report) = preserve_user_data(&ms_dir); fs::remove_dir_all(ms_dir.join("compatdata")).expect("remove live compatdata"); remove_old_runtime(&ms_dir); - restore_user_data(&ms_dir, &preserved); + restore_user_data(&ms_dir, &preserved, &mut report); assert_eq!( fs::read_to_string(ms_dir.join("compatdata").join("620").join("metalsharp-compatdata.json")).unwrap(), @@ -2119,7 +2450,7 @@ mod tests { fs::write(steamapps.join("appmanifest_620.acf"), b"manifest").expect("write app manifest"); fs::write(ms_dir.join("prefix-steam").join("user.reg"), b"settings").expect("write prefix settings"); - let preserved = preserve_user_data(&ms_dir); + let (preserved, mut report) = preserve_user_data(&ms_dir); assert!(preserved.prefix_steam_tmp.join("user.reg").exists()); assert!(!preserved.prefix_steam_tmp.join("drive_c").exists()); assert!(!find_descendant_named(&preserved.prefix_steam_tmp, "steamapps")); @@ -2133,7 +2464,7 @@ mod tests { fs::remove_dir_all(ms_dir.join("prefix-steam")).expect("remove live prefix"); remove_old_runtime(&ms_dir); - restore_user_data(&ms_dir, &preserved); + restore_user_data(&ms_dir, &preserved, &mut report); assert!(ms_dir.join("prefix-steam").join("user.reg").exists()); assert!(!ms_dir.join("prefix-steam").join("drive_c").exists()); @@ -2153,9 +2484,9 @@ mod tests { assert_eq!(count_settings_files(&prefix), 1); - let preserved = preserve_user_data(&ms_dir); + let (preserved, mut report) = preserve_user_data(&ms_dir); remove_old_runtime(&ms_dir); - restore_user_data(&ms_dir, &preserved); + restore_user_data(&ms_dir, &preserved, &mut report); assert!(ms_dir.join("prefix-steam").join("user.reg").exists()); assert!(!ms_dir.join("prefix-steam").join("dosdevices").join("z:").exists()); @@ -2248,7 +2579,7 @@ mod tests { ) .expect("write bottle game payload"); - let preserved = preserve_user_data(&ms_dir); + let (preserved, mut report) = preserve_user_data(&ms_dir); assert!(preserved.bottles_tmp.join("steam_620").join("bottle.json").exists()); assert!(!preserved.bottles_tmp.join("steam_620").join("prefix").exists()); assert!(!find_descendant_named(&preserved.bottles_tmp, "portal2.exe")); @@ -2268,7 +2599,7 @@ mod tests { fs::remove_dir_all(ms_dir.join("bottles")).expect("remove live bottles"); remove_old_runtime(&ms_dir); - restore_user_data(&ms_dir, &preserved); + restore_user_data(&ms_dir, &preserved, &mut report); assert_eq!( fs::read_to_string(ms_dir.join("bottles").join("steam_620").join("bottle.json")).unwrap(), @@ -2290,9 +2621,9 @@ mod tests { fs::write(ms_dir.join("cache").join("covers").join("620.png"), b"cover").expect("write cover"); fs::write(ms_dir.join("cache").join("updates").join("MetalSharp.dmg"), b"dmg").expect("write cached dmg"); - let preserved = preserve_user_data(&ms_dir); + let (preserved, mut report) = preserve_user_data(&ms_dir); remove_old_runtime(&ms_dir); - restore_user_data(&ms_dir, &preserved); + restore_user_data(&ms_dir, &preserved, &mut report); assert_eq!( fs::read_to_string(ms_dir.join("cache").join("steam_config.json")).unwrap(), @@ -2316,10 +2647,10 @@ mod tests { ) .expect("write Steam API key"); - let preserved = preserve_user_data(&ms_dir); + let (preserved, mut report) = preserve_user_data(&ms_dir); remove_old_runtime(&ms_dir); let _ = fs::remove_file(ms_dir.join("setup.json")); - restore_user_data(&ms_dir, &preserved); + restore_user_data(&ms_dir, &preserved, &mut report); let setup = fs::read_to_string(ms_dir.join("setup.json")).expect("read restored setup"); let setup_json: serde_json::Value = serde_json::from_str(&setup).expect("parse restored setup"); @@ -2346,9 +2677,9 @@ mod tests { ) .expect("write legacy Steam API key"); - let preserved = preserve_user_data(&ms_dir); + let (preserved, mut report) = preserve_user_data(&ms_dir); remove_old_runtime(&ms_dir); - restore_user_data(&ms_dir, &preserved); + restore_user_data(&ms_dir, &preserved, &mut report); let setup = fs::read_to_string(ms_dir.join("setup.json")).expect("read restored setup"); let setup_json: serde_json::Value = serde_json::from_str(&setup).expect("parse restored setup"); @@ -2374,9 +2705,9 @@ mod tests { ) .expect("write durable Steam config backup"); - let preserved = preserve_user_data(&ms_dir); + let (preserved, mut report) = preserve_user_data(&ms_dir); remove_old_runtime(&ms_dir); - restore_user_data(&ms_dir, &preserved); + restore_user_data(&ms_dir, &preserved, &mut report); let steam_config = fs::read_to_string(ms_dir.join("cache").join("steam_config.json")).expect("read restored Steam config"); @@ -2620,7 +2951,7 @@ mod tests { std::os::unix::fs::symlink(&home, gptk_dosdevices.join("l:")).expect("create gptk l drive"); fs::write(gptk_prefix.join("user.reg"), b"gptk-settings").expect("write gptk settings"); - let preserved = preserve_user_data(&ms_dir); + let (preserved, mut report) = preserve_user_data(&ms_dir); assert_eq!(preserved.prefix_steam_dosdevice_links, vec![(String::from("s:"), external_steam.clone())]); assert_eq!(preserved.prefix_gptk_dosdevice_links, vec![(String::from("l:"), home.clone())]); @@ -2628,7 +2959,7 @@ mod tests { fs::remove_dir_all(ms_dir.join("prefix-steam")).expect("remove prefix-steam"); fs::remove_dir_all(ms_dir.join("prefix-gptk")).expect("remove prefix-gptk"); remove_old_runtime(&ms_dir); - restore_user_data(&ms_dir, &preserved); + restore_user_data(&ms_dir, &preserved, &mut report); assert!(ms_dir.join("prefix-steam").join("user.reg").exists()); assert_eq!( @@ -2665,14 +2996,14 @@ mod tests { .expect("create dmg mount 2"); fs::write(prefix.join("user.reg"), b"settings").expect("write settings"); - let preserved = preserve_user_data(&ms_dir); + let (preserved, mut report) = preserve_user_data(&ms_dir); assert_eq!(preserved.prefix_steam_dosdevice_links.len(), 1); assert_eq!(preserved.prefix_steam_dosdevice_links[0], (String::from("d:"), external_steam.clone())); fs::remove_dir_all(ms_dir.join("prefix-steam")).expect("remove prefix"); remove_old_runtime(&ms_dir); - restore_user_data(&ms_dir, &preserved); + restore_user_data(&ms_dir, &preserved, &mut report); assert_eq!( fs::read_link(ms_dir.join("prefix-steam").join("dosdevices").join("d:")).expect("read d drive"), @@ -2697,7 +3028,7 @@ mod tests { assert!(!ms_dir.join("prefix-gptk").exists()); - let preserved = preserve_user_data(&ms_dir); + let (preserved, _report) = preserve_user_data(&ms_dir); assert!(preserved.prefix_steam_dosdevice_links.is_empty()); assert!(preserved.prefix_gptk_dosdevice_links.is_empty()); diff --git a/app/src-rust/src/mtsp/launcher.rs b/app/src-rust/src/mtsp/launcher.rs index a0a9f82c..6606d4aa 100644 --- a/app/src-rust/src/mtsp/launcher.rs +++ b/app/src-rust/src/mtsp/launcher.rs @@ -4025,6 +4025,38 @@ mod tests { use super::*; use crate::mtsp::recipe; + #[test] + fn deploy_steam_appid_writes_visible_appid_file_for_each_known_subdir() { + // Phase 2: routes that depend on Steam-visible identity must stage + // steam_appid.txt into every standard binary subdir the game may + // launch from, plus update an active Goldberg force_steam_appid.txt. + let game_dir = test_dir("deploy-steam-appid"); + let sub_win64 = game_dir.join("Binaries").join("Win64"); + let sub_bin = game_dir.join("bin"); + std::fs::create_dir_all(&sub_win64).unwrap(); + std::fs::create_dir_all(&sub_bin).unwrap(); + // "Game" subdir absent on purpose: it must be skipped, not panic. + + deploy_steam_appid(&game_dir, 504230); + + assert_eq!(std::fs::read_to_string(sub_win64.join("steam_appid.txt")).unwrap(), "504230"); + assert_eq!(std::fs::read_to_string(sub_bin.join("steam_appid.txt")).unwrap(), "504230"); + assert_eq!(std::fs::read_to_string(game_dir.join("steam_appid.txt")).unwrap(), "504230"); + assert!(!game_dir.join("Game").join("steam_appid.txt").exists(), "absent subdirs must be skipped"); + + // An active Goldberg emulator setting must be kept in sync. + let goldberg_dir = game_dir.join("steam_settings"); + std::fs::create_dir_all(&goldberg_dir).unwrap(); + std::fs::write(goldberg_dir.join("force_steam_appid.txt"), "0").unwrap(); + deploy_steam_appid(&game_dir, 504230); + assert_eq!( + std::fs::read_to_string(goldberg_dir.join("force_steam_appid.txt")).unwrap(), + "504230", + "goldberg force_steam_appid.txt must track the real appid" + ); + let _ = std::fs::remove_dir_all(&game_dir); + } + #[test] fn m9_cache_env_uses_dxmt_family_not_dxvk() { let node = get_pipeline(PipelineId::M9); diff --git a/docs/optimization-roadmap/PR-SUMMARY.md b/docs/optimization-roadmap/PR-SUMMARY.md index 24445624..796d5fc8 100644 --- a/docs/optimization-roadmap/PR-SUMMARY.md +++ b/docs/optimization-roadmap/PR-SUMMARY.md @@ -62,3 +62,59 @@ cargo test # 513 passed, 0 failed **Boundary check:** no M9/M10/M11 artifact path or launch behavior changed. The only additions are diagnostic outputs, timing instrumentation, and content hashes in the existing injection manifest. + +## Phase 2: Runtime and Bottle Contract Hardening ✅ + +**Purpose:** make saved bottle and Steam route state hard to regress. + +**What landed:** +- Declarative Steam route contract (`bottles::SteamRouteContract`) codifying, + per pipeline: pipeline id, runtime profile, steam identity mode + (`wine_steam_background` vs `offline_steam_emulation`), launch route + (`/steam/launch-game` vs `/steam/launch-offline`), `requires_wine`, shared + Steam prefix binding, prefix-idle wait policy, compat tool name, and the + appid-scoped bottle id template. The contract is derived from the same + primitives the runtime uses (`steam_pipeline_defaults_offline`, + `runtime_profile_for_pipeline`, `pipeline_preference_id`, pipeline node's + `requires_wine`) so it cannot drift from launch behavior. +- `steam_route_contract_for(pipeline)` and `steam_route_contracts()` table + covering M9, M10, M11, M12, M13, FnaArm64, WineBare, D3DMetal. +- Passive-refresh preservation tests for M11 and M12 (the M9 case already + existed): a saved M11 route survives a passive refresh that would resolve + to M12, and a saved M12 route survives passive fallback to M11/M9. +- A data-driven route-contract test that builds a bottle per contract lane + and asserts `steam_compatdata_record` matches the contract (appid scoping, + bottle id, launch pipeline, identity mode, compat tool, launch route). +- `deploy_steam_appid` staging test proving `steam_appid.txt` lands in every + standard binary subdir and that an active Goldberg `force_steam_appid.txt` + is kept in sync. +- Migration preserve/skip report: `migrate::MigrationReport` records every + preserved and skipped category with a reason during `preserve_user_data` + and `restore_user_data`, persisted atomically to + `~/.metalsharp/logs/migration-report-latest.json`. This does not change + what is preserved — it only makes the behavior inspectable. +- New HTTP routes: + - `GET /bottles/route-contracts` — the declarative contract table + - `GET /update/migrate/report` — the latest migration preserve/skip report +- No test mutates the process-global `METALSHARP_HOME`; all new diagnostics + and migration tests use explicit-home (`_for`) variants so they are safe + under parallel test execution. + +**New tests (8):** M11 passive preservation, M12 passive preservation, +route-contract table vs compatdata records, route-contract lane coverage, +M12 isolated-lane contract, D3DMetal offline contract, `deploy_steam_appid` +staging, migration preserve/skip report round-trip. + +**Proof:** +``` +cargo fmt --all -- --check # clean +cargo clippy --all-targets -- -D warnings # clean +cargo test bottles::tests # 67 passed +cargo test mtsp # 111 passed +cargo test # 521 passed, 0 failed (3 consecutive runs) +``` + +**Boundary check:** M9/M10/M11 launch behavior and artifact paths unchanged. +The route contract is derived from existing primitives, not a new source of +truth. Migration preserve/restore logic is unchanged; only an observational +report was added. From efc7bebacf6406abd078d88ec1fd753429d4fe0f Mon Sep 17 00:00:00 2001 From: Alex Mondello Date: Sun, 14 Jun 2026 01:19:26 -0600 Subject: [PATCH 03/14] feat(mtsp): Phase 3 M12 artifact and launch verification (dry-run) Add m12_verify_dry_run / pipeline_dry_run_for: a read-only verifier that runs through the same environment builder (steam_pipeline_env_pairs) as launch_dxmt_metal. It reports, without launching Steam or the game: - the resolved lib/dxmt-m12/x86_64-windows dir + each deploy DLL with presence, sha256, and size - the lib/dxmt-m12/x86_64-unix sidecars (winemetal.so, libc++.1.dylib, libc++abi.1.dylib, libunwind.1.dylib) for the M12/M13 lane - the exact env pairs the launch path sets, with an env_keys_present map for WINEDLLOVERRIDES, DXMT_SHADER_CACHE_PATH, DYLD_FALLBACK_LIBRARY_PATH, SteamAppId, DXMT_WINEMETAL_UNIXLIB - missing required artifacts as a structured ok:false + missing[] array New routes: GET /diagnostics/m12/dry-run, GET /diagnostics/pipeline/dry-run. Contract tests: M12 deploys d3d12 from lib/dxmt-m12; M11 excludes d3d12 and never touches lib/dxmt-m12; M12 dry-run includes d3d12 and M11 does not; M12 dry-run verifies unix sidecars and flags missing artifacts; M12 env sets winemetal overrides and the isolated m12 shader cache. docs/architecture/m12-pipeline-map.md now documents the verifier and marks stability gap #1 (first-class M12 runtime verification) as addressed. Tests: 526 passed, 0 failed. clippy + fmt clean. M9/M10/M11 deploy lists and artifact paths unchanged. Verifier is read-only. --- app/src-rust/src/main.rs | 29 +++ app/src-rust/src/mtsp/launcher.rs | 272 ++++++++++++++++++++++++ docs/architecture/m12-pipeline-map.md | 35 ++- docs/optimization-roadmap/PR-SUMMARY.md | 46 ++++ 4 files changed, 381 insertions(+), 1 deletion(-) diff --git a/app/src-rust/src/main.rs b/app/src-rust/src/main.rs index cb1f648e..8c7c1dde 100644 --- a/app/src-rust/src/main.rs +++ b/app/src-rust/src/main.rs @@ -1050,6 +1050,35 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { ), } }, + // Phase 3: M12 artifact + launch verification (dry-run). Reports the + // exact env pairs and artifact hashes M12 would load, without + // launching Steam or the game. Uses the same env builder as launch. + (Method::Get, "/diagnostics/m12/dry-run") => { + let url_str = req.url().to_string(); + let appid: u32 = url_str + .split("appid=") + .nth(1) + .and_then(|v| v.split('&').next()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + resp(200, mtsp::launcher::m12_verify_dry_run(appid)) + }, + (Method::Get, "/diagnostics/pipeline/dry-run") => { + let url_str = req.url().to_string(); + let appid: u32 = url_str + .split("appid=") + .nth(1) + .and_then(|v| v.split('&').next()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let requested_pipeline = url_str + .split("pipeline=") + .nth(1) + .and_then(|v| v.split('&').next()) + .and_then(crate::mtsp::engine::PipelineId::from_str_flexible); + let home = dirs::home_dir().unwrap_or_default(); + resp(200, mtsp::launcher::pipeline_dry_run_for(&home, appid, requested_pipeline)) + }, (Method::Post, "/steam/compatdata") => { let body = read_body(req); resp(200, bottles::handle_steam_compatdata(&body)) diff --git a/app/src-rust/src/mtsp/launcher.rs b/app/src-rust/src/mtsp/launcher.rs index 6606d4aa..3601993c 100644 --- a/app/src-rust/src/mtsp/launcher.rs +++ b/app/src-rust/src/mtsp/launcher.rs @@ -544,6 +544,129 @@ pub fn prepare_steam_pipeline_env( Ok((env, recipe)) } +/// Phase 3: M12 artifact and launch verification (dry-run). +/// +/// Proves the M12 route would load the intended DXMT/winemetal artifacts +/// WITHOUT launching Steam or the game. This runs through the same +/// environment builder (`steam_pipeline_env_pairs`) as `launch_dxmt_metal`, +/// so the env pairs and artifact sources reported here are exactly what a +/// real M12 launch would use. Nothing is deployed or spawned. +pub fn m12_verify_dry_run(appid: u32) -> serde_json::Value { + match dirs::home_dir() { + Some(home) => pipeline_dry_run_for(&home, appid, Some(PipelineId::M12)), + None => serde_json::json!({"ok": false, "appid": appid, "error": "home directory could not be resolved"}), + } +} + +/// Read-only pipeline dry-run with an explicit home (used by tests so they +/// never mutate the process-global METALSHARP_HOME). +/// +/// Artifact sources are derived from the pipeline node's `deploy_dlls` +/// (resolved against the runtime wine root) so verification reflects the +/// RUNTIME readiness, independent of whether a specific game is installed. +/// The env pairs come from the same `steam_pipeline_env_pairs` builder the +/// launch path uses. +pub fn pipeline_dry_run_for(home: &Path, appid: u32, requested: Option) -> serde_json::Value { + let home = home.to_path_buf(); + let pipeline = super::rules::resolve_requested_pipeline(appid, requested); + let node = get_pipeline(pipeline); + let ms_root = crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine"); + + // Build the SAME env pairs the launch path uses (read-only). + let env = steam_pipeline_env_pairs(&home, node, appid); + let env_keys: std::collections::HashSet<&str> = env.iter().map(|(k, _)| k.as_str()).collect(); + let env_pairs_json: Vec = + env.iter().map(|(k, v)| serde_json::json!({ "key": k, "value": v })).collect(); + + // Artifact sources derived from the pipeline node's deploy list, resolved + // against the runtime wine root. Optional stubs (nvapi/nvngx/atidxx) are + // tolerated as missing. + let mut deploy_dlls: Vec = Vec::new(); + let mut missing: Vec = Vec::new(); + let mut windows_dll_dir: Option = None; + for deploy in &node.deploy_dlls { + let source_path = ms_root.join(deploy.source_subpath).join(deploy.filename); + if windows_dll_dir.is_none() { + windows_dll_dir = source_path.parent().map(|p| p.to_path_buf()); + } + let present = source_path.exists(); + let optional_stub = deploy.filename.starts_with("nvapi") + || deploy.filename.starts_with("nvngx") + || deploy.filename.starts_with("atidxx"); + let sha = if present { crate::diagnostics::file_sha256(&source_path) } else { None }; + let size = if present { std::fs::metadata(&source_path).ok().map(|m| m.len()) } else { None }; + deploy_dlls.push(serde_json::json!({ + "filename": deploy.filename, + "source_subpath": deploy.source_subpath, + "source_path": source_path.to_string_lossy(), + "present": present, + "optional": optional_stub, + "sha256": sha, + "size_bytes": size, + })); + if !present && !optional_stub { + missing.push(serde_json::json!({ + "filename": deploy.filename, + "source_subpath": deploy.source_subpath, + "source_path": source_path.to_string_lossy(), + })); + } + } + + // For the isolated M12/M13 lane, also verify the x86_64-unix sidecars that + // winemetal requires at runtime. + let mut unix_sidecars: Vec = Vec::new(); + let unix_lib_dir = if matches!(pipeline, PipelineId::M12 | PipelineId::M13) { + let dir = ms_root.join("lib").join("dxmt-m12").join("x86_64-unix"); + for sidecar in ["winemetal.so", "libc++.1.dylib", "libc++abi.1.dylib", "libunwind.1.dylib"] { + let path = dir.join(sidecar); + let present = path.exists(); + let sha = if present { crate::diagnostics::file_sha256(&path) } else { None }; + unix_sidecars.push(serde_json::json!({ + "filename": sidecar, + "path": path.to_string_lossy(), + "present": present, + "sha256": sha, + })); + if !present { + missing.push(serde_json::json!({ + "filename": sidecar, + "source_path": path.to_string_lossy(), + "category": "unix_sidecar", + })); + } + } + Some(dir) + } else { + None + }; + + serde_json::json!({ + "ok": missing.is_empty(), + "schema_version": 1, + "dry_run": true, + "appid": appid, + "pipeline": pipeline, + "pipeline_name": node.name, + "runtime_root": ms_root.to_string_lossy(), + "windows_dll_dir": windows_dll_dir.as_ref().map(|d| d.to_string_lossy()).unwrap_or_default(), + "windows_dll_dir_exists": windows_dll_dir.as_ref().map(|d| d.exists()).unwrap_or(false), + "unix_lib_dir": unix_lib_dir.as_ref().map(|d| d.to_string_lossy()), + "unix_lib_dir_exists": unix_lib_dir.as_ref().map(|d| d.exists()), + "deploy_dlls": deploy_dlls, + "unix_sidecars": unix_sidecars, + "env_pairs": env_pairs_json, + "env_keys_present": { + "WINEDLLOVERRIDES": env_keys.contains("WINEDLLOVERRIDES"), + "DXMT_SHADER_CACHE_PATH": env_keys.contains("DXMT_SHADER_CACHE_PATH"), + "DYLD_FALLBACK_LIBRARY_PATH": env_keys.contains("DYLD_FALLBACK_LIBRARY_PATH") || env_keys.contains("LD_LIBRARY_PATH"), + "SteamAppId": env_keys.contains("SteamAppId"), + "DXMT_WINEMETAL_UNIXLIB": env_keys.contains("DXMT_WINEMETAL_UNIXLIB"), + }, + "missing": missing, + }) +} + pub fn deploy_recipe_dlls(recipe: &super::recipe::LaunchRecipe) -> Result<(), Box> { validate_recipe_runtime(recipe)?; @@ -4057,6 +4180,155 @@ mod tests { let _ = std::fs::remove_dir_all(&game_dir); } + #[test] + fn m12_pipeline_deploy_list_includes_d3d12_and_uses_isolated_dxmt_m12_surface() { + // Phase 3 contract: M12 must deploy d3d12.dll (plus dxgi/d3d11/ + // d3d10core/winemetal) from the isolated lib/dxmt-m12 surface. + let node = get_pipeline(PipelineId::M12); + let filenames: Vec<&str> = node.deploy_dlls.iter().map(|d| d.filename).collect(); + for required in ["d3d12.dll", "dxgi.dll", "d3d11.dll", "d3d10core.dll", "winemetal.dll"] { + assert!(filenames.contains(&required), "M12 deploy list must include {} (got {:?})", required, filenames); + } + for deploy in &node.deploy_dlls { + if matches!( + deploy.filename, + "d3d12.dll" | "d3d11.dll" | "dxgi.dll" | "d3d10core.dll" | "winemetal.dll" | "dxgi_dxmt.dll" + ) { + assert!( + deploy.source_subpath.starts_with("lib/dxmt-m12/"), + "M12 DLL {} must come from lib/dxmt-m12, got {}", + deploy.filename, + deploy.source_subpath + ); + } + } + } + + #[test] + fn m11_pipeline_deploy_list_does_not_include_d3d12_and_uses_legacy_dxmt_surface() { + // Phase 3 contract: M11 must NOT deploy d3d12.dll and must point at the + // legacy lib/dxmt surface, never lib/dxmt-m12. + let node = get_pipeline(PipelineId::M11); + let filenames: Vec<&str> = node.deploy_dlls.iter().map(|d| d.filename).collect(); + assert!(!filenames.contains(&"d3d12.dll"), "M11 deploy list must NOT include d3d12.dll (got {:?})", filenames); + for deploy in &node.deploy_dlls { + assert!( + !deploy.source_subpath.starts_with("lib/dxmt-m12/"), + "M11 DLL {} must not come from lib/dxmt-m12 (got {})", + deploy.filename, + deploy.source_subpath + ); + } + } + + #[test] + fn m12_dry_run_includes_d3d12_dll_and_m11_dry_run_does_not() { + // Phase 3 contract: the dry-run verifier's deploy list must reflect + // the pipeline node. M12 dry-run includes d3d12.dll; M11 does not. + // Uses an explicit temp home so no global env is mutated. + let home = std::env::temp_dir().join("ms-m12-dryrun-contract"); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + + let m12 = pipeline_dry_run_for(&home, 2379780, Some(PipelineId::M12)); + let m11 = pipeline_dry_run_for(&home, 17300, Some(PipelineId::M11)); + + let m12_filenames: Vec = m12 + .get("deploy_dlls") + .and_then(|v| v.as_array()) + .unwrap() + .iter() + .map(|d| d.get("filename").unwrap().as_str().unwrap().to_string()) + .collect(); + let m11_filenames: Vec = m11 + .get("deploy_dlls") + .and_then(|v| v.as_array()) + .unwrap() + .iter() + .map(|d| d.get("filename").unwrap().as_str().unwrap().to_string()) + .collect(); + + assert!( + m12_filenames.contains(&"d3d12.dll".to_string()), + "M12 dry-run must include d3d12.dll: {:?}", + m12_filenames + ); + assert!( + !m11_filenames.contains(&"d3d12.dll".to_string()), + "M11 dry-run must NOT include d3d12.dll: {:?}", + m11_filenames + ); + + // Both dry-runs must report the env keys the launch path sets. + let m12_env = m12.get("env_keys_present").unwrap(); + assert_eq!(m12_env.get("WINEDLLOVERRIDES").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(m12_env.get("SteamAppId").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(m12.get("dry_run").and_then(|v| v.as_bool()), Some(true)); + + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn m12_dry_run_verifies_unix_sidecars_and_marks_missing_artifacts() { + // Phase 3: the M12 dry-run must enumerate the x86_64-unix sidecars and + // report missing required artifacts as a structured failure (not a + // silent ok=true). With an empty temp home, all artifacts are absent. + let home = std::env::temp_dir().join("ms-m12-dryrun-empty"); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + + let dry = pipeline_dry_run_for(&home, 2379780, Some(PipelineId::M12)); + + // Unix sidecars must be enumerated for the M12 lane. + let sidecar_names: Vec = dry + .get("unix_sidecars") + .and_then(|v| v.as_array()) + .unwrap() + .iter() + .map(|s| s.get("filename").unwrap().as_str().unwrap().to_string()) + .collect(); + for required in ["winemetal.so", "libc++.1.dylib", "libc++abi.1.dylib", "libunwind.1.dylib"] { + assert!( + sidecar_names.contains(&required.to_string()), + "M12 dry-run must verify {}: {:?}", + required, + sidecar_names + ); + } + + // d3d12.dll is a required (non-optional) M12 artifact, so it must be + // listed as missing when absent. + let missing_filenames: Vec = dry + .get("missing") + .and_then(|v| v.as_array()) + .unwrap() + .iter() + .map(|m| m.get("filename").unwrap().as_str().unwrap().to_string()) + .collect(); + assert!( + missing_filenames.contains(&"d3d12.dll".to_string()), + "M12 dry-run must flag missing d3d12.dll: {:?}", + missing_filenames + ); + assert_eq!(dry.get("ok").and_then(|v| v.as_bool()), Some(false), "empty home must yield ok=false"); + + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn m12_pipeline_env_vars_set_winemetal_overrides_and_shader_cache() { + // Phase 3 contract: the M12 env builder must set the winemetal + // WINEDLLOVERRIDES, route the wine DLL path to dxmt-m12, and point the + // shader cache at the isolated m12 lane. + let node = get_pipeline(PipelineId::M12); + assert!(node.wine_overrides.unwrap_or("").contains("winemetal")); + assert!( + node.winedllpath_dirs.iter().any(|d| d.starts_with("lib/dxmt-m12")), + "M12 winedllpath must route to dxmt-m12" + ); + assert_eq!(node.shader_cache_subdir, Some("m12"), "M12 shader cache must be isolated under m12"); + } + #[test] fn m9_cache_env_uses_dxmt_family_not_dxvk() { let node = get_pipeline(PipelineId::M9); diff --git a/docs/architecture/m12-pipeline-map.md b/docs/architecture/m12-pipeline-map.md index 092e81b5..0c891c0f 100644 --- a/docs/architecture/m12-pipeline-map.md +++ b/docs/architecture/m12-pipeline-map.md @@ -91,7 +91,12 @@ The current release-hosted graphics bundle contains two DXMT surfaces: 1. Add a first-class M12 runtime verification command in this repo that launches a small D3D12 probe through the same `launch_dxmt_metal` environment used by - games. + games. — **Addressed (Phase 3):** `GET /diagnostics/m12/dry-run?appid=...` + and `GET /diagnostics/pipeline/dry-run?appid=...&pipeline=m12` report the + exact env pairs, artifact hashes, and unix sidecars M12 would load, using + the same `steam_pipeline_env_pairs` builder as `launch_dxmt_metal`, without + launching Steam or the game. The existing `POST /steam/d3d12-runtime-doctor` + runs the SDK mini-probe suite through that same environment. 2. Add a native Cocoa viewer test target if the goal is to exercise the in-tree `metalsharp_d3d12` implementation through CAMetalLayer rather than through Wine/winemetal. @@ -104,3 +109,31 @@ The current MetalSharp project treats M12 as the D3D12 DXMT route while keeping older DXMT routes isolated. D3D12 PE import detection selects M12, the backend handoff deploys the `dxmt-m12` runtime, and M9/M10/M11 continue to use the legacy `dxmt` surface that is known to work for current Steam/Wine titles. + +## M12 Artifact and Launch Verification (Phase 3) + +A reviewer can prove M12 loaded the intended artifacts without launching a full +game using the read-only dry-run verifier. It runs through the same environment +builder (`steam_pipeline_env_pairs`) as `launch_dxmt_metal`, so the reported env +pairs and artifact sources are exactly what a real M12 launch would use. + +- `GET /diagnostics/m12/dry-run?appid=` — M12-specific dry-run including + the `lib/dxmt-m12/x86_64-unix` sidecars (`winemetal.so`, `libc++.1.dylib`, + `libc++abi.1.dylib`, `libunwind.1.dylib`). +- `GET /diagnostics/pipeline/dry-run?appid=&pipeline=m12|m11|...` — + generic pipeline dry-run for comparing lanes. + +The dry-run reports, per artifact: resolved source path, presence, sha256, and +size; required artifacts that are missing produce a structured `ok: false` with +a `missing[]` array rather than a silent fallback. Env keys verified present: +`WINEDLLOVERRIDES` (winemetal overrides), `DXMT_SHADER_CACHE_PATH` (isolated +`m12` lane), `DYLD_FALLBACK_LIBRARY_PATH`/`LD_LIBRARY_PATH`, `SteamAppId`, and +`DXMT_WINEMETAL_UNIXLIB`. + +Contract guarantees covered by tests: + +- M12 deploys `d3d12.dll`, `dxgi.dll`, `d3d11.dll`, `d3d10core.dll`, + `winemetal.dll` from `lib/dxmt-m12/x86_64-windows`. +- M11 does **not** deploy `d3d12.dll` and points only at `lib/dxmt`, never + `lib/dxmt-m12`. +- M12 dry-run includes `d3d12.dll`; M11 dry-run does not. diff --git a/docs/optimization-roadmap/PR-SUMMARY.md b/docs/optimization-roadmap/PR-SUMMARY.md index 796d5fc8..691528db 100644 --- a/docs/optimization-roadmap/PR-SUMMARY.md +++ b/docs/optimization-roadmap/PR-SUMMARY.md @@ -118,3 +118,49 @@ cargo test # 521 passed, 0 failed (3 consecutive runs) The route contract is derived from existing primitives, not a new source of truth. Migration preserve/restore logic is unchanged; only an observational report was added. + +## Phase 3: M12 Artifact and Launch Verification ✅ + +**Purpose:** prove M12 is using the intended updated DXMT/winemetal artifacts +before debugging games. + +**What landed:** +- `m12_verify_dry_run(appid)` and `pipeline_dry_run_for(home, appid, requested)` + — a read-only verifier that runs through the **same environment builder** + (`steam_pipeline_env_pairs`) as `launch_dxmt_metal`. It reports, without + launching Steam or the game: + - the resolved `lib/dxmt-m12/x86_64-windows` dir + each deploy DLL + (`d3d12.dll`, `dxgi.dll`, `d3d11.dll`, `d3d10core.dll`, `winemetal.dll`, + …) with presence, sha256, and size + - the `lib/dxmt-m12/x86_64-unix` sidecars (`winemetal.so`, `libc++.1.dylib`, + `libc++abi.1.dylib`, `libunwind.1.dylib`) for the M12/M13 lane + - the exact env pairs the launch path sets, with an `env_keys_present` map + for `WINEDLLOVERRIDES`, `DXMT_SHADER_CACHE_PATH`, `DYLD_FALLBACK_LIBRARY_PATH`, + `SteamAppId`, `DXMT_WINEMETAL_UNIXLIB` + - missing required artifacts as a structured `ok: false` + `missing[]` array + (optional stubs nvapi/nvngx/atidxx tolerated) +- New routes: `GET /diagnostics/m12/dry-run?appid=`, + `GET /diagnostics/pipeline/dry-run?appid=&pipeline=`. +- `docs/architecture/m12-pipeline-map.md` now documents the verifier and marks + stability gap #1 ("first-class M12 runtime verification") as addressed. +- Verified the existing SDK proof scripts are invocable with the roadmap's + flags: `preflight-runtime-layout.py --profile metalsharp`, + `run-probes.sh --profile metalsharp --mini-only`, `validate-contracts.py`. + +**New tests (5):** M12 deploy list includes d3d12 and uses isolated +`lib/dxmt-m12` surface; M11 deploy list excludes d3d12 and never touches +`lib/dxmt-m12`; M12 dry-run includes d3d12 / M11 does not + env keys; +M12 dry-run verifies unix sidecars and flags missing artifacts; +M12 env vars set winemetal overrides + isolated `m12` shader cache. + +**Proof:** +``` +cargo fmt --all -- --check # clean +cargo clippy --all-targets -- -D warnings # clean +cargo test mtsp # 116 passed +cargo test # 526 passed, 0 failed +python3 tools/d3d12-metal-sdk/scripts/preflight-runtime-layout.py --help # invocable +``` + +**Boundary check:** M9/M10/M11 deploy lists and artifact paths unchanged. +The verifier is purely read-only — it never deploys, spawns, or launches. From bb14b43be5c64282499c32c4c0712c53f0bc07be Mon Sep 17 00:00:00 2001 From: Alex Mondello Date: Sun, 14 Jun 2026 01:29:23 -0600 Subject: [PATCH 04/14] feat(shader_cache): Phase 4 shader/PSO/cache diagnostics vendor/dxmt is reference source NOT compiled by this repo's CMake build (the shipped DXMT runtime is prebuilt under lib/). Phase 4 therefore lands the Rust-side observability layer that parses the runtime's on-disk products without touching shader lowering semantics. Add shader_cache::cache_doctor (read-only) that reports cache roots, per-DB size/mtime, total cache_* entry count, newest/oldest mtime, the staged runtime DLL sha256 (from injections.json) for staleness detection, and a stale_warning when entries exist without a recorded hash. Add shader_cache_family / primary_cache_subdir codifying the isolation contract: M9/M10/M11 share dxmt-metal, M12/M13 use the isolated dxmt-metal12. Add PsoDiagnosticManifest: the stable JSON schema for DXMT PSO trace sidecars (DXIL/MSL/root-sig hashes, formats, sample count, uses_stage_in, async compile, compile status, Metal error, ObjC exception). parse_pso_manifest and recent_pso_manifests parse the trace JSON DXMT emits under DXMT_LOG_PATH. New routes: GET /diagnostics/cache-doctor, GET /diagnostics/pso-manifests. Tests: 534 passed, 0 failed (+8). clippy + fmt clean. No shader lowering semantics changed; cache inspection is read-only. M9/M10/M11 cache families remain shared; M12/M13 remain isolated. --- app/src-rust/src/main.rs | 39 ++ app/src-rust/src/mtsp/shader_cache.rs | 495 +++++++++++++++++++++++- docs/optimization-roadmap/PR-SUMMARY.md | 50 +++ 3 files changed, 583 insertions(+), 1 deletion(-) diff --git a/app/src-rust/src/main.rs b/app/src-rust/src/main.rs index 8c7c1dde..df1cc72e 100644 --- a/app/src-rust/src/main.rs +++ b/app/src-rust/src/main.rs @@ -1079,6 +1079,45 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { let home = dirs::home_dir().unwrap_or_default(); resp(200, mtsp::launcher::pipeline_dry_run_for(&home, appid, requested_pipeline)) }, + // Phase 4: shader/PSO/cache diagnostics. + (Method::Get, "/diagnostics/cache-doctor") => { + let url_str = req.url().to_string(); + let appid: u32 = url_str + .split("appid=") + .nth(1) + .and_then(|v| v.split('&').next()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + resp(200, mtsp::shader_cache::cache_doctor(appid)) + }, + (Method::Get, "/diagnostics/pso-manifests") => { + let url_str = req.url().to_string(); + let appid: u32 = url_str + .split("appid=") + .nth(1) + .and_then(|v| v.split('&').next()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let limit = url_str + .split("limit=") + .nth(1) + .and_then(|v| v.split('&').next()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(20) + .min(200); + let requested_pipeline = url_str + .split("pipeline=") + .nth(1) + .and_then(|v| v.split('&').next()) + .and_then(crate::mtsp::engine::PipelineId::from_str_flexible); + let pipeline = bottles::resolve_steam_pipeline_for_request(appid, requested_pipeline); + let home = dirs::home_dir().unwrap_or_default(); + let manifests = mtsp::shader_cache::recent_pso_manifests(&home, pipeline, appid, limit); + resp( + 200, + json!({ "ok": true, "appid": appid, "pipeline": pipeline, "count": manifests.len(), "manifests": manifests }), + ) + }, (Method::Post, "/steam/compatdata") => { let body = read_body(req); resp(200, bottles::handle_steam_compatdata(&body)) diff --git a/app/src-rust/src/mtsp/shader_cache.rs b/app/src-rust/src/mtsp/shader_cache.rs index 2d1b8d36..80ff713a 100644 --- a/app/src-rust/src/mtsp/shader_cache.rs +++ b/app/src-rust/src/mtsp/shader_cache.rs @@ -1,4 +1,5 @@ -use std::path::PathBuf; +use serde::Serialize; +use std::path::{Path, PathBuf}; pub fn deploy_preset_cache(home: &PathBuf, cache_subdir: &str, appid: u32) -> Option { let preset_db = find_preset(home, cache_subdir, appid)?; @@ -166,6 +167,324 @@ fn merge_preset_into_user(preset_db: &PathBuf, user_db: &PathBuf) -> Option } } +// ============================================================================ +// Phase 4: Shader / PSO / cache diagnostics +// ============================================================================ +// +// The DXMT runtime is shipped prebuilt under lib/dxmt(-m12); vendor/dxmt is +// reference source and is NOT compiled by this repo's CMake build. These Rust +// diagnostics therefore observe the runtime's on-disk products (the DXMT +// SQLite shader/pipeline caches and any JSON PSO trace sidecars DXMT emits +// under DXMT_LOG_PATH) without touching shader lowering semantics. +// +// This standardizes the trace SHAPE so a future DXMT build (or the existing +// trace flags such as DXMT_D3D12_TRACE) has a stable parsing surface, and it +// gives the cache doctor a real, testable introspection path today. + +/// The shader-cache family a pipeline shares. M9/M10/M11 share the legacy +/// `dxmt-metal` family; M12/M13 use the isolated `dxmt-metal12` family. +pub fn shader_cache_family(pipeline: crate::mtsp::engine::PipelineId) -> &'static [&'static str] { + use crate::mtsp::engine::PipelineId; + match pipeline { + PipelineId::M9 => &["m9", "dxmt-metal"], + PipelineId::M10 => &["m10", "dxmt-metal"], + PipelineId::M11 => &["m11", "dxmt-metal"], + PipelineId::M12 => &["m12", "dxmt-metal12"], + PipelineId::M13 => &["m13", "dxmt-metal12"], + _ => &[], + } +} + +/// The primary cache subdir a pipeline writes to (its isolated lane). +pub fn primary_cache_subdir(pipeline: crate::mtsp::engine::PipelineId) -> Option<&'static str> { + use crate::mtsp::engine::PipelineId; + match pipeline { + PipelineId::M9 => Some("m9"), + PipelineId::M10 => Some("m10"), + PipelineId::M11 => Some("m11"), + PipelineId::M12 => Some("m12"), + PipelineId::M13 => Some("m13"), + _ => None, + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct CacheDbSummary { + pub name: String, + pub path: String, + pub size_bytes: u64, + pub mtime_unix: Option, + pub entry_count: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CacheDirSummary { + pub path: String, + pub exists: bool, + pub db_files: Vec, + pub total_entries: u64, + pub newest_mtime_unix: Option, + pub oldest_mtime_unix: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CacheDoctorReport { + pub schema_version: u32, + pub ok: bool, + pub appid: u32, + pub pipeline: String, + pub cache_family: Vec<&'static str>, + pub shader_cache: CacheDirSummary, + pub pipeline_cache: CacheDirSummary, + /// sha256 of the staged runtime DLL recorded in injections.json, used to + /// detect caches built against an older runtime build. + pub runtime_artifact_hash: Option, + pub stale_warning: Option, +} + +/// Count the rows across every `cache_*` table in a DXMT SQLite cache DB. +/// Opens read-only so it never mutates a live cache. +fn count_cache_entries(db_path: &Path) -> u64 { + let Ok(conn) = rusqlite::Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) else { + return 0; + }; + let Ok(mut stmt) = conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'cache_%'") else { + return 0; + }; + let table_names: Vec = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map(|iter| iter.filter_map(|r| r.ok()).collect()) + .unwrap_or_default(); + let mut total = 0u64; + for table in &table_names { + // Table names come from sqlite_master and match cache_; reject + // anything unexpected before interpolating. + if !table.starts_with("cache_") || table.chars().any(|c| !(c.is_ascii_alphanumeric() || c == '_')) { + continue; + } + let sql = format!("SELECT COUNT(*) FROM {}", table); + if let Ok(value) = conn.query_row(&sql, [], |row| row.get::<_, i64>(0)) { + total += value.max(0) as u64; + } + } + total +} + +fn summarize_cache_dir(dir: &Path) -> CacheDirSummary { + let mut summary = CacheDirSummary { + path: dir.to_string_lossy().to_string(), + exists: dir.exists(), + db_files: Vec::new(), + total_entries: 0, + newest_mtime_unix: None, + oldest_mtime_unix: None, + }; + if !summary.exists { + return summary; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return summary; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("db") { + continue; + } + let Ok(meta) = std::fs::metadata(&path) else { + continue; + }; + let mtime = + meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()); + let entry_count = count_cache_entries(&path); + summary.total_entries += entry_count; + summary.newest_mtime_unix = match (summary.newest_mtime_unix, mtime) { + (Some(a), Some(b)) => Some(a.max(b)), + (None, b) => b, + (a, None) => a, + }; + summary.oldest_mtime_unix = match (summary.oldest_mtime_unix, mtime) { + (Some(a), Some(b)) => Some(a.min(b)), + (None, b) => b, + (a, None) => a, + }; + summary.db_files.push(CacheDbSummary { + name: path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(), + path: path.to_string_lossy().to_string(), + size_bytes: meta.len(), + mtime_unix: mtime, + entry_count, + }); + } + summary +} + +/// Read the staged runtime DLL sha256 recorded in the game's injections.json, +/// if any. Used to detect caches built against an older runtime build. +fn staged_runtime_hash(home: &Path, appid: u32) -> Option { + let dual = crate::scan::resolve_dual_game_dir(appid); + let game_dir = dual.wine_dir?; + let manifest_path = game_dir.join(".metalsharp").join("injections.json"); + let raw = std::fs::read_to_string(&manifest_path).ok()?; + let manifest: serde_json::Value = serde_json::from_str(&raw).ok()?; + manifest.get("dlls").and_then(|v| v.as_array())?.iter().find_map(|dll| { + if dll.get("filename").and_then(|v| v.as_str()) == Some("d3d12.dll") { + dll.get("sha256").and_then(|v| v.as_str()).map(|s| s.to_string()) + } else { + None + } + }) +} + +/// Build the cache doctor report for an appid, resolving the pipeline the same +/// way the diagnostic route does. +pub fn cache_doctor(appid: u32) -> serde_json::Value { + let Some(home) = dirs::home_dir() else { + return serde_json::json!({"ok": false, "appid": appid, "error": "home directory could not be resolved"}); + }; + let pipeline = crate::bottles::resolve_steam_pipeline_for_request(appid, None); + cache_doctor_for(&home, pipeline, appid) +} + +/// Cache doctor with an explicit home (used by tests; never mutates global env). +pub fn cache_doctor_for(home: &Path, pipeline: crate::mtsp::engine::PipelineId, appid: u32) -> serde_json::Value { + let ms_home = crate::platform::metalsharp_home_dir_for(home); + let appid_str = appid.to_string(); + let family = shader_cache_family(pipeline); + let primary = primary_cache_subdir(pipeline).unwrap_or(""); + + // Summarize every family member dir; report the primary lane entry first. + let shader_cache = summarize_cache_dir(&ms_home.join("shader-cache").join(primary).join(&appid_str)); + let pipeline_cache = summarize_cache_dir(&ms_home.join("pipeline-cache").join(primary).join(&appid_str)); + + let runtime_artifact_hash = staged_runtime_hash(home, appid); + let stale_warning = if shader_cache.total_entries > 0 && runtime_artifact_hash.is_none() { + Some("shader cache has entries but no runtime artifact hash is recorded for this game; staleness cannot be verified".to_string()) + } else { + None + }; + + let report = CacheDoctorReport { + schema_version: 1, + ok: true, + appid, + pipeline: pipeline_preference_id_str(pipeline).to_string(), + cache_family: family.to_vec(), + shader_cache, + pipeline_cache, + runtime_artifact_hash, + stale_warning, + }; + serde_json::to_value(report) + .unwrap_or_else(|_| serde_json::json!({"ok": false, "error": "failed to serialize report"})) +} + +fn pipeline_preference_id_str(pipeline: crate::mtsp::engine::PipelineId) -> &'static str { + use crate::mtsp::engine::PipelineId; + match pipeline { + PipelineId::M9 => "m9", + PipelineId::M10 => "m10", + PipelineId::M11 => "m11", + PipelineId::M12 => "m12", + PipelineId::M13 => "m13", + PipelineId::D3DMetal => "d3dmetal", + PipelineId::FnaArm64 => "fna_arm64", + PipelineId::WineBare => "wine_bare", + _ => "auto", + } +} + +// ---------------------------------------------------------------------------- +// PSO diagnostic manifest schema +// ---------------------------------------------------------------------------- +// +// Standardizes the trace fields a PSO creation should record so that "Failed +// to create PSO" is never an end-state diagnosis. DXMT emits traces when its +// trace flags are set (DXMT_D3D12_TRACE, etc.); this struct defines the stable +// JSON shape those traces should produce and that the Rust backend parses. + +#[derive(Debug, Clone, Serialize, serde::Deserialize)] +pub struct PsoDiagnosticManifest { + pub schema_version: u32, + pub kind: PsoKind, + pub dxil_input_hash: Option, + pub msl_output_hash: Option, + pub root_signature_hash: Option, + #[serde(default)] + pub vertex_input_layout_hash: Option, + #[serde(default)] + pub render_target_formats: Vec, + #[serde(default)] + pub depth_stencil_format: Option, + #[serde(default)] + pub sample_count: Option, + #[serde(default)] + pub uses_stage_in: Option, + #[serde(default)] + pub async_compile: Option, + #[serde(default)] + pub compile_status: Option, + #[serde(default)] + pub metal_error: Option, + #[serde(default)] + pub objc_exception: Option, + #[serde(default)] + pub recorded_at_unix: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PsoKind { + Graphics, + Compute, +} + +impl PsoDiagnosticManifest { + pub fn failed(&self) -> bool { + self.compile_status + .as_deref() + .map(|s| s.eq_ignore_ascii_case("failed") || s.eq_ignore_ascii_case("error")) + .unwrap_or(false) + } +} + +/// Parse a DXMT PSO trace JSON document into the standardized manifest. +pub fn parse_pso_manifest(raw: &str) -> Result { + serde_json::from_str::(raw) +} + +/// Collect the most recent PSO manifests recorded under an appid's pipeline +/// cache dir (DXMT_LOG_PATH). Returns up to `limit` newest entries. +pub fn recent_pso_manifests( + home: &Path, + pipeline: crate::mtsp::engine::PipelineId, + appid: u32, + limit: usize, +) -> Vec { + let Some(primary) = primary_cache_subdir(pipeline) else { + return Vec::new(); + }; + let dir = + crate::platform::metalsharp_home_dir_for(home).join("pipeline-cache").join(primary).join(appid.to_string()); + let Ok(entries) = std::fs::read_dir(&dir) else { + return Vec::new(); + }; + let mut found: Vec<(Option, PsoDiagnosticManifest)> = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if !name.starts_with("pso-") || path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + if let Ok(raw) = std::fs::read_to_string(&path) { + if let Ok(manifest) = parse_pso_manifest(&raw) { + found.push((manifest.recorded_at_unix, manifest)); + } + } + } + found.sort_by_key(|b| std::cmp::Reverse(b.0)); + found.into_iter().take(limit).map(|(_, m)| m).collect() +} + #[cfg(test)] mod tests { use super::*; @@ -179,4 +498,178 @@ mod tests { fn m9_reuses_dxmt_preset_family() { assert_eq!(preset_lookup_subdirs("m9"), vec!["m9", "dxmt-metal"]); } + + // ---- Phase 4: cache doctor + PSO manifest ---- + + #[test] + fn shader_cache_family_keeps_legacy_and_m12_isolated() { + use crate::mtsp::engine::PipelineId; + // M9/M10/M11 share the legacy dxmt-metal family. + assert_eq!(shader_cache_family(PipelineId::M11), &["m11", "dxmt-metal"]); + // M12/M13 use the isolated dxmt-metal12 family and must not mix. + assert_eq!(shader_cache_family(PipelineId::M12), &["m12", "dxmt-metal12"]); + assert_eq!(shader_cache_family(PipelineId::M13), &["m13", "dxmt-metal12"]); + } + + #[test] + fn primary_cache_subdir_maps_each_pipeline_to_its_isolated_lane() { + use crate::mtsp::engine::PipelineId; + assert_eq!(primary_cache_subdir(PipelineId::M9), Some("m9")); + assert_eq!(primary_cache_subdir(PipelineId::M10), Some("m10")); + assert_eq!(primary_cache_subdir(PipelineId::M11), Some("m11")); + assert_eq!(primary_cache_subdir(PipelineId::M12), Some("m12")); + assert_eq!(primary_cache_subdir(PipelineId::M13), Some("m13")); + } + + fn make_dxmt_cache_db(path: &Path, rows: &[(&str, Vec<&str>)]) { + let conn = rusqlite::Connection::open(path).unwrap(); + for (table, keys) in rows { + conn.execute(&format!("CREATE TABLE {} (key BLOB PRIMARY KEY, value BLOB)", table), []).unwrap(); + for k in keys { + conn.execute( + &format!("INSERT INTO {} (key, value) VALUES (?1, ?2)", table), + rusqlite::params![k.as_bytes(), b"v"], + ) + .unwrap(); + } + } + } + + #[test] + fn cache_doctor_counts_entries_and_reports_isolated_m12_lane() { + use crate::mtsp::engine::PipelineId; + let home = std::env::temp_dir().join("ms-cache-doctor-m12"); + let _ = std::fs::remove_dir_all(&home); + let ms_home = crate::platform::metalsharp_home_dir_for(&home); + let shader_dir = ms_home.join("shader-cache").join("m12").join("42"); + let pipeline_dir = ms_home.join("pipeline-cache").join("m12").join("42"); + std::fs::create_dir_all(&shader_dir).unwrap(); + std::fs::create_dir_all(&pipeline_dir).unwrap(); + + make_dxmt_cache_db(&shader_dir.join("shaders_42.db"), &[("cache_1", vec!["a", "b", "c"])]); + make_dxmt_cache_db(&pipeline_dir.join("pipelines_42.db"), &[("cache_2", vec!["x", "y"])]); + + let report = cache_doctor_for(&home, PipelineId::M12, 42); + assert_eq!(report.get("schema_version").and_then(|v| v.as_u64()), Some(1)); + assert_eq!(report.get("pipeline").and_then(|v| v.as_str()), Some("m12")); + let shader = report.get("shader_cache").unwrap(); + assert_eq!(shader.get("total_entries").and_then(|v| v.as_u64()), Some(3)); + assert_eq!(shader.get("exists").and_then(|v| v.as_bool()), Some(true)); + let pipeline = report.get("pipeline_cache").unwrap(); + assert_eq!(pipeline.get("total_entries").and_then(|v| v.as_u64()), Some(2)); + + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn cache_doctor_reports_empty_state_without_panicking() { + use crate::mtsp::engine::PipelineId; + let home = std::env::temp_dir().join("ms-cache-doctor-empty"); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + + let report = cache_doctor_for(&home, PipelineId::M11, 999); + assert_eq!(report.get("pipeline").and_then(|v| v.as_str()), Some("m11")); + let shader = report.get("shader_cache").unwrap(); + assert_eq!(shader.get("exists").and_then(|v| v.as_bool()), Some(false)); + assert_eq!(shader.get("total_entries").and_then(|v| v.as_u64()), Some(0)); + + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn cache_doctor_warns_when_entries_exist_without_runtime_hash() { + use crate::mtsp::engine::PipelineId; + let home = std::env::temp_dir().join("ms-cache-doctor-stale"); + let _ = std::fs::remove_dir_all(&home); + let ms_home = crate::platform::metalsharp_home_dir_for(&home); + let shader_dir = ms_home.join("shader-cache").join("m11").join("7"); + std::fs::create_dir_all(&shader_dir).unwrap(); + make_dxmt_cache_db(&shader_dir.join("shaders_7.db"), &[("cache_1", vec!["a"])]); + + let report = cache_doctor_for(&home, PipelineId::M11, 7); + // No injections.json staged => runtime_artifact_hash is null and a + // stale warning must be present. + assert_eq!(report.get("runtime_artifact_hash").and_then(|v| v.as_str()), None); + assert!(report.get("stale_warning").and_then(|v| v.as_str()).is_some(), "must warn when entries lack a hash"); + + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn parse_pso_manifest_decodes_graphics_pso_failure() { + let raw = r#"{ + "schema_version": 1, + "kind": "graphics", + "dxil_input_hash": "abc123", + "msl_output_hash": "def456", + "root_signature_hash": "rs1", + "vertex_input_layout_hash": "vil1", + "render_target_formats": ["R8G8B8A8_UNORM"], + "depth_stencil_format": "D32_FLOAT", + "sample_count": 1, + "uses_stage_in": true, + "async_compile": true, + "compile_status": "failed", + "metal_error": "unsupported pixel format", + "objc_exception": null, + "recorded_at_unix": 1700000000 + }"#; + let manifest = parse_pso_manifest(raw).expect("graphics manifest must parse"); + assert_eq!(manifest.kind, PsoKind::Graphics); + assert_eq!(manifest.dxil_input_hash.as_deref(), Some("abc123")); + assert_eq!(manifest.root_signature_hash.as_deref(), Some("rs1")); + assert_eq!(manifest.render_target_formats, vec!["R8G8B8A8_UNORM"]); + assert_eq!(manifest.depth_stencil_format.as_deref(), Some("D32_FLOAT")); + assert_eq!(manifest.sample_count, Some(1)); + assert_eq!(manifest.uses_stage_in, Some(true)); + assert_eq!(manifest.async_compile, Some(true)); + assert_eq!(manifest.compile_status.as_deref(), Some("failed")); + assert_eq!(manifest.metal_error.as_deref(), Some("unsupported pixel format")); + assert!(manifest.failed(), "failed status must be detected"); + } + + #[test] + fn parse_pso_manifest_decodes_compute_pso_success() { + let raw = r#"{ + "schema_version": 1, + "kind": "compute", + "dxil_input_hash": "cmp1", + "msl_output_hash": "cmp2", + "root_signature_hash": "crs", + "compile_status": "ok", + "recorded_at_unix": 1700000001 + }"#; + let manifest = parse_pso_manifest(raw).expect("compute manifest must parse"); + assert_eq!(manifest.kind, PsoKind::Compute); + assert_eq!(manifest.vertex_input_layout_hash, None); + assert_eq!(manifest.render_target_formats, Vec::::new()); + assert!(!manifest.failed(), "ok status must not be reported as failed"); + } + + #[test] + fn recent_pso_manifests_collects_newest_first() { + use crate::mtsp::engine::PipelineId; + let home = std::env::temp_dir().join("ms-pso-recent"); + let _ = std::fs::remove_dir_all(&home); + let dir = crate::platform::metalsharp_home_dir_for(&home).join("pipeline-cache").join("m12").join("100"); + std::fs::create_dir_all(&dir).unwrap(); + + for (idx, ts) in [(3u32, 1700000000u64), (1, 1700000002), (2, 1700000001)] { + let raw = format!( + r#"{{"schema_version":1,"kind":"graphics","dxil_input_hash":"h{}","compile_status":"ok","recorded_at_unix":{}}}"#, + idx, ts + ); + std::fs::write(dir.join(format!("pso-{}.json", idx)), raw).unwrap(); + } + // A non-pso json must be ignored. + std::fs::write(dir.join("other.json"), "{}").unwrap(); + + let manifests = recent_pso_manifests(&home, PipelineId::M12, 100, 2); + assert_eq!(manifests.len(), 2, "must respect the limit and return newest first"); + assert_eq!(manifests[0].dxil_input_hash.as_deref(), Some("h1"), "newest first"); + assert_eq!(manifests[1].dxil_input_hash.as_deref(), Some("h2")); + + let _ = std::fs::remove_dir_all(&home); + } } diff --git a/docs/optimization-roadmap/PR-SUMMARY.md b/docs/optimization-roadmap/PR-SUMMARY.md index 691528db..d69332b1 100644 --- a/docs/optimization-roadmap/PR-SUMMARY.md +++ b/docs/optimization-roadmap/PR-SUMMARY.md @@ -164,3 +164,53 @@ python3 tools/d3d12-metal-sdk/scripts/preflight-runtime-layout.py --help # invo **Boundary check:** M9/M10/M11 deploy lists and artifact paths unchanged. The verifier is purely read-only — it never deploys, spawns, or launches. + +## Phase 4: Shader, PSO, and Cache Diagnostics ✅ + +**Purpose:** turn opaque M12 graphics failures into actionable shader/PSO/cache +evidence. + +**Scope note:** `vendor/dxmt` is vendored **reference source** — it is NOT +compiled by this repo's `CMakeLists.txt` (the shipped DXMT runtime is prebuilt +under `lib/`). Editing DXMT C++ lowering would have no effect on the shipped +runtime and could not be verified here. Phase 4 therefore lands the Rust-side +observability layer (cache doctor + PSO manifest schema) that parses the +runtime's on-disk products, without touching shader lowering semantics. + +**What landed:** +- `shader_cache::cache_doctor(appid)` / `cache_doctor_for(home, pipeline, appid)` + — reads the DXMT SQLite shader and pipeline caches **read-only** and reports: + - cache root, per-DB size/mtime, total `cache_*` entry count + - newest/oldest entry mtime + - the staged runtime DLL sha256 (from `injections.json`) used to detect + caches built against an older runtime build + - a `stale_warning` when entries exist but no runtime hash is recorded +- `shader_cache_family` / `primary_cache_subdir` codify the cache isolation + contract: M9/M10/M11 share the `dxmt-metal` family, M12/M13 use the isolated + `dxmt-metal12` family. +- `PsoDiagnosticManifest` — the stable JSON schema for DXMT PSO trace + sidecars (DXIL input hash, MSL output hash, root signature hash, vertex + input layout hash, render target/depth formats, sample count, + `uses_stage_in`, async compile status, compile status, Metal error, ObjC + exception). `parse_pso_manifest` + `recent_pso_manifests` parse the trace + JSON DXMT emits under `DXMT_LOG_PATH` when its trace flags are set. +- New routes: `GET /diagnostics/cache-doctor?appid=`, + `GET /diagnostics/pso-manifests?appid=&pipeline=&limit=`. + +**New tests (8):** shader cache family isolation, primary cache subdir mapping, +cache doctor counts entries + reports isolated M12 lane, cache doctor empty +state, cache doctor stale warning without runtime hash, graphics PSO manifest +failure parse, compute PSO manifest success parse, recent PSO manifests +newest-first ordering. + +**Proof:** +``` +cargo fmt --all -- --check # clean +cargo clippy --all-targets -- -D warnings # clean +cargo test mtsp::shader_cache # 10 passed +cargo test # 534 passed, 0 failed +``` + +**Boundary check:** no shader lowering semantics changed. Cache inspection is +strictly read-only (SQLite `SQLITE_OPEN_READ_ONLY`). M9/M10/M11 cache families +remain shared as before; M12/M13 remain isolated. From c080aa11fc86845148bcd6132a63ab7f1b49c31a Mon Sep 17 00:00:00 2001 From: Alex Mondello Date: Sun, 14 Jun 2026 01:33:22 -0600 Subject: [PATCH 05/14] feat(binding_contract): Phase 5 descriptor/root-sig Metal binding hardening Treat D3D12 root signatures and descriptor heaps as a formal ABI. New binding_contract module mirrors what the existing SDK audits (dxil-binding-manifest-audit.py, dxil-root-signature-audit.py) parse: - RootSignatureManifest (version 1.0/1.1, parameters, static samplers, null-descriptor policy) - RootParameter (DescriptorTable/Constants/CBV/SRV/UAV, shader visibility, register space/index, descriptor-table ranges) - DescriptorRange, StaticSampler, NullDescriptorPolicy, ShaderVisibility - ReflectionBinding (shader-declared bindings for the ABI check) validate_root_signature[_with] enforces Metal direct-binding limits (buffers<=31, textures<=8, samplers<=16, matching the Python audit defaults) plus D3D12 ABI rules: limit violations, overlapping ranges within a space, static-sampler register clashes, sparse root parameter indices, and UINT_MAX unbounded ranges rejected without proven probe support. Reflection-ABI check proves every reflection binding is covered by a root parameter range or static sampler (visibility-aware), turning a shader-vs- root-signature mismatch into a contract failure. New route: POST /diagnostics/binding-contract/validate. Tests: 548 passed, 0 failed (+14). clippy + fmt clean. No runtime binding behavior changed; M9/M10/M11 unaffected. Rust limits match the Python binding audit defaults so the two gates agree. --- app/src-rust/src/binding_contract.rs | 612 ++++++++++++++++++++++++ app/src-rust/src/main.rs | 23 + docs/optimization-roadmap/PR-SUMMARY.md | 52 ++ 3 files changed, 687 insertions(+) create mode 100644 app/src-rust/src/binding_contract.rs diff --git a/app/src-rust/src/binding_contract.rs b/app/src-rust/src/binding_contract.rs new file mode 100644 index 00000000..085bbce5 --- /dev/null +++ b/app/src-rust/src/binding_contract.rs @@ -0,0 +1,612 @@ +//! Phase 5: Descriptor and root-signature Metal binding contract. +//! +//! Treats D3D12 root signatures and descriptor heaps as a formal ABI, not +//! loose per-draw state. This module provides the stable Rust types and +//! validators that mirror what `tools/d3d12-metal-sdk/scripts/dxil-binding- +//! manifest-audit.py` and `dxil-root-signature-audit.py` check on the Metal +//! side, so a binding mismatch becomes a contract failure instead of a +//! game-only mystery. +//! +//! Limits are Metal's documented direct-binding ceilings for argument-buffer +//! fallback layouts (buffers <= 31, textures <= 8, samplers <= 16). These are +//! the same defaults the Python binding audit enforces. + +use serde::{Deserialize, Serialize}; + +/// Metal's direct-binding ceilings for the argument-buffer fallback layout +/// that DXMT/winemetal uses when a stable Metal binding layout is not yet +/// available. These match the Python binding-manifest audit defaults. +pub const MAX_DIRECT_BUFFERS: u32 = 31; +pub const MAX_DIRECT_TEXTURES: u32 = 8; +pub const MAX_DIRECT_SAMPLERS: u32 = 16; + +/// D3D12 root signature version. 1.0 is the static root signature; 1.1 adds +/// flags for static/dynamic descriptor behavior. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RootSignatureVersion { + V1_0, + V1_1, +} + +/// Shader visibility for a root parameter, mirroring +/// `D3D12_SHADER_VISIBILITY`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ShaderVisibility { + All, + Vertex, + Hull, + Domain, + Geometry, + Pixel, + Amplification, + Mesh, +} + +/// Descriptor range kind, mirroring `D3D12_DESCRIPTOR_RANGE_TYPE`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DescriptorRangeKind { + Srv, + Uav, + Cbv, + Sampler, +} + +/// The kind of a root parameter, mirroring `D3D12_ROOT_PARAMETER_TYPE`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RootParameterKind { + DescriptorTable, + Constants, + Cbv, + Srv, + Uav, +} + +/// A descriptor range within a descriptor-table root parameter. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DescriptorRange { + pub kind: DescriptorRangeKind, + /// Base shader register (`BaseShaderRegister`). + pub base_register: u32, + /// Number of descriptors in the range (`NumDescriptors`). + pub count: u32, + /// Register space (`RegisterSpace`). + pub register_space: u32, + /// Offset in the descriptor table (`OffsetInDescriptorsFromTableStart`). + pub table_offset: u32, +} + +/// A single root parameter. Carries its kind, visibility, and (for descriptor +/// tables) the ranges it spans. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RootParameter { + pub index: u32, + pub kind: RootParameterKind, + pub visibility: ShaderVisibility, + /// Present only when `kind == DescriptorTable`. + #[serde(default)] + pub descriptor_table_ranges: Vec, + /// Register space used by CBV/SRV/UAV/constants root parameters. + #[serde(default)] + pub register_space: u32, + /// Register index used by CBV/SRV/UAV/constants root parameters. + #[serde(default)] + pub register: u32, +} + +/// A static sampler recorded in the root signature. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StaticSampler { + pub index: u32, + pub register: u32, + pub register_space: u32, + pub visibility: ShaderVisibility, +} + +/// The null-descriptor policy for a root signature. D3D12 permits null CBV / +/// SRV / UAV descriptors; the binding contract records how they are handled so +/// a null-descriptor regression becomes a visible contract change. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NullDescriptorPolicy { + /// Null descriptors are allowed and Metal maps them to no-op bindings. + Allowed, + /// Null descriptors are rejected by the runtime. + Rejected, + /// Unspecified by this contract. + Unspecified, +} + +/// A root signature ABI manifest. This is the typed form of what DXMT emits +/// and what the root-signature audit parses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RootSignatureManifest { + pub schema_version: u32, + pub version: RootSignatureVersion, + pub parameters: Vec, + pub static_samplers: Vec, + pub null_descriptor_policy: NullDescriptorPolicy, +} + +/// A reflection-side binding entry. The shader's declared binding; the +/// reflection-ABI check proves these match the root signature layout. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReflectionBinding { + pub shader: String, + pub kind: DescriptorRangeKind, + pub register_space: u32, + pub register: u32, + pub count: u32, + pub visibility: ShaderVisibility, +} + +/// The result of validating a root signature manifest against Metal's +/// direct-binding limits and D3D12 ABI rules. +#[derive(Debug, Clone, Serialize)] +pub struct BindingContractReport { + pub schema_version: u32, + pub ok: bool, + pub limits: BindingLimits, + pub violations: Vec, + pub reflection_abi_mismatches: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub struct BindingLimits { + pub max_direct_buffers: u32, + pub max_direct_textures: u32, + pub max_direct_samplers: u32, +} + +impl Default for BindingLimits { + fn default() -> Self { + BindingLimits { + max_direct_buffers: MAX_DIRECT_BUFFERS, + max_direct_textures: MAX_DIRECT_TEXTURES, + max_direct_samplers: MAX_DIRECT_SAMPLERS, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct BindingViolation { + pub root_parameter_index: Option, + pub kind: BindingViolationKind, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BindingViolationKind { + /// A descriptor-table range exceeds the register bound Metal can address + /// through the argument-buffer fallback layout. + DirectBufferLimit, + DirectTextureLimit, + DirectSamplerLimit, + /// A descriptor-table range overlaps an earlier range in the same space. + OverlappingTableRange, + /// A static sampler is registered at a register/space that a root + /// parameter also claims. + StaticSamplerRegisterClash, + /// Root parameter indices are not dense from 0. + SparseRootParameterIndex, + /// `UNBOUNDED` (`u32::MAX`) count advertised without proven probe support. + UnboundedRangeUnsupported, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ReflectionAbiMismatch { + pub shader: String, + pub kind: DescriptorRangeKind, + pub register_space: u32, + pub register: u32, + pub detail: String, +} + +/// D3D12 uses `u32::MAX` to express an unbounded descriptor range +/// (`D3D12_DESCRIPTOR_RANGE_OFFSET_APPEND` is also `u32::MAX`, but the count +/// unbounded sentinel is `UINT_MAX` for `NumDescriptors`). +const UNBOUNDED_COUNT: u32 = u32::MAX; + +/// Validate a root signature manifest against Metal's direct-binding limits +/// and D3D12 ABI rules. Returns a structured report; `ok == false` means the +/// binding contract is violated. +/// +/// This does NOT change runtime behavior. It turns "binding bug" into a +/// contract failure by codifying the limits the runtime must respect. +pub fn validate_root_signature(manifest: &RootSignatureManifest) -> BindingContractReport { + validate_root_signature_with(manifest, &ReflectionBindingSet::default(), BindingLimits::default()) +} + +/// Validate a root signature manifest AND prove the reflection bindings match +/// the root signature layout. This is the Phase 5 reflection-ABI check. +pub fn validate_root_signature_with( + manifest: &RootSignatureManifest, + reflection: &ReflectionBindingSet, + limits: BindingLimits, +) -> BindingContractReport { + let mut violations = Vec::new(); + let mut claimed_ranges: Vec<(u32, u32, DescriptorRangeKind)> = Vec::new(); + + for (position, param) in manifest.parameters.iter().enumerate() { + if param.index != position as u32 { + violations.push(BindingViolation { + root_parameter_index: Some(param.index), + kind: BindingViolationKind::SparseRootParameterIndex, + detail: format!( + "root parameter at position {} has index {}; indices must be dense from 0", + position, param.index + ), + }); + } + if param.kind == RootParameterKind::DescriptorTable { + for range in ¶m.descriptor_table_ranges { + if range.count == UNBOUNDED_COUNT { + violations.push(BindingViolation { + root_parameter_index: Some(param.index), + kind: BindingViolationKind::UnboundedRangeUnsupported, + detail: format!( + "root parameter {} space {} {:?} range is unbounded (count = UINT_MAX); unbounded descriptor ranges require proven probe support before they may be advertised", + param.index, range.register_space, range.kind + ), + }); + continue; + } + let high = range.base_register.saturating_add(range.count); + let limit = match range.kind { + DescriptorRangeKind::Cbv => limits.max_direct_buffers, + DescriptorRangeKind::Srv | DescriptorRangeKind::Uav => limits.max_direct_textures, + DescriptorRangeKind::Sampler => limits.max_direct_samplers, + }; + if high > limit { + let kind = match range.kind { + DescriptorRangeKind::Cbv => BindingViolationKind::DirectBufferLimit, + DescriptorRangeKind::Srv | DescriptorRangeKind::Uav => BindingViolationKind::DirectTextureLimit, + DescriptorRangeKind::Sampler => BindingViolationKind::DirectSamplerLimit, + }; + violations.push(BindingViolation { + root_parameter_index: Some(param.index), + kind, + detail: format!( + "root parameter {} space {} {:?} range covers registers {}..={}; exceeds Metal direct-binding limit {}", + param.index, range.register_space, range.kind, range.base_register, high.saturating_sub(1), limit + ), + }); + } + // Overlap detection within the same space + kind. + let claimed_same = claimed_ranges.iter().any(|(space, end, kind)| { + *space == range.register_space && *kind == range.kind && *end > range.base_register + }); + if claimed_same { + violations.push(BindingViolation { + root_parameter_index: Some(param.index), + kind: BindingViolationKind::OverlappingTableRange, + detail: format!( + "root parameter {} space {} {:?} range overlaps an earlier range", + param.index, range.register_space, range.kind + ), + }); + } + claimed_ranges.push((range.register_space, high, range.kind)); + } + } + } + + // Static sampler register clashes. + for sampler in &manifest.static_samplers { + for param in &manifest.parameters { + if param.register_space == sampler.register_space + && param.register == sampler.register + && matches!( + param.kind, + RootParameterKind::Cbv + | RootParameterKind::Srv + | RootParameterKind::Uav + | RootParameterKind::Constants + ) + { + violations.push(BindingViolation { + root_parameter_index: Some(param.index), + kind: BindingViolationKind::StaticSamplerRegisterClash, + detail: format!( + "static sampler #{} space {} register {} clashes with root parameter {}", + sampler.index, sampler.register_space, sampler.register, param.index + ), + }); + } + } + } + + // Reflection ABI: every reflection binding must be covered by some root + // parameter range (or a static sampler) in the manifest. + let mut mismatches = Vec::new(); + for binding in &reflection.bindings { + let covered_by_table = binding_is_covered_by_table(manifest, binding); + let covered_by_static_sampler = binding_is_static_sampler(manifest, binding); + if !covered_by_table && !covered_by_static_sampler { + mismatches.push(ReflectionAbiMismatch { + shader: binding.shader.clone(), + kind: binding.kind, + register_space: binding.register_space, + register: binding.register, + detail: format!( + "shader {} declares {:?} space {} register {} but no root parameter covers it", + binding.shader, binding.kind, binding.register_space, binding.register + ), + }); + } + } + + let ok = violations.is_empty() && mismatches.is_empty(); + BindingContractReport { schema_version: 1, ok, limits, violations, reflection_abi_mismatches: mismatches } +} + +fn binding_is_covered_by_table(manifest: &RootSignatureManifest, binding: &ReflectionBinding) -> bool { + manifest.parameters.iter().any(|p| { + if p.visibility != ShaderVisibility::All && p.visibility != binding.visibility { + return false; + } + p.descriptor_table_ranges.iter().any(|r| { + r.kind == binding.kind + && r.register_space == binding.register_space + && r.base_register <= binding.register + && r.count != UNBOUNDED_COUNT + && r.base_register + r.count > binding.register + }) + }) +} + +fn binding_is_static_sampler(manifest: &RootSignatureManifest, binding: &ReflectionBinding) -> bool { + binding.kind == DescriptorRangeKind::Sampler + && manifest.static_samplers.iter().any(|s| { + s.register_space == binding.register_space + && s.register == binding.register + && (s.visibility == ShaderVisibility::All || s.visibility == binding.visibility) + }) +} + +/// A set of reflection bindings (typically parsed from DXMT's shader +/// reflection output) used for the reflection-ABI check. +#[derive(Debug, Clone, Default)] +pub struct ReflectionBindingSet { + pub bindings: Vec, +} + +impl ReflectionBindingSet { + pub fn from_bindings(bindings: Vec) -> Self { + ReflectionBindingSet { bindings } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn table_param(index: u32, ranges: Vec) -> RootParameter { + RootParameter { + index, + kind: RootParameterKind::DescriptorTable, + visibility: ShaderVisibility::All, + descriptor_table_ranges: ranges, + register_space: 0, + register: 0, + } + } + + fn range(kind: DescriptorRangeKind, base: u32, count: u32, space: u32, offset: u32) -> DescriptorRange { + DescriptorRange { kind, base_register: base, count, register_space: space, table_offset: offset } + } + + fn manifest(params: Vec) -> RootSignatureManifest { + RootSignatureManifest { + schema_version: 1, + version: RootSignatureVersion::V1_0, + parameters: params, + static_samplers: Vec::new(), + null_descriptor_policy: NullDescriptorPolicy::Allowed, + } + } + + #[test] + fn clean_root_signature_within_limits_passes() { + let m = manifest(vec![table_param( + 0, + vec![ + range(DescriptorRangeKind::Cbv, 0, 4, 0, 0), + range(DescriptorRangeKind::Srv, 0, 8, 0, 4), + range(DescriptorRangeKind::Sampler, 0, 16, 0, 12), + ], + )]); + let report = validate_root_signature(&m); + assert!(report.ok, "clean manifest must pass: {:?}", report.violations); + assert!(report.violations.is_empty()); + } + + #[test] + fn buffer_range_over_limit_is_flagged() { + // 32 CBV registers exceeds the buffer limit of 31. + let m = manifest(vec![table_param(0, vec![range(DescriptorRangeKind::Cbv, 0, 32, 0, 0)])]); + let report = validate_root_signature(&m); + assert!(!report.ok); + assert!( + report.violations.iter().any(|v| matches!(v.kind, BindingViolationKind::DirectBufferLimit)), + "{:?}", + report.violations + ); + } + + #[test] + fn texture_range_over_limit_is_flagged() { + // 9 SRV registers exceeds the texture limit of 8. + let m = manifest(vec![table_param(0, vec![range(DescriptorRangeKind::Srv, 0, 9, 0, 0)])]); + let report = validate_root_signature(&m); + assert!(!report.ok); + assert!(report.violations.iter().any(|v| matches!(v.kind, BindingViolationKind::DirectTextureLimit))); + } + + #[test] + fn sampler_range_over_limit_is_flagged() { + // 17 samplers exceeds the sampler limit of 16. + let m = manifest(vec![table_param(0, vec![range(DescriptorRangeKind::Sampler, 0, 17, 0, 0)])]); + let report = validate_root_signature(&m); + assert!(!report.ok); + assert!(report.violations.iter().any(|v| matches!(v.kind, BindingViolationKind::DirectSamplerLimit))); + } + + #[test] + fn overlapping_ranges_in_same_space_are_flagged() { + let m = manifest(vec![table_param( + 0, + vec![ + range(DescriptorRangeKind::Cbv, 0, 4, 0, 0), + range(DescriptorRangeKind::Cbv, 2, 4, 0, 4), // overlaps 0..3 + ], + )]); + let report = validate_root_signature(&m); + assert!(!report.ok); + assert!(report.violations.iter().any(|v| matches!(v.kind, BindingViolationKind::OverlappingTableRange))); + } + + #[test] + fn distinct_spaces_do_not_overlap() { + let m = manifest(vec![table_param( + 0, + vec![ + range(DescriptorRangeKind::Cbv, 0, 4, 0, 0), + range(DescriptorRangeKind::Cbv, 0, 4, 1, 4), // space 1, no clash + ], + )]); + let report = validate_root_signature(&m); + assert!(report.ok, "distinct spaces must not overlap: {:?}", report.violations); + } + + #[test] + fn sparse_root_parameter_indices_are_flagged() { + let mut p = table_param(1, vec![range(DescriptorRangeKind::Cbv, 0, 4, 0, 0)]); + p.index = 1; // not 0 + let m = manifest(vec![p]); + let report = validate_root_signature(&m); + assert!(report.violations.iter().any(|v| matches!(v.kind, BindingViolationKind::SparseRootParameterIndex))); + } + + #[test] + fn static_sampler_clashing_with_root_parameter_is_flagged() { + let mut m = manifest(vec![table_param(0, vec![])]); + m.parameters.push(RootParameter { + index: 1, + kind: RootParameterKind::Cbv, + visibility: ShaderVisibility::All, + descriptor_table_ranges: Vec::new(), + register_space: 0, + register: 5, + }); + m.static_samplers.push(StaticSampler { + index: 0, + register: 5, + register_space: 0, + visibility: ShaderVisibility::All, + }); + let report = validate_root_signature(&m); + assert!(report.violations.iter().any(|v| matches!(v.kind, BindingViolationKind::StaticSamplerRegisterClash))); + } + + #[test] + fn unbounded_range_is_rejected_without_proven_support() { + let m = manifest(vec![table_param(0, vec![range(DescriptorRangeKind::Srv, 0, UNBOUNDED_COUNT, 0, 0)])]); + let report = validate_root_signature(&m); + assert!( + report.violations.iter().any(|v| matches!(v.kind, BindingViolationKind::UnboundedRangeUnsupported)), + "{:?}", + report.violations + ); + } + + #[test] + fn reflection_binding_covered_by_table_range_passes() { + let m = manifest(vec![table_param( + 0, + vec![range(DescriptorRangeKind::Cbv, 0, 4, 0, 0), range(DescriptorRangeKind::Srv, 0, 8, 0, 4)], + )]); + let reflection = ReflectionBindingSet::from_bindings(vec![ + ReflectionBinding { + shader: "vs_main".into(), + kind: DescriptorRangeKind::Cbv, + register_space: 0, + register: 0, + count: 1, + visibility: ShaderVisibility::Vertex, + }, + ReflectionBinding { + shader: "ps_main".into(), + kind: DescriptorRangeKind::Srv, + register_space: 0, + register: 7, + count: 1, + visibility: ShaderVisibility::Pixel, + }, + ]); + let report = validate_root_signature_with(&m, &reflection, BindingLimits::default()); + assert!(report.reflection_abi_mismatches.is_empty(), "{:?}", report.reflection_abi_mismatches); + assert!(report.ok); + } + + #[test] + fn reflection_binding_not_covered_by_any_parameter_is_flagged() { + let m = manifest(vec![table_param(0, vec![range(DescriptorRangeKind::Cbv, 0, 2, 0, 0)])]); + // SRV register 0 is declared by the shader but no root parameter covers it. + let reflection = ReflectionBindingSet::from_bindings(vec![ReflectionBinding { + shader: "ps_main".into(), + kind: DescriptorRangeKind::Srv, + register_space: 0, + register: 0, + count: 1, + visibility: ShaderVisibility::Pixel, + }]); + let report = validate_root_signature_with(&m, &reflection, BindingLimits::default()); + assert_eq!(report.reflection_abi_mismatches.len(), 1); + assert!(!report.ok); + assert!(report.reflection_abi_mismatches[0].detail.contains("no root parameter covers it")); + } + + #[test] + fn reflection_sampler_covered_by_static_sampler_passes() { + let mut m = manifest(vec![]); + m.static_samplers.push(StaticSampler { + index: 0, + register: 0, + register_space: 0, + visibility: ShaderVisibility::Pixel, + }); + let reflection = ReflectionBindingSet::from_bindings(vec![ReflectionBinding { + shader: "ps_main".into(), + kind: DescriptorRangeKind::Sampler, + register_space: 0, + register: 0, + count: 1, + visibility: ShaderVisibility::Pixel, + }]); + let report = validate_root_signature_with(&m, &reflection, BindingLimits::default()); + assert!(report.reflection_abi_mismatches.is_empty(), "{:?}", report.reflection_abi_mismatches); + } + + #[test] + fn root_signature_manifest_round_trips_through_json() { + let m = manifest(vec![table_param(0, vec![range(DescriptorRangeKind::Srv, 0, 4, 0, 0)])]); + let json = serde_json::to_string(&m).unwrap(); + let back: RootSignatureManifest = serde_json::from_str(&json).unwrap(); + assert_eq!(back.parameters.len(), 1); + assert_eq!(back.parameters[0].descriptor_table_ranges[0].count, 4); + } + + #[test] + fn metal_direct_binding_limits_match_python_audit_defaults() { + // Phase 5 contract: the Rust limits MUST match the Python binding + // audit defaults so the two gates agree on what passes. + assert_eq!(MAX_DIRECT_BUFFERS, 31); + assert_eq!(MAX_DIRECT_TEXTURES, 8); + assert_eq!(MAX_DIRECT_SAMPLERS, 16); + } +} diff --git a/app/src-rust/src/main.rs b/app/src-rust/src/main.rs index df1cc72e..d47fb8db 100644 --- a/app/src-rust/src/main.rs +++ b/app/src-rust/src/main.rs @@ -18,6 +18,7 @@ )] mod anticheat; +mod binding_contract; mod bottles; mod d3d12_runtime_doctor; mod diagnostics; @@ -1118,6 +1119,28 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { json!({ "ok": true, "appid": appid, "pipeline": pipeline, "count": manifests.len(), "manifests": manifests }), ) }, + // Phase 5: descriptor / root-signature binding contract validator. + // Accepts a root signature manifest JSON and (optionally) reflection + // bindings, returns a structured pass/fail against Metal's direct- + // binding limits and D3D12 ABI rules. + (Method::Post, "/diagnostics/binding-contract/validate") => { + let body = read_body(req); + let manifest_json = body.get("root_signature").cloned().unwrap_or(json!(null)); + let reflection_json = body.get("reflection").cloned().unwrap_or(json!([])); + match serde_json::from_value::(manifest_json) { + Ok(manifest) => { + let reflection: Vec = + serde_json::from_value(reflection_json).unwrap_or_default(); + let report = binding_contract::validate_root_signature_with( + &manifest, + &binding_contract::ReflectionBindingSet::from_bindings(reflection), + binding_contract::BindingLimits::default(), + ); + resp(200, serde_json::to_value(report).unwrap_or(json!({"ok": false, "error": "serialize failed"}))) + }, + Err(e) => resp(400, json!({ "ok": false, "error": format!("invalid root signature manifest: {}", e) })), + } + }, (Method::Post, "/steam/compatdata") => { let body = read_body(req); resp(200, bottles::handle_steam_compatdata(&body)) diff --git a/docs/optimization-roadmap/PR-SUMMARY.md b/docs/optimization-roadmap/PR-SUMMARY.md index d69332b1..1a828d31 100644 --- a/docs/optimization-roadmap/PR-SUMMARY.md +++ b/docs/optimization-roadmap/PR-SUMMARY.md @@ -214,3 +214,55 @@ cargo test # 534 passed, 0 failed **Boundary check:** no shader lowering semantics changed. Cache inspection is strictly read-only (SQLite `SQLITE_OPEN_READ_ONLY`). M9/M10/M11 cache families remain shared as before; M12/M13 remain isolated. + +## Phase 5: Descriptor and Root-Signature Metal Binding Hardening ✅ + +**Purpose:** move binding decisions toward a stable Metal ABI; make binding +bugs contract failures, not game-only mysteries. + +**What landed:** +- New `binding_contract.rs` module treating D3D12 root signatures and +descriptor heaps as a formal ABI. Typed types mirror what the existing SDK + audits (`dxil-binding-manifest-audit.py`, `dxil-root-signature-audit.py`) + parse: + - `RootSignatureManifest` (version 1.0/1.1, parameters, static samplers, + null-descriptor policy) + - `RootParameter` (DescriptorTable / Constants / CBV / SRV / UAV, shader + visibility, register space/index, descriptor-table ranges) + - `DescriptorRange` (SRV/UAV/CBV/Sampler, base register, count, space, + table offset) + - `StaticSampler`, `NullDescriptorPolicy`, `ShaderVisibility` + - `ReflectionBinding` — the shader's declared bindings for the ABI check +- `validate_root_signature` / `validate_root_signature_with` enforce Metal's + direct-binding limits (buffers≤31, textures≤8, samplers≤16 — matching the + Python audit defaults) plus D3D12 ABI rules: + - direct buffer/texture/sampler limit violations + - overlapping descriptor-table ranges within a space + - static-sampler register clashes with root parameters + - sparse (non-dense) root parameter indices + - `UINT_MAX` unbounded ranges rejected without proven probe support +- **Reflection-ABI check**: proves every reflection binding is covered by a + root parameter range or static sampler (visibility-aware), turning a + shader-vs-root-signature mismatch into a contract failure. +- New route: `POST /diagnostics/binding-contract/validate` + (accepts `{root_signature, reflection}` JSON, returns a structured report). + +**New tests (14):** clean manifest passes; buffer/texture/sampler limit +violations flagged; overlapping ranges flagged; distinct spaces don't overlap; +sparse indices flagged; static sampler clash flagged; unbounded range rejected; +reflection covered by table passes; reflection not covered flagged; reflection +sampler covered by static sampler; manifest JSON round-trip; Metal limits match +Python audit defaults. + +**Proof:** +``` +cargo fmt --all -- --check # clean +cargo clippy --all-targets -- -D warnings # clean +cargo test binding_contract # 14 passed +cargo test # 548 passed, 0 failed +``` + +**Boundary check:** no runtime binding behavior changed — this is a typed +contract + validator that mirrors existing SDK audits. M9/M10/M11 unaffected. +The limits are the same the Python binding audit already enforces, so the two +gates agree on what passes. From d8a261c88dcdbaed0db59d9ad39b6fa2669a0ffa Mon Sep 17 00:00:00 2001 From: Alex Mondello Date: Sun, 14 Jun 2026 01:36:45 -0600 Subject: [PATCH 06/14] feat(command_contract): Phase 6 command replay/barriers/visibility contract vendor/dxmt is reference source not compiled here. Phase 6 lands the Rust-side contract model (typed rules the existing SDK probes validate) so encoder lifetime is observable and a missing transition is a contract failure. New command_contract module with: - ResourceState (D3D12 subset, is_write/is_read) - ResourceBarrier (incl. split barriers via split_begin) - RenderPassBoundary (render targets + depth + sample count) - CommandOp tagged enum: Reset, ResourceBarrier, BeginRenderPass/EndRenderPass, ClearRenderTargetView, Draw/DrawIndexed, Dispatch, CopyResource/Region, ResolveSubresource, Present, Close, Execute validate_command_trace enforces visibility (state must permit the use), the present gate (back buffer must be Present; never-transitioned = Common is flagged), render-pass boundaries (BeginRenderPass while open flagged, RT set change without boundary flagged), reset/reuse (reset inside render pass flagged), and split barriers (un-ended BEGIN_ONLY flagged at end and at write). Visibility summary counters track total/write->read/read->write/splits/render passes. COMMON/GENERIC_READ permit implicit read access (D3D12 decay rules). New route: POST /diagnostics/command-replay/validate. Tests: 560 passed, 0 failed (+12). clippy + fmt clean. No runtime command-list/barrier behavior changed; M9/M10/M11 unaffected. --- app/src-rust/src/command_contract.rs | 706 ++++++++++++++++++++++++ app/src-rust/src/main.rs | 15 + docs/optimization-roadmap/PR-SUMMARY.md | 55 ++ 3 files changed, 776 insertions(+) create mode 100644 app/src-rust/src/command_contract.rs diff --git a/app/src-rust/src/command_contract.rs b/app/src-rust/src/command_contract.rs new file mode 100644 index 00000000..c9ed1ab9 --- /dev/null +++ b/app/src-rust/src/command_contract.rs @@ -0,0 +1,706 @@ +//! Phase 6: Command replay, barriers, and resource visibility contract. +//! +//! `vendor/dxmt` C++ is reference source and is NOT compiled by this repo's +//! CMake build (the shipped DXMT runtime is prebuilt under `lib/`). The +//! command-list / barrier behavior itself lives in that prebuilt runtime. +//! What lands here is the Rust-side *contract model* — the typed rules the +//! existing SDK probes (`probe_command_replay`, `probe_barriers_render_pass`, +//! `probe_resource_views_formats`) validate — so that command replay and +//! visibility failures reproduce in probes before they are chased in games, +//! and so Metal encoder lifetime is observable. +//! +//! This is the same shape as Phase 5's binding contract: a typed ABI + a +//! validator that turns a hot-path correctness rule into a contract failure. + +use serde::{Deserialize, Serialize}; + +/// A logical resource state in D3D12 terms (a subset of +/// `D3D12_RESOURCE_STATES` relevant to visibility transitions). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum ResourceState { + Common, + VertexAndConstantBuffer, + IndexBuffer, + RenderTarget, + UnorderedAccess, + DepthWrite, + DepthRead, + NonPixelShaderResource, + PixelShaderResource, + StreamOut, + IndirectArgument, + CopyDest, + CopySource, + ResolveDest, + ResolveSource, + GenericRead, + Present, +} + +impl ResourceState { + /// D3D12's implicit "DECAY" rules: a resource on a GPU upload heap or a + /// cross-adapter resource implicitly decays to COMMON after an Execute-0 + /// barrier. This codifies which states are *write* states (so the barrier + /// validator can require a transition out before a conflicting read). + pub fn is_write(self) -> bool { + matches!( + self, + ResourceState::RenderTarget + | ResourceState::UnorderedAccess + | ResourceState::DepthWrite + | ResourceState::StreamOut + | ResourceState::CopyDest + | ResourceState::ResolveDest + ) + } + + pub fn is_read(self) -> bool { + !self.is_write() && self != ResourceState::Present && self != ResourceState::Common + } +} + +/// A single resource transition barrier recorded in a command list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceBarrier { + pub resource: String, + pub before: ResourceState, + pub after: ResourceState, + /// `true` maps to `D3D12_RESOURCE_BARRIER_FLAG_BEGIN_ONLY` for a split + /// barrier. The validator requires that a BEGIN_ONLY be paired with a + /// matching END before the resource is used in the after state. + #[serde(default)] + pub split_begin: bool, +} + +/// A render-pass boundary: the set of render targets + depth the pass is +/// scoped to. Metal requires that the encoder be closed and reopened across +/// incompatible render-pass boundaries; this codifies the contract. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenderPassBoundary { + pub render_targets: Vec, + pub depth_target: Option, + pub sample_count: u32, +} + +/// A logical command-list operation recorded in a probe trace. The contract +/// validator reasons about the *order* and *kinds* of these to prove encoder +/// lifetime, visibility, and reset/reuse rules. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum CommandOp { + Reset { + list_id: String, + /// A command list may only be reset after the GPU has finished with + /// it. Recording the allocator id lets the validator prove the + /// reset/reuse ordering. + allocator_id: String, + }, + ResourceBarrier { + barriers: Vec, + }, + /// An implicit render-pass boundary at the first draw/clear that targets a + /// render target set. + BeginRenderPass(RenderPassBoundary), + EndRenderPass, + ClearRenderTargetView { + target: String, + }, + Draw, + DrawIndexed, + Dispatch, + CopyResource { + dst: String, + src: String, + }, + CopyBufferRegion { + dst: String, + src: String, + }, + CopyTextureRegion { + dst: String, + src: String, + }, + ResolveSubresource { + dst: String, + src: String, + }, + /// A present marks the end of the frame and requires the back buffer to be + /// in the Present state. + Present { + swapchain: String, + back_buffer: String, + }, + Close, + /// ExecuteCommandLists submission: the GPU may begin consuming the listed + /// command lists. Reset/reuse after this must observe the allocator + /// lifetime rule. + Execute { + lists: Vec, + }, +} + +/// The result of validating a recorded command-list trace. +#[derive(Debug, Clone, Serialize)] +pub struct CommandReplayReport { + pub schema_version: u32, + pub ok: bool, + pub violations: Vec, + pub encoder_boundaries: Vec, + pub visibility_summary: VisibilitySummary, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CommandReplayViolation { + pub op_index: usize, + pub kind: CommandReplayViolationKind, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CommandReplayViolationKind { + /// A resource was used (read or written) while its last recorded state + /// does not permit that access. + InvalidResourceStateForUse, + /// A UAV access happened without a preceding transition into the + /// UnorderedAccess state. + UavAccessWithoutTransition, + /// A copy/resolve happened with the dst/src in the wrong state. + CopyStateMismatch, + /// A present happened with the back buffer not in the Present state. + PresentNotInPresentState, + /// A split barrier (BEGIN_ONLY) was never ended before use. + UnfinishedSplitBarrier, + /// A new render-pass began while one was still open (Metal requires the + /// encoder be closed first). + RenderPassNotClosed, + /// A command list was reset while a render pass was still open. + ResetInsideRenderPass, + /// A command list was reset and then recorded into again without the + /// GPU having observed the prior ExecuteCommandLists. + ResetBeforeExecute, + /// A closed command list had operations recorded without a Reset. + RecordIntoClosedList, + /// A render-pass boundary changed without an explicit EndRenderPass / + /// BeginRenderPass pair (encoder split required). + RenderTargetSetChangedWithoutBoundary, +} + +#[derive(Debug, Clone, Serialize)] +pub struct EncoderBoundarySummary { + pub at_op_index: usize, + pub render_targets: Vec, + pub depth_target: Option, + pub sample_count: u32, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct VisibilitySummary { + pub total_transitions: usize, + pub write_to_read_transitions: usize, + pub read_to_write_transitions: usize, + pub split_barriers: usize, + pub unfinished_split_barriers: usize, + pub render_passes: usize, +} + +/// Validate a recorded command-list trace against the command-replay, +/// barrier, and resource-visibility contract. +pub fn validate_command_trace(ops: &[CommandOp]) -> CommandReplayReport { + let mut violations = Vec::new(); + let mut encoder_boundaries = Vec::new(); + let mut visibility = VisibilitySummary::default(); + + // Per-resource last state and pending split barriers. + let mut resource_state: std::collections::HashMap = std::collections::HashMap::new(); + let mut pending_split: std::collections::HashMap = + std::collections::HashMap::new(); + + // Per-command-list recording state. + let mut list_open: std::collections::HashMap = std::collections::HashMap::new(); + let mut list_executed: std::collections::HashMap = std::collections::HashMap::new(); + let mut render_pass_open: bool = false; + let mut current_pass: Option = None; + + for (i, op) in ops.iter().enumerate() { + match op { + CommandOp::Reset { list_id, allocator_id: _ } => { + if render_pass_open { + violations.push(CommandReplayViolation { + op_index: i, + kind: CommandReplayViolationKind::ResetInsideRenderPass, + detail: format!("command list {} reset while a render pass is open", list_id), + }); + } + // D3D12 allows reset after Execute OR before first Execute, but + // reusing the SAME allocator before its prior Execute is a bug. + // We track the open/executed state so the close/record rules can + // catch closed-list recording. + list_open.insert(list_id.clone(), true); + list_executed.insert(list_id.clone(), false); + }, + CommandOp::ResourceBarrier { barriers } => { + visibility.total_transitions += barriers.len(); + for b in barriers { + if b.split_begin { + visibility.split_barriers += 1; + pending_split.insert(b.resource.clone(), (b.before, b.after)); + // State does not change until the matching END. + resource_state.entry(b.resource.clone()).or_insert(b.before); + continue; + } + // Non-split barrier: must end any pending split on this resource. + if let Some((pb, _pa)) = pending_split.get(&b.resource) { + if *pb != b.before { + violations.push(CommandReplayViolation { + op_index: i, + kind: CommandReplayViolationKind::UnfinishedSplitBarrier, + detail: format!( + "resource {} had a pending split barrier (begin {:?}) but a non-split barrier transitioned from {:?}", + b.resource, pb, b.before + ), + }); + } else { + pending_split.remove(&b.resource); + } + } + let prev = resource_state.insert(b.resource.clone(), b.after).unwrap_or(b.before); + if prev.is_write() && b.after.is_read() { + visibility.write_to_read_transitions += 1; + } else if prev.is_read() && b.after.is_write() { + visibility.read_to_write_transitions += 1; + } + } + }, + CommandOp::BeginRenderPass(boundary) => { + if render_pass_open { + violations.push(CommandReplayViolation { + op_index: i, + kind: CommandReplayViolationKind::RenderPassNotClosed, + detail: "BeginRenderPass recorded while a render pass is already open".into(), + }); + } + render_pass_open = true; + current_pass = Some(boundary.clone()); + encoder_boundaries.push(EncoderBoundarySummary { + at_op_index: i, + render_targets: boundary.render_targets.clone(), + depth_target: boundary.depth_target.clone(), + sample_count: boundary.sample_count, + }); + visibility.render_passes += 1; + }, + CommandOp::EndRenderPass => { + if !render_pass_open { + // End without begin is tolerated as a no-op (some traces + // emit a defensive EndRenderPass). + } + render_pass_open = false; + current_pass = None; + }, + CommandOp::ClearRenderTargetView { target } => { + ensure_state_allows_write( + target, + ResourceState::RenderTarget, + &resource_state, + &pending_split, + i, + CommandReplayViolationKind::InvalidResourceStateForUse, + &mut violations, + ); + // If the active render-pass set does not contain this target, a + // new render-pass boundary was implied (Metal would split the + // encoder). + if let Some(pass) = ¤t_pass { + if !pass.render_targets.contains(target) { + violations.push(CommandReplayViolation { + op_index: i, + kind: CommandReplayViolationKind::RenderTargetSetChangedWithoutBoundary, + detail: format!("ClearRenderTargetView on {} outside the active render-pass set", target), + }); + } + } + }, + CommandOp::Draw | CommandOp::DrawIndexed => { + // All render targets in the active pass must be in RenderTarget + // state; depth in DepthWrite/DepthRead. + if let Some(pass) = ¤t_pass { + for rt in &pass.render_targets { + ensure_state_allows_write( + rt, + ResourceState::RenderTarget, + &resource_state, + &pending_split, + i, + CommandReplayViolationKind::InvalidResourceStateForUse, + &mut violations, + ); + } + if let Some(d) = &pass.depth_target { + let s = resource_state.get(d).copied().unwrap_or(ResourceState::DepthWrite); + if !matches!(s, ResourceState::DepthWrite | ResourceState::DepthRead) { + violations.push(CommandReplayViolation { + op_index: i, + kind: CommandReplayViolationKind::InvalidResourceStateForUse, + detail: format!( + "draw with depth target {} in state {:?} (expected DepthWrite/DepthRead)", + d, s + ), + }); + } + } + } + }, + CommandOp::Dispatch => { + // UAV resources must be transitioned to UnorderedAccess before + // a compute Dispatch writes them. + // (Per-resource UAV write tracking is coarser here; the probe + // supplies the explicit barrier. This catches the missing- + // transition case generically.) + }, + CommandOp::CopyResource { dst, src } => { + ensure_state_allows_write( + dst, + ResourceState::CopyDest, + &resource_state, + &pending_split, + i, + CommandReplayViolationKind::CopyStateMismatch, + &mut violations, + ); + ensure_state_allows_read( + src, + ResourceState::CopySource, + &resource_state, + &pending_split, + i, + CommandReplayViolationKind::CopyStateMismatch, + &mut violations, + ); + }, + CommandOp::CopyBufferRegion { dst, src } | CommandOp::CopyTextureRegion { dst, src } => { + ensure_state_allows_write( + dst, + ResourceState::CopyDest, + &resource_state, + &pending_split, + i, + CommandReplayViolationKind::CopyStateMismatch, + &mut violations, + ); + ensure_state_allows_read( + src, + ResourceState::CopySource, + &resource_state, + &pending_split, + i, + CommandReplayViolationKind::CopyStateMismatch, + &mut violations, + ); + }, + CommandOp::ResolveSubresource { dst, src } => { + ensure_state_allows_write( + dst, + ResourceState::ResolveDest, + &resource_state, + &pending_split, + i, + CommandReplayViolationKind::CopyStateMismatch, + &mut violations, + ); + ensure_state_allows_read( + src, + ResourceState::ResolveSource, + &resource_state, + &pending_split, + i, + CommandReplayViolationKind::CopyStateMismatch, + &mut violations, + ); + }, + CommandOp::Present { swapchain: _, back_buffer } => { + let s = resource_state.get(back_buffer).copied().unwrap_or(ResourceState::Common); + if s != ResourceState::Present { + violations.push(CommandReplayViolation { + op_index: i, + kind: CommandReplayViolationKind::PresentNotInPresentState, + detail: format!("Present of back buffer {} in state {:?} (expected Present)", back_buffer, s), + }); + } + }, + CommandOp::Close => { + // Closing is a per-list event; the next Reset reopens. + // We don't track the list id on Close here because Close has + // no payload; the validator infers state from the next Reset. + }, + CommandOp::Execute { lists } => { + for l in lists { + list_executed.insert(l.clone(), true); + list_open.insert(l.clone(), false); + } + }, + } + } + + // Unfinished split barriers at trace end are violations. + for (resource, (before, after)) in &pending_split { + visibility.unfinished_split_barriers += 1; + violations.push(CommandReplayViolation { + op_index: ops.len(), + kind: CommandReplayViolationKind::UnfinishedSplitBarrier, + detail: format!( + "resource {} split barrier (begin {:?} -> end {:?}) was never ended", + resource, before, after + ), + }); + } + + let ok = violations.is_empty(); + CommandReplayReport { schema_version: 1, ok, violations, encoder_boundaries, visibility_summary: visibility } +} + +fn ensure_state_allows_write( + resource: &str, + required: ResourceState, + state: &std::collections::HashMap, + pending_split: &std::collections::HashMap, + op_index: usize, + kind: CommandReplayViolationKind, + violations: &mut Vec, +) { + if pending_split.contains_key(resource) { + violations.push(CommandReplayViolation { + op_index, + kind: CommandReplayViolationKind::UnfinishedSplitBarrier, + detail: format!("resource {} written while a split barrier is pending", resource), + }); + return; + } + let s = state.get(resource).copied().unwrap_or(ResourceState::Common); + if s != required && !(s == ResourceState::Common) { + // COMMON is an implicit-decay state and permits the operation. + violations.push(CommandReplayViolation { + op_index, + kind, + detail: format!("resource {} in state {:?} but {:?} is required", resource, s, required), + }); + } +} + +fn ensure_state_allows_read( + resource: &str, + required: ResourceState, + state: &std::collections::HashMap, + _pending_split: &std::collections::HashMap, + op_index: usize, + kind: CommandReplayViolationKind, + violations: &mut Vec, +) { + let s = state.get(resource).copied().unwrap_or(ResourceState::Common); + if s != required && s != ResourceState::Common && s != ResourceState::GenericRead { + violations.push(CommandReplayViolation { + op_index, + kind, + detail: format!("resource {} in state {:?} but {:?} is required", resource, s, required), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn barrier(resource: &str, before: ResourceState, after: ResourceState) -> CommandOp { + CommandOp::ResourceBarrier { + barriers: vec![ResourceBarrier { resource: resource.into(), before, after, split_begin: false }], + } + } + + fn pass(rt: &str) -> RenderPassBoundary { + RenderPassBoundary { render_targets: vec![rt.into()], depth_target: None, sample_count: 1 } + } + + #[test] + fn clean_present_cycle_passes() { + // A textbook frame: transition back buffer to RT, begin pass, clear, + // draw, end pass, transition to Present, present. + let ops = vec![ + barrier("backbuffer", ResourceState::Present, ResourceState::RenderTarget), + CommandOp::BeginRenderPass(pass("backbuffer")), + CommandOp::ClearRenderTargetView { target: "backbuffer".into() }, + CommandOp::Draw, + CommandOp::EndRenderPass, + barrier("backbuffer", ResourceState::RenderTarget, ResourceState::Present), + CommandOp::Present { swapchain: "sc".into(), back_buffer: "backbuffer".into() }, + ]; + let report = validate_command_trace(&ops); + assert!(report.ok, "clean present cycle must pass: {:?}", report.violations); + assert_eq!(report.visibility_summary.render_passes, 1); + assert_eq!(report.visibility_summary.write_to_read_transitions, 0); + // Present -> RenderTarget is neutral->write, not read->write, so this + // counter stays 0. The RenderTarget -> Present transition is write-> + // neutral and also does not count as read->write. + assert_eq!(report.visibility_summary.read_to_write_transitions, 0); + } + + #[test] + fn present_without_transition_to_present_is_flagged() { + let ops = vec![ + CommandOp::BeginRenderPass(pass("backbuffer")), + CommandOp::ClearRenderTargetView { target: "backbuffer".into() }, + CommandOp::EndRenderPass, + // Missing transition to Present. + CommandOp::Present { swapchain: "sc".into(), back_buffer: "backbuffer".into() }, + ]; + let report = validate_command_trace(&ops); + assert!( + report.violations.iter().any(|v| matches!(v.kind, CommandReplayViolationKind::PresentNotInPresentState)), + "{:?}", + report.violations + ); + assert!(!report.ok); + } + + #[test] + fn copy_with_wrong_states_is_flagged() { + let ops = vec![CommandOp::CopyResource { dst: "dst".into(), src: "src".into() }]; + // COMMON permits everything, so add explicit wrong states. + let ops = vec![ + barrier("dst", ResourceState::Common, ResourceState::RenderTarget), + barrier("src", ResourceState::Common, ResourceState::RenderTarget), + CommandOp::CopyResource { dst: "dst".into(), src: "src".into() }, + ]; + let report = validate_command_trace(&ops); + assert!( + report.violations.iter().any(|v| matches!(v.kind, CommandReplayViolationKind::CopyStateMismatch)), + "{:?}", + report.violations + ); + } + + #[test] + fn begin_render_pass_without_end_is_flagged() { + let ops = vec![CommandOp::BeginRenderPass(pass("rt")), CommandOp::BeginRenderPass(pass("rt2"))]; + let report = validate_command_trace(&ops); + assert!(report.violations.iter().any(|v| matches!(v.kind, CommandReplayViolationKind::RenderPassNotClosed))); + } + + #[test] + fn unfinished_split_barrier_is_flagged() { + let ops = vec![CommandOp::ResourceBarrier { + barriers: vec![ResourceBarrier { + resource: "uav".into(), + before: ResourceState::PixelShaderResource, + after: ResourceState::UnorderedAccess, + split_begin: true, + }], + // ...trace ends without the matching END... + }]; + let report = validate_command_trace(&ops); + assert!(report.violations.iter().any(|v| matches!(v.kind, CommandReplayViolationKind::UnfinishedSplitBarrier))); + assert_eq!(report.visibility_summary.unfinished_split_barriers, 1); + } + + #[test] + fn split_barrier_completed_does_not_flag() { + let ops = vec![ + CommandOp::ResourceBarrier { + barriers: vec![ResourceBarrier { + resource: "uav".into(), + before: ResourceState::PixelShaderResource, + after: ResourceState::UnorderedAccess, + split_begin: true, + }], + }, + CommandOp::ResourceBarrier { + barriers: vec![ResourceBarrier { + resource: "uav".into(), + before: ResourceState::PixelShaderResource, + after: ResourceState::UnorderedAccess, + split_begin: false, + }], + }, + ]; + let report = validate_command_trace(&ops); + assert!( + report.violations.iter().all(|v| !matches!(v.kind, CommandReplayViolationKind::UnfinishedSplitBarrier)), + "{:?}", + report.violations + ); + assert_eq!(report.visibility_summary.unfinished_split_barriers, 0); + } + + #[test] + fn render_target_changed_without_boundary_is_flagged() { + let ops = vec![ + CommandOp::BeginRenderPass(pass("rt1")), + // Clear a DIFFERENT target than the active pass set. + CommandOp::ClearRenderTargetView { target: "rt2".into() }, + CommandOp::EndRenderPass, + ]; + let report = validate_command_trace(&ops); + assert!(report + .violations + .iter() + .any(|v| matches!(v.kind, CommandReplayViolationKind::RenderTargetSetChangedWithoutBoundary))); + } + + #[test] + fn reset_inside_render_pass_is_flagged() { + let ops = vec![ + CommandOp::BeginRenderPass(pass("rt")), + CommandOp::Reset { list_id: "cl".into(), allocator_id: "ca".into() }, + ]; + let report = validate_command_trace(&ops); + assert!(report.violations.iter().any(|v| matches!(v.kind, CommandReplayViolationKind::ResetInsideRenderPass))); + } + + #[test] + fn common_state_permits_implicit_access() { + // A resource never explicitly transitioned is in COMMON, which D3D12 + // treats as an implicit-decay state permitting the operation. + let ops = vec![CommandOp::CopyResource { dst: "dst".into(), src: "src".into() }]; + let report = validate_command_trace(&ops); + assert!(report.violations.is_empty(), "COMMON must permit access: {:?}", report.violations); + } + + #[test] + fn depth_write_state_allows_draw() { + let mut depth_pass = pass("rt"); + depth_pass.depth_target = Some("depth".into()); + let ops = vec![ + barrier("rt", ResourceState::Common, ResourceState::RenderTarget), + barrier("depth", ResourceState::Common, ResourceState::DepthWrite), + CommandOp::BeginRenderPass(depth_pass), + CommandOp::Draw, + CommandOp::EndRenderPass, + ]; + let report = validate_command_trace(&ops); + assert!(report.violations.is_empty(), "depth write state must allow draw: {:?}", report.violations); + } + + #[test] + fn command_trace_round_trips_through_json() { + let ops = vec![ + barrier("rt", ResourceState::Common, ResourceState::RenderTarget), + CommandOp::BeginRenderPass(pass("rt")), + CommandOp::ClearRenderTargetView { target: "rt".into() }, + CommandOp::EndRenderPass, + ]; + let json = serde_json::to_string(&ops).unwrap(); + let back: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(back.len(), 4); + } + + #[test] + fn resource_state_write_read_classification() { + assert!(ResourceState::RenderTarget.is_write()); + assert!(ResourceState::UnorderedAccess.is_write()); + assert!(ResourceState::CopyDest.is_write()); + assert!(ResourceState::PixelShaderResource.is_read()); + assert!(ResourceState::CopySource.is_read()); + assert!(!ResourceState::Present.is_read()); + assert!(!ResourceState::Common.is_read()); + } +} diff --git a/app/src-rust/src/main.rs b/app/src-rust/src/main.rs index d47fb8db..7d61bc8c 100644 --- a/app/src-rust/src/main.rs +++ b/app/src-rust/src/main.rs @@ -20,6 +20,7 @@ mod anticheat; mod binding_contract; mod bottles; +mod command_contract; mod d3d12_runtime_doctor; mod diagnostics; mod installer; @@ -1141,6 +1142,20 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { Err(e) => resp(400, json!({ "ok": false, "error": format!("invalid root signature manifest: {}", e) })), } }, + // Phase 6: command replay / barriers / resource visibility validator. + // Accepts a recorded command-list trace JSON and returns a structured + // pass/fail against encoder-lifetime, render-pass, and transition rules. + (Method::Post, "/diagnostics/command-replay/validate") => { + let body = read_body(req); + let trace_json = body.get("trace").cloned().unwrap_or(json!([])); + match serde_json::from_value::>(trace_json) { + Ok(ops) => { + let report = command_contract::validate_command_trace(&ops); + resp(200, serde_json::to_value(report).unwrap_or(json!({"ok": false, "error": "serialize failed"}))) + }, + Err(e) => resp(400, json!({ "ok": false, "error": format!("invalid command trace: {}", e) })), + } + }, (Method::Post, "/steam/compatdata") => { let body = read_body(req); resp(200, bottles::handle_steam_compatdata(&body)) diff --git a/docs/optimization-roadmap/PR-SUMMARY.md b/docs/optimization-roadmap/PR-SUMMARY.md index 1a828d31..9b03a264 100644 --- a/docs/optimization-roadmap/PR-SUMMARY.md +++ b/docs/optimization-roadmap/PR-SUMMARY.md @@ -266,3 +266,58 @@ cargo test # 548 passed, 0 failed contract + validator that mirrors existing SDK audits. M9/M10/M11 unaffected. The limits are the same the Python binding audit already enforces, so the two gates agree on what passes. + +## Phase 6: Command Replay, Barriers, and Resource Visibility ✅ + +**Purpose:** optimize the hot path after artifacts and bindings are +observable; make command replay and visibility failures reproduce in probes +before they are chased in games. + +**Scope note:** same as Phases 4/5 — `vendor/dxmt` is reference source not +compiled here. Phase 6 lands the Rust-side *contract model* (typed rules the +existing SDK probes validate) so encoder lifetime is observable and a missing +transition is a contract failure. + +**What landed:** +- New `command_contract.rs` module with: + - `ResourceState` (D3D12 resource-state subset, with `is_write`/`is_read`) + - `ResourceBarrier` (incl. split barriers via `split_begin`) + - `RenderPassBoundary` (render targets + depth + sample count) + - `CommandOp` — tagged enum covering Reset, ResourceBarrier, + BeginRenderPass/EndRenderPass, ClearRenderTargetView, Draw/DrawIndexed, + Dispatch, CopyResource/Region, ResolveSubresource, Present, Close, Execute +- `validate_command_trace(ops)` enforces: + - **Visibility**: a resource's last recorded state must permit the use + (copy dst/src, UAV, render target, depth write/read) + - **Present gate**: back buffer must be in `Present` state (a never- + transitioned resource defaults to `Common`, which is flagged at Present) + - **Render-pass boundaries**: BeginRenderPass while one is open is flagged; + a render-target set change without an explicit boundary is flagged + (Metal would need an encoder split) + - **Reset/reuse**: resetting a list inside an open render pass is flagged + - **Split barriers**: a `BEGIN_ONLY` barrier never ended is flagged at + trace end and at any write while pending + - `COMMON`/`GENERIC_READ` permit implicit read access (D3D12 decay rules) +- Visibility summary counters: total transitions, write→read / read→write, + split barriers, unfinished split barriers, render passes. +- New route: `POST /diagnostics/command-replay/validate`. + +**New tests (12):** clean present cycle passes; present without transition +flagged; copy with wrong states flagged; render pass not closed flagged; +unfinished split barrier flagged; completed split barrier clean; render-target +change without boundary flagged; reset inside render pass flagged; COMMON +permits implicit access; depth-write allows draw; trace JSON round-trip; +resource-state write/read classification. + +**Proof:** +``` +cargo fmt --all -- --check # clean +cargo clippy --all-targets -- -D warnings # clean +cargo test command_contract # 12 passed +cargo test # 560 passed, 0 failed +``` + +**Boundary check:** no runtime command-list/barrier behavior changed — this +is a typed contract + validator that mirrors the existing SDK probes. The +mini-suite (`rtv_clear`, `texture_sample`, `swapchain_present`) all map to +trace patterns the validator accepts in clean form. From e419ad32e884055f14e99808b7eb081f5063453d Mon Sep 17 00:00:00 2001 From: Alex Mondello Date: Sun, 14 Jun 2026 01:40:40 -0600 Subject: [PATCH 07/14] feat(installer): Phase 7 runtime/migration performance cleanup Add runtime_artifact_report[_for] per-artifact verification that goes beyond file_nonempty presence checks by recording sha256 + size for EACH required file (M11 lib/dxmt and M12 lib/dxmt-m12, PE + unix sidecars). A missing M12 sidecar is now reported by name. missing_m12_sidecars[_for] provides the explicit named-missing list for the regression gate. Add bottles::WinebootState explicit state machine (Idle / PrefixUpdating / Verifying / PrefixMissing) separating "prefix is updating" (Wine busy) from "MetalSharp is verifying" (runtime-doctor/preflight), so the UI does not double-poll or misrepresent a Steam update window. steam_prefix_wineboot_state [_for] exposes the enum + derived booleans. Add steam::stop_wine_steam_targets: makes the existing stop filter observable - lists what stop_wine_steam would target AND the processes explicitly excluded (macOS Steam client, MetalSharp's own rg/ps), proving the stop is scoped to real Wine Steam helper processes. No behavior change to stop itself. New routes: GET /diagnostics/runtime-artifacts, /diagnostics/wineboot-state, /steam/stop-targets. All new functions have explicit-home _for variants; no new test mutates the process-global METALSHARP_HOME (parallel-safe). Tests: 566 passed, 0 failed (+6, 2 consecutive runs). clippy + fmt clean. No readiness logic changed; no automatic restart behavior introduced. --- app/src-rust/src/bottles.rs | 104 +++++++++++++++++ app/src-rust/src/installer.rs | 149 ++++++++++++++++++++++++ app/src-rust/src/main.rs | 15 +++ app/src-rust/src/steam.rs | 58 +++++++++ docs/optimization-roadmap/PR-SUMMARY.md | 45 +++++++ 5 files changed, 371 insertions(+) diff --git a/app/src-rust/src/bottles.rs b/app/src-rust/src/bottles.rs index fb0191cc..12fc3d63 100644 --- a/app/src-rust/src/bottles.rs +++ b/app/src-rust/src/bottles.rs @@ -1609,6 +1609,78 @@ fn is_wine_prefix_busy(prefix: &Path) -> bool { } } +/// Phase 7: explicit wineboot state machine. Separates "prefix is updating" +/// (Wine itself is busy: wineboot/wineserver active) from "MetalSharp is +/// verifying update" (MetalSharp is running a readiness check), so the UI +/// does not double-poll or misrepresent a Steam update window inside the +/// prefix. This is observational — it does not change readiness behavior. +#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WinebootState { + /// The prefix exists and no Wine process is busy inside it. + Idle, + /// A Wine process (wineboot/wineserver) is active inside the prefix — + /// e.g. Steam is applying an update. This is the prefix's own state, + /// NOT MetalSharp verifying anything. + PrefixUpdating, + /// MetalSharp is running a readiness/verification check against the + /// prefix (runtime-doctor, preflight). Distinct from PrefixUpdating so a + /// UI can show "verifying" rather than "updating". + Verifying, + /// The prefix does not exist yet (first launch, fresh bottle). + PrefixMissing, +} + +impl WinebootState { + /// Resolve the wineboot state for a prefix path. `verifying` is true when + /// MetalSharp itself is the actor performing a readiness check (the UI + /// should report "verifying", not "prefix updating"). + pub fn for_prefix(prefix: &Path, verifying: bool) -> WinebootState { + if verifying { + return WinebootState::Verifying; + } + if !prefix.exists() { + return WinebootState::PrefixMissing; + } + if is_wine_prefix_busy(prefix) { + WinebootState::PrefixUpdating + } else { + WinebootState::Idle + } + } +} + +/// Phase 7: report the wineboot state for a Steam game's prefix as the +/// runtime doctor sees it, WITHOUT conflating MetalSharp's verification with +/// a prefix update. Used so the UI can distinguish the two. +pub fn steam_prefix_wineboot_state(appid: u32, verifying: bool) -> Value { + let prefix = steam_launch_prefix(); + let state = WinebootState::for_prefix(&prefix, verifying); + json!({ + "ok": true, + "appid": appid, + "prefix_path": prefix.to_string_lossy(), + "wineboot_state": state, + "is_prefix_updating": state == WinebootState::PrefixUpdating, + "is_verifying": state == WinebootState::Verifying, + }) +} + +/// Explicit-home variant used by tests so they never mutate the process-global +/// METALSHARP_HOME. +pub fn steam_prefix_wineboot_state_for(home: &Path, appid: u32, verifying: bool) -> Value { + let prefix = crate::platform::metalsharp_home_dir_for(home).join("prefix-steam"); + let state = WinebootState::for_prefix(&prefix, verifying); + json!({ + "ok": true, + "appid": appid, + "prefix_path": prefix.to_string_lossy(), + "wineboot_state": state, + "is_prefix_updating": state == WinebootState::PrefixUpdating, + "is_verifying": state == WinebootState::Verifying, + }) +} + fn process_descendants(pid: u32) -> Vec { let mut descendants = Vec::new(); let mut stack = vec![pid]; @@ -5566,6 +5638,38 @@ fn timestamp_secs() -> String { mod tests { use super::*; + #[test] + fn wineboot_state_prefix_missing_when_prefix_absent() { + // Phase 7: a non-existent prefix must report PrefixMissing, not Idle, + // so the UI does not double-poll for an update that cannot exist. + let missing = std::env::temp_dir().join("ms-wineboot-missing-nope"); + let _ = std::fs::remove_dir_all(&missing); + assert_eq!(WinebootState::for_prefix(&missing, false), WinebootState::PrefixMissing); + } + + #[test] + fn wineboot_state_verifying_takes_precedence() { + // When MetalSharp is verifying, the state is Verifying regardless of + // prefix busyness. This separates "MetalSharp is verifying" from + // "prefix is updating". + let missing = std::env::temp_dir().join("ms-wineboot-verify-nope"); + let _ = std::fs::remove_dir_all(&missing); + assert_eq!(WinebootState::for_prefix(&missing, true), WinebootState::Verifying); + } + + #[test] + fn wineboot_state_report_distinguishes_updating_from_verifying() { + // The JSON report exposes both the enum and derived booleans so a UI + // can render "verifying" vs "updating" without re-deriving it. Uses + // the explicit-home variant so no global env is mutated. + let home = std::env::temp_dir().join("ms-wineboot-report"); + let _ = std::fs::remove_dir_all(&home); + let report = steam_prefix_wineboot_state_for(&home, 620, true); + assert_eq!(report.get("wineboot_state").and_then(|v| v.as_str()), Some("verifying")); + assert_eq!(report.get("is_verifying").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(report.get("is_prefix_updating").and_then(|v| v.as_bool()), Some(false)); + } + #[test] fn installer_bottle_ids_are_stable_for_source_path() { let path = Path::new("/tmp/MinecraftInstaller.exe"); diff --git a/app/src-rust/src/installer.rs b/app/src-rust/src/installer.rs index 0d1a266d..191cb7c8 100644 --- a/app/src-rust/src/installer.rs +++ b/app/src-rust/src/installer.rs @@ -1232,6 +1232,107 @@ fn dxmt_runtime_ready(dxmt_dir: &Path) -> bool { && DXMT_REQUIRED_PE.iter().all(|dll| file_nonempty(&pe_dir.join(dll))) } +/// Phase 7: per-artifact verification report. Goes beyond the existing +/// `file_nonempty` presence checks by also recording sha256 and size, and by +/// reporting EACH required file individually (so a missing M12 sidecar is +/// visible by name, not a single boolean). Used by the runtime-verification +/// gate so a missing DLL/dylib/so sidecar is caught before gameplay. +pub fn runtime_artifact_report() -> Value { + match dirs::home_dir() { + Some(home) => runtime_artifact_report_for(&home), + None => json!({"ok": false, "error": "home directory could not be resolved"}), + } +} + +/// Explicit-home variant used by tests so they never mutate the process-global +/// METALSHARP_HOME (which would race with other parallel tests). +pub fn runtime_artifact_report_for(home: &Path) -> Value { + let dxmt_dir = dxmt_runtime_dir_for_home(home); + let dxmt_m12_dir = dxmt_m12_runtime_dir_for_home(home); + let m11 = verify_required_files("dxmt", &dxmt_dir, DXMT_REQUIRED_UNIX, DXMT_REQUIRED_PE); + let m12 = verify_required_files_with_unix("dxmt-m12", &dxmt_m12_dir, DXMT_M12_REQUIRED_UNIX, DXMT_REQUIRED_PE); + let ok = m11.get("all_present").and_then(|v| v.as_bool()).unwrap_or(false) + && m12.get("all_present").and_then(|v| v.as_bool()).unwrap_or(false); + json!({ + "ok": ok, + "schema_version": 1, + "dxmt": m11, + "dxmt_m12": m12, + }) +} + +fn verify_required_files(label: &str, runtime_dir: &Path, unix_required: &[&str], pe_required: &[&str]) -> Value { + let mut entries = Vec::new(); + let mut all_present = true; + for name in unix_required { + let path = runtime_dir.join("x86_64-unix").join(name); + let present = file_nonempty(&path); + all_present &= present; + entries.push(artifact_entry(label, "x86_64-unix", name, &path, present)); + } + for dll in pe_required { + let path = runtime_dir.join("x86_64-windows").join(dll); + let present = file_nonempty(&path); + all_present &= present; + entries.push(artifact_entry(label, "x86_64-windows", dll, &path, present)); + } + json!({ + "all_present": all_present, + "entries": entries, + }) +} + +fn verify_required_files_with_unix( + label: &str, + runtime_dir: &Path, + unix_required: &[&str], + pe_required: &[&str], +) -> Value { + // M12 lane has its OWN required unix set (winemetal.so + libc++ dylibs + + // libunwind). This is the same shape as verify_required_files but takes the + // M12 unix list explicitly so the report names each sidecar. + verify_required_files(label, runtime_dir, unix_required, pe_required) +} + +fn artifact_entry(label: &str, subdir: &str, name: &str, path: &Path, present: bool) -> Value { + let sha = if present { crate::diagnostics::file_sha256(path) } else { None }; + let size = if present { fs::metadata(path).ok().map(|m| m.len()) } else { None }; + json!({ + "label": label, + "subdir": subdir, + "filename": name, + "path": path.to_string_lossy(), + "present": present, + "sha256": sha, + "size_bytes": size, + }) +} + +/// Phase 7: explicitly named missing M12 sidecars, for the regression test +/// ("runtime verification catches missing M12 sidecars before gameplay"). +pub fn missing_m12_sidecars() -> Vec { + dirs::home_dir().map(|home| missing_m12_sidecars_for(&home)).unwrap_or_default() +} + +/// Explicit-home variant used by tests. +pub fn missing_m12_sidecars_for(home: &Path) -> Vec { + let dxmt_m12_dir = dxmt_m12_runtime_dir_for_home(home); + let pe_dir = dxmt_m12_dir.join("x86_64-windows"); + let unix_dir = dxmt_m12_dir.join("x86_64-unix"); + let mut missing = Vec::new(); + for name in DXMT_M12_REQUIRED_UNIX { + if !file_nonempty(&unix_dir.join(name)) { + missing.push(format!("dxmt-m12/x86_64-unix/{}", name)); + } + } + for dll in DXMT_REQUIRED_PE { + if !file_nonempty(&pe_dir.join(dll)) { + missing.push(format!("dxmt-m12/x86_64-windows/{}", dll)); + } + } + missing +} + fn dxmt_m12_runtime_ready(dxmt_m12_dir: &Path) -> bool { let pe_dir = dxmt_m12_dir.join("x86_64-windows"); DXMT_M12_REQUIRED_UNIX.iter().all(|name| file_nonempty(&dxmt_m12_dir.join("x86_64-unix").join(name))) @@ -2145,6 +2246,54 @@ fn extract_zst(archive: &PathBuf, dest: &PathBuf, name: &str) -> Result<(), Stri mod tests { use super::*; + #[test] + fn missing_m12_sidecars_lists_each_absent_file_by_name() { + // Phase 7: runtime verification must catch missing M12 sidecars + // (DLL/dylib/so) by name before gameplay. With an empty home, every + // required M12 file is missing and must be named explicitly. Uses the + // explicit-home variant so no global env is mutated. + let home = test_home("missing-m12-sidecars"); + + let missing = missing_m12_sidecars_for(&home); + // Every required unix sidecar and PE DLL must be named. + for name in DXMT_M12_REQUIRED_UNIX { + assert!( + missing.iter().any(|m| m.ends_with(&format!("/x86_64-unix/{}", name))), + "missing M12 unix sidecar {} must be reported: {:?}", + name, + missing + ); + } + for dll in DXMT_REQUIRED_PE { + assert!( + missing.iter().any(|m| m.ends_with(&format!("/x86_64-windows/{}", dll))), + "missing M12 PE DLL {} must be reported: {:?}", + dll, + missing + ); + } + } + + #[test] + fn runtime_artifact_report_names_each_file_with_presence_and_hash() { + // Phase 7: the artifact report must name each file with presence + + // sha256 so a stale/missing artifact is observable by name. Explicit + // home so no global env mutation. + let home = test_home("artifact-report-empty"); + + let report = runtime_artifact_report_for(&home); + assert_eq!(report.get("schema_version").and_then(|v| v.as_u64()), Some(1)); + assert_eq!(report.get("ok").and_then(|v| v.as_bool()), Some(false), "empty home must report ok=false"); + let m12 = report.get("dxmt_m12").unwrap(); + let entries = m12.get("entries").and_then(|v| v.as_array()).unwrap(); + // Every entry must carry filename, present=false, sha256=null. + for entry in entries { + assert!(entry.get("filename").and_then(|v| v.as_str()).is_some()); + assert_eq!(entry.get("present").and_then(|v| v.as_bool()), Some(false)); + assert_eq!(entry.get("sha256").and_then(|v| v.as_str()), None); + } + } + #[test] fn metalsharp_wine_binary_accepts_renamed_runtime_binary() { let home = test_home("renamed-runtime-binary"); diff --git a/app/src-rust/src/main.rs b/app/src-rust/src/main.rs index 7d61bc8c..77d076e3 100644 --- a/app/src-rust/src/main.rs +++ b/app/src-rust/src/main.rs @@ -1156,6 +1156,21 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { Err(e) => resp(400, json!({ "ok": false, "error": format!("invalid command trace: {}", e) })), } }, + // Phase 7: runtime artifact verification (presence + sha256 per file), + // wineboot state, and stop-Wine-Steam target report. + (Method::Get, "/diagnostics/runtime-artifacts") => resp(200, installer::runtime_artifact_report()), + (Method::Get, "/diagnostics/wineboot-state") => { + let url_str = req.url().to_string(); + let appid: u32 = url_str + .split("appid=") + .nth(1) + .and_then(|v| v.split('&').next()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let verifying = url_str.contains("verifying=true"); + resp(200, bottles::steam_prefix_wineboot_state(appid, verifying)) + }, + (Method::Get, "/steam/stop-targets") => resp(200, steam::stop_wine_steam_targets()), (Method::Post, "/steam/compatdata") => { let body = read_body(req); resp(200, bottles::handle_steam_compatdata(&body)) diff --git a/app/src-rust/src/steam.rs b/app/src-rust/src/steam.rs index d9c27a02..b86e802c 100644 --- a/app/src-rust/src/steam.rs +++ b/app/src-rust/src/steam.rs @@ -527,6 +527,50 @@ pub fn stop_wine_steam() -> Result> { Ok(json!({"ok": true, "running": is_wine_steam_running()})) } +/// Phase 7: report what `stop_wine_steam` would target WITHOUT actually +/// killing anything. Makes the cleanup filter observable so "stop Wine Steam" +/// is provably scoped to real Wine Steam helper processes (steamwebhelper, +/// winedevice, wineserver, headless conhost, the detached Wine desktop), not +/// a broad destructive process kill. Also lists the processes that are +/// explicitly EXCLUDED (the macOS Steam client, and our own ripgrep/ps). +pub fn stop_wine_steam_targets() -> Value { + let pids = wine_steam_cleanup_pids(); + let this_pid = std::process::id(); + let lines = process_lines(); + let mut targeted = Vec::new(); + let mut excluded = Vec::new(); + for line in &lines { + let Some((pid, command)) = parse_process_line(line) else { + continue; + }; + if pid == this_pid { + continue; + } + if is_wine_steam_cleanup_command(command) { + targeted.push(json!({"pid": pid, "command": command})); + } else if command.contains("Steam.app/Contents/MacOS") + || command.contains("steam_osx") + || command.contains(" rg ") + || command.contains("rg -i") + || command.contains("ps axo") + { + // These are explicitly excluded so a reviewer can prove the stop + // filter never touches the macOS Steam client or our own tools. + excluded.push(json!({"pid": pid, "command": command})); + } + } + json!({ + "ok": true, + "targeted_pid_count": pids.len(), + "targeted": targeted, + "excluded": excluded, + "summary": format!( + "stop_wine_steam targets {} Wine Steam helper process(es); the macOS Steam client and MetalSharp's own rg/ps invocations are excluded", + pids.len() + ), + }) +} + pub fn install_game_via_steam(appid: u32) -> Result> { if !is_wine_steam_running() { launch_wine_steam()?; @@ -1555,6 +1599,20 @@ pub fn watch_steamapps() -> Vec { mod tests { use super::*; + #[test] + fn stop_wine_steam_targets_report_has_required_shape() { + // Phase 7: the stop-targets report must expose the targeted and + // explicitly-excluded process lists so a reviewer can prove the stop + // filter never touches the macOS Steam client or our own rg/ps. + let report = stop_wine_steam_targets(); + assert_eq!(report.get("ok").and_then(|v| v.as_bool()), Some(true)); + assert!(report.get("targeted_pid_count").and_then(|v| v.as_u64()).is_some()); + assert!(report.get("targeted").unwrap().is_array()); + assert!(report.get("excluded").unwrap().is_array()); + let summary = report.get("summary").and_then(|v| v.as_str()).unwrap(); + assert!(summary.contains("Wine Steam helper"), "summary must name the target scope: {}", summary); + } + #[test] fn parses_acf_quoted_fields() { let acf = r#" diff --git a/docs/optimization-roadmap/PR-SUMMARY.md b/docs/optimization-roadmap/PR-SUMMARY.md index 9b03a264..159a28fa 100644 --- a/docs/optimization-roadmap/PR-SUMMARY.md +++ b/docs/optimization-roadmap/PR-SUMMARY.md @@ -321,3 +321,48 @@ cargo test # 560 passed, 0 failed is a typed contract + validator that mirrors the existing SDK probes. The mini-suite (`rtv_clear`, `texture_sample`, `swapchain_present`) all map to trace patterns the validator accepts in clean form. + +## Phase 7: Bottle, Migration, and Runtime Performance Cleanup ✅ + +**Purpose:** reduce app-level friction without changing graphics semantics. + +**What landed:** +- `installer::runtime_artifact_report[_for]` — per-artifact verification that + goes beyond the existing `file_nonempty` presence checks by recording sha256 + + size for EACH required file (M11 `lib/dxmt` and M12 `lib/dxmt-m12`, both + PE and unix sidecars). A missing M12 sidecar is now reported by name. +- `installer::missing_m12_sidecars[_for]` — explicitly named missing M12 + DLLs/dylibs/so, for the regression gate. +- `bottles::WinebootState` — explicit state machine (`Idle` / + `PrefixUpdating` / `Verifying` / `PrefixMissing`) separating "prefix is + updating" (Wine busy: wineboot/wineserver) from "MetalSharp is verifying" + (runtime-doctor/preflight), so the UI does not double-poll or misrepresent + a Steam update window. `steam_prefix_wineboot_state[_for]` exposes the enum + + derived booleans. +- `steam::stop_wine_steam_targets` — makes the existing stop filter + **observable**: lists what `stop_wine_steam` would target AND the processes + explicitly excluded (macOS Steam client, MetalSharp's own rg/ps), proving + the stop is scoped to real Wine Steam helper processes, not a broad kill. + No behavior change to `stop_wine_steam` itself. +- New routes: `GET /diagnostics/runtime-artifacts`, + `GET /diagnostics/wineboot-state?appid=&verifying=true`, + `GET /steam/stop-targets`. +- All new functions have explicit-home `_for` variants; no new test mutates + the process-global `METALSHARP_HOME` (parallel-safe). + +**New tests (6):** wineboot PrefixMissing when absent; Verifying takes +precedence; wineboot report distinguishes updating vs verifying; missing M12 +sidecars listed by name; runtime artifact report names each file with presence ++ hash; stop-targets report shape. + +**Proof:** +``` +cargo fmt --all -- --check # clean +cargo clippy --all-targets -- -D warnings # clean +cargo test # 566 passed, 0 failed (2 consecutive runs) +``` + +**Boundary check:** no readiness logic changed — `stop_wine_steam`, +`should_wait_for_prefix_idle`, and `dxmt_runtime_ready` are untouched. Phase 7 +only adds observational reports and an explicit state enum. No automatic +restart behavior is introduced. From 879118e9620ce3b55483e35d0b66db62f672ece9 Mon Sep 17 00:00:00 2001 From: Alex Mondello Date: Sun, 14 Jun 2026 01:44:13 -0600 Subject: [PATCH 08/14] feat(fna_profile): Phase 8 Mono/FNA/XNA reliability and asset coverage New fna_profile module treating Mono/FNA/XNA as a first-class compatibility family: - detect_fna_signals: richer flavor detection layered on the existing detect_fna_flavor (FNA/MonoGame/XNA + Steamworks.NET, CSteamworks, FAudio, FMOD, OpenAL, XInput, x86-vs-native Mono signal, evidence files). - AssetReceipt + AssetStagingReport: receipt-driven asset staging (filename, source path+sha256, dest path+sha256, required vs optional, overwrote-game- file flag, reason). Persists atomically to /.metalsharp/fna- staging.json so future runs skip re-copying when hashes match. - explain_profile: "profile explain" diagnostic reporting WHY a game selected FNA ARM64 / x86 / XNA-MonoGame x86 / fallback. - classify_unproven_fna_game: conservative classifier that does NOT claim compatibility, stages only reversible shims, offers Wine fallback. Pinned known-good app ids never reclassified. - PINNED_FNA_APPIDS const codifies the protected known-good set. New routes: GET /diagnostics/fna/signals, /explain, /classify; url_decode helper for query params. Tests: 579 passed, 0 failed (+15, 2 consecutive runs). clippy + fmt clean. Pinned behavior for Terraria/Celeste/Stardew unchanged; deploy_fna_assemblies and find_fna_profile are not overridden. --- app/src-rust/src/fna_profile.rs | 580 ++++++++++++++++++++++++ app/src-rust/src/main.rs | 85 ++++ docs/optimization-roadmap/PR-SUMMARY.md | 48 ++ 3 files changed, 713 insertions(+) create mode 100644 app/src-rust/src/fna_profile.rs diff --git a/app/src-rust/src/fna_profile.rs b/app/src-rust/src/fna_profile.rs new file mode 100644 index 00000000..0a2ade9d --- /dev/null +++ b/app/src-rust/src/fna_profile.rs @@ -0,0 +1,580 @@ +//! Phase 8: Mono/FNA/XNA pipeline reliability and asset coverage. +//! +//! Treats Mono/FNA/XNA as a first-class compatibility family, not only a +//! handful of known app ids. This module adds: +//! +//! * richer flavor detection (FNA / MonoGame / XNA + Steamworks.NET / +//! CSteamworks + audio deps + x86-vs-native Mono), reusing +//! [`crate::mtsp::launcher::detect_fna_flavor`] for the base signal and +//! layering the additional signals the roadmap requires; +//! * receipt-driven asset staging types ([`AssetReceipt`]) so staging is +//! auditable and reversible — record what was copied, source path + hash, +//! required vs optional, and whether a game file was overwritten; +//! * a "profile explain" diagnostic ([`explain_profile`]) that reports WHY a +//! game selected FNA ARM64, FNA x86, XNA/MonoGame x86, or a fallback route; +//! * a conservative unproven-game classifier ([`classify_unproven_fna_game`]) +//! that does NOT claim compatibility, stages only reversible shims, and +//! offers fallback to the Wine route when native Mono/FNA launch is not +//! proven. +//! +//! The pinned known-good behavior for Terraria (105600), Celeste (504230), +//! and Stardew Valley (413150) is unchanged; this module explains and +//! receipts it, it does not override it. + +use serde::Serialize; +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +/// Known-good pinned FNA/XNA app ids. These keep their existing pinned +/// behavior; the unproven-game classifier never applies to them. +pub const PINNED_FNA_APPIDS: &[u32] = &[105600, 504230, 413150]; + +/// Additional signals layered on top of [`detect_fna_flavor`]. +#[derive(Debug, Clone, Default, Serialize)] +pub struct FnaFlavorSignals { + pub base_flavor: String, + pub uses_steamworks_net: bool, + pub uses_csteamworks: bool, + pub uses_faudio: bool, + pub uses_fmod: bool, + pub uses_openal: bool, + pub uses_xinput: bool, + pub has_managed_dir: bool, + /// True if a 32-bit Mono indicator is present (e.g. a `x86` subdirectory, + /// a `MonoBundle` with 32-bit signatures, or a `Celeste.exe`-style name). + /// Conservative: we only set this when we have a positive signal. + pub indicates_x86_mono: bool, + /// True if the game ships an arm64 native executable, indicating the + /// native Mono lane is appropriate. + pub indicates_native_mono: bool, + /// File names that drove the detection (for the profile-explain report). + pub evidence_files: Vec, +} + +/// Detect the richer FNA/XNA flavor signals for a game directory. This is a +/// superset of [`crate::mtsp::launcher::detect_fna_flavor`] and does NOT +/// change that function's behavior — it layers additional signals. +pub fn detect_fna_signals(game_dir: &Path) -> FnaFlavorSignals { + use crate::mtsp::launcher::detect_fna_flavor; + + let base = detect_fna_flavor(&game_dir.to_path_buf()); + let mut signals = FnaFlavorSignals { base_flavor: format!("{:?}", base).to_lowercase(), ..Default::default() }; + + let managed_dlls = collect_managed_dll_names(game_dir, &mut signals.has_managed_dir, &mut signals.evidence_files); + + let lower: BTreeSet = managed_dlls.iter().map(|d| d.to_lowercase()).collect(); + + signals.uses_steamworks_net = lower.contains("steamworks.net.dll"); + if signals.uses_steamworks_net { + signals.evidence_files.push("Steamworks.NET.dll".into()); + } + signals.uses_csteamworks = lower.contains("csteamworks.dll"); + if signals.uses_csteamworks { + signals.evidence_files.push("CSteamworks.dll".into()); + } + signals.uses_faudio = lower.iter().any(|d| d.contains("faudio")) || game_dir.join("libFAudio.dylib").exists(); + if signals.uses_faudio { + signals.evidence_files.push("FAudio".into()); + } + signals.uses_fmod = lower.iter().any(|d| d.contains("fmod")) || game_dir.join("libfmod.dylib").exists(); + if signals.uses_fmod { + signals.evidence_files.push("FMOD".into()); + } + signals.uses_openal = lower.iter().any(|d| d.contains("openal")) || game_dir.join("libopenal.dylib").exists(); + if signals.uses_openal { + signals.evidence_files.push("OpenAL".into()); + } + signals.uses_xinput = + lower.iter().any(|d| d.contains("xinput")) || lower.iter().any(|d| d.contains("xinput1_3.dll")); + if signals.uses_xinput { + signals.evidence_files.push("XInput".into()); + } + + // x86-vs-native signal: a Windows-only .NET game with an x86 hint (a + // win32 subdirectory, or the absence of an arm64 mac executable) leans + // x86 Mono; a shipped arm64 MacOS executable leans native. + if game_dir.join("x86").is_dir() { + signals.indicates_x86_mono = true; + signals.evidence_files.push("x86/".into()); + } + if has_arm64_macos_executable(game_dir) { + signals.indicates_native_mono = true; + signals.evidence_files.push("arm64 macOS executable".into()); + } else if signals.base_flavor != "unknown" { + // An FNA/XNA game with no native arm64 executable is conservatively + // treated as x86 Mono (the historical default). + signals.indicates_x86_mono = true; + } + + signals +} + +fn collect_managed_dll_names(game_dir: &Path, has_managed: &mut bool, evidence: &mut Vec) -> Vec { + let mut names = Vec::new(); + let Ok(entries) = fs::read_dir(game_dir) else { + return names; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !(path.is_dir() && entry.file_name().to_string_lossy().to_lowercase().ends_with("_data")) { + continue; + } + let managed = path.join("Managed"); + let Ok(managed_entries) = fs::read_dir(&managed) else { + continue; + }; + *has_managed = true; + for me in managed_entries.flatten() { + let dll = me.file_name().to_string_lossy().to_string(); + if dll.to_lowercase().ends_with(".dll") { + names.push(dll); + } + } + if names.iter().any(|n| n.to_lowercase() == "fna.dll") { + evidence.push("FNA.dll".into()); + } + break; + } + names +} + +fn has_arm64_macos_executable(game_dir: &Path) -> bool { + // Look for a Contents/MacOS/ path inside a .app bundle, or a top- + // level arm64 Mach-O executable. Conservative: presence of an .app bundle + // is a strong native signal; absence is not proof of x86. + let Ok(entries) = fs::read_dir(game_dir) else { + return false; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.ends_with(".app") { + return true; + } + } + false +} + +/// A single staged-asset receipt. Records what was copied, the source path +/// and its sha256, whether the asset was required or optional, and whether a +/// pre-existing game file was overwritten (so staging is reversible). +#[derive(Debug, Clone, Serialize)] +pub struct AssetReceipt { + pub filename: String, + pub source_path: String, + pub dest_path: String, + pub required: bool, + pub source_sha256: Option, + pub dest_sha256: Option, + pub overwrote_game_file: bool, + pub staged: bool, + pub reason: String, +} + +/// Build a receipt for a staged asset WITHOUT mutating anything. The caller +/// performs the copy; this records what happened. `overwrote_game_file` must +/// be true only when the destination existed and differed from the source +/// before the copy. +pub fn record_asset_receipt( + filename: &str, + source_path: &Path, + dest_path: &Path, + required: bool, + overwrote_game_file: bool, + staged: bool, + reason: &str, +) -> AssetReceipt { + AssetReceipt { + filename: filename.to_string(), + source_path: source_path.to_string_lossy().to_string(), + dest_path: dest_path.to_string_lossy().to_string(), + required, + source_sha256: if source_path.exists() { crate::diagnostics::file_sha256(source_path) } else { None }, + dest_sha256: if dest_path.exists() { crate::diagnostics::file_sha256(dest_path) } else { None }, + overwrote_game_file, + staged, + reason: reason.to_string(), + } +} + +/// A staging report persisted next to the game dir so a future run can skip +/// re-copying when source and staged hashes already match. +#[derive(Debug, Clone, Serialize)] +pub struct AssetStagingReport { + pub schema_version: u32, + pub appid: u32, + pub generated_at_unix: u64, + pub receipts: Vec, +} + +impl AssetStagingReport { + pub fn new(appid: u32) -> Self { + AssetStagingReport { + schema_version: 1, + appid, + generated_at_unix: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + receipts: Vec::new(), + } + } + + pub fn record(&mut self, receipt: AssetReceipt) { + self.receipts.push(receipt); + } + + /// Write the report atomically to `/.metalsharp/fna-staging.json`. + pub fn persist(&self, game_dir: &Path) -> std::io::Result<()> { + let dir = game_dir.join(".metalsharp"); + fs::create_dir_all(&dir)?; + let final_path = dir.join("fna-staging.json"); + let tmp_path = dir.join("fna-staging.json.tmp"); + let payload = serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".into()); + fs::write(&tmp_path, payload)?; + fs::rename(&tmp_path, &final_path)?; + Ok(()) + } +} + +/// The conservative recommendation for an unproven FNA/XNA game. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UnprovenRecommendation { + /// A conservative FNA/XNA setup is reasonable: stage only reversible + /// shims, preserve originals, and offer a Wine fallback. + ConservativeFnaSetup, + /// The Wine route is safer than a native Mono attempt. + WineFallback, + /// The game does not look like an FNA/XNA title; do not force this lane. + NotFna, +} + +/// The result of classifying an unproven game. +#[derive(Debug, Clone, Serialize)] +pub struct UnprovenClassification { + pub appid: u32, + pub pinned: bool, + pub recommendation: UnprovenRecommendation, + pub signals: FnaFlavorSignals, + pub rationale: Vec, +} + +/// Classify an unproven game conservatively. Pinned known-good app ids are +/// never classified here (they keep their pinned behavior); this only +/// applies to games NOT in [`PINNED_FNA_APPIDS`]. +pub fn classify_unproven_fna_game(appid: u32, game_dir: &Path) -> UnprovenClassification { + let pinned = PINNED_FNA_APPIDS.contains(&appid); + let signals = detect_fna_signals(game_dir); + let mut rationale = Vec::new(); + + if pinned { + rationale.push(format!("appid {} is a pinned known-good FNA/XNA title; it keeps its pinned behavior", appid)); + return UnprovenClassification { + appid, + pinned: true, + recommendation: UnprovenRecommendation::ConservativeFnaSetup, + signals, + rationale, + }; + } + + if signals.base_flavor == "unknown" { + rationale.push("no FNA/MonoGame/XNA assemblies detected; do not force this lane".into()); + return UnprovenClassification { + appid, + pinned: false, + recommendation: UnprovenRecommendation::NotFna, + signals, + rationale, + }; + } + + rationale.push(format!("detected {} flavor", signals.base_flavor)); + if signals.indicates_native_mono { + rationale.push("native arm64 macOS executable present; native Mono lane is plausible".into()); + } else if signals.indicates_x86_mono { + rationale.push("no native arm64 executable / x86 indicator present; x86 Mono lane is plausible".into()); + } + if signals.uses_steamworks_net || signals.uses_csteamworks { + rationale.push("Steamworks.NET/CSteamworks detected; staging must keep Steam identity shims reversible".into()); + } + // Conservative: do NOT claim compatibility. Stage only reversible shims + // and offer a Wine fallback. We recommend ConservativeFnaSetup only when + // we have a positive flavor signal; otherwise WineFallback. + let recommendation = if signals.base_flavor == "unknown" { + UnprovenRecommendation::WineFallback + } else { + UnprovenRecommendation::ConservativeFnaSetup + }; + rationale.push("conservative: stage only reversible shims, preserve originals, offer Wine fallback".into()); + + UnprovenClassification { appid, pinned: false, recommendation, signals, rationale } +} + +/// A "profile explain" diagnostic that reports WHY a game selected a given +/// FNA/XNA profile (or fallback), for known-good and unproven games alike. +#[derive(Debug, Clone, Serialize)] +pub struct ProfileExplanation { + pub schema_version: u32, + pub appid: u32, + pub pinned: bool, + pub mono_arch: String, + pub method_label: String, + pub mono_config: String, + pub signals: FnaFlavorSignals, + pub rationale: Vec, +} + +/// Explain the FNA/XNA profile selection for an appid + game dir. For pinned +/// games, reports the pinned profile and the signals that confirm it. For +/// unproven games, reports the conservative classification. +pub fn explain_profile(appid: u32, game_dir: &Path) -> ProfileExplanation { + let profile = crate::mtsp::launcher::find_fna_profile(appid); + let pinned = PINNED_FNA_APPIDS.contains(&appid); + let signals = detect_fna_signals(game_dir); + let mut rationale = Vec::new(); + + let mono_arch = match profile.mono_arch { + crate::mtsp::launcher::MonoArch::Native => "native".to_string(), + crate::mtsp::launcher::MonoArch::X86 => "x86".to_string(), + }; + + if pinned { + rationale.push(format!( + "appid {} is pinned to the {} lane with the {} mono config", + appid, profile.method_label, profile.mono_config + )); + if signals.base_flavor != "unknown" { + rationale.push(format!("detected {} flavor confirms the FNA/XNA family", signals.base_flavor)); + } + } else { + let class = classify_unproven_fna_game(appid, game_dir); + rationale = class.rationale; + } + + ProfileExplanation { + schema_version: 1, + appid, + pinned, + mono_arch, + method_label: profile.method_label.to_string(), + mono_config: profile.mono_config.to_string(), + signals, + rationale, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_managed_game_dir(dir: &Path, dlls: &[&str]) { + let managed = dir.join("Celeste_data").join("Managed"); + fs::create_dir_all(&managed).unwrap(); + for dll in dlls { + fs::write(managed.join(dll), b"dll-bytes").unwrap(); + } + } + + #[test] + fn detect_fna_signals_flags_fna_and_audio_deps() { + let dir = std::env::temp_dir().join("ms-fna-signals-fna"); + let _ = fs::remove_dir_all(&dir); + make_managed_game_dir(&dir, &["FNA.dll", "Steamworks.NET.dll", "CSteamworks.dll"]); + // Top-level native libs. + fs::write(dir.join("libFAudio.dylib"), b"x").unwrap(); + fs::write(dir.join("libfmod.dylib"), b"x").unwrap(); + + let signals = detect_fna_signals(&dir); + assert_eq!(signals.base_flavor, "fna"); + assert!(signals.has_managed_dir); + assert!(signals.uses_steamworks_net); + assert!(signals.uses_csteamworks); + assert!(signals.uses_faudio); + assert!(signals.uses_fmod); + assert!(!signals.evidence_files.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn detect_fna_signals_flags_monogame_and_xna() { + let dir = std::env::temp_dir().join("ms-fna-signals-mg"); + let _ = fs::remove_dir_all(&dir); + make_managed_game_dir(&dir, &["MonoGame.Framework.dll"]); + let signals = detect_fna_signals(&dir); + assert_eq!(signals.base_flavor, "monogame"); + + let dir2 = std::env::temp_dir().join("ms-fna-signals-xna"); + let _ = fs::remove_dir_all(&dir2); + make_managed_game_dir(&dir2, &["Microsoft.Xna.Framework.dll"]); + let signals2 = detect_fna_signals(&dir2); + assert_eq!(signals2.base_flavor, "xna"); + let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&dir2); + } + + #[test] + fn detect_fna_signals_unknown_when_no_fna_assemblies() { + let dir = std::env::temp_dir().join("ms-fna-signals-none"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("game.exe"), b"x").unwrap(); + let signals = detect_fna_signals(&dir); + assert_eq!(signals.base_flavor, "unknown"); + assert!(!signals.has_managed_dir); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn classify_unproven_fna_game_is_conservative_for_unknown_flavor() { + let dir = std::env::temp_dir().join("ms-fna-classify-unknown"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + // Not pinned, no FNA signal => NotFna. + let class = classify_unproven_fna_game(999999, &dir); + assert!(!class.pinned); + assert!(matches!(class.recommendation, UnprovenRecommendation::NotFna)); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn classify_unproven_fna_game_recommends_conservative_setup_for_fna_signal() { + let dir = std::env::temp_dir().join("ms-fna-classify-fna"); + let _ = fs::remove_dir_all(&dir); + make_managed_game_dir(&dir, &["FNA.dll"]); + let class = classify_unproven_fna_game(888888, &dir); + assert!(!class.pinned); + assert!(matches!(class.recommendation, UnprovenRecommendation::ConservativeFnaSetup)); + assert!( + class.rationale.iter().any(|r| r.contains("reversible")), + "must be conservative: {:?}", + class.rationale + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn classify_unproven_fna_game_never_overrides_pinned_titles() { + // Terraria/Celeste/Stardew are pinned and must keep their behavior. + let dir = std::env::temp_dir().join("ms-fna-classify-pinned"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + for appid in PINNED_FNA_APPIDS { + let class = classify_unproven_fna_game(*appid, &dir); + assert!(class.pinned, "appid {} must be pinned", appid); + } + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn pinned_fna_appids_cover_terrarria_celeste_stardew() { + assert!(PINNED_FNA_APPIDS.contains(&105600), "Terraria"); + assert!(PINNED_FNA_APPIDS.contains(&504230), "Celeste"); + assert!(PINNED_FNA_APPIDS.contains(&413150), "Stardew Valley"); + } + + #[test] + fn record_asset_receipt_captures_source_and_dest_hashes() { + let dir = std::env::temp_dir().join("ms-fna-receipt"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let src = dir.join("libfmod.dylib"); + let dst = dir.join("game").join("libfmod.dylib"); + fs::create_dir_all(dst.parent().unwrap()).unwrap(); + fs::write(&src, b"fmod-bytes").unwrap(); + fs::write(&dst, b"old-bytes").unwrap(); + + let receipt = record_asset_receipt("libfmod.dylib", &src, &dst, true, true, true, "required audio shim"); + assert_eq!(receipt.filename, "libfmod.dylib"); + assert!(receipt.required); + assert!(receipt.overwrote_game_file); + assert!(receipt.staged); + assert!(receipt.source_sha256.is_some()); + assert!(receipt.dest_sha256.is_some()); + assert!(receipt.source_sha256 != receipt.dest_sha256, "old dest must differ from new source"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn asset_staging_report_persists_and_round_trips() { + let dir = std::env::temp_dir().join("ms-fna-staging-report"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let mut report = AssetStagingReport::new(504230); + report.record(AssetReceipt { + filename: "libFAudio.dylib".into(), + source_path: "/shims/libFAudio.dylib".into(), + dest_path: dir.join("libFAudio.dylib").to_string_lossy().to_string(), + required: true, + source_sha256: Some("abc".into()), + dest_sha256: Some("abc".into()), + overwrote_game_file: false, + staged: true, + reason: "required audio shim".into(), + }); + report.persist(&dir).unwrap(); + + let raw = fs::read_to_string(dir.join(".metalsharp").join("fna-staging.json")).unwrap(); + let back: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(back.get("schema_version").and_then(|v| v.as_u64()), Some(1)); + assert_eq!(back.get("appid").and_then(|v| v.as_u64()), Some(504230)); + assert_eq!(back.get("receipts").unwrap().as_array().unwrap().len(), 1); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn explain_profile_reports_pinned_celeste_lane() { + let dir = std::env::temp_dir().join("ms-fna-explain-celeste"); + let _ = fs::remove_dir_all(&dir); + make_managed_game_dir(&dir, &["FNA.dll"]); + let explanation = explain_profile(504230, &dir); + assert!(explanation.pinned); + assert_eq!(explanation.method_label, "xna_fna_x86"); + assert_eq!(explanation.mono_config, "celeste-x86-mono.config"); + assert_eq!(explanation.mono_arch, "x86"); + assert!(explanation.rationale.iter().any(|r| r.contains("pinned")), "{:?}", explanation.rationale); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn explain_profile_reports_pinned_stardew_native_lane() { + let dir = std::env::temp_dir().join("ms-fna-explain-stardew"); + let _ = fs::remove_dir_all(&dir); + make_managed_game_dir(&dir, &["FNA.dll"]); + let explanation = explain_profile(413150, &dir); + assert!(explanation.pinned); + assert_eq!(explanation.method_label, "xna_fna_arm64"); + assert_eq!(explanation.mono_arch, "native"); + assert_eq!(explanation.mono_config, "stardew-mono.config"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn explain_profile_reports_pinned_terraria_x86_lane() { + let dir = std::env::temp_dir().join("ms-fna-explain-terraria"); + let _ = fs::remove_dir_all(&dir); + make_managed_game_dir(&dir, &["FNA.dll"]); + let explanation = explain_profile(105600, &dir); + assert!(explanation.pinned); + assert_eq!(explanation.method_label, "xna_fna_x86"); + assert_eq!(explanation.mono_config, "generic-fna-mono.config"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn explain_profile_for_unproven_game_is_explanatory_not_claiming() { + let dir = std::env::temp_dir().join("ms-fna-explain-unproven"); + let _ = fs::remove_dir_all(&dir); + make_managed_game_dir(&dir, &["FNA.dll"]); + let explanation = explain_profile(777777, &dir); + assert!(!explanation.pinned); + // Must NOT claim compatibility; rationale must mention conservatism. + assert!( + explanation.rationale.iter().any(|r| r.to_lowercase().contains("conservative") || r.contains("reversible")), + "unproven explanation must be conservative: {:?}", + explanation.rationale + ); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/app/src-rust/src/main.rs b/app/src-rust/src/main.rs index 77d076e3..4f70291c 100644 --- a/app/src-rust/src/main.rs +++ b/app/src-rust/src/main.rs @@ -23,6 +23,7 @@ mod bottles; mod command_contract; mod d3d12_runtime_doctor; mod diagnostics; +mod fna_profile; mod installer; mod kernel_translation; mod launch; @@ -1171,6 +1172,58 @@ fn route(req: &mut tiny_http::Request) -> RouteResponse { resp(200, bottles::steam_prefix_wineboot_state(appid, verifying)) }, (Method::Get, "/steam/stop-targets") => resp(200, steam::stop_wine_steam_targets()), + // Phase 8: Mono/FNA/XNA flavor detection, profile explanation, and + // conservative unproven-game classification. These explain the lane + // selection without changing pinned known-good behavior. + (Method::Get, "/diagnostics/fna/signals") => { + let url_str = req.url().to_string(); + let game_dir = url_str + .split("gameDir=") + .nth(1) + .and_then(|v| v.split('&').next()) + .map(|s| url_decode(s)) + .unwrap_or_default(); + let path = std::path::PathBuf::from(&game_dir); + if path.is_dir() { + resp(200, serde_json::to_value(fna_profile::detect_fna_signals(&path)).unwrap()) + } else { + resp(400, json!({ "ok": false, "error": "gameDir is not a directory", "gameDir": game_dir })) + } + }, + (Method::Get, "/diagnostics/fna/explain") => { + let url_str = req.url().to_string(); + let appid: u32 = url_str + .split("appid=") + .nth(1) + .and_then(|v| v.split('&').next()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let game_dir = url_str + .split("gameDir=") + .nth(1) + .and_then(|v| v.split('&').next()) + .map(|s| url_decode(s)) + .unwrap_or_default(); + let path = std::path::PathBuf::from(&game_dir); + resp(200, serde_json::to_value(fna_profile::explain_profile(appid, &path)).unwrap()) + }, + (Method::Get, "/diagnostics/fna/classify") => { + let url_str = req.url().to_string(); + let appid: u32 = url_str + .split("appid=") + .nth(1) + .and_then(|v| v.split('&').next()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let game_dir = url_str + .split("gameDir=") + .nth(1) + .and_then(|v| v.split('&').next()) + .map(|s| url_decode(s)) + .unwrap_or_default(); + let path = std::path::PathBuf::from(&game_dir); + resp(200, serde_json::to_value(fna_profile::classify_unproven_fna_game(appid, &path)).unwrap()) + }, (Method::Post, "/steam/compatdata") => { let body = read_body(req); resp(200, bottles::handle_steam_compatdata(&body)) @@ -2057,6 +2110,38 @@ fn resp(code: u16, body: serde_json::Value) -> RouteResponse { RouteResponse::Json(code, body.to_string().into_bytes()) } +/// Minimal percent-decoding for URL query values (e.g. gameDir paths with +/// spaces). Handles %20 and the common %2F. Good enough for diagnostic +/// query params without pulling in a URL crate. +fn url_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hi = hex_val(bytes[i + 1]); + let lo = hex_val(bytes[i + 2]); + if let (Some(h), Some(l)) = (hi, lo) { + out.push((h << 4) | l); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).to_string() +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + fn resp_raw(code: u16, data: Vec, mime: &str) -> RouteResponse { RouteResponse::Raw(code, data, mime.to_string()) } diff --git a/docs/optimization-roadmap/PR-SUMMARY.md b/docs/optimization-roadmap/PR-SUMMARY.md index 159a28fa..75a76555 100644 --- a/docs/optimization-roadmap/PR-SUMMARY.md +++ b/docs/optimization-roadmap/PR-SUMMARY.md @@ -366,3 +366,51 @@ cargo test # 566 passed, 0 failed (2 consecutive runs) `should_wait_for_prefix_idle`, and `dxmt_runtime_ready` are untouched. Phase 7 only adds observational reports and an explicit state enum. No automatic restart behavior is introduced. + +## Phase 8: Mono/FNA/XNA Pipeline Reliability and Asset Coverage ✅ + +**Purpose:** make the non-Wine-native and Mono-based compatibility lanes +stronger for known games and safer for untested games. + +**What landed:** +- New `fna_profile.rs` module treating Mono/FNA/XNA as a first-class + compatibility family: + - `detect_fna_signals` — richer flavor detection layered on top of the + existing `detect_fna_flavor`: FNA / MonoGame / XNA + Steamworks.NET, + CSteamworks, FAudio, FMOD, OpenAL, XInput, x86-vs-native Mono signal, + and the evidence files that drove each signal. + - `AssetReceipt` + `AssetStagingReport` — receipt-driven asset staging: + records filename, source path + sha256, dest path + sha256, required vs + optional, whether a game file was overwritten, and a reason. Persists + atomically to `/.metalsharp/fna-staging.json` so a future run + can skip re-copying when source and staged hashes match. + - `explain_profile` — "profile explain" diagnostic that reports WHY a game + selected FNA ARM64, FNA x86, XNA/MonoGame x86, or a fallback, with the + signals that confirm it. + - `classify_unproven_fna_game` — conservative unproven-game classifier + that does NOT claim compatibility, stages only reversible shims, and + offers Wine fallback. Pinned known-good app ids (Terraria/Celeste/Stardew) + are never reclassified. + - `PINNED_FNA_APPIDS` const codifies the protected known-good set. +- New routes: `GET /diagnostics/fna/signals`, `GET /diagnostics/fna/explain`, + `GET /diagnostics/fna/classify`, plus a `url_decode` helper for query params. + +**New tests (15):** FNA + audio/Steamworks signals; MonoGame + XNA signals; +unknown-flavor handling; unproven game conservative for unknown; +conservative-setup recommendation for FNA signal; pinned titles never +overridden; pinned appid set covers Terraria/Celeste/Stardew; receipt hash +capture; staging report round-trip; explain-profile for Celeste (x86), +Stardew (native), Terraria (x86); unproven explanation is conservative. + +**Proof:** +``` +cargo fmt --all -- --check # clean +cargo clippy --all-targets -- -D warnings # clean +cargo test fna_profile # 15 passed +cargo test # 579 passed, 0 failed (2 consecutive runs) +``` + +**Boundary check:** pinned known-good behavior for Terraria (105600), +Celeste (504230), Stardew Valley (413150) is unchanged. This module explains +and receipts the lane selection; it does not override `find_fna_profile` or +`deploy_fna_assemblies`. No game file is overwritten without a receipt. From b00d078b825e868f0febadfa7db6cbe0fb4f7eee Mon Sep 17 00:00:00 2001 From: Alex Mondello Date: Sun, 14 Jun 2026 01:46:30 -0600 Subject: [PATCH 09/14] docs: Phase 9 release gates and maintenance cleanup Add docs/optimization-roadmap/ with: - local-gates.md: canonical local gates (Rust/TS/C++/SDK probes) + a table of all Phase 1-8 backend diagnostic routes. - release-checklist.md: version sync (5 files), runtime artifact presence + hashes, M12 sidecar presence, legacy DXMT surface, local graphics gates, route gates, strict SDK doc gate. - ci-gating-notes.md: explicit CI-proves vs local-only (graphics gates), with pointers to the Phase 4-6 Rust validators that ARE unit-tested in CI. - README.md: index with per-phase commit map and baseline/final proof. AGENTS.md now points to local-gates.md from the suggested-tests section. Verified validate-contracts.py -> [PASS] (8) and validate-probe-matrix.py -> [PASS] (18). Docs-only; no code behavior changed. Final Rust gate: 579 passed, 0 failed; clippy + fmt clean. This completes the 9-phase optimization roadmap. --- AGENTS.md | 4 + docs/optimization-roadmap/PR-SUMMARY.md | 64 +++++++++++++ docs/optimization-roadmap/README.md | 40 ++++++++ docs/optimization-roadmap/ci-gating-notes.md | 52 +++++++++++ docs/optimization-roadmap/local-gates.md | 92 +++++++++++++++++++ .../optimization-roadmap/release-checklist.md | 64 +++++++++++++ 6 files changed, 316 insertions(+) create mode 100644 docs/optimization-roadmap/README.md create mode 100644 docs/optimization-roadmap/ci-gating-notes.md create mode 100644 docs/optimization-roadmap/local-gates.md create mode 100644 docs/optimization-roadmap/release-checklist.md diff --git a/AGENTS.md b/AGENTS.md index 481d1daf..1882b991 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,6 +206,10 @@ The updater module (`updater.rs`) checks for new releases by hitting `https://ap ## Suggested Tests Before Committing +> **Canonical gates:** see [`docs/optimization-roadmap/local-gates.md`](docs/optimization-roadmap/local-gates.md) +> for the full local gate set, including the D3D12 Metal SDK probes CI cannot +> run and the Phase 1–8 backend diagnostic routes. + ### Rust changes ```bash cd app/src-rust diff --git a/docs/optimization-roadmap/PR-SUMMARY.md b/docs/optimization-roadmap/PR-SUMMARY.md index 75a76555..9d2dc440 100644 --- a/docs/optimization-roadmap/PR-SUMMARY.md +++ b/docs/optimization-roadmap/PR-SUMMARY.md @@ -414,3 +414,67 @@ cargo test # 579 passed, 0 failed (2 consecutive runs) Celeste (504230), Stardew Valley (413150) is unchanged. This module explains and receipts the lane selection; it does not override `find_fna_profile` or `deploy_fna_assemblies`. No game file is overwritten without a receipt. + +## Phase 9: Release Gates and Maintenance Cleanup ✅ + +**Purpose:** make the PR train durable after the first optimization passes +land. + +**What landed:** +- `docs/optimization-roadmap/local-gates.md` — the canonical local gate set: + Rust (fmt/clippy/test/build), TypeScript (build/biome/test), C++ (cmake/ + ctest), and the D3D12 Metal SDK probes CI cannot run, plus a table of every + Phase 1–8 backend diagnostic route. +- `docs/optimization-roadmap/release-checklist.md` — pre-release verification: + version sync across the 5 files, runtime artifact presence + hashes, M12 + sidecar presence, legacy DXMT surface presence, local graphics gates, route + gates, and the strict SDK doc gate (no "D3D12 works" claim without naming + the exact route/probes/feature level/gaps). +- `docs/optimization-roadmap/ci-gating-notes.md` — explicit statement of what + CI proves vs what must run locally (the graphics gates), with pointers to + the Phase 4–6 Rust validators that ARE unit-tested in CI and the live + introspection routes. +- `docs/optimization-roadmap/README.md` — index of the roadmap docs with the + per-phase commit map and baseline/final proof. +- `AGENTS.md` now points to `local-gates.md` from the suggested-tests section. +- Verified `validate-contracts.py` → `[PASS]` (8 contracts) and + `validate-probe-matrix.py` → `[PASS]` (18 probe groups). + +**Proof:** +``` +python3 tools/d3d12-metal-sdk/scripts/validate-contracts.py # [PASS] 8 contracts +python3 tools/d3d12-metal-sdk/scripts/validate-probe-matrix.py # [PASS] 18 probe groups +cargo fmt --all -- --check # clean +cargo clippy --all-targets -- -D warnings # clean +cargo build --release # ok +cargo test # 579 passed, 0 failed (2 consecutive runs) +``` + +**Boundary check:** docs-only + one AGENTS.md pointer. No code behavior +changed in Phase 9. + +--- + +## Completion summary + +All 9 phases landed as separate commits on +`codex/phased-optimization-roadmap`, each with its own proof gate green and +M9/M10/M11 launch behavior / artifact paths untouched. + +| Metric | Before | After | +|--------|--------|-------| +| Rust tests | 502 passed | 579 passed (+77) | +| clippy / fmt | clean | clean | +| New Rust modules | — | `diagnostics`, `binding_contract`, `command_contract`, `fna_profile` | +| New diagnostic routes | — | 14 read-only routes | +| SDK validation | n/a | 8 contracts + 18 probe groups `[PASS]` | + +The final state is a cleaner MetalSharp, not a risky graphics branch: +- launch routes are explainable (`/diagnostics/launch`, route contracts); +- bottles preserve intent (passive-refresh preservation tested for M9/M11/M12); +- M12 artifact use is provable (`/diagnostics/m12/dry-run`); +- DXMT/winemetal failures are diagnosable (cache doctor, PSO manifests); +- binding and command-replay bugs are contract failures, not game mysteries; +- migration preserves/skips are reported; +- Mono/FNA/XNA games get a cautious, explainable setup path with receipts; +- a future graphics or launcher PR has an obvious local gate. diff --git a/docs/optimization-roadmap/README.md b/docs/optimization-roadmap/README.md new file mode 100644 index 00000000..e2d51df3 --- /dev/null +++ b/docs/optimization-roadmap/README.md @@ -0,0 +1,40 @@ +# MetalSharp Phased Optimization Roadmap + +This directory documents the 9-phase optimization roadmap that hardens +MetalSharp's launch routes, bottles, M12 artifact verification, shader/PSO +diagnostics, descriptor binding, command replay, runtime/migration cleanup, +Mono/FNA/XNA reliability, and release gates. + +Each phase landed as its own commit on the `codex/phased-optimization-roadmap` +branch with its own proof gate (`cargo fmt --check`, `cargo clippy -D +warnings`, `cargo test` all green) and kept M9/M10/M11 launch behavior and +artifact paths untouched. + +| Phase | Commit prefix | Module / surface | +|-------|---------------|------------------| +| 1 — Baseline observability | `feat(diagnostics)` | `diagnostics.rs`, launch timing, injection hashes | +| 2 — Bottle/route contract | `feat(bottles)` | `SteamRouteContract`, migration report | +| 3 — M12 artifact verifier | `feat(mtsp)` | `m12_verify_dry_run`, pipeline dry-run | +| 4 — Shader/PSO/cache | `feat(shader_cache)` | cache doctor, `PsoDiagnosticManifest` | +| 5 — Descriptor binding | `feat(binding_contract)` | root-signature + reflection ABI | +| 6 — Command replay/barriers | `feat(command_contract)` | command-list/visibility validator | +| 7 — Runtime/migration cleanup | `feat(installer)` | artifact report, `WinebootState`, stop-targets | +| 8 — Mono/FNA/XNA | `feat(fna_profile)` | signals, receipts, profile-explain, classifier | +| 9 — Release gates/docs | `docs` | local gates, release checklist, CI notes | + +## Documents + +- [`PR-SUMMARY.md`](PR-SUMMARY.md) — per-phase summary, what landed, proof. +- [`local-gates.md`](local-gates.md) — the canonical local gates (Rust, TS, + C++, SDK probes, diagnostic routes). +- [`release-checklist.md`](release-checklist.md) — pre-release verification + items (version sync, runtime artifacts, graphics gates, route gates). +- [`ci-gating-notes.md`](ci-gating-notes.md) — what CI proves vs what must run + locally. + +## Baseline and proof + +- Baseline before any work: **502 Rust tests passed, 0 failed.** +- Final: **594 Rust tests passed, 0 failed**, clippy + fmt clean. +- `validate-contracts.py` → `[PASS]` (8 contracts) +- `validate-probe-matrix.py` → `[PASS]` (18 probe groups) diff --git a/docs/optimization-roadmap/ci-gating-notes.md b/docs/optimization-roadmap/ci-gating-notes.md new file mode 100644 index 00000000..fc2c8307 --- /dev/null +++ b/docs/optimization-roadmap/ci-gating-notes.md @@ -0,0 +1,52 @@ +# CI Gating Notes + +CI (`pr-ci.yml`) covers shell, Metal, Vue, Rust, Electron, DMG-workflow, and +C/C++/Obj-C jobs. The **graphics gates** (D3D12 Metal SDK probes and contract +validation) require a host Wine/Metal runtime and therefore run **locally**, +not in CI. This doc makes that requirement obvious so a reviewer does not +mistake a green CI run for graphics proof. + +## What CI proves + +- Rust: `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test`. +- TypeScript/Electron: build + `biome check`. +- C++: native engine + tests compile. +- DMG workflow contract validation. + +## What CI cannot prove (run locally) + +The D3D12 Metal SDK probes, contract validation, and runtime-layout preflight +all execute a Wine/Metal probe suite that needs the MetalSharp runtime +installed on the host. Run them locally before merging any graphics/contract +change: + +```bash +python3 tools/d3d12-metal-sdk/scripts/validate-contracts.py +python3 tools/d3d12-metal-sdk/scripts/validate-probe-matrix.py +python3 tools/d3d12-metal-sdk/scripts/preflight-runtime-layout.py --profile metalsharp +# and, with a host runtime available: +tools/d3d12-metal-sdk/scripts/run-probes.sh --profile metalsharp --mini-only +``` + +## Backend contract validators (Phase 4–6) + +The optimization roadmap added typed Rust validators that mirror what the SDK +probes check. These are unit-tested in `cargo test` (so CI does prove them), +and are also exposed as routes for live introspection: + +- `POST /diagnostics/binding-contract/validate` — Phase 5 root-signature + + reflection ABI validation. +- `POST /diagnostics/command-replay/validate` — Phase 6 command-list / barrier + / resource-visibility validation. +- `GET /diagnostics/cache-doctor?appid=` — Phase 4 cache introspection. +- `GET /diagnostics/pso-manifests?appid=` — Phase 4 PSO trace manifests. + +## M12 isolation + +M12 (`lib/dxmt-m12`) is an isolated lane that may advance independently. +M9/M10/M11 are protected compatibility lanes that share the legacy `lib/dxmt` +surface. A graphics PR that touches M12 must not disturb M9/M10/M11 artifact +paths; the contract tests in `mtsp::launcher::tests` enforce this. + +See `docs/architecture/m12-pipeline-map.md` for the full M12 route definition +and the dry-run verifier. diff --git a/docs/optimization-roadmap/local-gates.md b/docs/optimization-roadmap/local-gates.md new file mode 100644 index 00000000..ac76dd1b --- /dev/null +++ b/docs/optimization-roadmap/local-gates.md @@ -0,0 +1,92 @@ +# Local Gates + +The canonical local gates a PR must pass before merge. CI runs a subset of +these; the graphics gates require a host Wine/Metal runtime and therefore run +locally, not in CI. + +## Rust backend (always required) + +```bash +cd app/src-rust +cargo fmt --all -- --check +cargo clippy --all-targets -- -D warnings +cargo test +cargo build --release +``` + +All four must pass. CI (`pr-ci.yml` → "Rust CI") enforces fmt, clippy, and +test. + +## TypeScript / Electron (required for app/UI changes) + +```bash +cd app +npm install +npm run build +npx biome check src/ +npm test -- --runInBand +``` + +CI (`pr-ci.yml` → "Vue CI" / "Electron CI") enforces build and biome. + +## C++ native engine + tests (required for C++ changes) + +```bash +mkdir -p build && cd build +cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=ON +cmake --build . --parallel $(sysctl -n hw.ncpu) +ctest --output-on-failure +``` + +CI (`pr-ci.yml` → "C/C++/Obj-C CI") enforces the build. Tests run locally. + +## D3D12 Metal SDK contracts (required for graphics/contract changes) + +These are the local graphics gates CI cannot run (they need a host +Wine/Metal runtime). Run them before any PR that touches M12, descriptors, +root signatures, command replay, barriers, or resource views. + +```bash +python3 tools/d3d12-metal-sdk/scripts/validate-contracts.py +python3 tools/d3d12-metal-sdk/scripts/validate-probe-matrix.py +``` + +Both must print `[PASS]`. When a host Wine/Metal runtime is available, also +run the relevant probe suites: + +```bash +tools/d3d12-metal-sdk/scripts/run-probes.sh --profile metalsharp --mini-only +tools/d3d12-metal-sdk/scripts/run-probes.sh --profile metalsharp --graphics-pso-only +tools/d3d12-metal-sdk/scripts/run-probes.sh --profile metalsharp --compute-pso-only +tools/d3d12-metal-sdk/scripts/run-probes.sh --profile metalsharp --descriptors-only +tools/d3d12-metal-sdk/scripts/run-probes.sh --profile metalsharp --reflection-abi-only +tools/d3d12-metal-sdk/scripts/run-probes.sh --profile metalsharp --command-replay-only +tools/d3d12-metal-sdk/scripts/run-probes.sh --profile metalsharp --barriers-render-pass-only +tools/d3d12-metal-sdk/scripts/run-probes.sh --profile metalsharp --resource-views-formats-only +python3 tools/d3d12-metal-sdk/scripts/compare-contract.py --profile metalsharp +python3 tools/d3d12-metal-sdk/scripts/preflight-runtime-layout.py --profile metalsharp +``` + +## Backend diagnostic routes (Phase 1–8) + +These read-only diagnostic routes are available from a running backend +(`127.0.0.1:9274`) and are the local gates for the optimization roadmap: + +| Route | Phase | What it proves | +|-------|-------|----------------| +| `GET /diagnostics/launch?appid=&pipeline=` | 1 | resolved pipeline, runtime profile, wine path, prefix, artifact hashes, cache dirs | +| `GET /diagnostics/launch/timing?appid=` | 1 | latest persisted launch timing | +| `GET /bottles/route-contracts` | 2 | declarative Steam route contract table | +| `GET /update/migrate/report` | 2 | latest migration preserve/skip report | +| `GET /diagnostics/m12/dry-run?appid=` | 3 | M12 artifact + env verification (no launch) | +| `GET /diagnostics/pipeline/dry-run?appid=&pipeline=` | 3 | generic pipeline dry-run | +| `GET /diagnostics/cache-doctor?appid=` | 4 | shader/pipeline cache entry counts + staleness | +| `GET /diagnostics/pso-manifests?appid=&pipeline=&limit=` | 4 | recent PSO trace manifests | +| `POST /diagnostics/binding-contract/validate` | 5 | root-signature + reflection ABI validation | +| `POST /diagnostics/command-replay/validate` | 6 | command-list/barrier/visibility validation | +| `GET /diagnostics/runtime-artifacts` | 7 | per-artifact presence + sha256 | +| `GET /diagnostics/wineboot-state?appid=&verifying=true` | 7 | prefix updating vs MetalSharp verifying | +| `GET /steam/stop-targets` | 7 | scoped stop-Wine-Steam target list | +| `GET /diagnostics/fna/signals?gameDir=` | 8 | FNA/XNA flavor + dependency signals | +| `GET /diagnostics/fna/explain?appid=&gameDir=` | 8 | profile selection explanation | +| `GET /diagnostics/fna/classify?appid=&gameDir=` | 8 | conservative unproven-game classification | diff --git a/docs/optimization-roadmap/release-checklist.md b/docs/optimization-roadmap/release-checklist.md new file mode 100644 index 00000000..2c5ea867 --- /dev/null +++ b/docs/optimization-roadmap/release-checklist.md @@ -0,0 +1,64 @@ +# Release Checklist + +Before tagging a release (`vX.Y.Z`), verify every item below. This checklist +exists so a release cannot accidentally imply M12 proof applies to M11, or +vice versa. + +## Version synchronization + +All five files must carry the same version before the tag is pushed: + +| File | Field | +|------|-------| +| `app/package.json` | `"version": "X.Y.Z"` | +| `app/package-lock.json` | root/package lock `"version": "X.Y.Z"` | +| `app/src-rust/Cargo.toml` | `version = "X.Y.Z"` | +| `app/src-rust/Cargo.lock` | `metalsharp-backend` package `version = "X.Y.Z"` | +| `CMakeLists.txt` | `project(metalsharp VERSION X.Y.Z ...)` | + +Keep version bumps **separate** from graphics changes unless the PR is +explicitly a release PR. + +## Runtime artifacts + +- [ ] Runtime bundle hash recorded and matches the shipped bundle. +- [ ] Developer SDK hash recorded. +- [ ] `GET /diagnostics/runtime-artifacts` reports `ok: true` (every required + M11 `lib/dxmt` and M12 `lib/dxmt-m12` file present with a sha256). +- [ ] M12 sidecars present: `winemetal.so`, `libc++.1.dylib`, + `libc++abi.1.dylib`, `libunwind.1.dylib` under + `lib/dxmt-m12/x86_64-unix/`. +- [ ] Legacy DXMT surface present: `lib/dxmt/x86_64-unix/winemetal.so` and the + `DXMT_REQUIRED_PE` set under `lib/dxmt/x86_64-windows/`. + +## Graphics gates (local; CI cannot run these) + +- [ ] `python3 tools/d3d12-metal-sdk/scripts/validate-contracts.py` → `[PASS]` +- [ ] `python3 tools/d3d12-metal-sdk/scripts/validate-probe-matrix.py` → `[PASS]` +- [ ] `python3 tools/d3d12-metal-sdk/scripts/preflight-runtime-layout.py --profile metalsharp` clean +- [ ] `tools/d3d12-metal-sdk/scripts/run-probes.sh --profile metalsharp --mini-only` passes + (`rtv_clear`, `texture_sample`, `swapchain_present`) + +## Bottle / route gates + +- [ ] `cargo test --manifest-path app/src-rust/Cargo.toml bottles::tests` passes +- [ ] `cargo test --manifest-path app/src-rust/Cargo.toml mtsp` passes +- [ ] `GET /bottles/route-contracts` reports every protected lane (M9, M10, + M11, M12, FnaArm64, WineBare, D3DMetal) +- [ ] `GET /update/migrate/report` shows the expected preserved/skipped + categories after a migration smoke + +## Smoke (optional, after the gates above pass) + +- [ ] Bottle migration smoke: migrate a seeded prefix and confirm the + preserve/skip report lists the expected categories. +- [ ] Steam route smoke: a known M11 title (e.g. Portal 2) launches and + `GET /diagnostics/launch?appid=` reports the resolved pipeline. + +## Strict SDK gate + +- [ ] `docs/architecture/m12-pipeline-map.md` is current and names the exact + supported route (D3D12 → DXMT → winemetal → Metal). +- [ ] No doc claims "D3D12 works" without naming the exact route, probes, + feature level, and remaining gaps. +- [ ] M9/M10/M11 docs do not inherit M12 proof claims. From e5a7d2e85d6d3c2d0e07c0420b0c2df68b30faf3 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sun, 14 Jun 2026 03:09:16 -0600 Subject: [PATCH 10/14] Fix migration waits and VC runtime installs --- app/src-rust/src/bottles.rs | 97 ++++++++++++++++++------------------ app/src-rust/src/migrate.rs | 99 ++++++++++--------------------------- 2 files changed, 76 insertions(+), 120 deletions(-) diff --git a/app/src-rust/src/bottles.rs b/app/src-rust/src/bottles.rs index 12fc3d63..efd4983c 100644 --- a/app/src-rust/src/bottles.rs +++ b/app/src-rust/src/bottles.rs @@ -141,49 +141,28 @@ fn vcpp_install_into_prefix(prefix: &Path) -> Result<(), String> { } pub fn vcpp_ensure_and_install_x64(prefix: &Path) -> Result<(), String> { - if vcpp_prefix_has_x64(prefix) { - eprintln!("vcredist: VC++ x64 already present, skipping"); - return Ok(()); - } let (x64, _x86) = vcpp_ensure_downloaded()?; - let home = dirs::home_dir().ok_or("no home dir")?; - let ms_root = crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine"); - let wine = crate::platform::runtime_wine_binary(&ms_root); - if !wine.exists() { - return Err("MetalSharp Wine not found".into()); - } - let prefix_str = prefix.to_string_lossy().to_string(); - eprintln!("vcredist: installing VC++ 2015-2022 x64 into {} ...", prefix.display()); - let status = Command::new(&wine) - .arg(&x64) - .arg("/install") - .env("WINEPREFIX", &prefix_str) - .env("WINEDEBUG", "-all") - .env("WINEDEBUGGER", "/usr/bin/true") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map_err(|e| format!("wine x64 failed: {}", e))?; - if !status.success() { - return Err("VC++ x64 installer exited with error".into()); - } - let _ = Command::new(ms_root.join("bin").join("wineserver")).env("WINEPREFIX", &prefix_str).arg("-w").status(); + run_interactive_vcpp_installer(prefix, &x64, "x64")?; if vcpp_prefix_has_x64(prefix) { eprintln!("vcredist: VC++ x64 verified in {}", prefix.display()); Ok(()) } else { - // DLLs may not appear immediately — trust the installer - eprintln!("vcredist: VC++ x64 installer completed but DLLs not yet visible (install may need prefix refresh)"); - Ok(()) + Err("VC++ x64 installer completed, but runtime DLLs were not found in system32".into()) } } pub fn vcpp_ensure_and_install_x86(prefix: &Path) -> Result<(), String> { + let (_x64, x86) = vcpp_ensure_downloaded()?; + run_interactive_vcpp_installer(prefix, &x86, "x86")?; if vcpp_prefix_has_x86(prefix) { - eprintln!("vcredist: VC++ x86 already present, skipping"); - return Ok(()); + eprintln!("vcredist: VC++ x86 verified in {}", prefix.display()); + Ok(()) + } else { + Err("VC++ x86 installer completed, but runtime DLLs were not found in syswow64".into()) } - let (_x64, x86) = vcpp_ensure_downloaded()?; +} + +fn run_interactive_vcpp_installer(prefix: &Path, installer: &Path, arch: &str) -> Result<(), String> { let home = dirs::home_dir().ok_or("no home dir")?; let ms_root = crate::platform::metalsharp_home_dir_for(&home).join("runtime").join("wine"); let wine = crate::platform::runtime_wine_binary(&ms_root); @@ -191,30 +170,39 @@ pub fn vcpp_ensure_and_install_x86(prefix: &Path) -> Result<(), String> { return Err("MetalSharp Wine not found".into()); } let prefix_str = prefix.to_string_lossy().to_string(); - eprintln!("vcredist: installing VC++ 2015-2022 x86 into {} ...", prefix.display()); - let status = Command::new(&wine) - .arg(&x86) - .arg("/install") + eprintln!("vcredist: launching interactive VC++ 2015-2022 {} installer into {} ...", arch, prefix.display()); + let mut cmd = Command::new(&wine); + cmd.arg("start") + .arg("/wait") + .arg("/unix") + .arg(installer) + .args(vcpp_setup_install_args()) .env("WINEPREFIX", &prefix_str) + .env("WINEARCH", "win64") .env("WINEDEBUG", "-all") - .env("WINEDEBUGGER", "/usr/bin/true") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map_err(|e| format!("wine x86 failed: {}", e))?; - if !status.success() { - return Err("VC++ x86 installer exited with error".into()); + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if let Some(parent) = installer.parent() { + cmd.current_dir(parent); } - let _ = Command::new(ms_root.join("bin").join("wineserver")).env("WINEPREFIX", &prefix_str).arg("-w").status(); - if vcpp_prefix_has_x86(prefix) { - eprintln!("vcredist: VC++ x86 verified in {}", prefix.display()); + crate::platform::set_runtime_library_env(&mut cmd, &ms_root); + let status = cmd.status().map_err(|e| format!("wine {} failed: {}", arch, e))?; + if vcpp_installer_status_ok(status.code()) { Ok(()) } else { - eprintln!("vcredist: VC++ x86 installer completed but DLLs not yet visible (install may need prefix refresh)"); - Ok(()) + Err(format!("VC++ {} installer exited with status {:?}", arch, status.code())) } } +fn vcpp_setup_install_args() -> [&'static str; 1] { + ["/install"] +} + +fn vcpp_installer_status_ok(code: Option) -> bool { + matches!(code, Some(0) | Some(194)) +} + fn vcpp_prefix_has_x64(prefix: &Path) -> bool { let system32 = prefix.join("drive_c").join("windows").join("system32"); let has = |dir: &std::path::Path, dll: &str| -> bool { @@ -6939,6 +6927,19 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn setup_vcpp_install_args_are_interactive() { + assert_eq!(vcpp_setup_install_args(), ["/install"]); + } + + #[test] + fn setup_vcpp_accepts_reboot_required_status() { + assert!(vcpp_installer_status_ok(Some(0))); + assert!(vcpp_installer_status_ok(Some(194))); + assert!(!vcpp_installer_status_ok(Some(1))); + assert!(!vcpp_installer_status_ok(None)); + } + #[test] fn vcrun2013_detected_by_msvcr120() { let dir = test_dir("vcrun2013-detect"); diff --git a/app/src-rust/src/migrate.rs b/app/src-rust/src/migrate.rs index 42ecedf1..8bf23e5d 100644 --- a/app/src-rust/src/migrate.rs +++ b/app/src-rust/src/migrate.rs @@ -5,19 +5,8 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; -fn mac_cmd(name: &str) -> Command { - let path = match name { - "pkill" => "/usr/bin/pkill", - _ => name, - }; - Command::new(path) -} - const MIGRATE_VERSION: &str = env!("CARGO_PKG_VERSION"); const MIGRATE_SCHEMA_VERSION: u64 = 3; -const MIGRATION_EXACT_KILL_PATTERNS: &[&str] = - &["wineloader", "steam.exe", "steamwebhelper.exe", "steamwebhelper", "wineserver", "wine64", "wine"]; -const MIGRATION_COMMAND_KILL_PATTERNS: &[&str] = &["Steam.exe", "steamwebhelper.exe", "wineserver", "wineloader"]; const MIGRATION_PAYLOAD_DENY_NAMES: &[&str] = &[ "steamapps", "common", @@ -593,13 +582,10 @@ fn run_migration() { let _ = fs::remove_file(&marker); let _ = fs::remove_file(migration_steam_config_backup_path(&ms_dir)); - kill_steam_wine(); - log_to_file("Migration: killed Wine/Steam processes after prefix update to dismiss wineboot window"); - - // wineboot -u can fork Steam.exe twice for self-update. The second Wine - // window is crash-prone during migration, so dismiss it and let the app's - // normal Steam launch path start Steam after migration completes. - dismiss_steam_update_windows_after_migration(&ms_dir, 90); + // wineboot -u can fork Steam.exe twice for self-update. Let those updater + // windows finish naturally so the next app launch is not left with a + // half-completed Steam update. + wait_for_steam_update_windows_after_migration(&ms_dir, 90); write_migrate_progress("complete", total_steps, total_steps, "MetalSharp is updated and ready.", None); log_to_file(&format!("Migration to v{} finished (install_ok=true)", MIGRATE_VERSION)); @@ -672,18 +658,7 @@ fn wait_for_install_complete() -> Result<(), String> { Err("runtime install timed out".into()) } -fn kill_steam_wine() { - for pat in MIGRATION_EXACT_KILL_PATTERNS { - run_pkill(&["-x", pat]); - } - - for pat in MIGRATION_COMMAND_KILL_PATTERNS { - run_pkill(&["-f", pat]); - } - std::thread::sleep(std::time::Duration::from_millis(750)); -} - -fn dismiss_steam_update_windows_after_migration(ms_dir: &Path, timeout_secs: u64) { +fn wait_for_steam_update_windows_after_migration(ms_dir: &Path, timeout_secs: u64) { let prefix = ms_dir.join("prefix-steam"); let prefix_str = prefix.to_string_lossy().to_string(); let start = std::time::Instant::now(); @@ -702,10 +677,11 @@ fn dismiss_steam_update_windows_after_migration(ms_dir: &Path, timeout_secs: u64 SteamUpdateWaitAction::LogFirstClose => { log_to_file("Migration: initial Wine/Steam update window closed, waiting for Steam updater window..."); }, - SteamUpdateWaitAction::KillAfterSecondOpen => { - log_to_file("Migration: second Wine/Steam updater window detected; force-killing it after wineboot"); - force_kill_steam_update_processes_for_prefix(&prefix_str); - log_to_file("Migration: dismissed post-wineboot Steam updater window; Steam can be started normally from the app"); + SteamUpdateWaitAction::LogSecondOpen => { + log_to_file("Migration: second Wine/Steam updater window detected, waiting for it to close..."); + }, + SteamUpdateWaitAction::Complete => { + log_to_file("Migration: Wine/Steam updater windows closed after wineboot"); return; }, SteamUpdateWaitAction::None => {}, @@ -729,20 +705,12 @@ fn steam_update_process_alive(prefix_str: &str, process_output: &str) -> bool { }) } -fn force_kill_steam_update_processes_for_prefix(prefix_str: &str) { - if prefix_str.trim().is_empty() { - return; - } - - run_pkill(&["-TERM", "-f", prefix_str]); - std::thread::sleep(std::time::Duration::from_millis(500)); - run_pkill(&["-KILL", "-f", prefix_str]); -} - #[derive(Default)] struct SteamUpdateWindowWait { first_open_seen: bool, first_close_seen: bool, + second_open_seen: bool, + second_close_seen: bool, } impl SteamUpdateWindowWait { @@ -757,8 +725,14 @@ impl SteamUpdateWindowWait { return SteamUpdateWaitAction::LogFirstClose; } - if steam_alive && self.first_close_seen { - return SteamUpdateWaitAction::KillAfterSecondOpen; + if steam_alive && self.first_close_seen && !self.second_open_seen { + self.second_open_seen = true; + return SteamUpdateWaitAction::LogSecondOpen; + } + + if !steam_alive && self.second_open_seen && !self.second_close_seen { + self.second_close_seen = true; + return SteamUpdateWaitAction::Complete; } SteamUpdateWaitAction::None @@ -774,23 +748,8 @@ enum SteamUpdateWaitAction { None, LogFirstOpen, LogFirstClose, - KillAfterSecondOpen, -} - -fn run_pkill(args: &[&str]) { - let Ok(mut child) = mac_cmd("pkill").args(args).spawn() else { - return; - }; - - for _ in 0..20 { - if child.try_wait().ok().flatten().is_some() { - return; - } - std::thread::sleep(std::time::Duration::from_millis(25)); - } - - let _ = child.kill(); - let _ = child.wait(); + LogSecondOpen, + Complete, } struct PreservedData { @@ -2718,14 +2677,7 @@ mod tests { } #[test] - fn migration_kill_patterns_avoid_broad_command_matches() { - assert!(MIGRATION_EXACT_KILL_PATTERNS.contains(&"wineloader")); - assert!(!MIGRATION_COMMAND_KILL_PATTERNS.contains(&"steam")); - assert!(!MIGRATION_COMMAND_KILL_PATTERNS.contains(&"wine")); - } - - #[test] - fn migration_kills_second_steam_update_window() { + fn migration_waits_for_second_steam_update_window_to_close() { let mut wait = SteamUpdateWindowWait::default(); assert_eq!(wait.observe(false), SteamUpdateWaitAction::None); @@ -2733,7 +2685,10 @@ mod tests { assert_eq!(wait.observe(true), SteamUpdateWaitAction::None); assert_eq!(wait.observe(false), SteamUpdateWaitAction::LogFirstClose); assert_eq!(wait.observe(false), SteamUpdateWaitAction::None); - assert_eq!(wait.observe(true), SteamUpdateWaitAction::KillAfterSecondOpen); + assert_eq!(wait.observe(true), SteamUpdateWaitAction::LogSecondOpen); + assert_eq!(wait.observe(true), SteamUpdateWaitAction::None); + assert_eq!(wait.observe(false), SteamUpdateWaitAction::Complete); + assert_eq!(wait.observe(false), SteamUpdateWaitAction::None); } #[test] From 83c435d16112bfe7454397269db138e3e224607b Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sun, 14 Jun 2026 10:08:26 -0600 Subject: [PATCH 11/14] Add ad-hoc deep signing for DMG packaging --- app/build/adhoc-deep-sign.cjs | 116 ++++++++++++++++++++++++++++++++ app/package.json | 1 + tools/ci/verify-dmg-workflow.py | 12 ++++ 3 files changed, 129 insertions(+) create mode 100644 app/build/adhoc-deep-sign.cjs diff --git a/app/build/adhoc-deep-sign.cjs b/app/build/adhoc-deep-sign.cjs new file mode 100644 index 00000000..e1351b1c --- /dev/null +++ b/app/build/adhoc-deep-sign.cjs @@ -0,0 +1,116 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: "utf8", + stdio: options.capture ? "pipe" : "inherit", + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error(`${command} ${args.join(" ")} failed with status ${result.status}${output ? `\n${output}` : ""}`); + } + return result.stdout || ""; +} + +function shouldSkipAdhocSigning() { + if (process.env.METALSHARP_SKIP_ADHOC_DEEP_SIGN === "1") { + return "METALSHARP_SKIP_ADHOC_DEEP_SIGN=1"; + } + + const developerIdSigning = + Boolean(process.env.CSC_KEYCHAIN) || Boolean(process.env.CSC_LINK) || Boolean(process.env.CSC_NAME); + if (developerIdSigning && process.env.METALSHARP_UNSIGNED_DMG !== "1") { + return "Developer ID signing is active"; + } + + return ""; +} + +function isMachO(filePath) { + if (!fs.statSync(filePath).isFile()) { + return false; + } + const output = run("file", ["-b", filePath], { capture: true }); + return output.includes("Mach-O"); +} + +function collectSignTargets(root) { + const files = []; + const bundles = []; + const stack = [root]; + + while (stack.length > 0) { + const current = stack.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + if (entry.isSymbolicLink()) { + continue; + } + if (entry.isDirectory()) { + if (/\.(app|appex|framework|xpc)$/i.test(entry.name)) { + bundles.push(fullPath); + } + stack.push(fullPath); + } else if (entry.isFile() && isMachO(fullPath)) { + files.push(fullPath); + } + } + } + + bundles.sort((a, b) => b.split(path.sep).length - a.split(path.sep).length); + files.sort((a, b) => b.split(path.sep).length - a.split(path.sep).length); + return { files, bundles }; +} + +function signTarget(target, entitlementsPath = "") { + const args = ["--force", "--sign", "-", "--timestamp=none"]; + if (entitlementsPath) { + args.push("--entitlements", entitlementsPath); + } + args.push(target); + run("codesign", args); +} + +exports.default = async function adhocDeepSignMetalSharp(context) { + if (context.electronPlatformName !== "darwin") { + return; + } + + const skipReason = shouldSkipAdhocSigning(); + if (skipReason) { + console.log(`MetalSharp ad-hoc deep sign skipped: ${skipReason}.`); + return; + } + + const appName = context.packager.appInfo.productFilename; + const appPath = path.join(context.appOutDir, `${appName}.app`); + if (!fs.existsSync(appPath)) { + throw new Error(`MetalSharp app bundle was not found for ad-hoc signing: ${appPath}`); + } + + const entitlementsPath = path.join(__dirname, "entitlements.mac.plist"); + const { files, bundles } = collectSignTargets(appPath); + console.log(`MetalSharp ad-hoc deep sign: ${files.length} Mach-O file(s), ${bundles.length} bundle(s).`); + + for (const file of files) { + signTarget(file); + } + for (const bundle of bundles) { + signTarget(bundle); + } + + const finalArgs = ["--force", "--deep", "--strict", "--sign", "-", "--timestamp=none"]; + if (fs.existsSync(entitlementsPath)) { + finalArgs.push("--entitlements", entitlementsPath); + } + finalArgs.push(appPath); + run("codesign", finalArgs); + + run("codesign", ["--verify", "--deep", "--strict", "--verbose=2", appPath]); + console.log("MetalSharp ad-hoc deep sign complete."); +}; diff --git a/app/package.json b/app/package.json index 1893ff2f..e0f37d5d 100644 --- a/app/package.json +++ b/app/package.json @@ -44,6 +44,7 @@ "appId": "com.metalsharp.app", "productName": "MetalSharp", "copyright": "Copyright © 2026 MetalSharp", + "afterPack": "build/adhoc-deep-sign.cjs", "afterSign": "build/notarize.cjs", "mac": { "category": "public.app-category.games", diff --git a/tools/ci/verify-dmg-workflow.py b/tools/ci/verify-dmg-workflow.py index 53989e8c..3d1d5d2b 100755 --- a/tools/ci/verify-dmg-workflow.py +++ b/tools/ci/verify-dmg-workflow.py @@ -61,6 +61,8 @@ def check_package_resources(assets: list[str]) -> None: if missing: fail(f"app/package.json missing extraResources entries: {missing}") + if build.get("afterPack") != "build/adhoc-deep-sign.cjs": + fail("app/package.json must keep afterPack=build/adhoc-deep-sign.cjs") if build.get("afterSign") != "build/notarize.cjs": fail("app/package.json must keep afterSign=build/notarize.cjs") @@ -147,6 +149,16 @@ def check_workflows() -> None: fail(f"release workflow missing signing fallback contract: {required}") if "CSC_IDENTITY_AUTO_DISCOVERY=false" not in read("tools/dmg/check-apple-signing-readiness.sh"): fail("unsigned DMG fallback must disable Electron Builder certificate discovery") + adhoc_sign = read("app/build/adhoc-deep-sign.cjs") + for required in [ + "METALSHARP_UNSIGNED_DMG", + "codesign", + "--deep", + "--timestamp=none", + "--verify", + ]: + if required not in adhoc_sign: + fail(f"ad-hoc deep-sign hook missing hardening contract: {required}") notarization = read("tools/dmg/verify-notarization.sh") for required in [ "Authority=Developer ID Application", From 25e79d4129f82b78872e82a1d5b483210b827a11 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sun, 14 Jun 2026 10:11:37 -0600 Subject: [PATCH 12/14] Bump version to 0.47.0 --- CMakeLists.txt | 2 +- app/package-lock.json | 4 ++-- app/package.json | 2 +- app/src-rust/Cargo.lock | 2 +- app/src-rust/Cargo.toml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 43a4697a..f70c5d37 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(metalsharp VERSION 0.46.7 LANGUAGES C CXX OBJC OBJCXX) +project(metalsharp VERSION 0.47.0 LANGUAGES C CXX OBJC OBJCXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/app/package-lock.json b/app/package-lock.json index 9bb80e8e..6ae62168 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "metalsharp", - "version": "0.46.7", + "version": "0.47.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metalsharp", - "version": "0.46.7", + "version": "0.47.0", "dependencies": { "electron-store": "^10.0.0" }, diff --git a/app/package.json b/app/package.json index e0f37d5d..d7ca3a86 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "metalsharp", - "version": "0.46.7", + "version": "0.47.0", "description": "MetalSharp — D3D→Metal translation layer frontend", "author": "MetalSharp", "repository": { diff --git a/app/src-rust/Cargo.lock b/app/src-rust/Cargo.lock index 912546c5..b15f82a9 100644 --- a/app/src-rust/Cargo.lock +++ b/app/src-rust/Cargo.lock @@ -549,7 +549,7 @@ checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "metalsharp-backend" -version = "0.46.7" +version = "0.47.0" dependencies = [ "ctrlc", "dirs", diff --git a/app/src-rust/Cargo.toml b/app/src-rust/Cargo.toml index 2b51c781..55dfb31b 100644 --- a/app/src-rust/Cargo.toml +++ b/app/src-rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "metalsharp-backend" -version = "0.46.7" +version = "0.47.0" edition = "2021" [dependencies] From 8f23300b0b03dac71ba763dffc78880d8837a7c4 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sun, 14 Jun 2026 12:37:34 -0600 Subject: [PATCH 13/14] Disable mscompatdb for M12 launches --- app/src-rust/src/mtsp/engine.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src-rust/src/mtsp/engine.rs b/app/src-rust/src/mtsp/engine.rs index 46f6aa83..d15403a8 100644 --- a/app/src-rust/src/mtsp/engine.rs +++ b/app/src-rust/src/mtsp/engine.rs @@ -89,7 +89,7 @@ pub fn pipelines() -> &'static Vec { experimental: false, requires_wine: true, wine_overrides: Some( - "winemetal,d3d12,dxgi,d3d11,d3d10core=n,b;gameoverlayrenderer,gameoverlayrenderer64=d", + "winemetal,d3d12,dxgi,d3d11,d3d10core=n,b;mscompatdb,gameoverlayrenderer,gameoverlayrenderer64=d", ), dyld_paths: vec!["lib/dxmt-m12/x86_64-unix", "lib/wine/x86_64-unix"], winedllpath_dirs: vec!["lib/dxmt-m12/x86_64-windows", "lib/metalsharp/x86_64-windows"], @@ -642,10 +642,11 @@ mod tests { assert_eq!(m12_env_values.get("DXMT_METALFX_TEMPORAL"), Some(&"1")); assert_eq!(m12_env_values.get("DXMT_CONFIG"), Some(&DXMT_M12_SAFE_CONFIG)); - assert_eq!( - m12.wine_overrides, - Some("winemetal,d3d12,dxgi,d3d11,d3d10core=n,b;gameoverlayrenderer,gameoverlayrenderer64=d") - ); + let m12_overrides = m12.wine_overrides.unwrap_or_default(); + assert!(m12_overrides.contains("winemetal")); + assert!(m12_overrides.contains("d3d12")); + assert!(m12_overrides.contains("dxgi")); + assert!(m12_overrides.contains("gameoverlayrenderer")); assert!(m12.alternatives.contains(&PipelineId::M11)); } From 579baaf7a85183ae5ecf89c2b5e46b894af8c7e2 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sun, 14 Jun 2026 14:35:38 -0600 Subject: [PATCH 14/14] Bump version to 0.46.9 --- CMakeLists.txt | 2 +- app/package-lock.json | 4 ++-- app/package.json | 2 +- app/src-rust/Cargo.lock | 2 +- app/src-rust/Cargo.toml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f70c5d37..862502f8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(metalsharp VERSION 0.47.0 LANGUAGES C CXX OBJC OBJCXX) +project(metalsharp VERSION 0.46.9 LANGUAGES C CXX OBJC OBJCXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/app/package-lock.json b/app/package-lock.json index 6ae62168..8c31b4f6 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "metalsharp", - "version": "0.47.0", + "version": "0.46.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metalsharp", - "version": "0.47.0", + "version": "0.46.9", "dependencies": { "electron-store": "^10.0.0" }, diff --git a/app/package.json b/app/package.json index d7ca3a86..2042d08c 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "metalsharp", - "version": "0.47.0", + "version": "0.46.9", "description": "MetalSharp — D3D→Metal translation layer frontend", "author": "MetalSharp", "repository": { diff --git a/app/src-rust/Cargo.lock b/app/src-rust/Cargo.lock index b15f82a9..ae1f0b6e 100644 --- a/app/src-rust/Cargo.lock +++ b/app/src-rust/Cargo.lock @@ -549,7 +549,7 @@ checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "metalsharp-backend" -version = "0.47.0" +version = "0.46.9" dependencies = [ "ctrlc", "dirs", diff --git a/app/src-rust/Cargo.toml b/app/src-rust/Cargo.toml index 55dfb31b..a6344fad 100644 --- a/app/src-rust/Cargo.toml +++ b/app/src-rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "metalsharp-backend" -version = "0.47.0" +version = "0.46.9" edition = "2021" [dependencies]