Fix terrain height and native frame capture
Docs Deploy / Build and Deploy MkDocs (push) Successful in 40s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 40s
Use raw world-space terrain heights throughout mesh, camera-floor, shadow, and sun-ray paths while preserving the projection far-plane conversion. Complete the verified selected-camera readback flow with frozen phase sampling, capture tooling, and documentation.
This commit is contained in:
@@ -9,9 +9,6 @@ use fparkan_msh::{
|
||||
use fparkan_render::{LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState};
|
||||
use fparkan_terrain_format::LandMeshDocument;
|
||||
|
||||
/// Legacy `Land.msh` stored-height to mission-world-height scale.
|
||||
const LEGACY_LAND_HEIGHT_SCALE: f32 = 1.0 / 32.0;
|
||||
|
||||
fn world_pipeline_state() -> LegacyPipelineState {
|
||||
LegacyPipelineState {
|
||||
depth: LegacyDepthMode::TestWrite,
|
||||
@@ -1139,31 +1136,6 @@ pub fn project_land_msh_to_static_mesh_in_world_space(
|
||||
})
|
||||
}
|
||||
|
||||
/// Projects terrain into the mission space consumed by the legacy D3D7 camera.
|
||||
///
|
||||
/// `Land.msh` stores horizontal coordinates directly, while its height stream
|
||||
/// uses 1/32 units. The scale is evidenced by the GOG `AutoDemo` map: raw terrain
|
||||
/// heights `95.29412..288.23532` become `2.978..9.007`, matching its placed
|
||||
/// object heights and the live Ngi32 camera's world-space Z coordinate.
|
||||
///
|
||||
/// This conversion is intentionally confined to the captured legacy-camera
|
||||
/// path. [`project_land_msh_to_static_mesh_in_world_space`] remains the raw
|
||||
/// source-coordinate bridge for format inspection.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanAssetMeshError`] when the terrain cannot enter the static
|
||||
/// Vulkan input contract.
|
||||
pub fn project_land_msh_to_static_mesh_in_legacy_world_space(
|
||||
terrain: &LandMeshDocument,
|
||||
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
||||
let mut mesh = project_land_msh_to_static_mesh_in_world_space(terrain)?;
|
||||
for vertex in &mut mesh.vertices {
|
||||
vertex.position[2] *= LEGACY_LAND_HEIGHT_SCALE;
|
||||
}
|
||||
Ok(mesh)
|
||||
}
|
||||
|
||||
fn static_terrain_indices(terrain: &LandMeshDocument) -> Result<Vec<u32>, VulkanAssetMeshError> {
|
||||
let mut indices = Vec::with_capacity(
|
||||
terrain
|
||||
@@ -2068,7 +2040,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_space_terrain_preserves_source_coordinates_and_faces() {
|
||||
fn world_space_terrain_preserves_nonzero_source_heights_and_faces() {
|
||||
let terrain = LandMeshDocument {
|
||||
streams: Vec::new(),
|
||||
nodes_raw: Vec::new(),
|
||||
@@ -2076,7 +2048,11 @@ mod tests {
|
||||
header_raw: Vec::new(),
|
||||
slots_raw: Vec::new(),
|
||||
},
|
||||
positions: vec![[10.0, 20.0, 30.0], [40.0, 50.0, 60.0], [70.0, 80.0, 90.0]],
|
||||
positions: vec![
|
||||
[100.0, 200.0, 32.0],
|
||||
[300.0, 400.0, 288.0],
|
||||
[500.0, 600.0, 96.0],
|
||||
],
|
||||
normals: Vec::new(),
|
||||
uv0: vec![[1024, 65024], [0, 2048], [64512, 512]],
|
||||
accelerator: Vec::new(),
|
||||
@@ -2089,41 +2065,12 @@ mod tests {
|
||||
.expect("representable source-space terrain");
|
||||
|
||||
assert_eq!(mesh.indices, vec![2, 0, 1]);
|
||||
assert_eq!(mesh.vertices[0].position, [10.0, 20.0, 30.0]);
|
||||
assert_eq!(mesh.vertices[2].position, [70.0, 80.0, 90.0]);
|
||||
assert_eq!(mesh.vertices[0].position, [100.0, 200.0, 32.0]);
|
||||
assert_eq!(mesh.vertices[1].position, [300.0, 400.0, 288.0]);
|
||||
assert_eq!(mesh.vertices[2].position, [500.0, 600.0, 96.0]);
|
||||
assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_world_space_terrain_scales_only_stored_height() {
|
||||
let terrain = LandMeshDocument {
|
||||
streams: Vec::new(),
|
||||
nodes_raw: Vec::new(),
|
||||
slots: TerrainSlotTable {
|
||||
header_raw: Vec::new(),
|
||||
slots_raw: Vec::new(),
|
||||
},
|
||||
positions: vec![
|
||||
[100.0, 200.0, 96.0],
|
||||
[300.0, 400.0, 288.0],
|
||||
[500.0, 600.0, 192.0],
|
||||
],
|
||||
normals: Vec::new(),
|
||||
uv0: vec![[0, 0]; 3],
|
||||
accelerator: Vec::new(),
|
||||
aux14: Vec::new(),
|
||||
aux18: Vec::new(),
|
||||
faces: vec![terrain_face([0, 1, 2])],
|
||||
};
|
||||
|
||||
let mesh = project_land_msh_to_static_mesh_in_legacy_world_space(&terrain)
|
||||
.expect("representable terrain");
|
||||
|
||||
assert_eq!(mesh.vertices[0].position, [100.0, 200.0, 3.0]);
|
||||
assert_eq!(mesh.vertices[1].position, [300.0, 400.0, 9.0]);
|
||||
assert_eq!(mesh.vertices[2].position, [500.0, 600.0, 6.0]);
|
||||
}
|
||||
|
||||
fn terrain_face(vertices: [u16; 3]) -> TerrainFace28 {
|
||||
terrain_face_with_tag(0, vertices)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ mod validation;
|
||||
pub use self::asset_mesh::{
|
||||
node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
|
||||
node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh,
|
||||
project_land_msh_to_static_mesh_in_legacy_world_space,
|
||||
project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame,
|
||||
project_msh_to_static_mesh, project_msh_to_static_mesh_in_world_space,
|
||||
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
|
||||
|
||||
+524
-128
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,17 @@ the exported `stdRenderGame` is RVA `0x13BD0`. It first calls
|
||||
`Terrain::stdSetCurrentCamera2(camera)`, stores the camera pointer only for
|
||||
the frame, and clears it before return. `sendEndOfRender` is a separate export
|
||||
at RVA `0x13D90`. This is frame-order evidence only: the camera ABI, matrices
|
||||
and viewport values still require dynamic capture or further decompilation.
|
||||
and viewport values are not recovered by this call-order analysis. The
|
||||
implemented preview path below accepts selector-0 input produced through the
|
||||
[native frame capture workflow](../../tools/native-frame-capture/README.md).
|
||||
The capture workflow also records an observed mission path and, when
|
||||
its thread/generation and clock checks succeed, an `atmosphere_seconds` sample.
|
||||
For a fixed-camera preview, the renderer uses that optional absolute schedule
|
||||
phase when no `--atmosphere-seconds` override is supplied and keeps schedule
|
||||
sampling at that exact phase across readback frames. An explicit CLI value takes
|
||||
precedence. The phase sample is not full simulation time: weather, RNG, and
|
||||
other mission state remain unknown, so this evidence does not establish
|
||||
full-frame parity.
|
||||
|
||||
Receiver-side GOG Terrain evidence refines this contract. `stdSetCurrentCamera2`
|
||||
(RVA `0x4FD40` in Terrain SHA-256
|
||||
@@ -67,6 +77,29 @@ mode string: only `REFLECTION` and `REFLECTION_SHIFTED` produce a non-zero mode
|
||||
value at `this + 0x1A0`. This is factory ABI evidence, not a recovered view or
|
||||
projection matrix contract.
|
||||
|
||||
## Legacy camera preview path
|
||||
|
||||
`fparkan-game --legacy-camera-capture <json>` consumes the selector-0 transform,
|
||||
the native D3D7 viewport RECT `[left, top, right, bottom]`, near/far planes and
|
||||
field of view. It derives the D3D7 view and clip transform from those inputs,
|
||||
carries the resulting `clip_from_world` matrix into the preview camera, and
|
||||
keeps the view-matrix camera axes directly, so the captured basis, including
|
||||
roll, is not reduced to yaw/pitch. The RECT becomes a physical drawable extent
|
||||
of `right - left` by `bottom - top`. The preview window is fixed to that extent;
|
||||
startup and non-zero resize events reject a physical-size mismatch instead of
|
||||
stretching the captured view. Compare the result with the corresponding
|
||||
`[left, top, right, bottom]` crop of the original PNG. `FreeFlightCamera` is not
|
||||
created in this mode, but redraws still update environment and projected
|
||||
shadows from the captured camera. Before environment updates can emit sound
|
||||
events, the audio listener receives the captured position, forward and up axes;
|
||||
initial world draw sorting uses the same captured position. A phase capture may
|
||||
include an observed mission path and a verified `atmosphere_seconds` value; the
|
||||
latter is used only when no explicit CLI override is supplied. This value pins
|
||||
the atmosphere schedule sample during a fixed-camera comparison while FX still
|
||||
receives real frame time. `simulation_time_seconds`, weather, RNG and complete
|
||||
mission state remain unknown, and a camera/phase match alone does not establish
|
||||
visual parity with a native frame.
|
||||
|
||||
## Parity risks
|
||||
|
||||
`VulkanStaticCamera::from_legacy_d3d7` keeps the D3D7 view and projection
|
||||
|
||||
+23
-4
@@ -871,9 +871,12 @@ RVA `0x36610` строит `Rz(z) * Ry(y) * Rx(x)` с переносом в по
|
||||
Повороты и переносы узлов модели применяются до размещения объекта в миссии.
|
||||
|
||||
Горизонтальная плоскость карты — XY, высота — Z. У GOG AutoDemo диапазон XY
|
||||
ландшафта составляет `0..1190.6976`. Высоты, сохранённые в `Land.msh`, переводятся
|
||||
в единицы мира делением на 32. Это отдельное преобразование ландшафта;
|
||||
применять его к координатам размещённых объектов нельзя.
|
||||
ландшафта составляет `0..1190.6976`, а исходный диапазон Z в `Land.msh` —
|
||||
`0..94.50981`. Проверенный native frame с той же камерой совпадает с силуэтом
|
||||
ландшафта при прямом использовании исходного Z; деление на 32 опускало бы этот
|
||||
горизонт примерно на 185 пикселей. Terrain, camera floor, shadow receivers и
|
||||
sun-ray queries поэтому используют исходные координаты `Land.msh` без
|
||||
преобразования Z.
|
||||
|
||||
`Ngi32!niGet3DRender` (RVA `0x5640`) возвращает графический интерфейс.
|
||||
Вызов Direct3D7 `SetTransform` устанавливает projection из RVA `0x7030`
|
||||
@@ -946,7 +949,23 @@ projected shadows и audio events обновляются вместе с кам
|
||||
`--atmosphere-seconds` задаёт старт времени, `--frames 0` оставляет цикл
|
||||
бесконечным, а `--preview-roots` служит только диагностическим ограничителем.
|
||||
`--legacy-camera-capture` выбирает воспроизводимую камеру без free-flight
|
||||
управления.
|
||||
управления: запись selector 0 задаёт D3D7 transform, clip параметры и native
|
||||
viewport RECT `[left, top, right, bottom]`. Путь сохраняет оси basis из view
|
||||
matrix вместе с roll, переносит полученный `clip_from_world` в preview camera,
|
||||
переводит RECT в физический drawable размер `(right - left) × (bottom - top)`
|
||||
и отклоняет несовпадение размера при
|
||||
создании или resize вместо растягивания кадра. Результат нужно сравнивать с
|
||||
тем же crop исходного PNG `[left, top, right, bottom]`. Контроллер
|
||||
`FreeFlightCamera` в этом режиме не создаётся, но redraw продолжает обновлять
|
||||
environment и projected shadows от captured camera. Перед environment update и
|
||||
его audio events listener получает position, forward и up captured camera;
|
||||
начальная сортировка world draw использует её же position. Phase capture может
|
||||
содержать наблюдаемый mission path и проверенный `atmosphere_seconds`; viewer
|
||||
использует эту абсолютную фазу по умолчанию, если не указан явный CLI override,
|
||||
и удерживает sample атмосферы на ней через readback frames. FX по-прежнему
|
||||
получает реальный frame time. Это не полное simulation time: `weather`, RNG и
|
||||
остальное mission state остаются неизвестными, поэтому совпадение камеры и
|
||||
фазы не доказывает visual parity с native frame.
|
||||
|
||||
Для сохранения кадра surface должен поддерживать `TRANSFER_SRC`. Renderer
|
||||
копирует последний отправленный swapchain image в host-visible buffer и
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
# Native frame capture
|
||||
|
||||
This x86 helper reads one live frame from the isolated GOG-compatible scratch copy of `Parkan - Iron Strategy`. It writes a PNG and a `fparkan-legacy-camera-v1` JSON file. The 64-byte camera matrix is sampled at the World3D render entry and reread at the projection-building boundary; only a same-thread, byte-compared snapshot paired with perspective values can produce usable renderer input. A passive `iron3d.dll` callsite probe records a path only when the active call arguments and path string are verified. It also samples `Terrain+0x421DC` after verifying the GOG module hash and the `EBP - [ESI+0x144]`, remainder by `[ESI+0x150]` instruction bytes. `atmosphere_seconds` is emitted only if the sampled `EDX` phase matches that arithmetic, the raw clock matches `World3D+0x32A38`, and sample, camera, and projection share the same render thread and generation. Otherwise it is `null`. This phase does not establish a general simulation-time, weather, or RNG value.
|
||||
|
||||
The capture helper expects an isolated copy at `target\shadow-probe\Parkan - Iron Strategy`; the original GOG installation stays untouched. For the default local install path, create that scratch copy from the repository root with:
|
||||
|
||||
```powershell
|
||||
$originalGame = 'C:\GOG Games\Parkan - Iron Strategy'
|
||||
$scratchGame = 'target\shadow-probe\Parkan - Iron Strategy'
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $scratchGame) | Out-Null
|
||||
robocopy $originalGame $scratchGame /E /COPY:DAT /R:1 /W:1
|
||||
if ($LASTEXITCODE -ge 8) { throw 'Scratch copy failed; inspect robocopy output.' }
|
||||
```
|
||||
|
||||
Build the viewer from the repository root with `cargo build --release -p fparkan-game`. No package installation is needed.
|
||||
|
||||
Build and run the ABI self-check from the repository root:
|
||||
|
||||
```powershell
|
||||
& 'C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe' /nologo /platform:x86 /r:System.Drawing.dll /r:System.Web.Extensions.dll /out:target\native-frame-capture.exe tools\native-frame-capture\NativeFrameCapture.cs tools\native-frame-capture\NativeFrameCapture.Readback.cs
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Native capture helper build failed; inspect compiler output.' }
|
||||
& 'target\native-frame-capture.exe' --self-check
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Native capture helper self-check failed; inspect its output.' }
|
||||
```
|
||||
|
||||
Start the scratch executable from its own directory, then pass its PID and UTC start ticks to the helper. Keep the game visible while it waits; the helper does not synthesize input. It waits up to five minutes for a live world to reach the verified camera, projection, and Present boundaries. The default startup can remain at the shell until a mission begins, so a timeout is not a successful capture. The helper rejects other executable paths, stale PIDs, and mismatched game DLLs. Output must remain under `target`.
|
||||
|
||||
```powershell
|
||||
$game = (Resolve-Path 'target\shadow-probe\Parkan - Iron Strategy').Path
|
||||
$process = Start-Process -FilePath (Join-Path $game 'iron_3d.exe') -WorkingDirectory $game -PassThru
|
||||
& 'target\native-frame-capture.exe' --capture $process.Id $process.StartTime.ToUniversalTime().Ticks target\native-frame-capture\frame.json
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Native frame capture failed; inspect its output and choose fresh output paths before retrying.' }
|
||||
```
|
||||
|
||||
After a passive capture succeeds, its JSON can serve as the optional selected-camera input. The following example uses the matrix and projection values from `frame.json` and writes a new basename:
|
||||
|
||||
```powershell
|
||||
$cameraInput = 'target\native-frame-capture\frame.json'
|
||||
$selectedFrame = 'target\native-frame-capture\selected-frame.json'
|
||||
& 'target\native-frame-capture.exe' --validate-camera-input $cameraInput
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Camera JSON is not a usable fparkan-legacy-camera-v1 input.' }
|
||||
& 'target\native-frame-capture.exe' --capture $process.Id $process.StartTime.ToUniversalTime().Ticks $selectedFrame --camera-input $cameraInput
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Selected-camera capture failed; inspect its log before retrying.' }
|
||||
```
|
||||
|
||||
`--validate-camera-input` parses and checks the JSON before opening a game process. The input must contain a valid `fparkan-legacy-camera-v1` matrix and projection values. At runtime the helper first observes an unmodified camera render and requires its viewport, near/far planes, and FOV to match the input. It applies only the 64-byte camera matrix to that same camera on the next same-thread render and reads the resulting surface at the verified Present boundary while the selected matrix is still active. At the projection boundary it verifies that the active matrix still exactly matches the requested input, as well as the projection values. It then restores the original matrix through the verified setter at that render invocation's return boundary. Pixels remain in memory until the original Present context, camera matrix, armed breakpoints, and debugger attachment have all been restored; the helper creates the PNG and JSON only after confirmed detach. It rejects output if the projection or requested matrix no longer matches, the selected matrix changes before return, or another render invalidates pixel attribution. Two repeated captures verified the selected matrix, projection, exact restoration, readback, and detach.
|
||||
|
||||
This verifies repeatable camera handling, not deterministic whole-scene pixels. The helper has no setter for FOV, near/far planes, or fog, and does not freeze simulation state; `atmosphere_seconds` remains an observed phase sample rather than a restored simulation clock. If the callsite probe cannot verify a mission path, `mission_path` remains `null` and `mission_identity` remains `unknown`.
|
||||
|
||||
For a second capture, reuse the still-running PID only after the first run detached successfully, and choose a new output basename. Reuse the same validated camera input if comparing the same pose. The helper rejects any existing JSON, PNG, or log with the chosen basename and never overwrites those files. After a successful one-shot capture it detaches and leaves the scratch game running. If recovery requires termination, it stops only the exact path/tick/hash-verified scratch process.
|
||||
|
||||
To compare a viewer frame with a native capture, use the same phase JSON for the camera and the PNG conversion. When `atmosphere_seconds` is present, the viewer uses it unless `--atmosphere-seconds` is given, and holds the atmosphere schedule at that sample for the fixed-camera readback. Choose fresh `viewer.bin` and `viewer.png` paths because the viewer writes the raw file and conversion refuses to overwrite an existing PNG.
|
||||
|
||||
```powershell
|
||||
$cameraJson = 'target\native-frame-capture\frame.json'
|
||||
$viewerRaw = 'target\native-frame-capture\viewer.bin'
|
||||
$viewerPng = 'target\native-frame-capture\viewer.png'
|
||||
|
||||
cargo build --release -p fparkan-game
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Viewer build failed; inspect cargo output.' }
|
||||
& '.\tools\native-frame-capture\convert-viewer-readback.ps1' -SelfTest
|
||||
& 'target\release\fparkan-game.exe' `
|
||||
--root 'target\shadow-probe\Parkan - Iron Strategy' `
|
||||
--mission 'MISSIONS\Autodemo.00\data.tma' `
|
||||
--legacy-camera-capture $cameraJson `
|
||||
--frames 3 `
|
||||
--readback-out $viewerRaw
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Viewer readback failed; inspect its output before converting any raw file.' }
|
||||
& '.\tools\native-frame-capture\convert-viewer-readback.ps1' `
|
||||
-InputRaw $viewerRaw `
|
||||
-CameraJson $cameraJson `
|
||||
-VkFormat 37 `
|
||||
-OutputPng $viewerPng
|
||||
```
|
||||
|
||||
The converter derives raw image dimensions from the `viewport` rectangle. If
|
||||
`pixel_capture` is present, its width and height describe the full native PNG;
|
||||
the viewport must fit within those bounds, but can be a smaller crop with a
|
||||
nonzero origin. The viewer readback itself contains only the viewport extent.
|
||||
The converter checks the exact raw byte length (up to 64 MiB), refuses outputs
|
||||
outside `target` or existing PNG paths, and accepts VkFormat `37`/`43` for RGBA
|
||||
or `44`/`50` for BGRA. Pass the viewer log's `readback_format` value; `37` is
|
||||
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.
|
||||
@@ -0,0 +1,346 @@
|
||||
[CmdletBinding(DefaultParameterSetName = 'Convert')]
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Convert')]
|
||||
[string]$InputRaw,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Convert')]
|
||||
[string]$CameraJson,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Convert')]
|
||||
[ValidateSet('37', '43', '44', '50')]
|
||||
[int]$VkFormat,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'Convert')]
|
||||
[string]$OutputPng,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'SelfTest')]
|
||||
[switch]$SelfTest
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
function Assert-ReadbackByteLength {
|
||||
param(
|
||||
[long]$ActualLength,
|
||||
[int]$Width,
|
||||
[int]$Height
|
||||
)
|
||||
|
||||
$maximumLength = [long]64 * 1024 * 1024
|
||||
if ($Width -le 0 -or $Height -le 0) {
|
||||
throw [System.IO.InvalidDataException]::new('Readback dimensions must be positive.')
|
||||
}
|
||||
$expectedLength = [long]$Width * [long]$Height * 4
|
||||
if ($expectedLength -gt $maximumLength) {
|
||||
throw [System.IO.InvalidDataException]::new("Readback exceeds the 64 MiB limit: $expectedLength bytes.")
|
||||
}
|
||||
if ($ActualLength -ne $expectedLength) {
|
||||
throw [System.IO.InvalidDataException]::new(
|
||||
"Readback must contain exactly width * height * 4 bytes; got $ActualLength for ${Width}x${Height} (expected $expectedLength)."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertTo-ViewportCoordinate {
|
||||
param(
|
||||
[object]$Value,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
try {
|
||||
$number = [System.Convert]::ToDecimal($Value, [System.Globalization.CultureInfo]::InvariantCulture)
|
||||
}
|
||||
catch {
|
||||
throw "Camera JSON viewport $Name must be an integer."
|
||||
}
|
||||
if ($number -ne [decimal]::Truncate($number) -or
|
||||
$number -lt [int]::MinValue -or $number -gt [int]::MaxValue) {
|
||||
throw "Camera JSON viewport $Name must be an in-range 32-bit integer."
|
||||
}
|
||||
return [int]$number
|
||||
}
|
||||
|
||||
function Convert-ReadbackToBgra {
|
||||
param(
|
||||
[byte[]]$Source,
|
||||
[int]$Format,
|
||||
[int]$Width,
|
||||
[int]$Height
|
||||
)
|
||||
|
||||
Assert-ReadbackByteLength -ActualLength $Source.Length -Width $Width -Height $Height
|
||||
|
||||
# VK_FORMAT_R8G8B8A8_* (37, 43) needs R/B swapped for System.Drawing's
|
||||
# little-endian Format32bppArgb storage. VK_FORMAT_B8G8R8A8_* (44, 50)
|
||||
# already has the byte order expected by that bitmap format.
|
||||
if ($Format -eq 44 -or $Format -eq 50) {
|
||||
return ,$Source
|
||||
}
|
||||
|
||||
$converted = New-Object byte[] $Source.Length
|
||||
for ($offset = 0; $offset -lt $Source.Length; $offset += 4) {
|
||||
$converted[$offset] = $Source[$offset + 2]
|
||||
$converted[$offset + 1] = $Source[$offset + 1]
|
||||
$converted[$offset + 2] = $Source[$offset]
|
||||
$converted[$offset + 3] = $Source[$offset + 3]
|
||||
}
|
||||
|
||||
return ,$converted
|
||||
}
|
||||
|
||||
function Assert-ViewportWithinCapture {
|
||||
param(
|
||||
[int]$Left,
|
||||
[int]$Top,
|
||||
[int]$Right,
|
||||
[int]$Bottom,
|
||||
[int]$CaptureWidth,
|
||||
[int]$CaptureHeight
|
||||
)
|
||||
|
||||
if ($CaptureWidth -le 0 -or $CaptureHeight -le 0) {
|
||||
throw [System.IO.InvalidDataException]::new('Camera JSON pixel_capture dimensions must be positive.')
|
||||
}
|
||||
if ($Left -lt 0 -or $Top -lt 0 -or $Right -gt $CaptureWidth -or $Bottom -gt $CaptureHeight) {
|
||||
throw [System.IO.InvalidDataException]::new('Camera JSON viewport must fit within the full pixel_capture image bounds.')
|
||||
}
|
||||
}
|
||||
|
||||
function Save-BgraPngToStream {
|
||||
param(
|
||||
[byte[]]$Pixels,
|
||||
[int]$Width,
|
||||
[int]$Height,
|
||||
[System.IO.Stream]$Stream
|
||||
)
|
||||
|
||||
$bitmap = New-Object System.Drawing.Bitmap(
|
||||
$Width,
|
||||
$Height,
|
||||
[System.Drawing.Imaging.PixelFormat]::Format32bppArgb
|
||||
)
|
||||
$rectangle = New-Object System.Drawing.Rectangle(0, 0, $Width, $Height)
|
||||
$bitmapData = $null
|
||||
try {
|
||||
$bitmapData = $bitmap.LockBits(
|
||||
$rectangle,
|
||||
[System.Drawing.Imaging.ImageLockMode]::WriteOnly,
|
||||
[System.Drawing.Imaging.PixelFormat]::Format32bppArgb
|
||||
)
|
||||
|
||||
$rowBytes = $Width * 4
|
||||
if ([Math]::Abs($bitmapData.Stride) -lt $rowBytes) {
|
||||
throw 'Bitmap stride is shorter than a pixel row.'
|
||||
}
|
||||
|
||||
for ($row = 0; $row -lt $Height; $row++) {
|
||||
$destination = [IntPtr]::Add($bitmapData.Scan0, $row * $bitmapData.Stride)
|
||||
[System.Runtime.InteropServices.Marshal]::Copy(
|
||||
$Pixels,
|
||||
$row * $rowBytes,
|
||||
$destination,
|
||||
$rowBytes
|
||||
)
|
||||
}
|
||||
|
||||
$bitmap.UnlockBits($bitmapData)
|
||||
$bitmapData = $null
|
||||
$bitmap.Save($Stream, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $bitmapData) {
|
||||
$bitmap.UnlockBits($bitmapData)
|
||||
}
|
||||
$bitmap.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CameraExtentFromObject {
|
||||
param([object]$Camera)
|
||||
|
||||
$camera = $Camera
|
||||
if ($camera.schema -ne 'fparkan-legacy-camera-v1' -or $null -eq $camera.viewport -or $camera.viewport.Count -ne 4) {
|
||||
throw 'Camera JSON must be fparkan-legacy-camera-v1 and contain a four-value viewport.'
|
||||
}
|
||||
|
||||
$left = ConvertTo-ViewportCoordinate -Value $camera.viewport[0] -Name 'left'
|
||||
$top = ConvertTo-ViewportCoordinate -Value $camera.viewport[1] -Name 'top'
|
||||
$right = ConvertTo-ViewportCoordinate -Value $camera.viewport[2] -Name 'right'
|
||||
$bottom = ConvertTo-ViewportCoordinate -Value $camera.viewport[3] -Name 'bottom'
|
||||
$widthLong = [long]$right - [long]$left
|
||||
$heightLong = [long]$bottom - [long]$top
|
||||
if ($widthLong -gt [int]::MaxValue -or $heightLong -gt [int]::MaxValue) {
|
||||
throw 'Camera JSON viewport extent exceeds 32-bit dimensions.'
|
||||
}
|
||||
$width = [int]$widthLong
|
||||
$height = [int]$heightLong
|
||||
if ($width -le 0 -or $height -le 0) {
|
||||
throw 'Camera JSON viewport has an empty or inverted extent.'
|
||||
}
|
||||
|
||||
$expectedBytes = [long]$width * [long]$height * 4
|
||||
if ($expectedBytes -gt ([long]64 * 1024 * 1024)) {
|
||||
throw "Camera JSON viewport exceeds the 64 MiB readback limit: $expectedBytes bytes."
|
||||
}
|
||||
if ($null -ne $camera.pixel_capture) {
|
||||
$captureWidth = ConvertTo-ViewportCoordinate -Value $camera.pixel_capture.width -Name 'pixel_capture.width'
|
||||
$captureHeight = ConvertTo-ViewportCoordinate -Value $camera.pixel_capture.height -Name 'pixel_capture.height'
|
||||
Assert-ViewportWithinCapture -Left $left -Top $top -Right $right -Bottom $bottom -CaptureWidth $captureWidth -CaptureHeight $captureHeight
|
||||
}
|
||||
|
||||
return [pscustomobject]@{ Width = $width; Height = $height }
|
||||
}
|
||||
|
||||
function Get-CameraExtent {
|
||||
param([string]$Path)
|
||||
|
||||
$camera = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
return Get-CameraExtentFromObject -Camera $camera
|
||||
}
|
||||
|
||||
function Invoke-SelfTest {
|
||||
$rgba = [byte[]]@(
|
||||
255, 0, 0, 255, 0, 255, 0, 255,
|
||||
0, 0, 255, 255, 255, 255, 255, 255
|
||||
)
|
||||
$expectedBgra = [byte[]]@(
|
||||
0, 0, 255, 255, 0, 255, 0, 255,
|
||||
255, 0, 0, 255, 255, 255, 255, 255
|
||||
)
|
||||
|
||||
$rgbaResult = Convert-ReadbackToBgra -Source $rgba -Format 37 -Width 2 -Height 2
|
||||
$bgraResult = Convert-ReadbackToBgra -Source $expectedBgra -Format 44 -Width 2 -Height 2
|
||||
for ($index = 0; $index -lt $expectedBgra.Length; $index++) {
|
||||
if ($rgbaResult[$index] -ne $expectedBgra[$index] -or $bgraResult[$index] -ne $expectedBgra[$index]) {
|
||||
throw 'Self-check failed: RGBA/BGRA channel mapping.'
|
||||
}
|
||||
}
|
||||
|
||||
$mismatchRejected = $false
|
||||
try {
|
||||
Assert-ReadbackByteLength -ActualLength 15 -Width 2 -Height 2
|
||||
}
|
||||
catch [System.IO.InvalidDataException] {
|
||||
$mismatchRejected = $true
|
||||
}
|
||||
if (-not $mismatchRejected) {
|
||||
throw 'Self-check failed: wrong byte length was accepted.'
|
||||
}
|
||||
|
||||
Assert-ReadbackByteLength -ActualLength ([long]64 * 1024 * 1024) -Width 4096 -Height 4096
|
||||
$oversizeRejected = $false
|
||||
try {
|
||||
Assert-ReadbackByteLength -ActualLength ([long]64 * 1024 * 1024) -Width 4097 -Height 4096
|
||||
}
|
||||
catch [System.IO.InvalidDataException] {
|
||||
$oversizeRejected = $true
|
||||
}
|
||||
if (-not $oversizeRejected) {
|
||||
throw 'Self-check failed: readback over the 64 MiB boundary was accepted.'
|
||||
}
|
||||
|
||||
foreach ($invalidCoordinate in @([double]1.5, [long]2147483648)) {
|
||||
$coordinateRejected = $false
|
||||
try {
|
||||
ConvertTo-ViewportCoordinate -Value $invalidCoordinate -Name 'self-check'
|
||||
}
|
||||
catch {
|
||||
$coordinateRejected = $true
|
||||
}
|
||||
if (-not $coordinateRejected) {
|
||||
throw 'Self-check failed: fractional or out-of-range viewport coordinate was accepted.'
|
||||
}
|
||||
}
|
||||
|
||||
# A crop can be smaller than the full native PNG and start away from (0,0).
|
||||
# Raw viewer readback is the crop only, so its dimensions stay 4x4 here.
|
||||
$cropCamera = [pscustomobject]@{
|
||||
schema = 'fparkan-legacy-camera-v1'
|
||||
viewport = @(4, 6, 8, 10)
|
||||
pixel_capture = [pscustomobject]@{ width = 12; height = 16 }
|
||||
}
|
||||
$cropExtent = Get-CameraExtentFromObject -Camera $cropCamera
|
||||
if ($cropExtent.Width -ne 4 -or $cropExtent.Height -ne 4) {
|
||||
throw 'Self-check failed: cropped readback extent was not derived from the viewport rectangle.'
|
||||
}
|
||||
Assert-ReadbackByteLength -ActualLength 64 -Width 4 -Height 4
|
||||
$cropOutsideCaptureRejected = $false
|
||||
try {
|
||||
$cropCamera.viewport = @(4, 6, 13, 10)
|
||||
Get-CameraExtentFromObject -Camera $cropCamera | Out-Null
|
||||
}
|
||||
catch [System.IO.InvalidDataException] {
|
||||
$cropOutsideCaptureRejected = $true
|
||||
}
|
||||
if (-not $cropOutsideCaptureRejected) {
|
||||
throw 'Self-check failed: viewport extending beyond the full pixel_capture image was accepted.'
|
||||
}
|
||||
|
||||
$stream = New-Object System.IO.MemoryStream
|
||||
$decoded = $null
|
||||
try {
|
||||
Save-BgraPngToStream -Pixels $expectedBgra -Width 2 -Height 2 -Stream $stream
|
||||
$stream.Position = 0
|
||||
$decoded = [System.Drawing.Bitmap]::new($stream)
|
||||
$expectedColors = @(
|
||||
[System.Drawing.Color]::Red,
|
||||
[System.Drawing.Color]::FromArgb(255, 0, 255, 0),
|
||||
[System.Drawing.Color]::Blue,
|
||||
[System.Drawing.Color]::White
|
||||
)
|
||||
$points = @(@(0, 0), @(1, 0), @(0, 1), @(1, 1))
|
||||
for ($index = 0; $index -lt $points.Count; $index++) {
|
||||
$point = $points[$index]
|
||||
$actualColor = $decoded.GetPixel($point[0], $point[1])
|
||||
if ($actualColor.ToArgb() -ne $expectedColors[$index].ToArgb()) {
|
||||
throw "Self-check failed: PNG pixel $index expected $($expectedColors[$index]) but read $actualColor."
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $decoded) { $decoded.Dispose() }
|
||||
$stream.Dispose()
|
||||
}
|
||||
|
||||
'Self-check passed (2x2 RGBA/BGRA PNG, byte-length/64 MiB limits, viewport-coordinate bounds, and cropped pixel_capture bounds).'
|
||||
}
|
||||
|
||||
if ($SelfTest) {
|
||||
Invoke-SelfTest
|
||||
exit 0
|
||||
}
|
||||
|
||||
$repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
|
||||
$targetRoot = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot 'target'))
|
||||
$rawPath = (Resolve-Path -LiteralPath $InputRaw).Path
|
||||
$cameraPath = (Resolve-Path -LiteralPath $CameraJson).Path
|
||||
|
||||
if (-not (Test-Path -LiteralPath $rawPath -PathType Leaf)) { throw "Raw readback is not a file: $rawPath" }
|
||||
if (-not (Test-Path -LiteralPath $cameraPath -PathType Leaf)) { throw "Camera JSON is not a file: $cameraPath" }
|
||||
|
||||
$outputPath = [System.IO.Path]::GetFullPath($OutputPng)
|
||||
$targetPrefix = $targetRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
|
||||
if (-not $outputPath.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Output must be beneath the repository target directory: $targetRoot"
|
||||
}
|
||||
if ([System.IO.Path]::GetExtension($outputPath) -ine '.png') { throw 'Output filename must end in .png.' }
|
||||
if (Test-Path -LiteralPath $outputPath) { throw "Refusing to overwrite existing output: $outputPath" }
|
||||
$outputDirectory = [System.IO.Path]::GetDirectoryName($outputPath)
|
||||
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
|
||||
throw "Output directory does not exist: $outputDirectory"
|
||||
}
|
||||
|
||||
$extent = Get-CameraExtent -Path $cameraPath
|
||||
$rawInfo = Get-Item -LiteralPath $rawPath
|
||||
Assert-ReadbackByteLength -ActualLength $rawInfo.Length -Width $extent.Width -Height $extent.Height
|
||||
$source = [System.IO.File]::ReadAllBytes($rawPath)
|
||||
$pixels = Convert-ReadbackToBgra -Source $source -Format $VkFormat -Width $extent.Width -Height $extent.Height
|
||||
$stream = [System.IO.File]::Open($outputPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
|
||||
try {
|
||||
Save-BgraPngToStream -Pixels $pixels -Width $extent.Width -Height $extent.Height -Stream $stream
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
"Wrote $($extent.Width)x$($extent.Height) PNG to $outputPath (VkFormat $VkFormat)."
|
||||
Reference in New Issue
Block a user