aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/reflection.rs5
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs31
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/tests.rs26
-rw-r--r--adapters/fparkan-render-vulkan/src/policy.rs2
-rw-r--r--apps/fparkan-game/src/main.rs75
-rw-r--r--crates/fparkan-fx/src/lib.rs16
-rw-r--r--crates/fparkan-render/src/water.rs29
-rw-r--r--docs/appendices/knowledge-boundaries.md5
-rw-r--r--docs/reference/environment-effects.md9
-rw-r--r--docs/reference/render-frame.md27
-rw-r--r--docs/tomes/05-render.md17
-rw-r--r--tools/native-frame-capture/README.md6
-rw-r--r--tools/native-frame-capture/compare-frames.ps126
-rw-r--r--tools/native-frame-capture/convert-viewer-readback.ps114
14 files changed, 250 insertions, 38 deletions
diff --git a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
index 62a0d1e..1a9c9ae 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
@@ -221,6 +221,9 @@ impl ReflectionResources {
depth_request: fparkan_platform::DepthStencilSupport,
render_pass: vk::RenderPass,
) -> Result<(), VulkanSmokeRendererError> {
+ let side = fparkan_render::water::legacy_embm_reflection_size([extent.0, extent.1]);
+ let extent = (side, side);
+ eprintln!("render_reflection target_extent={side}x{side}");
let info = vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(vk::Format::from_raw(format))
@@ -236,7 +239,7 @@ impl ReflectionResources {
.usage(vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::SAMPLED)
.sharing_mode(vk::SharingMode::EXCLUSIVE)
.initial_layout(vk::ImageLayout::UNDEFINED);
- // SAFETY: This target uses the validated non-zero swapchain extent and format.
+ // SAFETY: The validated swapchain format is retained; the square side is in 2..=1024.
let image = unsafe { self.device.create_image(&info, None) }.map_err(|result| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateImage(reflection)",
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
index ef19ee8..a016645 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
@@ -373,7 +373,7 @@ impl VulkanSmokeRenderer {
plane_heights.sort_unstable();
plane_heights.dedup();
if !plane_heights.is_empty() {
- eprintln!("render_coverage planar_reflection_render_context_approx={} (native shifted window and bump UV; main-view depth range, sky/fog/FX and swapchain-sized target remain approximations; non-perspective cameras use mirror fallback)", plane_heights.len());
+ eprintln!("render_coverage planar_reflection_render_context_approx={} (native shifted window, target size and bump UV; main-view depth range and sky/fog/FX remain approximations; non-perspective cameras use mirror fallback)", plane_heights.len());
eprintln!("render_coverage planar_reflection_other_planes_unsupported={} (nearest active plane is reflected)", plane_heights.len().saturating_sub(1));
}
if unsupported_nonplanar != 0 {
@@ -1874,17 +1874,27 @@ impl VulkanSmokeRenderer {
},
},
];
- let render_area = vk::Rect2D {
- offset: vk::Offset2D { x: 0, y: 0 },
- extent: vk::Extent2D {
- width: swapchain.report.plan.extent.0,
- height: swapchain.report.plan.extent.1,
- },
- };
for reflected in [true, false] {
if reflected && reflection_plane.is_none() {
continue;
}
+ let extent = if reflected {
+ let target = resources.reflection.color.as_ref().ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "active reflection without a color target",
+ },
+ )?;
+ (target.width, target.height)
+ } else {
+ swapchain.report.plan.extent
+ };
+ let render_area = vk::Rect2D {
+ offset: vk::Offset2D { x: 0, y: 0 },
+ extent: vk::Extent2D {
+ width: extent.0,
+ height: extent.1,
+ },
+ };
let framebuffer = if reflected {
resources.reflection.framebuffer
} else {
@@ -2073,10 +2083,7 @@ impl VulkanSmokeRenderer {
} else {
0
}) as f32;
- let viewport = super::swapchain_resources::draw_viewport(
- swapchain.report.plan.extent,
- material_mode,
- );
+ let viewport = super::swapchain_resources::draw_viewport(extent, material_mode);
let next_viewport_depth = [viewport.min_depth, viewport.max_depth];
if viewport_depth != Some(next_viewport_depth) {
device
diff --git a/adapters/fparkan-render-vulkan/src/ffi/tests.rs b/adapters/fparkan-render-vulkan/src/ffi/tests.rs
index b06bb26..4b2f7d2 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/tests.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/tests.rs
@@ -419,6 +419,32 @@ fn swapchain_plan_enables_transfer_source_when_surface_supports_it() {
}
#[test]
+fn swapchain_plan_falls_back_to_immediate_without_overriding_supported_preference() {
+ let mut request = swapchain_request();
+ request.present_modes = vec![
+ vk::PresentModeKHR::IMMEDIATE.as_raw(),
+ vk::PresentModeKHR::FIFO.as_raw(),
+ ];
+ assert_eq!(
+ plan_vulkan_swapchain(&request).unwrap().present_mode,
+ vk::PresentModeKHR::IMMEDIATE.as_raw()
+ );
+ request.preferred_present_mode = vk::PresentModeKHR::FIFO.as_raw();
+ assert_eq!(
+ plan_vulkan_swapchain(&request).unwrap().present_mode,
+ vk::PresentModeKHR::FIFO.as_raw()
+ );
+ request
+ .present_modes
+ .push(vk::PresentModeKHR::MAILBOX.as_raw());
+ request.preferred_present_mode = vk::PresentModeKHR::MAILBOX.as_raw();
+ assert_eq!(
+ plan_vulkan_swapchain(&request).unwrap().present_mode,
+ vk::PresentModeKHR::MAILBOX.as_raw()
+ );
+}
+
+#[test]
fn swapchain_plan_uses_fifo_and_current_extent_fallbacks() {
let mut request = swapchain_request();
request.preferred_present_mode = vk::PresentModeKHR::IMMEDIATE.as_raw();
diff --git a/adapters/fparkan-render-vulkan/src/policy.rs b/adapters/fparkan-render-vulkan/src/policy.rs
index e1f01c8..8eceb22 100644
--- a/adapters/fparkan-render-vulkan/src/policy.rs
+++ b/adapters/fparkan-render-vulkan/src/policy.rs
@@ -495,6 +495,8 @@ fn undefined_surface_format_override(
fn select_present_mode(present_modes: &[i32], preferred: i32) -> Result<i32, VulkanSwapchainError> {
if present_modes.contains(&preferred) {
Ok(preferred)
+ } else if present_modes.contains(&vk::PresentModeKHR::IMMEDIATE.as_raw()) {
+ Ok(vk::PresentModeKHR::IMMEDIATE.as_raw())
} else if present_modes.contains(&vk::PresentModeKHR::FIFO.as_raw()) {
Ok(vk::PresentModeKHR::FIFO.as_raw())
} else {
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index b57d690..ca8742d 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -131,7 +131,6 @@ fn run(args: &[String]) -> Result<String, String> {
.ok_or_else(|| "mission terrain is unavailable after loading".to_string())?;
let roots = loaded_mission_object_drafts(&engine)
.map(|drafts| &drafts[..args.preview_roots.get().min(drafts.len())])
- .filter(|roots| !roots.is_empty())
.ok_or_else(|| {
"selected mission object drafts are unavailable after loading".to_string()
})?;
@@ -5498,6 +5497,8 @@ fn append_initial_control_effect_slots(
let Some(point_list) = control.point_list.as_ref() else {
return Ok(());
};
+ let mut unsupported_commands = 0;
+ let mut state_dependent_commands = 0;
for row in
selected_initial_control_effect_rows(control.initial_effect_group, &control.effect_section)
{
@@ -5511,10 +5512,22 @@ fn append_initial_control_effect_slots(
)
})?;
let key = format!("{archive}/{name}");
+ let mut record_skip = |reason: &str,
+ count: usize,
+ needs_state: bool,
+ command: Option<usize>| {
+ if needs_state {
+ state_dependent_commands += count;
+ } else {
+ unsupported_commands += count;
+ }
+ eprintln!("unexpanded Control FX reason:{reason} count:{count} needs_game_state:{needs_state} object:{object_index} component:{component_index} source:{key} table:{} row:{} command:{command:?}", row.table_index, row.row_index);
+ };
if !effect_documents.contains_key(&key) {
let bytes = load_resource_entry_bytes_from_root(root, &archive, &name)
.map_err(|error| format!("load Control FX {key}: {error}"))?;
let Ok(document) = decode_fxid(bytes) else {
+ eprintln!("render_coverage control_fx_invalid_documents=1 (object:{object_index} component:{component_index} source:{key})");
continue;
};
effect_documents.insert(key.clone(), document);
@@ -5531,17 +5544,24 @@ fn append_initial_control_effect_slots(
let duration_seconds = header.duration_seconds;
if !duration_seconds.is_finite()
|| duration_seconds < 0.0
- || !matches!(header.time_mode, 0 | 1 | 2)
- || (header.time_mode != 0 && duration_seconds <= 0.0)
|| !header.phase_jitter.is_finite()
|| !header.random_shift.iter().all(|value| value.is_finite())
|| !header.scale.iter().all(|value| value.is_finite())
{
- // Control can reference action effects whose header timing and
- // scale conventions are outside this ambient subset. Leave them
- // unexpanded until their lifetime and transform contract are
- // understood rather than failing the whole mission preview.
*unsupported_sounds += sound_commands;
+ record_skip("header_values", document.commands().len(), false, None);
+ continue;
+ }
+ if !matches!(header.time_mode, 0 | 1 | 2)
+ || (header.time_mode != 0 && duration_seconds <= 0.0)
+ {
+ *unsupported_sounds += sound_commands;
+ record_skip(
+ "time_mode",
+ document.commands().len(),
+ fparkan_fx::fx_time_mode_requires_owner_state(header.time_mode),
+ None,
+ );
continue;
}
let owner_node_index =
@@ -5554,6 +5574,7 @@ fn append_initial_control_effect_slots(
});
let Some(owner_node_index) = owner_node_index else {
*unsupported_sounds += sound_commands;
+ record_skip("owner_node", document.commands().len(), false, None);
continue;
};
let owner_node_pose = control_effect_owner_node_pose(
@@ -5563,6 +5584,7 @@ fn append_initial_control_effect_slots(
);
let Some(owner_node_pose) = owner_node_pose else {
*unsupported_sounds += sound_commands;
+ record_skip("owner_pose", document.commands().len(), false, None);
continue;
};
let root_transform = LegacyIron3dEulerTransform {
@@ -5629,6 +5651,7 @@ fn append_initial_control_effect_slots(
});
} else {
*unsupported_sounds += 1;
+ record_skip("sound_body", 1, false, Some(command_index));
eprintln!("unsupported Control Op2 object={object_index} component={component_index} effect={key} command={command_index} (mode, backend parameter or malformed body)");
}
} else {
@@ -5637,6 +5660,7 @@ fn append_initial_control_effect_slots(
continue;
}
let Some(kind) = ambient_effect_command_kind(command) else {
+ record_skip("opcode_or_body", 1, false, Some(command_index));
continue;
};
if !ambient_effect_render_selector_allows_header(
@@ -5646,11 +5670,13 @@ fn append_initial_control_effect_slots(
continue;
}
let Some(reference) = command.resource_refs.first() else {
+ record_skip("material_reference", 1, false, Some(command_index));
continue;
};
let material_archive = String::from_utf8_lossy(reference.archive_name()).into_owned();
let material_name = String::from_utf8_lossy(reference.resource_name()).into_owned();
if material_archive.is_empty() || material_name.is_empty() {
+ record_skip("empty_material_reference", 1, false, Some(command_index));
continue;
}
let cache_key = format!("{material_archive}/{material_name}");
@@ -5671,6 +5697,7 @@ fn append_initial_control_effect_slots(
&prepared.phase_directional_rgb,
&prepared.phase_additive_rgb,
) {
+ record_skip("material_tint", 1, false, Some(command_index));
continue;
}
let first_material = *prepared
@@ -5688,6 +5715,7 @@ fn append_initial_control_effect_slots(
[0.0; 3],
AMBIENT_EFFECT_NATIVE_CATEGORY,
) else {
+ record_skip("mesh_template", 1, false, Some(command_index));
continue;
};
Some(template)
@@ -5822,6 +5850,7 @@ fn append_initial_control_effect_slots(
});
}
}
+ eprintln!("render_coverage control_fx_unsupported={unsupported_commands} control_fx_requires_game_state={state_dependent_commands}");
Ok(())
}
@@ -8534,9 +8563,6 @@ fn static_preview_mesh_and_materials(
dynamic_component_geometry_by_object.push(dynamic_component_geometry);
control_animation_instances_by_object.push(control_animation_instances);
}
- if mesh_components == 0 {
- return Err("selected static preview roots have no mesh-backed visual".to_string());
- }
let free_camera = if legacy_camera.is_none() {
Some(FreeFlightCamera::from_mesh_and_terrain(&mesh, terrain)?)
} else {
@@ -13137,6 +13163,35 @@ mod tests {
#[test]
#[ignore = "requires installed GOG resources; set FPARKAN_GAME_ROOT"]
+ fn gog_static_preview_accepts_terrain_without_placed_objects() {
+ let root = std::env::var_os("FPARKAN_GAME_ROOT")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
+ let path = "DATA/MAPS/41/Land.msh";
+ let bytes = std::fs::read(root.join(path)).expect("original map mesh");
+ let document = fparkan_assets::decode_nres_payload(Arc::from(bytes)).unwrap();
+ let land = fparkan_terrain_format::decode_land_msh(&document).unwrap();
+ let terrain = TerrainWorld::from_land_msh(&land).unwrap();
+ let preview = static_preview_mesh_and_materials(
+ &MissionAssets::default(),
+ &terrain,
+ &[],
+ None,
+ None,
+ None,
+ &root,
+ path,
+ )
+ .expect("terrain is a complete scene without object meshes");
+ assert_eq!(preview.mesh_components, 0);
+ assert_eq!(preview.terrain_components, 1);
+ assert!(!preview.mesh.vertices.is_empty());
+ assert!(!preview.mesh.indices.is_empty());
+ assert!(!preview.materials.is_empty());
+ }
+
+ #[test]
+ #[ignore = "requires installed GOG resources; set FPARKAN_GAME_ROOT"]
fn gog_terrain_detail_uses_multitexture_animation_block() {
let root = std::env::var_os("FPARKAN_GAME_ROOT")
.map(PathBuf::from)
diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs
index af7408a..8a567de 100644
--- a/crates/fparkan-fx/src/lib.rs
+++ b/crates/fparkan-fx/src/lib.rs
@@ -67,6 +67,12 @@ pub struct FxHeader {
pub scale: [f32; 3],
}
+/// Effect.dll RVA 0x5C60 reads an owner/provider for modes 4 through 17, including mode 14.
+#[must_use]
+pub const fn fx_time_mode_requires_owner_state(mode: u32) -> bool {
+ matches!(mode, 4..=17)
+}
+
/// FX opcode.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum FxOpcode {
@@ -624,6 +630,16 @@ mod tests {
use std::path::{Path, PathBuf};
#[test]
+ fn phase_sources_distinguish_elapsed_damage_and_progress_modes() {
+ for mode in [0, 1, 2, 3, 18, u32::MAX] {
+ assert!(!fx_time_mode_requires_owner_state(mode));
+ }
+ for mode in [4, 5, 9, 13, 14, 15, 16, 17] {
+ assert!(fx_time_mode_requires_owner_state(mode));
+ }
+ }
+
+ #[test]
fn decodes_synthetic_opcodes_and_refs() {
let mut bytes = header(2);
bytes.extend_from_slice(&command_with_ref_at(
diff --git a/crates/fparkan-render/src/water.rs b/crates/fparkan-render/src/water.rs
index 4f161cc..58a2817 100644
--- a/crates/fparkan-render/src/water.rs
+++ b/crates/fparkan-render/src/water.rs
@@ -7,6 +7,17 @@ pub const EMBM_BUMP_TILE: f32 = 100.0;
/// Native diagonal bump matrix, without luminance modulation.
pub const EMBM_COEFFICIENTS: [f32; 2] = [0.01, 0.01];
+/// Square reflection size chosen by Terrain.dll RVA 0x27FC7 from renderer dimensions.
+#[must_use]
+pub fn legacy_embm_reflection_size(extent: [u32; 2]) -> u32 {
+ let limit = extent[0].min(extent[1]).min(1024);
+ let mut side = 2;
+ while side * 2 <= limit {
+ side *= 2;
+ }
+ side
+}
+
/// Generates the signed bytes written by Terrain.dll RVA 0x2BF50 with CW 0x007F.
#[must_use]
pub fn legacy_embm_bump_map() -> Vec<[i8; 2]> {
@@ -117,6 +128,24 @@ mod tests {
use super::*;
#[test]
+ fn reflection_size_matches_native_renderer_dimension_vectors() {
+ for (extent, expected) in [
+ ([1280, 1024], 1024),
+ ([1280, 720], 512),
+ ([800, 600], 512),
+ ([640, 480], 256),
+ ([1920, 1080], 1024),
+ ([2048, 2048], 1024),
+ ([4, 7], 4),
+ ([3, 300], 2),
+ ([1, 1], 2),
+ ([700, 700], 512),
+ ] {
+ assert_eq!(legacy_embm_reflection_size(extent), expected, "{extent:?}");
+ }
+ }
+
+ #[test]
fn shifted_camera_maps_native_water_corners_and_retains_perspective_depth() {
let projection = crate::LegacyD3d7Projection {
viewport: [0, 0, 1280, 1024],
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index 7b71e9e..a60fa68 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -76,7 +76,7 @@ MSH-запрос со свободной preview-камерой; это не пÐ
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
log на минимальных сценах с одним типом материала.
-Горизонтальные грани Land.msh с флагом `0x20000` используют исходную карту Du/Dv и окно shifted-камеры, проверенное исполнением Terrain.dll. Диапазон глубины, небо/туман/FX основного вида и размер target остаются приближениями: `planar_reflection_render_context_approx`; неперспективная камера API использует обычное зеркало.
+Горизонтальные грани Land.msh с флагом `0x20000` используют исходную карту Du/Dv, окно shifted-камеры и квадратный размер текстуры, проверенные исполнением DLL. Диапазон глубины, небо/туман/FX основного вида и соответствие размеров при обрезанном capture остаются приближениями: `planar_reflection_render_context_approx`; неперспективная камера API использует обычное зеркало.
Raw queue clock задаёт фазу волн отдельно от времени суток и sky elapsed; старые захваты без него считаются в `captured_water_phase_unverified`.
Один target отражает ближайшую активную высоту воды; остальные высоты и неплоские грани считаются в `planar_reflection_other_planes_unsupported` и `planar_reflection_nonplanar_unsupported` и сохраняют обычный terrain shader.
`terrain_face8_pipeline_unverified` теперь считает только исходные грани с `0x8` без `0x20000`; сама семантика оставшихся специальных проходов ещё не установлена.
@@ -90,6 +90,9 @@ Control Op1 пока использует цвет и радиус без слу
Control Op2 воспроизводит modes 0/2; modes 1/3, body+76 и неподдержанные
time modes/привязки считаются как `render_coverage control_op2_unsupported`.
Управление фазой экземпляра командами Control остаётся до его подключения в runtime.
+При расширении выбранных начальных строк все пропущенные команды считаются
+в `control_fx_unsupported`, а time modes 4–17 — в `control_fx_requires_game_state`;
+причина и владелец выводятся в лог. Неразобранные FXID считаются отдельно в `control_fx_invalid_documents`.
Размеры команд, resource references, lifecycle, flags families и используемые
time modes известны. Не закрыто значение каждого поля body opcodes 1--10,
diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md
index f3ecfb4..677c159 100644
--- a/docs/reference/environment-effects.md
+++ b/docs/reference/environment-effects.md
@@ -420,6 +420,15 @@ FX-команды используют общий preview clock и ограниÑ
mode 0 оставляет фазу равной нулю, mode 1 вычисляет `elapsed / duration` без
зажатия, mode 2 повторяет цикл. Header flags `0x200` и `0x20` преобразуют эту
фазу; command path `0x8120` зажимает результат только после преобразований.
+В [эталонной Effect.dll](original-binaries.md) RVA `0x5C60` режимы 4–17
+читают состояние владельца или провайдера; режим 14 тоже входит в эту группу.
+Пропуски при расширении выбранных начальных Control-строк учитываются по
+командам: `control_fx_requires_game_state` для этих режимов и
+`control_fx_unsupported` для остальных непреобразованных команд. Диагностика
+сохраняет причину, ресурс, объект, компонент, строку и номер команды.
+`control_fx_invalid_documents` отдельно считает неразобранные FXID, где число
+команд неизвестно. Счётчики суммируются по компонентам миссии; они не заменяют
+учёт ещё не выбранных Control-групп и условных строк в фазе G.
Effect renderer проверяет header bit `0x800` только при
render selector 0; native AniMesh component path был захвачен с selector 1,
поэтому preview использует selector 1 и не подавляет такие visual commands.
diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md
index df3f248..7adc4e6 100644
--- a/docs/reference/render-frame.md
+++ b/docs/reference/render-frame.md
@@ -6,6 +6,11 @@
## Порядок кадра
+Сам рельеф образует полноценную графическую сцену: список размещённых объектов
+может быть пустым. Наличие MSH-компонентов объектов не является условием
+отрисовки карты. Проверочные TMA без объектов позволяют отдельно загружать
+карты ресурсов, для которых в установленной игре нет штатной миссии.
+
```text
системные сообщения и ввод
-> расчёт симуляции
@@ -213,6 +218,22 @@ RVA `0x4EBE0` использует соглашение о вызовах `__std
`REFLECTION_SHIFTED`. Это свидетельство ABI фабрики, а не восстановленный
контракт матрицы вида или проекции.
+## Представление готового кадра
+
+Vulkan сохраняет явно выбранный режим, если поверхность его поддерживает.
+Приложение предпочитает MAILBOX: готовый новый кадр заменяет ожидающий,
+а показ остаётся привязан к развёртке. При отсутствии MAILBOX выбирается
+IMMEDIATE, затем FIFO. IMMEDIATE допускает разрывы изображения, зато не
+навязывает ожидание вертикальной развёртки; FIFO остаётся запасным режимом.
+Семантика режимов описана в [спецификации Vulkan](https://docs.vulkan.org/refpages/latest/refpages/source/VkPresentModeKHR.html).
+
+Оконная ветка GOG Ngi32.dll (SHA-256 из [таблицы модулей](original-binaries.md))
+по RVA `0x6E1B` вызывает `IDirectDrawSurface7::Blt` с `DDBLT_WAIT`.
+Эта ветка наблюдалась при нативных захватах. Флаг ждёт готовности блиттера,
+а не задаёт интервал развёртки — см. [описание Microsoft](https://learn.microsoft.com/en-us/windows/win32/api/ddraw/nf-ddraw-idirectdrawsurface7-blt).
+Современный композитор и драйвер всё равно могут влиять на фактический показ;
+это правило выбора API, а не гарантия одинаковой задержки двух движков.
+
## Предпросмотр по захвату камеры D3D7
`fparkan-game --legacy-camera-capture <json>` принимает матрицу камеры
@@ -348,3 +369,9 @@ D3D7 в `fparkan-render`, а затем один раз меняет знак в
каналов не определяет. Последовательность не содержит структурированных ID
видимых объектов, списка элементов отрисовки, ключей конвейера, матриц или
хэшей промежуточных буферов.
+
+При преобразовании кадра в PNG сохраняется исходный RGB, а прозрачность
+выключается: окно показывает готовое непрозрачное изображение. Альфа-канал
+GPU может содержать результат смешивания поверхностей; повторное применение
+его к готовому цвету затемнило бы кадр ещё раз. Поэтому конвертер и сборщик
+сравнительных пар игнорируют эту альфу, а raw-файл сохраняют без изменений.
diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md
index 53667c8..4aba919 100644
--- a/docs/tomes/05-render.md
+++ b/docs/tomes/05-render.md
@@ -1169,6 +1169,18 @@ Rust-тест отдельно проверяет углы водоёма и гÑ
водоёма с добавленным смещением волн. Для неперспективной камеры API остаётся
обычное зеркало с экранными координатами.
+Размер изображения отражения выбирается отдельно от размера основного кадра.
+Terrain.dll в конструкторе CShade (RVA `0x278E0`, цикл с `0x27FC7`) берёт
+ширину и высоту renderer через Ngi32.dll RVA `0x7FB0`. Сторона квадратной
+текстуры — наибольшая степень двойки, не превосходящая меньшую из этих
+величин и 1024; нижний предел равен 2. Например, экран 1280×1024 даёт
+отражение 1024×1024, а 1280×720 — 512×512. Getter размеров и исходный цикл
+выбора проверены исполнением DLL на десяти входах; создание нативной текстуры
+в этом оракуле пропущено. Vulkan создаёт цвет и глубину с этой стороной и
+задаёт каждому проходу собственные viewport и область очистки. Так уменьшение
+отражения не обрезает основной кадр и воспроизводит исходный масштаб его
+пикселей, от которого зависит чёткость берегов и ряби.
+
Цветовой target переходит из attachment layout в shader-read layout после
записи. Следующий зеркальный проход ждёт предыдущего чтения этой текстуры;
основной и отражённый виды используют разные слоты uniform buffer. Во время
@@ -1196,9 +1208,10 @@ Rust-тест отдельно проверяет углы водоёма и гÑ
В Vulkan карта хранится в двух каналах несжатой текстуры и возвращается к
знаковым значениям перед вычислением смещения.
-Матрица положения камеры, её окно и координаты волн восстановлены, но весь
+Матрица положения камеры, её окно, размер текстуры и координаты волн восстановлены, но весь
контекст отражённого вида ещё не повторён. Диапазон глубины, небо, туман и FX
-заимствуются из основного вида, а размер target совпадает со swapchain.
+заимствуются из основного вида. Для захвата с обрезанным viewport ещё не
+подтверждено соответствие размера окна FParkan полному renderer оригинала.
Эти ограничения учитываются в `planar_reflection_render_context_approx`;
совпадение генератора волн и окна не доказывает совпадения итогового отражения.
Один target обслуживает ближайшую
diff --git a/tools/native-frame-capture/README.md b/tools/native-frame-capture/README.md
index d8f621c..1f7166b 100644
--- a/tools/native-frame-capture/README.md
+++ b/tools/native-frame-capture/README.md
@@ -117,6 +117,12 @@ the verified value for the run above, not a universal surface format. The
converter uses the installed `System.Drawing` PNG encoder and adds no package
dependency.
+PNG exports treat the captured frame as opaque and preserve its stored RGB.
+Framebuffer alpha is a rendering result, not image transparency; applying it
+again when displaying the PNG would darken surfaces a second time. Raw files
+retain all four original channels. The comparison script also ignores alpha
+in older PNG exports, without changing the measured RGB values.
+
## Сравнение областей кадра
`compare-frames.ps1` собирает пару «оригинал / FParkan» без масштабирования и
diff --git a/tools/native-frame-capture/compare-frames.ps1 b/tools/native-frame-capture/compare-frames.ps1
index 2a83bf0..b8d3855 100644
--- a/tools/native-frame-capture/compare-frames.ps1
+++ b/tools/native-frame-capture/compare-frames.ps1
@@ -41,7 +41,21 @@ if ($SelfTest) {
try { Get-MeanRgb $test ([System.Drawing.Rectangle]::new(1, 0, 2, 1)) | Out-Null }
catch { $rejected = $true }
if (-not $rejected) { throw 'Out-of-bounds region was accepted.' }
- Write-Output 'self-test: exact region RGB means and bounds rejection OK'
+ $test.SetPixel(0, 0, [System.Drawing.Color]::FromArgb(0, 31, 16, 24))
+ $test.SetPixel(1, 0, [System.Drawing.Color]::FromArgb(64, 31, 16, 24))
+ $opaque = $test.Clone([System.Drawing.Rectangle]::new(0, 0, 2, 1), [System.Drawing.Imaging.PixelFormat]::Format32bppRgb)
+ $pair = [System.Drawing.Bitmap]::new(2, 1)
+ $graphics = [System.Drawing.Graphics]::FromImage($pair)
+ try {
+ $graphics.Clear([System.Drawing.Color]::Black)
+ $graphics.DrawImageUnscaled($opaque, 0, 0)
+ foreach ($x in @(0, 1)) {
+ if ($pair.GetPixel($x, 0).ToArgb() -ne [System.Drawing.Color]::FromArgb(31, 16, 24).ToArgb()) {
+ throw 'Comparison applied framebuffer alpha to stored RGB.'
+ }
+ }
+ } finally { $graphics.Dispose(); $pair.Dispose(); $opaque.Dispose() }
+ Write-Output 'self-test: exact region RGB means, opaque presentation and bounds rejection OK'
} finally { $test.Dispose() }
return
}
@@ -63,19 +77,21 @@ foreach ($value in $viewport) {
}
$crop = [System.Drawing.Rectangle]::new($viewport[0], $viewport[1], $viewport[2] - $viewport[0], $viewport[3] - $viewport[1])
$native = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $NativePng).Path)
-$viewer = $nativeCrop = $pair = $graphics = $null
+$viewer = $viewerOpaque = $nativeCrop = $pair = $graphics = $null
try {
$viewer = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $ViewerPng).Path)
if ($crop.Width -le 0 -or $crop.Height -le 0 -or $crop.Right -gt $native.Width -or $crop.Bottom -gt $native.Height -or
$viewer.Width -ne $crop.Width -or $viewer.Height -ne $crop.Height) {
throw 'Native crop and viewer dimensions must match without scaling.'
}
- $nativeCrop = $native.Clone($crop, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
+ # Captured frames are opaque presentations; framebuffer alpha is not PNG opacity.
+ $nativeCrop = $native.Clone($crop, [System.Drawing.Imaging.PixelFormat]::Format32bppRgb)
+ $viewerOpaque = $viewer.Clone([System.Drawing.Rectangle]::new(0, 0, $viewer.Width, $viewer.Height), [System.Drawing.Imaging.PixelFormat]::Format32bppRgb)
$pair = [System.Drawing.Bitmap]::new($viewer.Width * 2, $viewer.Height + 24)
$graphics = [System.Drawing.Graphics]::FromImage($pair)
$graphics.Clear([System.Drawing.Color]::Black)
$graphics.DrawImageUnscaled($nativeCrop, 0, 24)
- $graphics.DrawImageUnscaled($viewer, $viewer.Width, 24)
+ $graphics.DrawImageUnscaled($viewerOpaque, $viewer.Width, 24)
$font = [System.Drawing.SystemFonts]::DefaultFont
$graphics.DrawString('Original', $font, [System.Drawing.Brushes]::White, 4, 4)
$graphics.DrawString('FParkan', $font, [System.Drawing.Brushes]::White, $viewer.Width + 4, 4)
@@ -100,7 +116,7 @@ try {
ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding UTF8
Write-Output "Wrote $pngPath and $jsonPath"
} finally {
- foreach ($resource in @($graphics, $pair, $nativeCrop, $viewer, $native)) {
+ foreach ($resource in @($graphics, $pair, $nativeCrop, $viewerOpaque, $viewer, $native)) {
if ($null -ne $resource) { $resource.Dispose() }
}
}
diff --git a/tools/native-frame-capture/convert-viewer-readback.ps1 b/tools/native-frame-capture/convert-viewer-readback.ps1
index 3bafe14..17c136a 100644
--- a/tools/native-frame-capture/convert-viewer-readback.ps1
+++ b/tools/native-frame-capture/convert-viewer-readback.ps1
@@ -118,7 +118,7 @@ function Save-BgraPngToStream {
$bitmap = New-Object System.Drawing.Bitmap(
$Width,
$Height,
- [System.Drawing.Imaging.PixelFormat]::Format32bppArgb
+ [System.Drawing.Imaging.PixelFormat]::Format32bppRgb
)
$rectangle = New-Object System.Drawing.Rectangle(0, 0, $Width, $Height)
$bitmapData = $null
@@ -126,7 +126,7 @@ function Save-BgraPngToStream {
$bitmapData = $bitmap.LockBits(
$rectangle,
[System.Drawing.Imaging.ImageLockMode]::WriteOnly,
- [System.Drawing.Imaging.PixelFormat]::Format32bppArgb
+ [System.Drawing.Imaging.PixelFormat]::Format32bppRgb
)
$rowBytes = $Width * 4
@@ -201,12 +201,12 @@ function Get-CameraExtent {
function Invoke-SelfTest {
$rgba = [byte[]]@(
- 255, 0, 0, 255, 0, 255, 0, 255,
- 0, 0, 255, 255, 255, 255, 255, 255
+ 255, 0, 0, 0, 0, 255, 0, 64,
+ 0, 0, 255, 128, 255, 255, 255, 255
)
$expectedBgra = [byte[]]@(
- 0, 0, 255, 255, 0, 255, 0, 255,
- 255, 0, 0, 255, 255, 255, 255, 255
+ 0, 0, 255, 0, 0, 255, 0, 64,
+ 255, 0, 0, 128, 255, 255, 255, 255
)
$rgbaResult = Convert-ReadbackToBgra -Source $rgba -Format 37 -Width 2 -Height 2
@@ -303,7 +303,7 @@ function Invoke-SelfTest {
$stream.Dispose()
}
- 'Self-check passed (2x2 RGBA/BGRA PNG, byte-length/64 MiB limits, viewport-coordinate bounds, and cropped pixel_capture bounds).'
+ 'Self-check passed (opaque 2x2 RGBA/BGRA PNG, byte-length/64 MiB limits, viewport-coordinate bounds, and cropped pixel_capture bounds).'
}
if ($SelfTest) {