feat(game): preview multiple mission roots
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m6s
Test / Test (push) Skipped
Test / Render parity (push) Skipped

This commit is contained in:
2026-07-18 10:36:29 +04:00
parent 6e077f6c9d
commit 5df5814488
3 changed files with 232 additions and 109 deletions
+158 -102
View File
@@ -36,16 +36,17 @@ use fparkan_render_vulkan::{
VulkanStaticXzFrame, VulkanStaticXzFrame,
}; };
use fparkan_runtime::{ use fparkan_runtime::{
create, frame, load_mission, load_mission_static_preview, create, frame, load_mission, load_mission_static_preview, load_mission_static_preview_roots,
load_mission_static_preview_with_progress, load_mission_with_progress, loaded_mission_assets, load_mission_static_preview_roots_with_progress, load_mission_with_progress,
loaded_mission_object_drafts, loaded_terrain, EngineConfig, EngineMode, EngineServices, loaded_mission_assets, loaded_mission_object_drafts, loaded_terrain, EngineConfig, EngineMode,
MissionAssets, MissionLoadPhase, MissionObjectDraft, MissionRequest, EngineServices, MissionAssets, MissionLoadPhase, MissionObjectDraft, MissionRequest,
}; };
use fparkan_terrain::TerrainWorld; use fparkan_terrain::TerrainWorld;
use fparkan_vfs::DirectoryVfs; use fparkan_vfs::DirectoryVfs;
#[cfg(test)] #[cfg(test)]
use fparkan_world::OriginalObjectId; use fparkan_world::OriginalObjectId;
use fparkan_world::WorldSnapshot; use fparkan_world::WorldSnapshot;
use std::num::NonZeroUsize;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use winit::application::ApplicationHandler; use winit::application::ApplicationHandler;
@@ -86,15 +87,16 @@ fn run(args: &[String]) -> Result<String, String> {
.ok_or_else(|| "mission assets are unavailable after loading".to_string())?; .ok_or_else(|| "mission assets are unavailable after loading".to_string())?;
let terrain = loaded_terrain(&engine) let terrain = loaded_terrain(&engine)
.ok_or_else(|| "mission terrain is unavailable after loading".to_string())?; .ok_or_else(|| "mission terrain is unavailable after loading".to_string())?;
let root = loaded_mission_object_drafts(&engine) let roots = loaded_mission_object_drafts(&engine)
.and_then(|drafts| drafts.first()) .map(|drafts| &drafts[..args.preview_roots.get().min(drafts.len())])
.ok_or_else(|| "first mission object draft is unavailable after loading".to_string())?; .filter(|roots| !roots.is_empty())
let preview = static_preview_mesh_and_materials(mission_assets, terrain, root)?; .ok_or_else(|| {
"selected mission object drafts are unavailable after loading".to_string()
})?;
let preview = static_preview_mesh_and_materials(mission_assets, terrain, roots)?;
return run_static_vulkan_mode( return run_static_vulkan_mode(
preview.mesh, preview,
preview.materials, roots.len(),
preview.mesh_components,
preview.terrain_components,
args.frames, args.frames,
&args.mission, &args.mission,
loaded.object_count, loaded.object_count,
@@ -154,13 +156,18 @@ fn load_requested_mission(
prepare_load_progress_path(progress_path)?; prepare_load_progress_path(progress_path)?;
let mut write_error = None; let mut write_error = None;
let loaded = if args.backend == RenderBackendMode::StaticVulkan { let loaded = if args.backend == RenderBackendMode::StaticVulkan {
load_mission_static_preview_with_progress(engine, request, |phase| { load_mission_static_preview_roots_with_progress(
if write_error.is_none() { engine,
if let Err(err) = write_load_progress(progress_path, phase) { request,
write_error = Some(err); args.preview_roots,
|phase| {
if write_error.is_none() {
if let Err(err) = write_load_progress(progress_path, phase) {
write_error = Some(err);
}
} }
} },
}) )
} else { } else {
load_mission_with_progress(engine, request, |phase| { load_mission_with_progress(engine, request, |phase| {
if write_error.is_none() { if write_error.is_none() {
@@ -179,7 +186,11 @@ fn load_requested_mission(
return Ok(loaded); return Ok(loaded);
} }
if args.backend == RenderBackendMode::StaticVulkan { if args.backend == RenderBackendMode::StaticVulkan {
load_mission_static_preview(engine, request) if args.preview_roots.get() == 1 {
load_mission_static_preview(engine, request)
} else {
load_mission_static_preview_roots(engine, request, args.preview_roots)
}
} else { } else {
load_mission(engine, request) load_mission(engine, request)
} }
@@ -204,16 +215,12 @@ struct StaticPreviewScene {
fn static_preview_mesh_and_materials( fn static_preview_mesh_and_materials(
assets: &MissionAssets, assets: &MissionAssets,
terrain: &TerrainWorld, terrain: &TerrainWorld,
root: &MissionObjectDraft, roots: &[MissionObjectDraft],
) -> Result<StaticPreviewScene, String> { ) -> Result<StaticPreviewScene, String> {
let visual_ids = assets.visuals_for_object(0);
if visual_ids.is_empty() {
return Err("first static preview root has no prepared visuals".to_string());
}
let terrain_mesh = terrain let terrain_mesh = terrain
.source_mesh() .source_mesh()
.ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?; .ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?;
let frame = static_preview_xz_frame(assets, visual_ids, terrain, root)?; let frame = static_preview_xz_frame(assets, terrain, roots)?;
let mut mesh = VulkanStaticMesh { let mut mesh = VulkanStaticMesh {
vertices: Vec::new(), vertices: Vec::new(),
indices: Vec::new(), indices: Vec::new(),
@@ -231,30 +238,34 @@ fn static_preview_mesh_and_materials(
.map_err(|err| format!("project mission terrain for Vulkan: {err}"))?; .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?;
append_static_preview_component(&mut mesh, terrain_component, &[(0, 0)])?; append_static_preview_component(&mut mesh, terrain_component, &[(0, 0)])?;
let mut mesh_components = 0; let mut mesh_components = 0;
for visual_id in visual_ids { for (object_index, root) in roots.iter().enumerate() {
let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { for visual_id in assets.visuals_for_object(object_index) {
format!("first static preview root references unknown visual {visual_id:?}") let visual = assets.visual_by_id(*visual_id).ok_or_else(|| {
})?; format!(
let Some(model_id) = visual.model_id else { "static preview root {object_index} references unknown visual {visual_id:?}"
continue; )
}; })?;
let model = assets.model_by_id(model_id).ok_or_else(|| { let Some(model_id) = visual.model_id else {
format!("static preview visual {visual_id:?} references unknown model {model_id:?}") continue;
})?; };
let component = project_msh_to_static_mesh_in_xz_frame( let model = assets.model_by_id(model_id).ok_or_else(|| {
&model.validated, format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
frame, })?;
root.position, let component = project_msh_to_static_mesh_in_xz_frame(
root.scale, &model.validated,
) frame,
.map_err(|err| format!("project mission MSH for Vulkan: {err}"))?; root.position,
let selector_remap = root.scale,
static_preview_component_materials(assets, visual, &component, &mut materials)?; )
append_static_preview_component(&mut mesh, component, &selector_remap)?; .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
mesh_components += 1; let selector_remap =
static_preview_component_materials(assets, visual, &component, &mut materials)?;
append_static_preview_component(&mut mesh, component, &selector_remap)?;
mesh_components += 1;
}
} }
if mesh_components == 0 { if mesh_components == 0 {
return Err("first static preview root has no mesh-backed visual".to_string()); return Err("selected static preview roots have no mesh-backed visual".to_string());
} }
Ok(StaticPreviewScene { Ok(StaticPreviewScene {
mesh, mesh,
@@ -266,18 +277,9 @@ fn static_preview_mesh_and_materials(
fn static_preview_xz_frame( fn static_preview_xz_frame(
assets: &MissionAssets, assets: &MissionAssets,
visual_ids: &[fparkan_assets::AssetId<PreparedVisual>],
terrain: &TerrainWorld, terrain: &TerrainWorld,
root: &MissionObjectDraft, roots: &[MissionObjectDraft],
) -> Result<VulkanStaticXzFrame, String> { ) -> Result<VulkanStaticXzFrame, String> {
if !root
.position
.iter()
.chain(root.scale.iter())
.all(|value| value.is_finite())
{
return Err("first static preview root has a non-finite position or scale".to_string());
}
let mut min_x = f32::INFINITY; let mut min_x = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY; let mut max_x = f32::NEG_INFINITY;
let mut min_z = f32::INFINITY; let mut min_z = f32::INFINITY;
@@ -288,28 +290,42 @@ fn static_preview_xz_frame(
for position in terrain_positions { for position in terrain_positions {
extend_static_preview_xz_bounds(*position, &mut min_x, &mut max_x, &mut min_z, &mut max_z)?; extend_static_preview_xz_bounds(*position, &mut min_x, &mut max_x, &mut min_z, &mut max_z)?;
} }
for visual_id in visual_ids { for (object_index, root) in roots.iter().enumerate() {
let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { if !root
format!("first static preview root references unknown visual {visual_id:?}") .position
})?; .iter()
let Some(model_id) = visual.model_id else { .chain(root.scale.iter())
continue; .all(|value| value.is_finite())
}; {
let model = assets.model_by_id(model_id).ok_or_else(|| { return Err(format!(
format!("static preview visual {visual_id:?} references unknown model {model_id:?}") "static preview root {object_index} has a non-finite position or scale"
})?; ));
for position in &model.validated.positions { }
extend_static_preview_xz_bounds( for visual_id in assets.visuals_for_object(object_index) {
[ let visual = assets.visual_by_id(*visual_id).ok_or_else(|| {
position[0] * root.scale[0] + root.position[0], format!(
position[1] * root.scale[1] + root.position[1], "static preview root {object_index} references unknown visual {visual_id:?}"
position[2] * root.scale[2] + root.position[2], )
], })?;
&mut min_x, let Some(model_id) = visual.model_id else {
&mut max_x, continue;
&mut min_z, };
&mut max_z, let model = assets.model_by_id(model_id).ok_or_else(|| {
)?; format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
})?;
for position in &model.validated.positions {
extend_static_preview_xz_bounds(
[
position[0] * root.scale[0] + root.position[0],
position[1] * root.scale[1] + root.position[1],
position[2] * root.scale[2] + root.position[2],
],
&mut min_x,
&mut max_x,
&mut min_z,
&mut max_z,
)?;
}
} }
} }
VulkanStaticXzFrame::from_bounds(min_x, max_x, min_z, max_z) VulkanStaticXzFrame::from_bounds(min_x, max_x, min_z, max_z)
@@ -461,25 +477,16 @@ fn write_load_progress(path: &std::path::Path, phase: MissionLoadPhase) -> Resul
} }
fn run_static_vulkan_mode( fn run_static_vulkan_mode(
mesh: VulkanStaticMesh, preview: StaticPreviewScene,
materials: Vec<VulkanStaticMaterial>, preview_roots: usize,
mesh_components: usize,
terrain_components: usize,
target_frames: u64, target_frames: u64,
mission: &str, mission: &str,
object_count: usize, object_count: usize,
) -> Result<String, String> { ) -> Result<String, String> {
let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?;
event_loop.set_control_flow(ControlFlow::Poll); event_loop.set_control_flow(ControlFlow::Poll);
let mut app = StaticVulkanApp::new( let mut app =
mesh, StaticVulkanApp::new(preview, preview_roots, target_frames, mission, object_count);
materials,
mesh_components,
terrain_components,
target_frames,
mission,
object_count,
);
if let Err(err) = event_loop.run_app(&mut app) { if let Err(err) = event_loop.run_app(&mut app) {
app.error = Some(format!("winit event loop: {err}")); app.error = Some(format!("winit event loop: {err}"));
} }
@@ -491,6 +498,7 @@ struct StaticVulkanApp {
materials: Vec<VulkanStaticMaterial>, materials: Vec<VulkanStaticMaterial>,
mesh_components: usize, mesh_components: usize,
terrain_components: usize, terrain_components: usize,
preview_roots: usize,
target_frames: u64, target_frames: u64,
mission: String, mission: String,
object_count: usize, object_count: usize,
@@ -504,19 +512,18 @@ struct StaticVulkanApp {
impl StaticVulkanApp { impl StaticVulkanApp {
fn new( fn new(
mesh: VulkanStaticMesh, preview: StaticPreviewScene,
materials: Vec<VulkanStaticMaterial>, preview_roots: usize,
mesh_components: usize,
terrain_components: usize,
target_frames: u64, target_frames: u64,
mission: &str, mission: &str,
object_count: usize, object_count: usize,
) -> Self { ) -> Self {
Self { Self {
mesh, mesh: preview.mesh,
materials, materials: preview.materials,
mesh_components, mesh_components: preview.mesh_components,
terrain_components, terrain_components: preview.terrain_components,
preview_roots,
target_frames, target_frames,
mission: mission.to_string(), mission: mission.to_string(),
object_count, object_count,
@@ -559,10 +566,11 @@ impl StaticVulkanApp {
return; return;
} }
self.output = Some(format!( self.output = Some(format!(
"{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"mesh_components\":{},\"terrain_components\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_bytes\":{},\"readback_hash\":{}}}", "{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"mesh_components\":{},\"terrain_components\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_bytes\":{},\"readback_hash\":{}}}",
json_string(&self.mission), json_string(&self.mission),
self.object_count, self.object_count,
self.frames_presented, self.frames_presented,
self.preview_roots,
self.mesh_components, self.mesh_components,
self.terrain_components, self.terrain_components,
self.materials.len(), self.materials.len(),
@@ -809,6 +817,7 @@ struct Args {
frames: u64, frames: u64,
backend: RenderBackendMode, backend: RenderBackendMode,
load_progress: Option<PathBuf>, load_progress: Option<PathBuf>,
preview_roots: NonZeroUsize,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -824,6 +833,7 @@ impl Args {
let mut frames = 1; let mut frames = 1;
let mut backend = RenderBackendMode::Planning; let mut backend = RenderBackendMode::Planning;
let mut load_progress = None; let mut load_progress = None;
let mut preview_roots = NonZeroUsize::MIN;
let mut iter = args.iter(); let mut iter = args.iter();
while let Some(arg) = iter.next() { while let Some(arg) = iter.next() {
match arg.as_str() { match arg.as_str() {
@@ -866,6 +876,13 @@ impl Args {
.ok_or_else(|| "--load-progress requires a path".to_string())?, .ok_or_else(|| "--load-progress requires a path".to_string())?,
); );
} }
"--preview-roots" => {
preview_roots = iter
.next()
.ok_or_else(|| "--preview-roots requires a value".to_string())?
.parse()
.map_err(|_| "--preview-roots must be a non-zero integer".to_string())?;
}
_ => return Err(usage()), _ => return Err(usage()),
} }
} }
@@ -880,6 +897,7 @@ impl Args {
frames, frames,
backend, backend,
load_progress, load_progress,
preview_roots,
}) })
} }
} }
@@ -916,7 +934,7 @@ fn json_hash(hash: &[u8; 32]) -> String {
} }
fn usage() -> String { fn usage() -> String {
"usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>] [--load-progress <path>]".to_string() "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>] [--preview-roots <non-zero n>] [--load-progress <path>]".to_string()
} }
#[cfg(test)] #[cfg(test)]
@@ -947,6 +965,7 @@ mod tests {
frames: 3, frames: 3,
backend: RenderBackendMode::Planning, backend: RenderBackendMode::Planning,
load_progress: None, load_progress: None,
preview_roots: NonZeroUsize::MIN,
}) })
); );
} }
@@ -968,10 +987,46 @@ mod tests {
frames: 1, frames: 1,
backend: RenderBackendMode::StaticVulkan, backend: RenderBackendMode::StaticVulkan,
load_progress: None, load_progress: None,
preview_roots: NonZeroUsize::MIN,
}) })
); );
} }
#[test]
fn parses_nonzero_static_preview_root_count() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--backend",
"static-vulkan",
"--preview-roots",
"2",
]))
.expect("valid static preview arguments");
assert_eq!(
parsed.preview_roots,
NonZeroUsize::new(2).expect("non-zero literal")
);
}
#[test]
fn rejects_zero_static_preview_root_count() {
let error = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--preview-roots",
"0",
]));
assert_eq!(
error,
Err("--preview-roots must be a non-zero integer".to_string())
);
}
#[test] #[test]
fn parses_load_progress_path() { fn parses_load_progress_path() {
assert_eq!( assert_eq!(
@@ -989,6 +1044,7 @@ mod tests {
frames: 1, frames: 1,
backend: RenderBackendMode::Planning, backend: RenderBackendMode::Planning,
load_progress: Some(PathBuf::from("target/probe.txt")), load_progress: Some(PathBuf::from("target/probe.txt")),
preview_roots: NonZeroUsize::MIN,
}) })
); );
} }
+52
View File
@@ -37,6 +37,7 @@ use fparkan_world::{
construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft, construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft,
OriginalObjectId, World, WorldConfig, WorldSnapshot, OriginalObjectId, World, WorldConfig, WorldSnapshot,
}; };
use std::num::NonZeroUsize;
use std::sync::Arc; use std::sync::Arc;
pub use fparkan_assets::MissionAssets; pub use fparkan_assets::MissionAssets;
@@ -193,6 +194,8 @@ enum MissionAssetScope {
Full, Full,
/// Prepare only the first mission root for a static preview. /// Prepare only the first mission root for a static preview.
FirstMeshPreview, FirstMeshPreview,
/// Prepare a non-zero prefix of mission roots for a static preview.
PreviewRoots(NonZeroUsize),
} }
/// Loaded mission. /// Loaded mission.
@@ -492,6 +495,46 @@ pub fn load_mission_static_preview(
load_mission_static_preview_with_progress(engine, request, |_| {}) load_mission_static_preview_with_progress(engine, request, |_| {})
} }
/// Loads a static preview for the first requested non-zero count of mission roots.
///
/// Normal gameplay still uses [`load_mission`] and always resolves every root.
///
/// # Errors
///
/// Returns [`EngineError`] under the same conditions as
/// [`load_mission_static_preview`].
pub fn load_mission_static_preview_roots(
engine: &mut Engine,
request: MissionRequest,
root_count: NonZeroUsize,
) -> Result<LoadedMission, EngineError> {
load_mission_static_preview_roots_with_progress(engine, request, root_count, |_| {})
}
/// Loads selected static-preview roots while synchronously reporting phases.
///
/// # Errors
///
/// Returns [`EngineError`] under the same conditions as
/// [`load_mission_static_preview_roots`].
pub fn load_mission_static_preview_roots_with_progress(
engine: &mut Engine,
request: MissionRequest,
root_count: NonZeroUsize,
mut on_phase: impl FnMut(MissionLoadPhase),
) -> Result<LoadedMission, EngineError> {
load_mission_with_options_and_progress(
engine,
request,
MissionLoadOptions {
asset_scope: MissionAssetScope::PreviewRoots(root_count),
..MissionLoadOptions::default()
},
Some(&mut on_phase),
)
.map(|(loaded, _trace)| loaded)
}
/// Loads a static preview while synchronously reporting entered loading phases. /// Loads a static preview while synchronously reporting entered loading phases.
/// ///
/// This has the same bounded asset scope as [`load_mission_static_preview`]. /// This has the same bounded asset scope as [`load_mission_static_preview`].
@@ -633,6 +676,9 @@ fn load_mission_with_options_and_progress(
let scoped_graph_roots = match options.asset_scope { let scoped_graph_roots = match options.asset_scope {
MissionAssetScope::Full => graph_roots.as_slice(), MissionAssetScope::Full => graph_roots.as_slice(),
MissionAssetScope::FirstMeshPreview => graph_roots.get(..1).unwrap_or_default(), MissionAssetScope::FirstMeshPreview => graph_roots.get(..1).unwrap_or_default(),
MissionAssetScope::PreviewRoots(root_count) => graph_roots
.get(..root_count.get().min(graph_roots.len()))
.unwrap_or_default(),
}; };
let (mut prototype_graph, resolved_prototypes, mut prototype_report) = let (mut prototype_graph, resolved_prototypes, mut prototype_report) =
build_prototype_graph_report(&repository, vfs.as_ref(), scoped_graph_roots); build_prototype_graph_report(&repository, vfs.as_ref(), scoped_graph_roots);
@@ -658,6 +704,12 @@ fn load_mission_with_options_and_progress(
&prototype_graph.root_prototype_request_spans, &prototype_graph.root_prototype_request_spans,
&resolved_prototypes, &resolved_prototypes,
), ),
MissionAssetScope::PreviewRoots(root_count) => asset_manager.prepare_mission_assets(
&prototype_graph.root_prototype_request_spans[..root_count
.get()
.min(prototype_graph.root_prototype_request_spans.len())],
&resolved_prototypes,
),
} }
.map_err(|source| EngineError::AssetPreparation { .map_err(|source| EngineError::AssetPreparation {
mission: request.key.clone(), mission: request.key.clone(),
+22 -7
View File
@@ -971,13 +971,12 @@ startup is not attributed to Vulkan. This is only a cross-corpus confirmation
of the first-root static-preview bridge, not a claim that the two games' full of the first-root static-preview bridge, not a claim that the two games' full
mission renderers are compatible. mission renderers are compatible.
The preview now asks `load_mission_static_preview` for both graph and assets. The preview now has an explicit root-prefix contract. `--preview-roots N`
Normal mission loading remains full and transactional; preview graph traversal prepares the first non-zero `N` TMA roots for the opt-in static Vulkan path;
and asset preparation are restricted to the first TMA root. It is therefore not the default remains one root. Normal mission loading remains full and
a hidden relaxation of gameplay validation. The preceding all-root preview transactional, so this is not a hidden relaxation of gameplay validation. The
probe timed out at `Graph`; this reduction is what allowed the successful GOG previous all-root probe timed out at `Graph`; the prefix makes broader scene
native run. If that first root has no usable MSH, static preview fails explicitly work measurable without pretending that a bounded preview is the full game.
rather than expanding later roots or pretending to render the entire mission.
### Shared terrain/root diagnostic frame ### Shared terrain/root diagnostic frame
@@ -1002,6 +1001,22 @@ one terrain component, 15 descriptors, a 7,372,800-byte readback (FNV-1a
from the earlier first-root-only preview is expected because the submitted from the earlier first-root-only preview is expected because the submitted
geometry and descriptor set changed; it is not an original-frame comparison. geometry and descriptor set changed; it is not an original-frame comparison.
### Multiple static-preview roots
The native bridge now combines every mesh-backed visual of each selected root,
using that root's preserved TMA `position` and `scale` in the shared diagnostic
XZ frame. It reports the actual selected root count as `preview_roots`, rather
than implying that all mission objects are rendered. Raw orientation, original
camera/frustum and visibility remain unresolved.
Fresh canonical GOG `Autodemo.00` evidence with `--preview-roots 2` completed
in 59.7 seconds: 25 submitted MSH components, one terrain component and 26
descriptors on the native 1280x720 two-image swapchain, with validation
warnings/errors `0/0`. The readback FNV-1a remained
`10739087367165646439`, equal to the one-root terrain/root run. That equality
does **not** prove the second root is visible or equivalent: it is recorded as
a camera/frustum/overlap investigation target, not hidden as a parity result.
### Camera ownership boundary from the GOG renderer ### Camera ownership boundary from the GOG renderer
The GOG `World3D.dll` export `LoadCamera` at RVA `0x1FB06` is only an import The GOG `World3D.dll` export `LoadCamera` at RVA `0x1FB06` is only an import