aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-23 22:27:47 +0300
committerValentin Popov <valentin@popov.link>2026-06-23 22:27:47 +0300
commit227d95fc49b0fbce93e61a0fb0fd647f33bed4bc (patch)
tree979f6cc3d8c5921e60592e0f205045454609a91c
parentdceea70122276971532e0fecf22d2fbe71fdb897 (diff)
downloadfparkan-227d95fc49b0fbce93e61a0fb0fd647f33bed4bc.tar.xz
fparkan-227d95fc49b0fbce93e61a0fb0fd647f33bed4bc.zip
feat: add Vulkan smoke runner entrypoint
-rw-r--r--.github/workflows/ci.yml2
-rw-r--r--Cargo.lock7
-rw-r--r--Cargo.toml1
-rw-r--r--apps/fparkan-vulkan-smoke/Cargo.toml12
-rw-r--r--apps/fparkan-vulkan-smoke/src/main.rs359
-rw-r--r--fixtures/acceptance/coverage.tsv3
-rw-r--r--fixtures/acceptance/stage_0_2_roadmap.md1
-rw-r--r--xtask/src/main.rs8
8 files changed, 390 insertions, 3 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 67aaabc..6a5e83e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -73,7 +73,7 @@ jobs:
if: always()
shell: bash
run: >
- cargo xtask native-smoke report
+ cargo run -p fparkan-vulkan-smoke --locked --
--platform "${{ matrix.smoke_platform }}"
--out "target/fparkan/native-smoke/${{ runner.os }}.json"
--status blocked
diff --git a/Cargo.lock b/Cargo.lock
index 012ca0e..2c2a00e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -764,6 +764,13 @@ dependencies = [
]
[[package]]
+name = "fparkan-vulkan-smoke"
+version = "0.1.0"
+dependencies = [
+ "fparkan-render-vulkan",
+]
+
+[[package]]
name = "fparkan-world"
version = "0.1.0"
dependencies = [
diff --git a/Cargo.toml b/Cargo.toml
index daa0fb8..018fe73 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,6 +30,7 @@ members = [
"apps/fparkan-cli",
"apps/fparkan-game",
"apps/fparkan-headless",
+ "apps/fparkan-vulkan-smoke",
"apps/fparkan-viewer",
"xtask",
]
diff --git a/apps/fparkan-vulkan-smoke/Cargo.toml b/apps/fparkan-vulkan-smoke/Cargo.toml
new file mode 100644
index 0000000..793a4c4
--- /dev/null
+++ b/apps/fparkan-vulkan-smoke/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "fparkan-vulkan-smoke"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
+
+[lints]
+workspace = true
diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs
new file mode 100644
index 0000000..dded309
--- /dev/null
+++ b/apps/fparkan-vulkan-smoke/src/main.rs
@@ -0,0 +1,359 @@
+#![forbid(unsafe_code)]
+#![cfg_attr(
+ test,
+ allow(
+ clippy::expect_used,
+ clippy::needless_raw_string_hashes,
+ clippy::panic,
+ clippy::unwrap_used
+ )
+)]
+#![allow(clippy::print_stderr, clippy::print_stdout)]
+//! Native Vulkan smoke runner entrypoint.
+
+use fparkan_render_vulkan::{triangle_shader_manifest, validate_shader_manifest};
+use std::path::PathBuf;
+use std::process::Command;
+
+const SCHEMA_VERSION: &str = "fparkan-native-smoke-v1";
+const RUST_TOOLCHAIN: &str = "1.87.0";
+
+fn main() {
+ let args = std::env::args().skip(1).collect::<Vec<_>>();
+ let code = match run(&args) {
+ Ok(output) => {
+ println!("{output}");
+ 0
+ }
+ Err(err) => {
+ eprintln!("{err}");
+ 2
+ }
+ };
+ std::process::exit(code);
+}
+
+fn run(args: &[String]) -> Result<String, String> {
+ let options = SmokeOptions::parse(args)?;
+ validate_smoke_options(&options)?;
+ let report = render_smoke_report_json(&options)?;
+ if let Some(parent) = options.out.parent() {
+ std::fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?;
+ }
+ std::fs::write(&options.out, &report)
+ .map_err(|err| format!("{}: {err}", options.out.display()))?;
+ Ok(report)
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct SmokeOptions {
+ platform: SmokePlatform,
+ out: PathBuf,
+ status: SmokeStatus,
+ frames: u32,
+ resize_count: u32,
+ validation_error_count: Option<u32>,
+ reason: Option<String>,
+}
+
+impl SmokeOptions {
+ fn parse(args: &[String]) -> Result<Self, String> {
+ let mut platform = None;
+ let mut out = None;
+ let mut status = SmokeStatus::Blocked;
+ let mut frames = 0;
+ let mut resize_count = 0;
+ let mut validation_error_count = None;
+ let mut reason = None;
+ let mut iter = args.iter();
+ while let Some(arg) = iter.next() {
+ match arg.as_str() {
+ "--platform" => {
+ let value = iter
+ .next()
+ .ok_or_else(|| "--platform requires a value".to_string())?;
+ platform = Some(SmokePlatform::parse(value)?);
+ }
+ "--out" => {
+ let value = iter
+ .next()
+ .ok_or_else(|| "--out requires a path".to_string())?;
+ out = Some(PathBuf::from(value));
+ }
+ "--status" => {
+ let value = iter
+ .next()
+ .ok_or_else(|| "--status requires a value".to_string())?;
+ status = SmokeStatus::parse(value)?;
+ }
+ "--frames" => {
+ let value = iter
+ .next()
+ .ok_or_else(|| "--frames requires a value".to_string())?;
+ frames = parse_u32("--frames", value)?;
+ }
+ "--resize-count" => {
+ let value = iter
+ .next()
+ .ok_or_else(|| "--resize-count requires a value".to_string())?;
+ resize_count = parse_u32("--resize-count", value)?;
+ }
+ "--validation-error-count" => {
+ let value = iter
+ .next()
+ .ok_or_else(|| "--validation-error-count requires a value".to_string())?;
+ validation_error_count = Some(parse_u32("--validation-error-count", value)?);
+ }
+ "--reason" => {
+ let value = iter
+ .next()
+ .ok_or_else(|| "--reason requires a value".to_string())?;
+ reason = Some(value.to_string());
+ }
+ _ => return Err(format!("unknown native smoke option: {arg}")),
+ }
+ }
+ Ok(Self {
+ platform: platform.ok_or_else(|| "missing --platform".to_string())?,
+ out: out.ok_or_else(|| "missing --out".to_string())?,
+ status,
+ frames,
+ resize_count,
+ validation_error_count,
+ reason,
+ })
+ }
+}
+
+fn parse_u32(name: &str, value: &str) -> Result<u32, String> {
+ value
+ .parse::<u32>()
+ .map_err(|_| format!("invalid {name} value: {value}"))
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum SmokePlatform {
+ Windows,
+ Linux,
+ Macos,
+}
+
+impl SmokePlatform {
+ fn parse(value: &str) -> Result<Self, String> {
+ match value {
+ "windows" => Ok(Self::Windows),
+ "linux" => Ok(Self::Linux),
+ "macos" => Ok(Self::Macos),
+ _ => Err(format!("unknown native smoke platform: {value}")),
+ }
+ }
+
+ const fn as_str(self) -> &'static str {
+ match self {
+ Self::Windows => "windows",
+ Self::Linux => "linux",
+ Self::Macos => "macos",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum SmokeStatus {
+ Blocked,
+ Passed,
+}
+
+impl SmokeStatus {
+ fn parse(value: &str) -> Result<Self, String> {
+ match value {
+ "blocked" => Ok(Self::Blocked),
+ "passed" => Ok(Self::Passed),
+ _ => Err(format!("unknown native smoke status: {value}")),
+ }
+ }
+
+ const fn as_str(self) -> &'static str {
+ match self {
+ Self::Blocked => "blocked",
+ Self::Passed => "passed",
+ }
+ }
+}
+
+fn validate_smoke_options(options: &SmokeOptions) -> Result<(), String> {
+ match options.status {
+ SmokeStatus::Blocked => {
+ if options
+ .reason
+ .as_deref()
+ .unwrap_or_default()
+ .trim()
+ .is_empty()
+ {
+ return Err("blocked native smoke report requires --reason".to_string());
+ }
+ }
+ SmokeStatus::Passed => {
+ if options.frames < 300 {
+ return Err("passed native smoke report requires --frames >= 300".to_string());
+ }
+ if options.resize_count == 0 {
+ return Err("passed native smoke report requires --resize-count >= 1".to_string());
+ }
+ if options.validation_error_count != Some(0) {
+ return Err(
+ "passed native smoke report requires --validation-error-count 0".to_string(),
+ );
+ }
+ }
+ }
+ Ok(())
+}
+
+fn render_smoke_report_json(options: &SmokeOptions) -> Result<String, String> {
+ let shader_manifest = validate_shader_manifest(&triangle_shader_manifest())
+ .map_err(|err| format!("shader manifest: {err}"))?;
+ let validation_error_count = options
+ .validation_error_count
+ .map_or_else(|| "null".to_string(), |value| value.to_string());
+ let reason = options
+ .reason
+ .as_ref()
+ .map_or_else(|| "null".to_string(), |value| json_string(value));
+ Ok(format!(
+ concat!(
+ "{{\n",
+ " \"schema_version\": \"{}\",\n",
+ " \"commit_sha\": \"{}\",\n",
+ " \"rust_toolchain\": \"{}\",\n",
+ " \"platform\": \"{}\",\n",
+ " \"status\": \"{}\",\n",
+ " \"frames\": {},\n",
+ " \"resize_count\": {},\n",
+ " \"validation_error_count\": {},\n",
+ " \"shader_manifest_hash\": \"{}\",\n",
+ " \"reason\": {}\n",
+ "}}\n"
+ ),
+ SCHEMA_VERSION,
+ json_escape(&current_git_commit_sha()),
+ RUST_TOOLCHAIN,
+ options.platform.as_str(),
+ options.status.as_str(),
+ options.frames,
+ options.resize_count,
+ validation_error_count,
+ json_escape(&shader_manifest.manifest_hash),
+ reason
+ ))
+}
+
+fn current_git_commit_sha() -> String {
+ Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .output()
+ .ok()
+ .filter(|output| output.status.success())
+ .and_then(|output| String::from_utf8(output.stdout).ok())
+ .map(|value| value.trim().to_string())
+ .filter(|value| value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit()))
+ .unwrap_or_else(|| "unknown".to_string())
+}
+
+fn json_string(value: &str) -> String {
+ format!("\"{}\"", json_escape(value))
+}
+
+fn json_escape(value: &str) -> String {
+ let mut out = String::new();
+ for ch in value.chars() {
+ match ch {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ ch if ch.is_control() => {
+ use std::fmt::Write as _;
+ let _ = write!(out, "\\u{:04x}", ch as u32);
+ }
+ ch => out.push(ch),
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn strings(values: &[&str]) -> Vec<String> {
+ values.iter().map(|value| (*value).to_string()).collect()
+ }
+
+ #[test]
+ fn parses_blocked_smoke_args() -> Result<(), String> {
+ let options = SmokeOptions::parse(&strings(&[
+ "--platform",
+ "linux",
+ "--out",
+ "target/native.json",
+ "--status",
+ "blocked",
+ "--reason",
+ "runner unavailable",
+ ]))?;
+
+ assert_eq!(options.platform, SmokePlatform::Linux);
+ assert_eq!(options.status, SmokeStatus::Blocked);
+ assert_eq!(options.reason.as_deref(), Some("runner unavailable"));
+ validate_smoke_options(&options)
+ }
+
+ #[test]
+ fn rejects_false_pass_without_full_evidence() {
+ let options = SmokeOptions::parse(&strings(&[
+ "--platform",
+ "linux",
+ "--out",
+ "target/native.json",
+ "--status",
+ "passed",
+ "--frames",
+ "299",
+ "--resize-count",
+ "1",
+ "--validation-error-count",
+ "0",
+ ]))
+ .expect("options");
+
+ assert_eq!(
+ validate_smoke_options(&options),
+ Err("passed native smoke report requires --frames >= 300".to_string())
+ );
+ }
+
+ #[test]
+ fn blocked_report_includes_shader_manifest_hash() -> Result<(), String> {
+ let options = SmokeOptions::parse(&strings(&[
+ "--platform",
+ "macos",
+ "--out",
+ "target/native.json",
+ "--status",
+ "blocked",
+ "--reason",
+ "runner unavailable",
+ ]))?;
+
+ let json = render_smoke_report_json(&options)?;
+
+ assert!(json.contains("\"schema_version\": \"fparkan-native-smoke-v1\""));
+ assert!(json.contains("\"platform\": \"macos\""));
+ assert!(json.contains("\"status\": \"blocked\""));
+ assert!(json.contains("\"shader_manifest_hash\": \""));
+ assert!(json.contains("\"reason\": \"runner unavailable\""));
+ Ok(())
+ }
+}
diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv
index e2c482d..4c39deb 100644
--- a/fixtures/acceptance/coverage.tsv
+++ b/fixtures/acceptance/coverage.tsv
@@ -15,7 +15,7 @@ S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rus
S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift
S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job
S0-ARCH-010 covered cargo xtask acceptance audit emits commit_sha, rust_toolchain, and msrv metadata into the JSON artifact
-S0-ARCH-011 blocked cargo xtask native-smoke report emits explicit per-platform blocked artifacts until real Vulkan 300-frame validation=0 runner is available
+S0-ARCH-011 blocked cargo run -p fparkan-vulkan-smoke emits explicit per-platform blocked artifacts until real Vulkan 300-frame validation=0 runner is available
S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context
S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable
S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent
@@ -50,6 +50,7 @@ S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_
S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers
S0-VK-021 covered cargo test -p fparkan-render-vulkan --offline frame_submission_plan_json_is_stable
S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents
+S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_hash
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
diff --git a/fixtures/acceptance/stage_0_2_roadmap.md b/fixtures/acceptance/stage_0_2_roadmap.md
index 1d1c9d4..baa33b6 100644
--- a/fixtures/acceptance/stage_0_2_roadmap.md
+++ b/fixtures/acceptance/stage_0_2_roadmap.md
@@ -50,6 +50,7 @@
`S0-VK-020`
`S0-VK-021`
`S0-VK-022`
+`S0-VK-023`
`S0-LIMIT-001`
`S0-LIMIT-002`
`L1-P1-NRES-001`
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index dd58b53..8289123 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -807,7 +807,11 @@ fn validate_dependency_boundaries(root: &Path, failures: &mut Vec<String>) -> Re
fn is_app_package(package: &str) -> bool {
matches!(
package,
- "fparkan-cli" | "fparkan-game" | "fparkan-headless" | "fparkan-viewer"
+ "fparkan-cli"
+ | "fparkan-game"
+ | "fparkan-headless"
+ | "fparkan-vulkan-smoke"
+ | "fparkan-viewer"
)
}
@@ -1209,6 +1213,7 @@ const ALL_WORKSPACE_PACKAGES: &[&str] = &[
"fparkan-cli",
"fparkan-game",
"fparkan-headless",
+ "fparkan-vulkan-smoke",
"fparkan-viewer",
"xtask",
];
@@ -2087,6 +2092,7 @@ fn stage_packages(stage: u8) -> Result<&'static [&'static str], String> {
"fparkan-runtime",
"fparkan-headless",
"fparkan-game",
+ "fparkan-vulkan-smoke",
]),
_ => Err(format!("stage out of range: {stage}")),
}