aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs6
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs73
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs3
-rw-r--r--apps/fparkan-game/src/main.rs90
-rw-r--r--crates/fparkan-msh/src/lib.rs31
-rw-r--r--crates/fparkan-runtime/src/lib.rs1
-rw-r--r--docs/appendices/knowledge-boundaries.md21
-rw-r--r--docs/reference/msh.md4
-rw-r--r--docs/reference/render-frame.md17
9 files changed, 234 insertions, 12 deletions
diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
index e2a8395..f691b34 100644
--- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs
+++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
@@ -1379,6 +1379,7 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices,
batches,
triangle_descriptors: None,
@@ -1439,6 +1440,7 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: vec![
batch(0, 0, 0),
@@ -1686,6 +1688,7 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: vec![0, 1, 2, 0, 1, 2],
batches: vec![batch(0, 3, 0), batch(3, 3, 0)],
triangle_descriptors: None,
@@ -1746,6 +1749,7 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
triangle_descriptors: None,
@@ -1853,6 +1857,7 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: vec![0, 1, 2, 0, 1, 2],
batches: vec![batch(0, 3, 0), batch(3, 3, 0)],
triangle_descriptors: None,
@@ -1945,6 +1950,7 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: vec![0, 1, 2],
batches: vec![batch(0, 3, 0)],
triangle_descriptors: None,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
index 90bce14..522f95f 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
@@ -423,6 +423,7 @@ impl VulkanSmokeRenderer {
draw_range_sort_keys: vec![None; create_info.mesh.draw_ranges.len()],
draw_order_dirty: false,
draw_range_world_queue: vec![None; create_info.mesh.draw_ranges.len()],
+ draw_range_diffuse_alpha_overrides: vec![None; create_info.mesh.draw_ranges.len()],
world_transparent_background_ranges: Vec::new(),
draw_range_base_pipeline_states: create_info
.mesh
@@ -901,6 +902,27 @@ impl VulkanSmokeRenderer {
Ok(())
}
+ /// Overrides one draw's diffuse alpha without changing its shared material.
+ pub fn set_draw_range_diffuse_alpha_override(
+ &mut self,
+ range_index: usize,
+ diffuse_alpha: Option<f32>,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ if diffuse_alpha.is_some_and(|alpha| !alpha.is_finite() || !(0.0..=1.0).contains(&alpha)) {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw-range diffuse alpha must be finite and 0..1",
+ });
+ }
+ let alpha_override = self
+ .draw_range_diffuse_alpha_overrides
+ .get_mut(range_index)
+ .ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw-range alpha index is out of bounds",
+ })?;
+ *alpha_override = diffuse_alpha;
+ Ok(())
+ }
+
/// Sets the native per-range alpha reference in legacy 0..=255 units.
///
/// Zero remains an explicit value; use the native-default constant when
@@ -1874,11 +1896,16 @@ impl VulkanSmokeRenderer {
context: "static material alpha",
},
)?;
- let material_alphas = [
- material_alpha[0].to_ne_bytes(),
- material_alpha[1].to_ne_bytes(),
- ]
- .concat();
+ let diffuse_alpha_override = self
+ .draw_range_diffuse_alpha_overrides
+ .get(range_index)
+ .ok_or(VulkanSmokeRendererError::InvariantViolation {
+ context: "draw-range diffuse-alpha override",
+ })?;
+ let material_alphas =
+ draw_material_alphas(*material_alpha, *diffuse_alpha_override)
+ .map(f32::to_ne_bytes)
+ .concat();
device.device().cmd_push_constants(
command_buffer,
resources.pipeline_layout,
@@ -2368,6 +2395,16 @@ fn apply_world_transparency(
}
}
+fn draw_material_alphas(
+ material_alphas: [f32; 2],
+ diffuse_alpha_override: Option<f32>,
+) -> [f32; 2] {
+ [
+ diffuse_alpha_override.unwrap_or(material_alphas[0]),
+ material_alphas[1],
+ ]
+}
+
fn world_material_pipeline_states(
original: LegacyPipelineState,
category: u8,
@@ -2535,10 +2572,10 @@ mod tests {
use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh};
use super::{
apply_geometry_range_updates, apply_world_transparency, completed_readback_buffer_index,
- mark_draw_order_dirty_on_activity_change, set_and_mark_dirty_if_changed,
- sorted_world_draw_order, take_runtime_children_with_validation_snapshot,
- take_runtime_owners_in_dependency_order, validate_geometry_upload,
- world_material_pipeline_states, RollbackOnDrop,
+ draw_material_alphas, mark_draw_order_dirty_on_activity_change,
+ set_and_mark_dirty_if_changed, sorted_world_draw_order,
+ take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order,
+ validate_geometry_upload, world_material_pipeline_states, RollbackOnDrop,
};
use fparkan_render::{LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState};
use std::cell::RefCell;
@@ -3141,4 +3178,22 @@ mod tests {
assert!(world_material_pipeline_states(original, 5, Some(false)).is_none());
assert!(world_material_pipeline_states(fixed_state, 5, None).is_none());
}
+
+ #[test]
+ fn draw_alpha_override_is_per_range_and_none_restores_material_alpha() {
+ let shared_material = [0.25, 0.5];
+
+ assert_eq!(draw_material_alphas(shared_material, Some(0.0)), [0.0, 0.5]);
+ assert_eq!(draw_material_alphas(shared_material, None), shared_material);
+
+ let updated_material = [0.75, 0.125];
+ assert_eq!(
+ draw_material_alphas(updated_material, None),
+ updated_material
+ );
+ assert_eq!(
+ draw_material_alphas(updated_material, Some(0.0)),
+ [0.0, 0.125]
+ );
+ }
}
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
index 51f8384..f004cb2 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
@@ -1456,6 +1456,9 @@ pub struct VulkanSmokeRenderer {
/// a fixed screen/sky/weather pass; `Some(false)` is world opaque and
/// `Some(true)` is world transparent.
pub(super) draw_range_world_queue: Vec<Option<bool>>,
+ /// Optional diffuse-alpha override for each draw range, independent of
+ /// the shared material record.
+ pub(super) draw_range_diffuse_alpha_overrides: Vec<Option<f32>>,
/// Fixed sky background ranges placed between world opaque and transparent.
pub(super) world_transparent_background_ranges: Vec<usize>,
/// Pipeline state before transparent queue depth policy is applied.
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index 1da3312..61c2d1e 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -1053,6 +1053,13 @@ fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) -
diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0
}
+fn native_msh_diffuse_alpha_override(batch_flags: u16, type16_present: bool) -> Option<f32> {
+ if type16_present || batch_flags & 0x0008 == 0 {
+ return None;
+ }
+ (batch_flags & 0x0040 != 0 && batch_flags & 0x0010 == 0).then_some(0.0)
+}
+
fn native_msh_handedness(transform: LegacyIron3dEulerTransform, scale: [f32; 3]) -> i8 {
let Some(origin) = transform.try_transform_scaled_point([0.0; 3], scale) else {
return 0;
@@ -2273,6 +2280,7 @@ impl EnvironmentGpuScene {
fn new(
mesh: &mut VulkanStaticMesh,
native_handedness_by_range: &mut HashMap<usize, i8>,
+ type16_present_ranges: &mut HashSet<usize>,
materials: &mut Vec<VulkanStaticMaterial>,
preview_material_animations: &mut Vec<MaterialAnimationBinding>,
sun_occlusion_range_indices: &mut Vec<usize>,
@@ -2700,6 +2708,15 @@ impl EnvironmentGpuScene {
remapped_handedness.insert(new_range_index, handedness);
}
*native_handedness_by_range = remapped_handedness;
+ let mut remapped_type16_present_ranges =
+ HashSet::with_capacity(type16_present_ranges.len());
+ for old_range_index in std::mem::take(type16_present_ranges) {
+ let new_range_index = remap.get(old_range_index).copied().ok_or_else(|| {
+ format!("type-16 MSH draw range remap is missing range {old_range_index}")
+ })?;
+ remapped_type16_present_ranges.insert(new_range_index);
+ }
+ *type16_present_ranges = remapped_type16_present_ranges;
for layer in &mut scene.sky_layers {
layer.range.range_index = *remap
.get(layer.range.range_index)
@@ -3965,6 +3982,7 @@ fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
struct StaticPreviewScene {
mesh: VulkanStaticMesh,
native_handedness_by_range: HashMap<usize, i8>,
+ type16_present_ranges: HashSet<usize>,
materials: Vec<VulkanStaticMaterial>,
material_animations: Vec<MaterialAnimationBinding>,
sun_occlusion_range_indices: Vec<usize>,
@@ -4401,6 +4419,7 @@ fn static_preview_mesh_and_materials(
draw_ranges: Vec::new(),
};
let mut native_handedness_by_range = HashMap::new();
+ let mut type16_present_ranges = HashSet::new();
let mut materials = Vec::new();
let mut material_animations = Vec::new();
let mut sun_occlusion_range_indices = Vec::new();
@@ -4768,6 +4787,9 @@ fn static_preview_mesh_and_materials(
)?;
for range_index in range_start..mesh.draw_ranges.len() {
native_handedness_by_range.insert(range_index, component_handedness);
+ if model.validated.type16_present {
+ type16_present_ranges.insert(range_index);
+ }
}
if root_sun_eligible {
sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len());
@@ -4842,6 +4864,7 @@ fn static_preview_mesh_and_materials(
Ok(StaticPreviewScene {
mesh,
native_handedness_by_range,
+ type16_present_ranges,
materials,
material_animations,
sun_occlusion_range_indices,
@@ -5562,6 +5585,7 @@ fn run_static_vulkan_mode(
Some(EnvironmentGpuScene::new(
&mut preview.mesh,
&mut preview.native_handedness_by_range,
+ &mut preview.type16_present_ranges,
&mut preview.materials,
&mut preview.material_animations,
&mut preview.sun_occlusion_range_indices,
@@ -5677,6 +5701,7 @@ fn replay_environment_delta_seconds(last_applied_ms: u32, current_ms: u32) -> f3
struct StaticVulkanApp {
mesh: VulkanStaticMesh,
native_handedness_by_range: HashMap<usize, i8>,
+ type16_present_ranges: HashSet<usize>,
materials: Vec<VulkanStaticMaterial>,
material_animations: Vec<MaterialAnimationBinding>,
sun_occlusion_range_indices: Vec<usize>,
@@ -5765,6 +5790,7 @@ impl StaticVulkanApp {
Self {
mesh: preview.mesh,
native_handedness_by_range: preview.native_handedness_by_range,
+ type16_present_ranges: preview.type16_present_ranges,
materials: preview.materials,
material_animations: preview.material_animations,
sun_occlusion_range_indices: preview.sun_occlusion_range_indices,
@@ -5832,7 +5858,17 @@ impl StaticVulkanApp {
.materials
.get(usize::from(range.material_index))
.map_or(1.0, |material| material.diffuse_alpha);
- let transparent = native_world_transparent(range, alpha);
+ let alpha_override = native_msh_diffuse_alpha_override(
+ range.batch_flags,
+ self.type16_present_ranges.contains(&range_index),
+ );
+ let effective_alpha = alpha_override.unwrap_or(alpha);
+ renderer
+ .set_draw_range_diffuse_alpha_override(range_index, alpha_override)
+ .map_err(|error| {
+ format!("configure world draw range {range_index} alpha: {error}")
+ })?;
+ let transparent = native_world_transparent(range, effective_alpha);
renderer
.set_draw_range_transparency(range_index, transparent)
.map_err(|error| format!("configure world draw range {range_index}: {error}"))?;
@@ -5853,7 +5889,7 @@ impl StaticVulkanApp {
&self.mesh,
range,
range.index_count,
- alpha,
+ effective_alpha,
self.view_camera.position,
self.native_handedness_by_range.get(&range_index).copied(),
),
@@ -5869,6 +5905,7 @@ impl StaticVulkanApp {
mesh: &VulkanStaticMesh,
materials: &[VulkanStaticMaterial],
world_range_indices: &[usize],
+ type16_present_ranges: &HashSet<usize>,
world_range_index_counts: &HashMap<usize, u32>,
native_handedness_by_range: &HashMap<usize, i8>,
eye_world: [f32; 3],
@@ -5881,7 +5918,7 @@ impl StaticVulkanApp {
.get(range_index)
.ok_or_else(|| "world draw range index is out of bounds".to_string())?;
let state = active_materials.get(&range_index);
- let alpha = state.map_or_else(
+ let material_alpha = state.map_or_else(
|| {
materials
.get(usize::from(range.material_index))
@@ -5889,6 +5926,14 @@ impl StaticVulkanApp {
},
|state| state.diffuse_alpha,
);
+ let alpha_override = native_msh_diffuse_alpha_override(
+ range.batch_flags,
+ type16_present_ranges.contains(&range_index),
+ );
+ let alpha = alpha_override.unwrap_or(material_alpha);
+ renderer
+ .set_draw_range_diffuse_alpha_override(range_index, alpha_override)
+ .map_err(|error| format!("update world draw range {range_index} alpha: {error}"))?;
renderer
.set_draw_range_transparency(range_index, native_world_transparent(range, alpha))
.map_err(|error| format!("update world draw range {range_index}: {error}"))?;
@@ -6322,6 +6367,7 @@ impl StaticVulkanApp {
&self.mesh,
&self.materials,
&self.world_range_indices,
+ &self.type16_present_ranges,
&self.world_range_index_counts,
&self.native_handedness_by_range,
frame.camera.position,
@@ -7342,6 +7388,7 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: vec![],
batches: vec![],
triangle_descriptors: None,
@@ -7671,6 +7718,7 @@ mod tests {
let preview = StaticPreviewScene {
mesh: initial_mesh.clone(),
native_handedness_by_range: HashMap::new(),
+ type16_present_ranges: HashSet::new(),
materials: Vec::new(),
material_animations: Vec::new(),
sun_occlusion_range_indices: Vec::new(),
@@ -8242,6 +8290,17 @@ mod tests {
}
#[test]
+ fn native_cpu_shaded_msh_alpha_override_uses_only_the_constant_batch_rule() {
+ assert_eq!(native_msh_diffuse_alpha_override(0x0148, false), Some(0.0));
+ assert_eq!(native_msh_diffuse_alpha_override(0x0108, false), None);
+ assert_eq!(native_msh_diffuse_alpha_override(0x0118, false), None);
+ assert_eq!(native_msh_diffuse_alpha_override(0x0058, false), None);
+ assert_eq!(native_msh_diffuse_alpha_override(0x0148, true), None);
+ assert_eq!(native_msh_diffuse_alpha_override(0x0058, true), None);
+ assert_eq!(native_msh_diffuse_alpha_override(0x0040, false), None);
+ }
+
+ #[test]
fn material_animation_selects_pipeline_category_with_active_phase() {
let variant = MaterialVariantSet {
phase_material_indices: vec![10, 11],
@@ -10009,4 +10068,29 @@ mod tests {
original_count
);
}
+ #[test]
+ fn world_draw_range_count_clears_and_reapplies_constant_alpha_override() {
+ let triangle = native_front_triangle();
+ let (mesh, mut range) = facing_mesh(0x0148, &triangle);
+ let original_count = range.index_count;
+ let mut alpha_override = None;
+ let update = |range: &VulkanStaticDrawRange, alpha_override: &mut Option<f32>| {
+ *alpha_override = native_msh_diffuse_alpha_override(range.batch_flags, false);
+ world_draw_range_index_count(
+ &mesh,
+ range,
+ original_count,
+ alpha_override.unwrap_or(1.0),
+ [0.0; 3],
+ None,
+ )
+ };
+
+ assert_eq!(update(&range, &mut alpha_override), 0);
+ range.batch_flags = 0x0108;
+ assert_eq!(update(&range, &mut alpha_override), original_count);
+ range.batch_flags = 0x0148;
+ assert_eq!(update(&range, &mut alpha_override), 0);
+ assert_eq!(original_count, 3);
+ }
}
diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs
index c35a3dc..3bbe17e 100644
--- a/crates/fparkan-msh/src/lib.rs
+++ b/crates/fparkan-msh/src/lib.rs
@@ -21,6 +21,8 @@ pub const STREAM_UV0: u32 = 5;
/// Optional secondary texture coordinate stream used by native lightmap/detail
/// stages. MSH stores it as the type-18 four-byte packed UV stream.
pub const STREAM_UV1: u32 = 18;
+/// Optional type-16 stream. Its presence marks MSH vertex-colour data.
+pub const STREAM_TYPE_16: u32 = 16;
/// Triangle index stream.
pub const STREAM_INDICES: u32 = 6;
/// Optional triangle-descriptor stream.
@@ -101,6 +103,10 @@ pub struct ModelAsset {
pub uv0: Option<Vec<[u16; 2]>>,
/// Optional secondary packed texture coordinates from type 18.
pub uv1: Option<Vec<[u16; 2]>>,
+ /// Whether the source MSH contains a type-16 vertex-colour stream.
+ ///
+ /// The stream payload is preserved separately and is not decoded here.
+ pub type16_present: bool,
/// Triangle indices.
pub indices: Vec<u16>,
/// Draw batches.
@@ -1116,6 +1122,7 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
let uv1 = read_optional_stream(document, STREAM_UV1)?
.map(|raw| parse_secondary_uv_stream(raw, positions.len()))
.transpose()?;
+ let type16_present = read_optional_stream(document, STREAM_TYPE_16)?.is_some();
let triangle_descriptors = read_optional_triangle_descriptors(document)?
.map(|raw| parse_triangle_descriptors(&raw.bytes))
.transpose()?;
@@ -1135,6 +1142,7 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
normals,
uv0,
uv1,
+ type16_present,
indices,
batches,
triangle_descriptors,
@@ -1641,6 +1649,25 @@ mod tests {
assert_eq!(model.node_count, 0);
assert!(model.slots.is_empty());
assert_eq!(model.triangle_descriptors, None);
+ assert!(!model.type16_present);
+ }
+
+ #[test]
+ fn type16_presence_is_preserved_without_decoding_its_payload() {
+ let payload = [0xDE, 0xAD, 0xBE, 0xEF];
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream(STREAM_TYPE_16, 0, b"Res16", &payload),
+ ]))
+ .expect("nested NRes");
+ let model =
+ validate_msh(&decode_msh(&document).expect("MSH document")).expect("model asset");
+
+ assert!(model.type16_present);
}
#[test]
@@ -2262,6 +2289,7 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
triangle_descriptors: None,
@@ -2324,6 +2352,7 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
triangle_descriptors: None,
@@ -2389,6 +2418,7 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
triangle_descriptors: None,
@@ -3282,6 +3312,7 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices,
batches,
node_names: None,
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 1bacc54..16c5b02 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -6808,6 +6808,7 @@ mod tests {
normals: None,
uv0: None,
uv1: None,
+ type16_present: false,
indices: Vec::new(),
batches: Vec::new(),
triangle_descriptors: None,
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index 68a01d0..580d545 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -18,6 +18,27 @@ state transitions и окончательный порядок части transp
стене и дверном проёме. Мировое преобразование, фильтры категории и
подключение к свободной камере остаются открытыми ([MSH](../reference/msh.md)).
+Для MSH без потока цветов type 16 подтверждён только один постоянный alpha
+override для диапазонов с CPU-цветом и raw-флагом `Batch20` `0x0008`: GOG
+`Terrain.dll`, SHA-256
+`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`, RVA
+`0x1B0F0`, задаёт alpha `0`, если установлен bit `0x0040` и сброшен `0x0010`.
+Это покрывает `0x0148`; `0x0108`, `0x0118` и `0x0058` сохраняют alpha MAT0,
+причём в `0x0058` bit `0x0010` имеет приоритет. Поток type 16 или отсутствие
+`0x0008` исключают override. Проверка заводской сцены с фиксированной нативной
+камерой подтвердила на одном кадре входа восстановление X-образного рисунка
+двери, который виден и в оригинале; прежний Vulkan-readback показывал на этом
+месте сплошной чёрный прямоугольник. Сравнение выполнено при времени сцены
+2000 мс и трёх кадрах, запуск с Vulkan validation завершился без предупреждений
+и ошибок. Это подтверждает только структуру проёма в этой сцене: правильность
+освещения и полный попиксельный паритет не установлены.
+
+Общее fade-поведение по расстоянию в preview не реализовано. Открыты точная
+метрика расстояния, выбор первого CPU-принятого треугольника диапазона и
+эквивалентность frustum/culling-отбора оригинала; эти вопросы нельзя вывести из
+захвата заводской двери. Данные потока type 16 ещё не декодированы, поэтому
+правило выше относится только к MSH-диапазонам без этого потока цветов.
+
Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
log на минимальных сценах с одним типом материала.
diff --git a/docs/reference/msh.md b/docs/reference/msh.md
index 7c6eca7..e2716d2 100644
--- a/docs/reference/msh.md
+++ b/docs/reference/msh.md
@@ -18,6 +18,7 @@ type 9 service stream
type 10 strings and node names
type 13 Batch20 records
type 15 auxiliary stream
+type 16 optional vertex-colour data
type 17 auxiliary data
type 18 packed UV1 (optional secondary coordinates)
type 19 animation frame map
@@ -25,6 +26,9 @@ type 20 rare auxiliary table
```
Reader ищет entries по type, но сохраняет исходный порядок для roundtrip.
+Поле `ModelAsset.type16_present` отмечает только наличие entry типа 16. Данные
+payload сохраняются, но этот путь рендера пока не преобразует их в цвета
+вершин.
## Node and slot selection
diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md
index 8cba630..3269c67 100644
--- a/docs/reference/render-frame.md
+++ b/docs/reference/render-frame.md
@@ -68,6 +68,23 @@ Pending frame хранит пакет MAT0 для мира и, при налич
`batch` `0x108`. Такой диапазон при включённой проверке глубины не записывает
глубину. За фиксированными диапазонами окружения сохраняются их явные состояния.
+Для MSH без потока цветов type 16 диапазон, цвет которого вычисляет CPU, при
+raw-флаге `Batch20` `0x0008` получает переопределение alpha только в одном
+подтверждённом случае. В GOG `Terrain.dll` с SHA-256
+`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead` проверка
+по RVA `0x1B0F0` задаёт alpha `0`, когда установлен bit `0x0040` и сброшен
+`0x0010`; поэтому для `0x0148` alpha равна нулю. Для `0x0108` и `0x0118`
+превью сохраняет alpha MAT0; для `0x0058` действует приоритет bit `0x0010`,
+также оставляя alpha MAT0. При наличии type 16 или отсутствии `0x0008`
+переопределения нет. Оно относится к одному draw range и не меняет общий
+материал; alpha `0` временно задаёт число индексов `0`, а при очистке
+переопределения исходное число индексов восстанавливается.
+
+Общий fade по расстоянию пока не реализован. Открыты точная метрика расстояния,
+выбор первого CPU-принятого треугольника диапазона и эквивалентность frustum- и
+culling-отбору оригинала. Поэтому состояния `0x0108` и `0x0118` оставляют alpha
+MAT0, пока эти входные данные не установлены.
+
В превью с фиксированной камерой диапазоны туманности и звёзд, купола и облаков
явно помечаются как прозрачный фон. Непрозрачные диапазоны мира остаются на
своих местах; прозрачная очередь мира сортируется от дальних элементов к