aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md5
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag15
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag.spvbin14512 -> 14716 bytes
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/reflection.rs5
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs57
-rw-r--r--apps/fparkan-game/src/main.rs42
-rw-r--r--crates/fparkan-fx/src/lib.rs16
-rw-r--r--crates/fparkan-render/src/water.rs137
-rw-r--r--docs/appendices/knowledge-boundaries.md5
-rw-r--r--docs/reference/environment-effects.md9
-rw-r--r--docs/reference/render-frame.md6
-rw-r--r--docs/tomes/05-render.md48
-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
15 files changed, 336 insertions, 55 deletions
diff --git a/README.md b/README.md
index 1aac809..4e16908 100644
--- a/README.md
+++ b/README.md
@@ -12,8 +12,9 @@
Рельеф переключает исходные LOD0/LOD1 по ячейкам относительно текущей камеры;
под основаниями строений сохраняется подробная сетка.
Горизонтальная вода использует плоское отражение ближайшей активной высоты;
-волны используют карту Du/Dv и часы оригинала, а shifted-камера отражения
-пока заменена обычным зеркалом.
+волны используют карту Du/Dv и часы оригинала. Камера отражения смотрит вверх
+из отражённой точки наблюдения через прямоугольник водоёма; глубина и часть
+параметров отражённого вида пока заимствуются из основного прохода.
## Запуск
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag
index 5d10152..2c71278 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.frag
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag
@@ -82,12 +82,15 @@ void main() {
vec2 bump_uv = plane_uv * frame.embm_wave.y + frame.embm_wave.x;
vec2 duv = max(vec2(-1.0), (texture(embm_bump, bump_uv).rg * 255.0 - 128.0) / 127.0);
vec2 displacement = duv * frame.embm_wave.zw;
- // shortcut: convert native map-UV displacement to the current mirror projection;
- // replace this projection when accepting the original shifted reflection camera.
- vec3 displaced_world = in_world_position - vec3(displacement.yx * span, 0.0);
- vec4 displaced_clip = frame.clip_from_world * vec4(displaced_world, 1.0);
- if (displaced_clip.w > 0.000001) {
- reflection_uv = displaced_clip.xy / displaced_clip.w * 0.5 + 0.5;
+ if ((mode & 128) != 0) {
+ reflection_uv = plane_uv + displacement;
+ } else {
+ // shortcut: non-perspective API cameras retain the projective mirror fallback.
+ vec3 displaced_world = in_world_position - vec3(displacement.yx * span, 0.0);
+ vec4 displaced_clip = frame.clip_from_world * vec4(displaced_world, 1.0);
+ if (displaced_clip.w > 0.000001) {
+ reflection_uv = displaced_clip.xy / displaced_clip.w * 0.5 + 0.5;
+ }
}
vec3 rgb = texture(reflection_color, reflection_uv).rgb * in_base_diffuse + in_base_specular;
out_color = vec4(mix(rgb * in_color, frame.fog_color.rgb, in_fog_factor), material.diffuse_alpha);
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
index de3ace6..6deef9d 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
Binary files differ
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 bd745ad..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_shifted_camera_unsupported={} (native bump map and clock; using a projective mirror until shifted-camera mapping is implemented)", 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 {
@@ -1564,11 +1564,18 @@ impl VulkanSmokeRenderer {
.active_reflection_plane()
.map(|height| {
let mut frame = self.frame_uniforms;
- frame.clip_from_world =
+ frame.clip_from_world = fparkan_render::water::legacy_embm_reflection_clip(
+ frame.clip_from_world,
+ frame.camera_position,
+ height,
+ frame.embm_bounds,
+ )
+ .or_else(|| {
fparkan_render::reflect_clip_from_world_about_z(frame.clip_from_world, height)
- .ok_or(VulkanSmokeRendererError::InvalidStaticCamera {
- context: "non-finite reflected clip transform",
- })?;
+ })
+ .ok_or(VulkanSmokeRendererError::InvalidStaticCamera {
+ context: "non-finite reflected clip transform",
+ })?;
frame.camera_position[2] = 2.0 * height - frame.camera_position[2];
if !frame.is_finite() {
return Err(VulkanSmokeRendererError::InvalidStaticCamera {
@@ -1819,6 +1826,15 @@ impl VulkanSmokeRenderer {
self.draw_order_dirty = false;
}
let reflection_plane = self.active_reflection_plane();
+ let shifted_reflection = reflection_plane.is_some_and(|height| {
+ fparkan_render::water::legacy_embm_reflection_clip(
+ self.frame_uniforms.clip_from_world,
+ self.frame_uniforms.camera_position,
+ height,
+ self.frame_uniforms.embm_bounds,
+ )
+ .is_some()
+ });
let device = self.device_ref()?;
let swapchain = self.swapchain_ref()?;
let resources = self.resources_ref()?;
@@ -1858,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 {
@@ -2053,14 +2079,11 @@ impl VulkanSmokeRenderer {
&& reflection_plane.is_some()
&& self.reflection_planes[range_index] == reflection_plane
{
- 64
+ 64 | if shifted_reflection { 128 } else { 0 }
} 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/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index b57d690..860922b 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -5498,6 +5498,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 +5513,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 +5545,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 +5575,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 +5585,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 +5652,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 +5661,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 +5671,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 +5698,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 +5716,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 +5851,7 @@ fn append_initial_control_effect_slots(
});
}
}
+ eprintln!("render_coverage control_fx_unsupported={unsupported_commands} control_fx_requires_game_state={state_dependent_commands}");
Ok(())
}
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 0c7ad2a..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]> {
@@ -51,11 +62,137 @@ pub fn legacy_embm_uv(world_xy: [f32; 2], bounds: [f32; 4]) -> Option<[f32; 2]>
])
}
+/// The EMBM camera looks up from the mirrored eye, with its window on the water rectangle.
+/// Terrain.dll+4EFF0 supplies `[min_y-eye_y, min_x-eye_x, span_y, span_x, eye_z-height]`.
+/// The ordinary perspective's depth mapping is retained; orthographic inputs use the caller's fallback.
+#[must_use]
+pub fn legacy_embm_reflection_clip(
+ main_clip: [f32; 16],
+ eye: [f32; 3],
+ height: f32,
+ bounds: [f32; 4],
+) -> Option<[f32; 16]> {
+ let span = [bounds[2] - bounds[0], bounds[3] - bounds[1]];
+ let distance = eye[2] - height;
+ if !main_clip
+ .into_iter()
+ .chain(eye)
+ .chain([height])
+ .chain(bounds)
+ .chain(span)
+ .all(f32::is_finite)
+ || distance <= 0.0
+ || span.into_iter().any(|v| v <= 0.0)
+ {
+ return None;
+ }
+ let w = [main_clip[3], main_clip[7], main_clip[11]];
+ let squared = w.into_iter().map(|v| v * v).sum::<f32>();
+ if !squared.is_finite() || squared <= f32::EPSILON {
+ return None;
+ }
+ let depth_a = (main_clip[2] * w[0] + main_clip[6] * w[1] + main_clip[10] * w[2]) / squared;
+ // shortcut: retain the main depth range until the native shifted depth matrix is verified.
+ let depth_b = (main_clip[14] - depth_a * main_clip[15]) / squared.sqrt();
+ if !depth_a.is_finite() || !depth_b.is_finite() || depth_a < 1.0 || depth_b >= 0.0 {
+ return None;
+ }
+ let z = 2.0 * height - eye[2];
+ let x_scale = -2.0 * distance / span[1];
+ let y_scale = -2.0 * distance / span[0];
+ let x_shift = 1.0 + 2.0 * (bounds[1] - eye[1]) / span[1];
+ let y_shift = 1.0 + 2.0 * (bounds[0] - eye[0]) / span[0];
+ let matrix = [
+ 0.0,
+ y_scale,
+ 0.0,
+ 0.0,
+ x_scale,
+ 0.0,
+ 0.0,
+ 0.0,
+ x_shift,
+ y_shift,
+ depth_a,
+ 1.0,
+ -x_scale * eye[1] - x_shift * z,
+ -y_scale * eye[0] - y_shift * z,
+ depth_b - depth_a * z,
+ -z,
+ ];
+ matrix.into_iter().all(f32::is_finite).then_some(matrix)
+}
+
#[cfg(test)]
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],
+ near_plane: 0.5,
+ far_plane: 700.0,
+ field_of_view_radians: 1.3,
+ }
+ .try_direct3d7_projection_row_major()
+ .unwrap();
+ let eye = [1200.0, 800.0, 185.0];
+ let mut main = [0.0; 16];
+ main[2] = projection[10];
+ main[3] = projection[11];
+ main[14] = -eye[0] * projection[10] + projection[14];
+ main[15] = -eye[0] * projection[11];
+ let bounds = [1000.0, 500.0, 1500.0, 1200.0];
+ let matrix = legacy_embm_reflection_clip(main, eye, 150.0, bounds).unwrap();
+ let project = |point: [f32; 3]| -> [f32; 4] {
+ std::array::from_fn(|column| {
+ point[0] * matrix[column]
+ + point[1] * matrix[4 + column]
+ + point[2] * matrix[8 + column]
+ + matrix[12 + column]
+ })
+ };
+ for point in [
+ [1000.0, 500.0, 150.0],
+ [1500.0, 1200.0, 150.0],
+ [1125.0, 1025.0, 150.0],
+ ] {
+ let clip = project(point);
+ let expected = legacy_embm_uv([point[0], point[1]], bounds).unwrap();
+ for axis in 0..2 {
+ assert!((clip[axis] / clip[3] * 0.5 + 0.5 - expected[axis]).abs() < 0.00001);
+ }
+ assert_eq!(clip[3], 35.0);
+ }
+ let near = project([1200.0, 800.0, 115.5]);
+ assert!((near[2] / near[3]).abs() < 0.001);
+ let far = project([1200.0, 800.0, 815.0]);
+ assert!((far[2] / far[3] - 1.0).abs() < 0.00001);
+ assert!(legacy_embm_reflection_clip([0.0; 16], eye, 150.0, bounds).is_none());
+ assert!(legacy_embm_reflection_clip(main, eye, 185.0, bounds).is_none());
+ assert!(legacy_embm_reflection_clip(main, eye, 150.0, [1.0; 4]).is_none());
+ }
+
+ #[test]
fn wave_bytes_match_native_generation_and_clock_wraps_without_float_time_loss() {
let texels = legacy_embm_bump_map();
assert_eq!(texels.len(), 128 * 128);
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index d632bf3..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` используют Vulkan-отражение и исходную карту волн Du/Dv; перенос её смещений в обычное зеркало приближённый: `planar_reflection_shifted_camera_unsupported`.
+Горизонтальные грани 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..80796a3 100644
--- a/docs/reference/render-frame.md
+++ b/docs/reference/render-frame.md
@@ -348,3 +348,9 @@ D3D7 в `fparkan-render`, а затем один раз меняет знак в
каналов не определяет. Последовательность не содержит структурированных ID
видимых объектов, списка элементов отрисовки, ключей конвейера, матриц или
хэшей промежуточных буферов.
+
+При преобразовании кадра в PNG сохраняется исходный RGB, а прозрачность
+выключается: окно показывает готовое непрозрачное изображение. Альфа-канал
+GPU может содержать результат смешивания поверхностей; повторное применение
+его к готовому цвету затемнило бы кадр ещё раз. Поэтому конвертер и сборщик
+сравнительных пар игнорируют эту альфу, а raw-файл сохраняют без изменений.
diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md
index a7a1396..4aba919 100644
--- a/docs/tomes/05-render.md
+++ b/docs/tomes/05-render.md
@@ -1147,13 +1147,39 @@ lightmap state передаются в `VulkanStaticMaterial`, а shader посÐ
В [эталонной сборке](../reference/original-binaries.md) выбор грани находится
в Terrain.dll RVA `0x28A50`, а подготовка EMBM-пакета — в RVA `0x1B540`.
-Первый Vulkan-проход строит плоское зеркало. Для горизонтальной воды на
-высоте `h` отражённая координата точки равна `z′ = 2h − z`. Это преобразование
-применяется перед обычной проекцией. Оно меняет порядок вершин треугольника,
-поэтому проход меняет сторону отсечения и отбрасывает геометрию ниже воды.
+Для горизонтальной воды на высоте `h` камера отражения находится в точке
+`[eye_x, eye_y, 2h − eye_z]`. Но она не повторяет наклон основной камеры:
+смотрит вертикально вверх, а её окно лежит на прямоугольнике водоёма. Поэтому
+текстура отражения привязана к местности, а не к экрану игрока. В плоскости
+воды координаты изображения равны `U = 1 − Ynorm`, `V = 1 − Xnorm`.
+
+Terrain.dll RVA `0x4EFF0` передаёт shifted-проекции пять значений:
+`[min_y − eye_y, min_x − eye_x, span_y, span_x, eye_z − h]`. Например,
+для наблюдателя `[1200, 800, 185]`, воды `150` и границ
+`[1000, 500, 1500, 1200]` это `[-300, -200, 700, 500, 35]`.
+Матрица камеры и эти аргументы проверены исполнением оригинальной функции
+при двух положениях и разных поворотах основной камеры. Оракул подменяет
+ввод-вывод очереди, ландшафта и камеры; сам renderer и матрица глубины в эту
+проверку не входят. Vulkan-проекция сохраняет near/far основного вида;
+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 после
записи. Следующий зеркальный проход ждёт предыдущего чтения этой текстуры;
@@ -1182,10 +1208,12 @@ lightmap state передаются в `VulkanStaticMaterial`, а shader посÐ
В Vulkan карта хранится в двух каналах несжатой текстуры и возвращается к
знаковым значениям перед вычислением смещения.
-Обычное зеркало ещё не повторяет shifted-камеру оригинала: смещения Du/Dv
-сейчас переводятся из координат водоёма в текущую зеркальную проекцию.
-Это приближение учитывается в `planar_reflection_shifted_camera_unsupported`;
-совпадение генератора волн не доказывает совпадения итогового отражения.
+Матрица положения камеры, её окно, размер текстуры и координаты волн восстановлены, но весь
+контекст отражённого вида ещё не повторён. Диапазон глубины, небо, туман и FX
+заимствуются из основного вида. Для захвата с обрезанным 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) {