Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e593af8dc0 |
@@ -9,7 +9,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
msrv-backend-neutral:
|
msrv-backend-neutral:
|
||||||
name: Pinned Rust backend-neutral crates
|
name: MSRV backend-neutral crates
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
env:
|
env:
|
||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
|
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
|
||||||
with:
|
with:
|
||||||
toolchain: 1.97.1
|
toolchain: 1.87.0
|
||||||
- name: Test backend-neutral crates
|
- name: Test backend-neutral crates
|
||||||
run: >
|
run: >
|
||||||
cargo test
|
cargo test
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
|
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
|
||||||
with:
|
with:
|
||||||
toolchain: 1.97.1
|
toolchain: 1.87.0
|
||||||
components: clippy,rustfmt
|
components: clippy,rustfmt
|
||||||
- name: Provision macOS Vulkan runtime
|
- name: Provision macOS Vulkan runtime
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -147,7 +147,7 @@ jobs:
|
|||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
|
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
|
||||||
with:
|
with:
|
||||||
toolchain: 1.97.1
|
toolchain: 1.87.0
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||||
with:
|
with:
|
||||||
pattern: native-smoke-*
|
pattern: native-smoke-*
|
||||||
|
|||||||
Generated
+490
-110
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -18,7 +18,6 @@ members = [
|
|||||||
"crates/fparkan-render",
|
"crates/fparkan-render",
|
||||||
"crates/fparkan-resource",
|
"crates/fparkan-resource",
|
||||||
"crates/fparkan-rsli",
|
"crates/fparkan-rsli",
|
||||||
"crates/fparkan-script",
|
|
||||||
"crates/fparkan-runtime",
|
"crates/fparkan-runtime",
|
||||||
"crates/fparkan-terrain",
|
"crates/fparkan-terrain",
|
||||||
"crates/fparkan-terrain-format",
|
"crates/fparkan-terrain-format",
|
||||||
@@ -39,7 +38,7 @@ members = [
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.97"
|
rust-version = "1.87"
|
||||||
license = "GPL-2.0-only"
|
license = "GPL-2.0-only"
|
||||||
repository = "https://github.com/valentineus/fparkan"
|
repository = "https://github.com/valentineus/fparkan"
|
||||||
|
|
||||||
|
|||||||
@@ -24,17 +24,7 @@ Open source проект с реализацией компонентов игр
|
|||||||
- [crates/fparkan-rsli](crates/fparkan-rsli) — чтение, lookup и lossless roundtrip архивов RsLi.
|
- [crates/fparkan-rsli](crates/fparkan-rsli) — чтение, lookup и lossless roundtrip архивов RsLi.
|
||||||
- [crates/fparkan-msh](crates/fparkan-msh) — validated static MSH geometry.
|
- [crates/fparkan-msh](crates/fparkan-msh) — validated static MSH geometry.
|
||||||
- [crates/fparkan-runtime](crates/fparkan-runtime) — transactional mission loading и headless runtime foundation.
|
- [crates/fparkan-runtime](crates/fparkan-runtime) — transactional mission loading и headless runtime foundation.
|
||||||
- [apps/fparkan-cli](apps/fparkan-cli) — CLI для архивов, графов и acceptance-отчетов.
|
- [apps/fparkan-cli](apps/fparkan-cli), [apps/fparkan-viewer](apps/fparkan-viewer), [apps/fparkan-headless](apps/fparkan-headless), [apps/fparkan-game](apps/fparkan-game) — composition roots.
|
||||||
- [apps/fparkan-viewer](apps/fparkan-viewer) — inspection-only CLI для archive/model/texture/map без live Vulkan draw path.
|
|
||||||
- [apps/fparkan-headless](apps/fparkan-headless) — headless runtime composition root.
|
|
||||||
- [apps/fparkan-game](apps/fparkan-game) — mission composition root: по умолчанию выдаёт planning report; opt-in `--backend static-vulkan` открывает native Vulkan окно и рисует terrain и все подготовленные MSH-компоненты выбранных mission roots. По умолчанию выбираются все roots; `--preview-roots N` оставляет bounded diagnostic scope.
|
|
||||||
|
|
||||||
## Текущий статус рендера
|
|
||||||
|
|
||||||
- `fparkan-vulkan-smoke` доказывает живой Stage 0 Vulkan triangle path с native window, swapchain и validation telemetry.
|
|
||||||
- `VulkanPlanningBackend` и default-режим `fparkan-game` подтверждают только deterministic command planning/capture, а не draw пикселей. `fparkan-game --backend static-vulkan` — узкий mission-to-native-Vulkan bridge: он объединяет terrain и подготовленные MSH-компоненты всех выбранных roots, применяет static TMA transforms и загружает первую MAT0 diffuse texture на каждый используемый selector с preview-local remap. Legacy D3D7 camera capture поддерживается; полный corpus, material phases, dynamic ownership/visibility и pixel parity ещё не подтверждены.
|
|
||||||
- `fparkan-viewer` пока является инспектором ассетов. `fparkan-vulkan-smoke` имеет live Stage 3 bridge для original `MSH`/`Texm`/`WEAR`/`MAT0` и geometry-only `Land.msh`; полноценный viewer, исходные terrain-material states, camera и pixel parity ещё не закрыты.
|
|
||||||
- Truth table и evidence-артефакты вынесены в [`docs/rendering/renderer_truth_table.md`](docs/rendering/renderer_truth_table.md) и [`docs/evidence/`](docs/evidence).
|
|
||||||
|
|
||||||
## Тестирование
|
## Тестирование
|
||||||
|
|
||||||
@@ -90,10 +80,21 @@ cargo run -p fparkan-vulkan-smoke --locked -- \
|
|||||||
--timeout-seconds 120
|
--timeout-seconds 120
|
||||||
```
|
```
|
||||||
|
|
||||||
Поддерживается только Windows. Перед запуском убедитесь, что установлен Vulkan runtime
|
Перед запуском убедитесь, что на машине доступен Vulkan loader и рабочий ICD:
|
||||||
от GPU vendor или LunarG Vulkan SDK и активный runtime предоставляет
|
|
||||||
`VK_LAYER_KHRONOS_validation`. Linux, macOS/MoltenVK и их smoke-пути не входят в
|
- macOS: используйте ту же схему, что и GitHub CI (`macos-15` arm64):
|
||||||
проектный scope.
|
|
||||||
|
```bash
|
||||||
|
brew install molten-vk vulkan-loader vulkan-tools vulkan-validationlayers
|
||||||
|
export VK_ICD_FILENAMES="$(brew --prefix)/opt/molten-vk/etc/vulkan/icd.d/MoltenVK_icd.json"
|
||||||
|
export VK_LAYER_PATH="$(brew --prefix)/opt/vulkan-validationlayers/share/vulkan/explicit_layer.d"
|
||||||
|
export DYLD_FALLBACK_LIBRARY_PATH="$(brew --prefix)/opt/vulkan-loader/lib:$(brew --prefix)/opt/molten-vk/lib"
|
||||||
|
vulkaninfo --summary
|
||||||
|
```
|
||||||
|
|
||||||
|
Workflow fail-closed проверяет exact formula versions и ожидает наличие `VK_LAYER_KHRONOS_validation`.
|
||||||
|
- Linux: установлен `libvulkan` и драйвер/ICD (`mesa-vulkan-drivers`, Lavapipe или vendor GPU stack); smoke нужно запускать из активной графической сессии X11/Wayland.
|
||||||
|
- Windows: установлен Vulkan runtime от GPU vendor или LunarG Vulkan SDK; validation layer должен быть доступен из активного runtime.
|
||||||
|
|
||||||
Для полного локального closure gate используйте:
|
Для полного локального closure gate используйте:
|
||||||
|
|
||||||
@@ -101,8 +102,11 @@ cargo run -p fparkan-vulkan-smoke --locked -- \
|
|||||||
cargo xtask ci
|
cargo xtask ci
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows native smoke — единственный platform acceptance path. Вопросы hosted CI
|
В текущем macOS-only цикле GitHub workflow собирает только macOS report и проверяет его через `native-smoke audit`. Windows и Linux smoke stages сознательно не входят в этот closure:
|
||||||
и других операционных систем намеренно находятся вне scope разработки.
|
|
||||||
|
```bash
|
||||||
|
cargo xtask native-smoke audit --dir target/fparkan/native-smoke-artifacts
|
||||||
|
```
|
||||||
|
|
||||||
## Contributing & Support
|
## Contributing & Support
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||||
raw-window-handle = "0.6"
|
raw-window-handle = "0.6"
|
||||||
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
|
winit = "0.30"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -9,12 +9,9 @@ build = "build.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
ash = "0.38"
|
ash = "0.38"
|
||||||
ash-window = "0.13"
|
ash-window = "0.13"
|
||||||
fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
|
fparkan-binary = { path = "../../crates/fparkan-binary" }
|
||||||
fparkan-binary = { path = "../../crates/fparkan-binary", version = "0.1.0" }
|
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||||
fparkan-msh = { path = "../../crates/fparkan-msh", version = "0.1.0" }
|
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||||
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
|
||||||
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
|
|
||||||
fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format", version = "0.1.0" }
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"schema":2,"target_env":"vulkan1.1","compiler":{"name":"glslangValidator","version":"11:16.3.0","binary_sha256":"9bcd69d830b350aaa6e2254915ff74e46070e217b67f38daad27c1fc1f22910f"},"validator":{"name":"spirv-val","version":"SPIRV-Tools v2026.2 unknown hash, 2026-04-29T17:02:58+00:00","binary_sha256":"f6d5b96ff19f073f3af0c0bcfa0c18702d288d3ec598efc242d01cd104d8354f"},"modules":[{"name":"triangle.vert","stage":"vertex","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert","source_sha256":"fe3721202477220d2d9677b6572d7f3baecb374e94f04eea9d7286ed5e98b6c9","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","word_count":358,"sha256":"4e2051ac43b933a57e5557e596bcc95952b4efbfcb45315180f89581e094398d","descriptor_sets":0,"push_constant_bytes":64,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S vert -e main adapters/fparkan-render-vulkan/shaders/triangle.vert -o adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","interface_hash":"f76cc92b9aaa2261eda33bd320503e1be100a2dd7e1a9fcfe13351678dda2a06"},{"name":"triangle.frag","stage":"fragment","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag","source_sha256":"fb3ed435af48e4fb4a817f160b467f7a561c7c547c87b6f0c1e89f5f58af7329","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","word_count":296,"sha256":"8ab7bf835e166892b04b10fa1100258daf355d2f693ad311d014dbee22de5c7b","descriptor_sets":1,"push_constant_bytes":68,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S frag -e main adapters/fparkan-render-vulkan/shaders/triangle.frag -o adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","interface_hash":"91552fce09a408a2baf8a608bc642b6632372d3d2316217051384159ba14cee2"}],"manifest_hash":"cf471972978ddb5c8919711ad64c93d0d64e18b1f590a89ce014f1e2a743cfb5"}
|
{"schema":2,"target_env":"vulkan1.1","compiler":{"name":"glslangValidator","version":"11:16.3.0","binary_sha256":"9bcd69d830b350aaa6e2254915ff74e46070e217b67f38daad27c1fc1f22910f"},"validator":{"name":"spirv-val","version":"SPIRV-Tools v2026.2 unknown hash, 2026-04-29T17:02:58+00:00","binary_sha256":"f6d5b96ff19f073f3af0c0bcfa0c18702d288d3ec598efc242d01cd104d8354f"},"modules":[{"name":"triangle.vert","stage":"vertex","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert","source_sha256":"1e57f14d193fc61457c0749081c452ad25669998913107df12f3ccc3c33e0341","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","word_count":253,"sha256":"4d3ceca7b42ebc971d831b0a0d816457397bd9aeda47fb8d44c4b1aeaa5e7ba0","descriptor_sets":0,"push_constant_bytes":0,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S vert -e main adapters/fparkan-render-vulkan/shaders/triangle.vert -o adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","interface_hash":"23e1d3d9d32e7f7ec0b9ca87f8b86be8f8363c7eb5d745fc5a157cb8433eb138"},{"name":"triangle.frag","stage":"fragment","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag","source_sha256":"f19e74d001d07fb537d4b0f9e621f9b8bc40eeb68816130220853abea6bd4445","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","word_count":125,"sha256":"5a7441be03cd3c25d557268b2e58d5aa50504c87bffcb4c3fd7cbcf007db0b96","descriptor_sets":0,"push_constant_bytes":0,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S frag -e main adapters/fparkan-render-vulkan/shaders/triangle.frag -o adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","interface_hash":"f09342c22d58c8768151ab8579e54e49af586434a4005d16a24e816d881a64f0"}],"manifest_hash":"11e3feb65200ebd2ac87b7e776e9c6433a5da9d71a651bfadea89a51be17ff05"}
|
||||||
@@ -1,19 +1,8 @@
|
|||||||
#version 450
|
#version 450
|
||||||
|
|
||||||
layout(location = 0) in vec3 in_color;
|
layout(location = 0) in vec3 in_color;
|
||||||
layout(location = 1) in vec2 in_uv;
|
|
||||||
layout(location = 0) out vec4 out_color;
|
layout(location = 0) out vec4 out_color;
|
||||||
|
|
||||||
layout(set = 0, binding = 0) uniform sampler2D base_color;
|
|
||||||
|
|
||||||
layout(push_constant) uniform AlphaTestConstants {
|
|
||||||
layout(offset = 64)
|
|
||||||
float alpha_cutoff;
|
|
||||||
} alpha_test;
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
out_color = texture(base_color, in_uv) * vec4(in_color, 1.0);
|
out_color = vec4(in_color, 1.0);
|
||||||
if (out_color.a < alpha_test.alpha_cutoff) {
|
|
||||||
discard;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -1,18 +1,11 @@
|
|||||||
#version 450
|
#version 450
|
||||||
|
|
||||||
layout(location = 0) in vec3 in_position;
|
layout(location = 0) in vec2 in_position;
|
||||||
layout(location = 1) in vec3 in_color;
|
layout(location = 1) in vec3 in_color;
|
||||||
layout(location = 2) in vec2 in_uv;
|
|
||||||
|
|
||||||
layout(location = 0) out vec3 out_color;
|
layout(location = 0) out vec3 out_color;
|
||||||
layout(location = 1) out vec2 out_uv;
|
|
||||||
|
|
||||||
layout(push_constant) uniform CameraConstants {
|
|
||||||
mat4 clip_from_world;
|
|
||||||
} camera;
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
out_color = in_color;
|
out_color = in_color;
|
||||||
out_uv = in_uv;
|
gl_Position = vec4(in_position, 0.0, 1.0);
|
||||||
gl_Position = camera.clip_from_world * vec4(in_position, 1.0);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -26,8 +26,6 @@
|
|||||||
//!
|
//!
|
||||||
//! This crate is the declared low-level Vulkan boundary.
|
//! This crate is the declared low-level Vulkan boundary.
|
||||||
|
|
||||||
#[path = "asset_mesh.rs"]
|
|
||||||
mod asset_mesh;
|
|
||||||
mod capabilities;
|
mod capabilities;
|
||||||
mod instance;
|
mod instance;
|
||||||
mod resources;
|
mod resources;
|
||||||
@@ -39,18 +37,6 @@ mod swapchain;
|
|||||||
mod swapchain_resources;
|
mod swapchain_resources;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
pub use self::asset_mesh::{
|
|
||||||
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,
|
|
||||||
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
|
|
||||||
project_msh_to_static_mesh_in_world_space_with_transform,
|
|
||||||
project_msh_to_static_mesh_in_xy_frame,
|
|
||||||
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses,
|
|
||||||
project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanAssetMeshError,
|
|
||||||
VulkanStaticXyFrame,
|
|
||||||
};
|
|
||||||
pub use self::capabilities::{
|
pub use self::capabilities::{
|
||||||
probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request,
|
probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request,
|
||||||
VulkanRuntimeCapabilityError, VulkanRuntimeCapabilityProbe,
|
VulkanRuntimeCapabilityError, VulkanRuntimeCapabilityProbe,
|
||||||
@@ -64,22 +50,18 @@ pub use self::instance::{
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use self::instance::{cstring_vec, ensure_instance_extensions_available};
|
use self::instance::{cstring_vec, ensure_instance_extensions_available};
|
||||||
use self::resources::{
|
use self::resources::{
|
||||||
color_subresource_range, create_command_pool, create_depth_attachment, create_frame_sync,
|
color_subresource_range, create_command_pool, create_frame_sync, create_triangle_index_buffer,
|
||||||
create_readback_buffer, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer,
|
create_triangle_vertex_buffer, destroy_allocated_buffer, VulkanAllocatedBuffer,
|
||||||
create_static_texture_image, destroy_allocated_buffer, destroy_allocated_image,
|
VulkanFrameSync,
|
||||||
destroy_depth_attachment, readback_buffer_bytes, VulkanAllocatedBuffer, VulkanAllocatedImage,
|
|
||||||
VulkanDepthAttachment, VulkanFrameSync,
|
|
||||||
};
|
};
|
||||||
pub use self::runtime::{
|
pub use self::runtime::{
|
||||||
create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request,
|
create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request,
|
||||||
VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport,
|
VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport,
|
||||||
};
|
};
|
||||||
pub use self::smoke_types::{
|
pub use self::smoke_types::{
|
||||||
VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot,
|
VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot, VulkanSmokeFrameOutcome,
|
||||||
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
|
VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError,
|
||||||
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
|
VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanValidationReport,
|
||||||
VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh,
|
|
||||||
VulkanStaticTexture, VulkanStaticVertex, VulkanValidationReport,
|
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use self::surface::extension_name;
|
use self::surface::extension_name;
|
||||||
@@ -103,8 +85,7 @@ const EXT_DEBUG_UTILS_EXTENSION: &str = "VK_EXT_debug_utils";
|
|||||||
const VALIDATION_LAYER_NAME: &str = "VK_LAYER_KHRONOS_validation";
|
const VALIDATION_LAYER_NAME: &str = "VK_LAYER_KHRONOS_validation";
|
||||||
pub(crate) const SPIRV_MAGIC: u32 = 0x0723_0203;
|
pub(crate) const SPIRV_MAGIC: u32 = 0x0723_0203;
|
||||||
pub(crate) const SPIRV_VERSION_1_0: u32 = 0x0001_0000;
|
pub(crate) const SPIRV_VERSION_1_0: u32 = 0x0001_0000;
|
||||||
#[allow(dead_code)]
|
pub(crate) const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &[
|
||||||
const LEGACY_TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &[
|
|
||||||
0x0723_0203,
|
0x0723_0203,
|
||||||
0x0001_0300,
|
0x0001_0300,
|
||||||
0x0008_000b,
|
0x0008_000b,
|
||||||
@@ -359,8 +340,7 @@ const LEGACY_TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &[
|
|||||||
0x0001_00fd,
|
0x0001_00fd,
|
||||||
0x0001_0038,
|
0x0001_0038,
|
||||||
];
|
];
|
||||||
#[allow(dead_code)]
|
pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[
|
||||||
const LEGACY_TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[
|
|
||||||
0x0723_0203,
|
0x0723_0203,
|
||||||
0x0001_0300,
|
0x0001_0300,
|
||||||
0x0008_000b,
|
0x0008_000b,
|
||||||
@@ -488,28 +468,5 @@ const LEGACY_TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[
|
|||||||
0x0001_0038,
|
0x0001_0038,
|
||||||
];
|
];
|
||||||
|
|
||||||
const fn spirv_words<const WORD_COUNT: usize>(bytes: &[u8]) -> [u32; WORD_COUNT] {
|
|
||||||
let mut words = [0_u32; WORD_COUNT];
|
|
||||||
let mut index = 0;
|
|
||||||
while index < WORD_COUNT {
|
|
||||||
let offset = index * 4;
|
|
||||||
words[index] = u32::from_le_bytes([
|
|
||||||
bytes[offset],
|
|
||||||
bytes[offset + 1],
|
|
||||||
bytes[offset + 2],
|
|
||||||
bytes[offset + 3],
|
|
||||||
]);
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
words
|
|
||||||
}
|
|
||||||
|
|
||||||
static TRIANGLE_VERTEX_SHADER_DATA: [u32; 358] =
|
|
||||||
spirv_words(include_bytes!("../shaders/triangle.vert.spv"));
|
|
||||||
static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; 296] =
|
|
||||||
spirv_words(include_bytes!("../shaders/triangle.frag.spv"));
|
|
||||||
pub(crate) const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &TRIANGLE_VERTEX_SHADER_DATA;
|
|
||||||
pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &TRIANGLE_FRAGMENT_SHADER_DATA;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -32,9 +32,7 @@ impl VulkanInstanceConfig {
|
|||||||
Self {
|
Self {
|
||||||
application_name: application_name.into(),
|
application_name: application_name.into(),
|
||||||
required_extensions: Vec::new(),
|
required_extensions: Vec::new(),
|
||||||
// The runtime is Windows-only, so portability enumeration is not part of
|
enable_portability_enumeration: cfg!(target_os = "macos"),
|
||||||
// the supported instance contract.
|
|
||||||
enable_portability_enumeration: false,
|
|
||||||
enable_validation: false,
|
enable_validation: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,190 +1,14 @@
|
|||||||
#![allow(unsafe_code)]
|
#![allow(unsafe_code)]
|
||||||
|
|
||||||
use ash::vk;
|
use ash::vk;
|
||||||
use fparkan_platform::DepthStencilSupport;
|
|
||||||
|
|
||||||
use super::{
|
use super::{VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError};
|
||||||
VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanStaticMesh,
|
|
||||||
VulkanStaticTexture,
|
|
||||||
};
|
|
||||||
use crate::select_depth_stencil_attachment_format;
|
|
||||||
|
|
||||||
pub(super) struct VulkanAllocatedBuffer {
|
pub(super) struct VulkanAllocatedBuffer {
|
||||||
pub(super) buffer: vk::Buffer,
|
pub(super) buffer: vk::Buffer,
|
||||||
pub(super) memory: vk::DeviceMemory,
|
pub(super) memory: vk::DeviceMemory,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct VulkanAllocatedImage {
|
|
||||||
pub(super) image: vk::Image,
|
|
||||||
pub(super) memory: vk::DeviceMemory,
|
|
||||||
pub(super) view: vk::ImageView,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Depth/stencil image and the exact format selected for its render pass.
|
|
||||||
pub(super) struct VulkanDepthAttachment {
|
|
||||||
pub(super) image: VulkanAllocatedImage,
|
|
||||||
pub(super) format: vk::Format,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
pub(super) fn create_depth_attachment(
|
|
||||||
instance: &VulkanInstanceProbe,
|
|
||||||
device: &VulkanLogicalDeviceProbe,
|
|
||||||
extent: (u32, u32),
|
|
||||||
request: DepthStencilSupport,
|
|
||||||
) -> Result<VulkanDepthAttachment, VulkanSmokeRendererError> {
|
|
||||||
let supported_formats = depth_attachment_formats(instance, device);
|
|
||||||
let format = select_depth_stencil_attachment_format(&supported_formats, request)
|
|
||||||
.map(vk::Format::from_raw)
|
|
||||||
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
|
|
||||||
context: "logical device has no selected depth attachment format",
|
|
||||||
})?;
|
|
||||||
let image_info = vk::ImageCreateInfo::default()
|
|
||||||
.image_type(vk::ImageType::TYPE_2D)
|
|
||||||
.format(format)
|
|
||||||
.extent(vk::Extent3D {
|
|
||||||
width: extent.0,
|
|
||||||
height: extent.1,
|
|
||||||
depth: 1,
|
|
||||||
})
|
|
||||||
.mip_levels(1)
|
|
||||||
.array_layers(1)
|
|
||||||
.samples(vk::SampleCountFlags::TYPE_1)
|
|
||||||
.tiling(vk::ImageTiling::OPTIMAL)
|
|
||||||
.usage(vk::ImageUsageFlags::DEPTH_STENCIL_ATTACHMENT)
|
|
||||||
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
|
||||||
.initial_layout(vk::ImageLayout::UNDEFINED);
|
|
||||||
// SAFETY: The creation info is stack-owned and uses the live non-zero swapchain extent.
|
|
||||||
let image = unsafe { device.device().create_image(&image_info, None) }.map_err(|result| {
|
|
||||||
VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkCreateImage(depth attachment)",
|
|
||||||
result,
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
// SAFETY: The newly created image belongs to this device.
|
|
||||||
let requirements = unsafe { device.device().get_image_memory_requirements(image) };
|
|
||||||
let Some(memory_type_index) = find_memory_type(
|
|
||||||
instance,
|
|
||||||
device.physical_device(),
|
|
||||||
requirements.memory_type_bits,
|
|
||||||
vk::MemoryPropertyFlags::DEVICE_LOCAL,
|
|
||||||
) else {
|
|
||||||
// SAFETY: The unbound image is rolled back on its owning device.
|
|
||||||
unsafe { device.device().destroy_image(image, None) };
|
|
||||||
return Err(VulkanSmokeRendererError::MissingMemoryType {
|
|
||||||
context: "depth attachment",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
let allocation = vk::MemoryAllocateInfo::default()
|
|
||||||
.allocation_size(requirements.size)
|
|
||||||
.memory_type_index(memory_type_index);
|
|
||||||
// SAFETY: Allocation parameters are derived from this image's requirements.
|
|
||||||
let allocation_result = unsafe { device.device().allocate_memory(&allocation, None) };
|
|
||||||
let memory = match allocation_result {
|
|
||||||
Ok(memory) => memory,
|
|
||||||
Err(result) => {
|
|
||||||
// SAFETY: The unbound image is rolled back on allocation failure.
|
|
||||||
unsafe { device.device().destroy_image(image, None) };
|
|
||||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkAllocateMemory(depth attachment)",
|
|
||||||
result,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// SAFETY: The allocation satisfies this image's queried requirements.
|
|
||||||
if let Err(result) = unsafe { device.device().bind_image_memory(image, memory, 0) } {
|
|
||||||
// SAFETY: Both newly created resources are rolled back together.
|
|
||||||
unsafe {
|
|
||||||
device.device().destroy_image(image, None);
|
|
||||||
device.device().free_memory(memory, None);
|
|
||||||
}
|
|
||||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkBindImageMemory(depth attachment)",
|
|
||||||
result,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let range = vk::ImageSubresourceRange::default()
|
|
||||||
.aspect_mask(depth_aspect(format))
|
|
||||||
.base_mip_level(0)
|
|
||||||
.level_count(1)
|
|
||||||
.base_array_layer(0)
|
|
||||||
.layer_count(1);
|
|
||||||
let view_info = vk::ImageViewCreateInfo::default()
|
|
||||||
.image(image)
|
|
||||||
.view_type(vk::ImageViewType::TYPE_2D)
|
|
||||||
.format(format)
|
|
||||||
.subresource_range(range);
|
|
||||||
// SAFETY: The image is bound and the depth/stencil aspect matches its selected format.
|
|
||||||
let view_result = unsafe { device.device().create_image_view(&view_info, None) };
|
|
||||||
let view = match view_result {
|
|
||||||
Ok(view) => view,
|
|
||||||
Err(result) => {
|
|
||||||
// SAFETY: Both newly created resources are rolled back together.
|
|
||||||
unsafe {
|
|
||||||
device.device().destroy_image(image, None);
|
|
||||||
device.device().free_memory(memory, None);
|
|
||||||
}
|
|
||||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkCreateImageView(depth attachment)",
|
|
||||||
result,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(VulkanDepthAttachment {
|
|
||||||
image: VulkanAllocatedImage {
|
|
||||||
image,
|
|
||||||
memory,
|
|
||||||
view,
|
|
||||||
},
|
|
||||||
format,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn destroy_depth_attachment(
|
|
||||||
device: &VulkanLogicalDeviceProbe,
|
|
||||||
attachment: &VulkanDepthAttachment,
|
|
||||||
) {
|
|
||||||
destroy_allocated_image(device, &attachment.image);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn depth_attachment_formats(
|
|
||||||
instance: &VulkanInstanceProbe,
|
|
||||||
device: &VulkanLogicalDeviceProbe,
|
|
||||||
) -> Vec<i32> {
|
|
||||||
[
|
|
||||||
vk::Format::D16_UNORM,
|
|
||||||
vk::Format::X8_D24_UNORM_PACK32,
|
|
||||||
vk::Format::D32_SFLOAT,
|
|
||||||
vk::Format::D16_UNORM_S8_UINT,
|
|
||||||
vk::Format::D24_UNORM_S8_UINT,
|
|
||||||
vk::Format::D32_SFLOAT_S8_UINT,
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.filter(|format| {
|
|
||||||
// SAFETY: The physical device belongs to this instance and the query returns a value copy.
|
|
||||||
unsafe {
|
|
||||||
instance
|
|
||||||
.instance
|
|
||||||
.get_physical_device_format_properties(device.physical_device(), *format)
|
|
||||||
}
|
|
||||||
.optimal_tiling_features
|
|
||||||
.contains(vk::FormatFeatureFlags::DEPTH_STENCIL_ATTACHMENT)
|
|
||||||
})
|
|
||||||
.map(vk::Format::as_raw)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn depth_aspect(format: vk::Format) -> vk::ImageAspectFlags {
|
|
||||||
match format {
|
|
||||||
vk::Format::D16_UNORM_S8_UINT
|
|
||||||
| vk::Format::D24_UNORM_S8_UINT
|
|
||||||
| vk::Format::D32_SFLOAT_S8_UINT => {
|
|
||||||
vk::ImageAspectFlags::DEPTH | vk::ImageAspectFlags::STENCIL
|
|
||||||
}
|
|
||||||
_ => vk::ImageAspectFlags::DEPTH,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct VulkanFrameSync {
|
pub(super) struct VulkanFrameSync {
|
||||||
pub(super) image_available: vk::Semaphore,
|
pub(super) image_available: vk::Semaphore,
|
||||||
pub(super) render_finished: vk::Semaphore,
|
pub(super) render_finished: vk::Semaphore,
|
||||||
@@ -206,19 +30,18 @@ pub(super) fn create_command_pool(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn create_static_mesh_vertex_buffer(
|
pub(super) fn create_triangle_vertex_buffer(
|
||||||
instance: &VulkanInstanceProbe,
|
instance: &VulkanInstanceProbe,
|
||||||
device: &VulkanLogicalDeviceProbe,
|
device: &VulkanLogicalDeviceProbe,
|
||||||
mesh: &VulkanStaticMesh,
|
|
||||||
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||||
let mut bytes = Vec::with_capacity(mesh.vertices.len() * 8 * std::mem::size_of::<f32>());
|
let vertices: [[f32; 5]; 3] = [
|
||||||
for vertex in &mesh.vertices {
|
[0.0, -0.55, 1.0, 0.2, 0.2],
|
||||||
for value in vertex
|
[0.55, 0.55, 0.2, 1.0, 0.2],
|
||||||
.position
|
[-0.55, 0.55, 0.2, 0.4, 1.0],
|
||||||
.into_iter()
|
];
|
||||||
.chain(vertex.color)
|
let mut bytes = Vec::with_capacity(vertices.len() * 5 * std::mem::size_of::<f32>());
|
||||||
.chain(vertex.uv)
|
for vertex in vertices {
|
||||||
{
|
for value in vertex {
|
||||||
bytes.extend_from_slice(&value.to_ne_bytes());
|
bytes.extend_from_slice(&value.to_ne_bytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,17 +50,17 @@ pub(super) fn create_static_mesh_vertex_buffer(
|
|||||||
device,
|
device,
|
||||||
&bytes,
|
&bytes,
|
||||||
vk::BufferUsageFlags::VERTEX_BUFFER,
|
vk::BufferUsageFlags::VERTEX_BUFFER,
|
||||||
"static mesh vertex buffer",
|
"triangle vertex buffer",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn create_static_mesh_index_buffer(
|
pub(super) fn create_triangle_index_buffer(
|
||||||
instance: &VulkanInstanceProbe,
|
instance: &VulkanInstanceProbe,
|
||||||
device: &VulkanLogicalDeviceProbe,
|
device: &VulkanLogicalDeviceProbe,
|
||||||
mesh: &VulkanStaticMesh,
|
|
||||||
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||||
let mut bytes = Vec::with_capacity(mesh.indices.len() * std::mem::size_of::<u32>());
|
let indices = [0_u16, 1_u16, 2_u16];
|
||||||
for &index in &mesh.indices {
|
let mut bytes = Vec::with_capacity(indices.len() * std::mem::size_of::<u16>());
|
||||||
|
for index in indices {
|
||||||
bytes.extend_from_slice(&index.to_ne_bytes());
|
bytes.extend_from_slice(&index.to_ne_bytes());
|
||||||
}
|
}
|
||||||
create_host_visible_buffer(
|
create_host_visible_buffer(
|
||||||
@@ -245,276 +68,10 @@ pub(super) fn create_static_mesh_index_buffer(
|
|||||||
device,
|
device,
|
||||||
&bytes,
|
&bytes,
|
||||||
vk::BufferUsageFlags::INDEX_BUFFER,
|
vk::BufferUsageFlags::INDEX_BUFFER,
|
||||||
"static mesh index buffer",
|
"triangle index buffer",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
pub(super) fn create_static_texture_image(
|
|
||||||
instance: &VulkanInstanceProbe,
|
|
||||||
device: &VulkanLogicalDeviceProbe,
|
|
||||||
command_pool: vk::CommandPool,
|
|
||||||
texture: &VulkanStaticTexture,
|
|
||||||
) -> Result<VulkanAllocatedImage, VulkanSmokeRendererError> {
|
|
||||||
let staging = create_host_visible_buffer(
|
|
||||||
instance,
|
|
||||||
device,
|
|
||||||
&texture.rgba8,
|
|
||||||
vk::BufferUsageFlags::TRANSFER_SRC,
|
|
||||||
"static texture staging buffer",
|
|
||||||
)?;
|
|
||||||
let image_info = vk::ImageCreateInfo::default()
|
|
||||||
.image_type(vk::ImageType::TYPE_2D)
|
|
||||||
.format(vk::Format::R8G8B8A8_UNORM)
|
|
||||||
.extent(vk::Extent3D {
|
|
||||||
width: texture.width,
|
|
||||||
height: texture.height,
|
|
||||||
depth: 1,
|
|
||||||
})
|
|
||||||
.mip_levels(1)
|
|
||||||
.array_layers(1)
|
|
||||||
.samples(vk::SampleCountFlags::TYPE_1)
|
|
||||||
.tiling(vk::ImageTiling::OPTIMAL)
|
|
||||||
.usage(vk::ImageUsageFlags::TRANSFER_DST | vk::ImageUsageFlags::SAMPLED)
|
|
||||||
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
|
||||||
.initial_layout(vk::ImageLayout::UNDEFINED);
|
|
||||||
// SAFETY: The create info only contains stack-owned values and a validated non-zero extent.
|
|
||||||
let image = unsafe { device.device().create_image(&image_info, None) }.map_err(|result| {
|
|
||||||
destroy_allocated_buffer(device, &staging);
|
|
||||||
VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkCreateImage(static texture)",
|
|
||||||
result,
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
// SAFETY: The image belongs to this device and is queried immediately after creation.
|
|
||||||
let requirements = unsafe { device.device().get_image_memory_requirements(image) };
|
|
||||||
let Some(memory_type_index) = find_memory_type(
|
|
||||||
instance,
|
|
||||||
device.physical_device(),
|
|
||||||
requirements.memory_type_bits,
|
|
||||||
vk::MemoryPropertyFlags::DEVICE_LOCAL,
|
|
||||||
) else {
|
|
||||||
// SAFETY: Both resources were created on this device and are being rolled back once.
|
|
||||||
unsafe { device.device().destroy_image(image, None) };
|
|
||||||
destroy_allocated_buffer(device, &staging);
|
|
||||||
return Err(VulkanSmokeRendererError::MissingMemoryType {
|
|
||||||
context: "static texture image",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
let allocate_info = vk::MemoryAllocateInfo::default()
|
|
||||||
.allocation_size(requirements.size)
|
|
||||||
.memory_type_index(memory_type_index);
|
|
||||||
// SAFETY: The allocation matches the image memory requirements queried above.
|
|
||||||
let memory = {
|
|
||||||
// SAFETY: The allocation matches the image memory requirements queried above.
|
|
||||||
unsafe { device.device().allocate_memory(&allocate_info, None) }
|
|
||||||
}
|
|
||||||
.map_err(|result| {
|
|
||||||
// SAFETY: Both resources were created on this device and are being rolled back once.
|
|
||||||
unsafe { device.device().destroy_image(image, None) };
|
|
||||||
destroy_allocated_buffer(device, &staging);
|
|
||||||
VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkAllocateMemory(static texture)",
|
|
||||||
result,
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
// SAFETY: The allocation matches this image's queried requirements.
|
|
||||||
if let Err(result) = unsafe { device.device().bind_image_memory(image, memory, 0) } {
|
|
||||||
// SAFETY: Both resources were created on this device and are being rolled back once.
|
|
||||||
unsafe {
|
|
||||||
device.device().destroy_image(image, None);
|
|
||||||
device.device().free_memory(memory, None);
|
|
||||||
};
|
|
||||||
destroy_allocated_buffer(device, &staging);
|
|
||||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkBindImageMemory(static texture)",
|
|
||||||
result,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Err(error) = upload_static_texture(
|
|
||||||
device,
|
|
||||||
command_pool,
|
|
||||||
staging.buffer,
|
|
||||||
image,
|
|
||||||
texture.width,
|
|
||||||
texture.height,
|
|
||||||
) {
|
|
||||||
// SAFETY: Both resources were created on this device and are being rolled back once.
|
|
||||||
unsafe {
|
|
||||||
device.device().destroy_image(image, None);
|
|
||||||
device.device().free_memory(memory, None);
|
|
||||||
};
|
|
||||||
destroy_allocated_buffer(device, &staging);
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
destroy_allocated_buffer(device, &staging);
|
|
||||||
let view_info = vk::ImageViewCreateInfo::default()
|
|
||||||
.image(image)
|
|
||||||
.view_type(vk::ImageViewType::TYPE_2D)
|
|
||||||
.format(vk::Format::R8G8B8A8_UNORM)
|
|
||||||
.subresource_range(color_subresource_range());
|
|
||||||
// SAFETY: The image is live, initialized and has the stated color subresource.
|
|
||||||
let view = {
|
|
||||||
// SAFETY: The image is live, initialized and has the stated color subresource.
|
|
||||||
unsafe { device.device().create_image_view(&view_info, None) }
|
|
||||||
}
|
|
||||||
.map_err(|result| {
|
|
||||||
// SAFETY: Both resources were created on this device and are being rolled back once.
|
|
||||||
unsafe {
|
|
||||||
device.device().destroy_image(image, None);
|
|
||||||
device.device().free_memory(memory, None);
|
|
||||||
};
|
|
||||||
VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkCreateImageView(static texture)",
|
|
||||||
result,
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
Ok(VulkanAllocatedImage {
|
|
||||||
image,
|
|
||||||
memory,
|
|
||||||
view,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
fn upload_static_texture(
|
|
||||||
device: &VulkanLogicalDeviceProbe,
|
|
||||||
command_pool: vk::CommandPool,
|
|
||||||
staging_buffer: vk::Buffer,
|
|
||||||
image: vk::Image,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
) -> Result<(), VulkanSmokeRendererError> {
|
|
||||||
let allocate_info = vk::CommandBufferAllocateInfo::default()
|
|
||||||
.command_pool(command_pool)
|
|
||||||
.level(vk::CommandBufferLevel::PRIMARY)
|
|
||||||
.command_buffer_count(1);
|
|
||||||
// SAFETY: The command pool is live and owned by the current logical device.
|
|
||||||
let command_buffer = unsafe { device.device().allocate_command_buffers(&allocate_info) }
|
|
||||||
.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkAllocateCommandBuffers(texture upload)",
|
|
||||||
result,
|
|
||||||
})?[0];
|
|
||||||
let begin =
|
|
||||||
vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT);
|
|
||||||
// SAFETY: The command buffer is freshly allocated from the current pool.
|
|
||||||
if let Err(result) = unsafe { device.device().begin_command_buffer(command_buffer, &begin) } {
|
|
||||||
// SAFETY: The command buffer belongs to this pool and is released on failure.
|
|
||||||
unsafe {
|
|
||||||
device
|
|
||||||
.device()
|
|
||||||
.free_command_buffers(command_pool, &[command_buffer]);
|
|
||||||
};
|
|
||||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkBeginCommandBuffer(texture upload)",
|
|
||||||
result,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let range = color_subresource_range();
|
|
||||||
let to_transfer = vk::ImageMemoryBarrier::default()
|
|
||||||
.old_layout(vk::ImageLayout::UNDEFINED)
|
|
||||||
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
|
||||||
.src_access_mask(vk::AccessFlags::empty())
|
|
||||||
.dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
|
|
||||||
.image(image)
|
|
||||||
.subresource_range(range);
|
|
||||||
let region = vk::BufferImageCopy::default()
|
|
||||||
.image_subresource(
|
|
||||||
vk::ImageSubresourceLayers::default()
|
|
||||||
.aspect_mask(vk::ImageAspectFlags::COLOR)
|
|
||||||
.mip_level(0)
|
|
||||||
.base_array_layer(0)
|
|
||||||
.layer_count(1),
|
|
||||||
)
|
|
||||||
.image_extent(vk::Extent3D {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
depth: 1,
|
|
||||||
});
|
|
||||||
let to_sampled = vk::ImageMemoryBarrier::default()
|
|
||||||
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
|
||||||
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
|
|
||||||
.src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
|
|
||||||
.dst_access_mask(vk::AccessFlags::SHADER_READ)
|
|
||||||
.image(image)
|
|
||||||
.subresource_range(range);
|
|
||||||
// SAFETY: The commands operate on live, exclusively owned resources and the stated color subresource.
|
|
||||||
unsafe {
|
|
||||||
device.device().cmd_pipeline_barrier(
|
|
||||||
command_buffer,
|
|
||||||
vk::PipelineStageFlags::TOP_OF_PIPE,
|
|
||||||
vk::PipelineStageFlags::TRANSFER,
|
|
||||||
vk::DependencyFlags::empty(),
|
|
||||||
&[],
|
|
||||||
&[],
|
|
||||||
&[to_transfer],
|
|
||||||
);
|
|
||||||
device.device().cmd_copy_buffer_to_image(
|
|
||||||
command_buffer,
|
|
||||||
staging_buffer,
|
|
||||||
image,
|
|
||||||
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
|
|
||||||
&[region],
|
|
||||||
);
|
|
||||||
device.device().cmd_pipeline_barrier(
|
|
||||||
command_buffer,
|
|
||||||
vk::PipelineStageFlags::TRANSFER,
|
|
||||||
vk::PipelineStageFlags::FRAGMENT_SHADER,
|
|
||||||
vk::DependencyFlags::empty(),
|
|
||||||
&[],
|
|
||||||
&[],
|
|
||||||
&[to_sampled],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// SAFETY: Command recording is complete on the current command buffer.
|
|
||||||
if let Err(result) = unsafe { device.device().end_command_buffer(command_buffer) } {
|
|
||||||
// SAFETY: The command buffer belongs to this pool and is released on failure.
|
|
||||||
unsafe {
|
|
||||||
device
|
|
||||||
.device()
|
|
||||||
.free_command_buffers(command_pool, &[command_buffer]);
|
|
||||||
};
|
|
||||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkEndCommandBuffer(texture upload)",
|
|
||||||
result,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let command_buffers = [command_buffer];
|
|
||||||
let submit = [vk::SubmitInfo::default().command_buffers(&command_buffers)];
|
|
||||||
// SAFETY: The graphics queue and submitted command buffer are live for the duration of the wait.
|
|
||||||
let submit_result = unsafe {
|
|
||||||
device
|
|
||||||
.device()
|
|
||||||
.queue_submit(device.graphics_queue(), &submit, vk::Fence::null())
|
|
||||||
};
|
|
||||||
let result = submit_result.and_then(|()| {
|
|
||||||
// SAFETY: The graphics queue remains live until the synchronous idle wait completes.
|
|
||||||
unsafe { device.device().queue_wait_idle(device.graphics_queue()) }
|
|
||||||
});
|
|
||||||
// SAFETY: Submission completed or failed synchronously and the command buffer is no longer needed.
|
|
||||||
unsafe {
|
|
||||||
device
|
|
||||||
.device()
|
|
||||||
.free_command_buffers(command_pool, &[command_buffer]);
|
|
||||||
};
|
|
||||||
result.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkQueueSubmit/WaitIdle(texture upload)",
|
|
||||||
result,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn destroy_allocated_image(
|
|
||||||
device: &VulkanLogicalDeviceProbe,
|
|
||||||
image: &VulkanAllocatedImage,
|
|
||||||
) {
|
|
||||||
// SAFETY: The image, view and allocation belong to this device and are destroyed once after idle.
|
|
||||||
unsafe {
|
|
||||||
device.device().destroy_image_view(image.view, None);
|
|
||||||
device.device().destroy_image(image.image, None);
|
|
||||||
device.device().free_memory(image.memory, None);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_host_visible_buffer(
|
fn create_host_visible_buffer(
|
||||||
instance: &VulkanInstanceProbe,
|
instance: &VulkanInstanceProbe,
|
||||||
device: &VulkanLogicalDeviceProbe,
|
device: &VulkanLogicalDeviceProbe,
|
||||||
@@ -698,49 +255,6 @@ pub(super) fn destroy_allocated_buffer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allocates a host-coherent transfer destination for a swapchain image copy.
|
|
||||||
pub(super) fn create_readback_buffer(
|
|
||||||
instance: &VulkanInstanceProbe,
|
|
||||||
device: &VulkanLogicalDeviceProbe,
|
|
||||||
byte_len: usize,
|
|
||||||
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
|
||||||
create_host_visible_buffer(
|
|
||||||
instance,
|
|
||||||
device,
|
|
||||||
&vec![0; byte_len],
|
|
||||||
vk::BufferUsageFlags::TRANSFER_DST,
|
|
||||||
"vkCreateBuffer(readback)",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Copies a completed host-coherent readback allocation into CPU-owned bytes.
|
|
||||||
pub(super) fn readback_buffer_bytes(
|
|
||||||
device: &VulkanLogicalDeviceProbe,
|
|
||||||
buffer: &VulkanAllocatedBuffer,
|
|
||||||
byte_len: usize,
|
|
||||||
) -> Result<Vec<u8>, VulkanSmokeRendererError> {
|
|
||||||
// SAFETY: The caller waits for device idle before mapping this host-visible allocation.
|
|
||||||
let mapped = unsafe {
|
|
||||||
device.device().map_memory(
|
|
||||||
buffer.memory,
|
|
||||||
0,
|
|
||||||
u64::try_from(byte_len).unwrap_or(u64::MAX),
|
|
||||||
vk::MemoryMapFlags::empty(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkMapMemory(readback)",
|
|
||||||
result,
|
|
||||||
})?;
|
|
||||||
let mut bytes = vec![0; byte_len];
|
|
||||||
// SAFETY: Both ranges contain exactly byte_len initialized bytes and do not overlap.
|
|
||||||
unsafe {
|
|
||||||
std::ptr::copy_nonoverlapping(mapped.cast::<u8>(), bytes.as_mut_ptr(), byte_len);
|
|
||||||
device.device().unmap_memory(buffer.memory);
|
|
||||||
}
|
|
||||||
Ok(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn color_subresource_range() -> vk::ImageSubresourceRange {
|
pub(super) fn color_subresource_range() -> vk::ImageSubresourceRange {
|
||||||
vk::ImageSubresourceRange::default()
|
vk::ImageSubresourceRange::default()
|
||||||
.aspect_mask(vk::ImageAspectFlags::COLOR)
|
.aspect_mask(vk::ImageAspectFlags::COLOR)
|
||||||
|
|||||||
@@ -2,15 +2,13 @@
|
|||||||
|
|
||||||
use ash::vk;
|
use ash::vk;
|
||||||
|
|
||||||
use super::smoke_types::resolve_draw_texture_indices;
|
|
||||||
use super::{
|
use super::{
|
||||||
create_command_pool, create_frame_sync, create_static_mesh_index_buffer,
|
create_command_pool, create_frame_sync, create_swapchain_resources,
|
||||||
create_static_mesh_vertex_buffer, create_static_texture_image, create_swapchain_resources,
|
create_triangle_index_buffer, create_triangle_vertex_buffer, create_validation_messenger,
|
||||||
create_validation_messenger, create_vulkan_instance_probe,
|
create_vulkan_instance_probe, create_vulkan_logical_device_probe_for_request,
|
||||||
create_vulkan_logical_device_probe_for_request, create_vulkan_surface_probe,
|
create_vulkan_surface_probe, create_vulkan_swapchain_probe_for_extent,
|
||||||
create_vulkan_swapchain_probe_for_extent, destroy_allocated_buffer, destroy_allocated_image,
|
destroy_allocated_buffer, destroy_swapchain_resources, plan_vulkan_surface,
|
||||||
destroy_swapchain_resources, plan_vulkan_surface, readback_buffer_bytes, VulkanAllocatedBuffer,
|
VulkanAllocatedBuffer, VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe,
|
||||||
VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanReadbackArtifact,
|
|
||||||
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
|
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
|
||||||
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
|
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
|
||||||
VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainResources, VulkanValidationMessenger,
|
VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainResources, VulkanValidationMessenger,
|
||||||
@@ -19,7 +17,6 @@ use super::{
|
|||||||
use crate::policy::KHR_PORTABILITY_SUBSET_EXTENSION;
|
use crate::policy::KHR_PORTABILITY_SUBSET_EXTENSION;
|
||||||
use crate::shader_manifest::{triangle_shader_manifest, validate_shader_manifest};
|
use crate::shader_manifest::{triangle_shader_manifest, validate_shader_manifest};
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn take_runtime_owners_in_dependency_order<Instance, Validation, Surface, Device, Swapchain>(
|
fn take_runtime_owners_in_dependency_order<Instance, Validation, Surface, Device, Swapchain>(
|
||||||
instance: &mut Option<Instance>,
|
instance: &mut Option<Instance>,
|
||||||
validation: &mut Option<Validation>,
|
validation: &mut Option<Validation>,
|
||||||
@@ -34,27 +31,28 @@ fn take_runtime_owners_in_dependency_order<Instance, Validation, Surface, Device
|
|||||||
instance.take();
|
instance.take();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn take_runtime_children_with_validation_snapshot<
|
fn take_runtime_owners_with_validation_snapshot<
|
||||||
|
Instance,
|
||||||
|
Validation,
|
||||||
Surface,
|
Surface,
|
||||||
Device,
|
Device,
|
||||||
Swapchain,
|
Swapchain,
|
||||||
Validation,
|
|
||||||
Snapshot,
|
Snapshot,
|
||||||
Capture,
|
Capture,
|
||||||
>(
|
>(
|
||||||
|
instance: &mut Option<Instance>,
|
||||||
|
validation: &mut Option<Validation>,
|
||||||
surface: &mut Option<Surface>,
|
surface: &mut Option<Surface>,
|
||||||
device: &mut Option<Device>,
|
device: &mut Option<Device>,
|
||||||
swapchain: &mut Option<Swapchain>,
|
swapchain: &mut Option<Swapchain>,
|
||||||
validation: Option<&Validation>,
|
|
||||||
capture: Capture,
|
capture: Capture,
|
||||||
) -> Option<Snapshot>
|
) -> Option<Snapshot>
|
||||||
where
|
where
|
||||||
Capture: FnOnce(&Validation) -> Snapshot,
|
Capture: FnOnce(&Validation) -> Snapshot,
|
||||||
{
|
{
|
||||||
swapchain.take();
|
let snapshot = validation.as_ref().map(capture);
|
||||||
device.take();
|
take_runtime_owners_in_dependency_order(instance, validation, surface, device, swapchain);
|
||||||
surface.take();
|
snapshot
|
||||||
validation.map(capture)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RollbackOnDrop<T, F>
|
struct RollbackOnDrop<T, F>
|
||||||
@@ -107,18 +105,6 @@ impl VulkanSmokeRenderer {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
create_info: &VulkanSmokeRendererCreateInfo,
|
create_info: &VulkanSmokeRendererCreateInfo,
|
||||||
) -> Result<Self, VulkanSmokeRendererError> {
|
) -> Result<Self, VulkanSmokeRendererError> {
|
||||||
create_info
|
|
||||||
.mesh
|
|
||||||
.validate()
|
|
||||||
.map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?;
|
|
||||||
if !create_info.camera.is_finite() {
|
|
||||||
return Err(VulkanSmokeRendererError::InvalidStaticCamera {
|
|
||||||
context: "non-finite clip_from_world matrix",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let draw_texture_indices =
|
|
||||||
resolve_draw_texture_indices(&create_info.mesh.draw_ranges, &create_info.materials)
|
|
||||||
.map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?;
|
|
||||||
let bootstrap_progress = create_info.bootstrap_progress.as_ref();
|
let bootstrap_progress = create_info.bootstrap_progress.as_ref();
|
||||||
let shader_manifest = validate_shader_manifest(&triangle_shader_manifest())
|
let shader_manifest = validate_shader_manifest(&triangle_shader_manifest())
|
||||||
.map_err(VulkanSmokeRendererError::ShaderManifest)?;
|
.map_err(VulkanSmokeRendererError::ShaderManifest)?;
|
||||||
@@ -167,8 +153,7 @@ impl VulkanSmokeRenderer {
|
|||||||
progress.mark_swapchain_created();
|
progress.mark_swapchain_created();
|
||||||
}
|
}
|
||||||
let command_pool = create_command_pool(&device)?;
|
let command_pool = create_command_pool(&device)?;
|
||||||
let vertex_buffer =
|
let vertex_buffer = match create_triangle_vertex_buffer(&instance, &device) {
|
||||||
match create_static_mesh_vertex_buffer(&instance, &device, &create_info.mesh) {
|
|
||||||
Ok(buffer) => buffer,
|
Ok(buffer) => buffer,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
// SAFETY: The command pool belongs to this live logical device and is destroyed on setup failure.
|
// SAFETY: The command pool belongs to this live logical device and is destroyed on setup failure.
|
||||||
@@ -176,8 +161,7 @@ impl VulkanSmokeRenderer {
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let index_buffer =
|
let index_buffer = match create_triangle_index_buffer(&instance, &device) {
|
||||||
match create_static_mesh_index_buffer(&instance, &device, &create_info.mesh) {
|
|
||||||
Ok(buffer) => buffer,
|
Ok(buffer) => buffer,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
// SAFETY: The command pool belongs to this live logical device and is destroyed on setup failure.
|
// SAFETY: The command pool belongs to this live logical device and is destroyed on setup failure.
|
||||||
@@ -186,38 +170,6 @@ impl VulkanSmokeRenderer {
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Keep the compatibility triangle path valid: it samples a white texel
|
|
||||||
// when no original TEXM was supplied.
|
|
||||||
let fallback_texture = super::VulkanStaticTexture {
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
rgba8: vec![255, 255, 255, 255],
|
|
||||||
};
|
|
||||||
let texture_sources = if create_info.materials.is_empty() {
|
|
||||||
vec![&fallback_texture]
|
|
||||||
} else {
|
|
||||||
create_info
|
|
||||||
.materials
|
|
||||||
.iter()
|
|
||||||
.map(|material| &material.texture)
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
let mut textures = Vec::with_capacity(texture_sources.len());
|
|
||||||
for texture_source in texture_sources {
|
|
||||||
match create_static_texture_image(&instance, &device, command_pool, texture_source) {
|
|
||||||
Ok(image) => textures.push(image),
|
|
||||||
Err(error) => {
|
|
||||||
for texture in &textures {
|
|
||||||
destroy_allocated_image(&device, texture);
|
|
||||||
}
|
|
||||||
// SAFETY: These resources belong to this live device and are rolled back before it drops.
|
|
||||||
unsafe { device.device().destroy_command_pool(command_pool, None) };
|
|
||||||
destroy_allocated_buffer(&device, &index_buffer);
|
|
||||||
destroy_allocated_buffer(&device, &vertex_buffer);
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut renderer = Self {
|
let mut renderer = Self {
|
||||||
instance: Some(instance),
|
instance: Some(instance),
|
||||||
validation,
|
validation,
|
||||||
@@ -228,15 +180,9 @@ impl VulkanSmokeRenderer {
|
|||||||
swapchain_resources: None,
|
swapchain_resources: None,
|
||||||
vertex_buffer: Some(vertex_buffer),
|
vertex_buffer: Some(vertex_buffer),
|
||||||
index_buffer: Some(index_buffer),
|
index_buffer: Some(index_buffer),
|
||||||
textures,
|
|
||||||
draw_ranges: create_info.mesh.draw_ranges.clone(),
|
|
||||||
draw_texture_indices,
|
|
||||||
camera: create_info.camera,
|
|
||||||
frame_sync: Vec::new(),
|
frame_sync: Vec::new(),
|
||||||
images_in_flight: Vec::new(),
|
images_in_flight: Vec::new(),
|
||||||
current_frame: 0,
|
current_frame: 0,
|
||||||
last_readback_image_index: None,
|
|
||||||
depth_request: create_info.render_request.depth,
|
|
||||||
pending_extent: None,
|
pending_extent: None,
|
||||||
swapchain_recreate_count: 0,
|
swapchain_recreate_count: 0,
|
||||||
report: VulkanSmokeRendererReport {
|
report: VulkanSmokeRendererReport {
|
||||||
@@ -249,11 +195,6 @@ impl VulkanSmokeRenderer {
|
|||||||
enabled_extension_count: 0,
|
enabled_extension_count: 0,
|
||||||
swapchain_extent: (0, 0),
|
swapchain_extent: (0, 0),
|
||||||
swapchain_image_count: 0,
|
swapchain_image_count: 0,
|
||||||
swapchain_image_format: 0,
|
|
||||||
swapchain_image_usage: 0,
|
|
||||||
readback_copy_count: 0,
|
|
||||||
readback_byte_count: 0,
|
|
||||||
readback_fnv1a64: 0,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
renderer.rebuild_swapchain_resources(false)?;
|
renderer.rebuild_swapchain_resources(false)?;
|
||||||
@@ -281,11 +222,6 @@ impl VulkanSmokeRenderer {
|
|||||||
.unwrap_or(u32::MAX),
|
.unwrap_or(u32::MAX),
|
||||||
swapchain_extent: swapchain_ref.report.plan.extent,
|
swapchain_extent: swapchain_ref.report.plan.extent,
|
||||||
swapchain_image_count: swapchain_ref.report.image_count,
|
swapchain_image_count: swapchain_ref.report.image_count,
|
||||||
swapchain_image_format: swapchain_ref.report.plan.format.format,
|
|
||||||
swapchain_image_usage: swapchain_ref.report.plan.image_usage,
|
|
||||||
readback_copy_count: 0,
|
|
||||||
readback_byte_count: 0,
|
|
||||||
readback_fnv1a64: 0,
|
|
||||||
};
|
};
|
||||||
Ok(renderer)
|
Ok(renderer)
|
||||||
}
|
}
|
||||||
@@ -315,32 +251,6 @@ impl VulkanSmokeRenderer {
|
|||||||
self.swapchain_recreate_count
|
self.swapchain_recreate_count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the camera that will be uploaded for the next recorded frame.
|
|
||||||
#[must_use]
|
|
||||||
pub const fn camera(&self) -> super::VulkanStaticCamera {
|
|
||||||
self.camera
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replaces the camera for subsequent frames without recreating GPU resources.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns [`VulkanSmokeRendererError::InvalidStaticCamera`] when a matrix
|
|
||||||
/// element is non-finite. The caller must update only between
|
|
||||||
/// [`Self::draw_frame`] calls on the renderer-owning thread.
|
|
||||||
pub fn set_camera(
|
|
||||||
&mut self,
|
|
||||||
camera: super::VulkanStaticCamera,
|
|
||||||
) -> Result<(), VulkanSmokeRendererError> {
|
|
||||||
if !camera.is_finite() {
|
|
||||||
return Err(VulkanSmokeRendererError::InvalidStaticCamera {
|
|
||||||
context: "non-finite clip_from_world matrix",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.camera = camera;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Explicitly idles and tears down the renderer while the native window is still alive.
|
/// Explicitly idles and tears down the renderer while the native window is still alive.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -380,16 +290,6 @@ impl VulkanSmokeRenderer {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn textures_ref(&self) -> Result<&[super::VulkanAllocatedImage], VulkanSmokeRendererError> {
|
|
||||||
if self.textures.is_empty() {
|
|
||||||
Err(VulkanSmokeRendererError::InvariantViolation {
|
|
||||||
context: "static material textures",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Ok(&self.textures)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn surface_ref(&self) -> Result<&VulkanSurfaceProbe, VulkanSmokeRendererError> {
|
fn surface_ref(&self) -> Result<&VulkanSurfaceProbe, VulkanSmokeRendererError> {
|
||||||
self.surface
|
self.surface
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -518,10 +418,6 @@ impl VulkanSmokeRenderer {
|
|||||||
context: "vkQueueSubmit",
|
context: "vkQueueSubmit",
|
||||||
result: error,
|
result: error,
|
||||||
})?;
|
})?;
|
||||||
if !self.resources_ref()?.readback_buffers.is_empty() {
|
|
||||||
self.report.readback_copy_count = self.report.readback_copy_count.saturating_add(1);
|
|
||||||
self.last_readback_image_index = Some(image_index_usize);
|
|
||||||
}
|
|
||||||
|
|
||||||
let present_wait = [render_finished];
|
let present_wait = [render_finished];
|
||||||
let swapchains = [self.swapchain_ref()?.swapchain()];
|
let swapchains = [self.swapchain_ref()?.swapchain()];
|
||||||
@@ -603,15 +499,11 @@ impl VulkanSmokeRenderer {
|
|||||||
let swapchain = self.swapchain_ref()?;
|
let swapchain = self.swapchain_ref()?;
|
||||||
let resources = RollbackOnDrop::new(
|
let resources = RollbackOnDrop::new(
|
||||||
create_swapchain_resources(
|
create_swapchain_resources(
|
||||||
self.instance_ref()?,
|
|
||||||
device,
|
device,
|
||||||
swapchain,
|
swapchain,
|
||||||
self.command_pool,
|
self.command_pool,
|
||||||
self.vertex_buffer_ref()?,
|
self.vertex_buffer_ref()?,
|
||||||
self.index_buffer_ref()?,
|
self.index_buffer_ref()?,
|
||||||
self.textures_ref()?,
|
|
||||||
&self.draw_ranges,
|
|
||||||
self.depth_request,
|
|
||||||
reuse_command_pool,
|
reuse_command_pool,
|
||||||
)?,
|
)?,
|
||||||
|resources| destroy_swapchain_resources(device, self.command_pool, resources),
|
|resources| destroy_swapchain_resources(device, self.command_pool, resources),
|
||||||
@@ -619,15 +511,11 @@ impl VulkanSmokeRenderer {
|
|||||||
let frame_sync = create_frame_sync(device)?;
|
let frame_sync = create_frame_sync(device)?;
|
||||||
let swapchain_extent = self.swapchain_ref()?.report.plan.extent;
|
let swapchain_extent = self.swapchain_ref()?.report.plan.extent;
|
||||||
let swapchain_image_count = self.swapchain_ref()?.report.image_count;
|
let swapchain_image_count = self.swapchain_ref()?.report.image_count;
|
||||||
let swapchain_image_usage = self.swapchain_ref()?.report.plan.image_usage;
|
|
||||||
let resources = resources.commit();
|
let resources = resources.commit();
|
||||||
self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()];
|
self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()];
|
||||||
self.frame_sync = frame_sync;
|
self.frame_sync = frame_sync;
|
||||||
self.last_readback_image_index = None;
|
|
||||||
self.report.swapchain_extent = swapchain_extent;
|
self.report.swapchain_extent = swapchain_extent;
|
||||||
self.report.swapchain_image_count = swapchain_image_count;
|
self.report.swapchain_image_count = swapchain_image_count;
|
||||||
self.report.swapchain_image_format = self.swapchain_ref()?.report.plan.format.format;
|
|
||||||
self.report.swapchain_image_usage = swapchain_image_usage;
|
|
||||||
self.swapchain_resources = Some(resources);
|
self.swapchain_resources = Some(resources);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -663,19 +551,11 @@ impl VulkanSmokeRenderer {
|
|||||||
result: error,
|
result: error,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let clear_values = [
|
let clear_values = [vk::ClearValue {
|
||||||
vk::ClearValue {
|
|
||||||
color: vk::ClearColorValue {
|
color: vk::ClearColorValue {
|
||||||
float32: [0.05, 0.08, 0.11, 1.0],
|
float32: [0.05, 0.08, 0.11, 1.0],
|
||||||
},
|
},
|
||||||
},
|
}];
|
||||||
vk::ClearValue {
|
|
||||||
depth_stencil: vk::ClearDepthStencilValue {
|
|
||||||
depth: 1.0,
|
|
||||||
stencil: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let render_area = vk::Rect2D {
|
let render_area = vk::Rect2D {
|
||||||
offset: vk::Offset2D { x: 0, y: 0 },
|
offset: vk::Offset2D { x: 0, y: 0 },
|
||||||
extent: vk::Extent2D {
|
extent: vk::Extent2D {
|
||||||
@@ -695,6 +575,11 @@ impl VulkanSmokeRenderer {
|
|||||||
&render_pass_info,
|
&render_pass_info,
|
||||||
vk::SubpassContents::INLINE,
|
vk::SubpassContents::INLINE,
|
||||||
);
|
);
|
||||||
|
device.device().cmd_bind_pipeline(
|
||||||
|
command_buffer,
|
||||||
|
vk::PipelineBindPoint::GRAPHICS,
|
||||||
|
resources.pipeline,
|
||||||
|
);
|
||||||
let vertex_buffers = [self.vertex_buffer_ref()?.buffer];
|
let vertex_buffers = [self.vertex_buffer_ref()?.buffer];
|
||||||
let offsets = [0_u64];
|
let offsets = [0_u64];
|
||||||
device
|
device
|
||||||
@@ -704,116 +589,12 @@ impl VulkanSmokeRenderer {
|
|||||||
command_buffer,
|
command_buffer,
|
||||||
self.index_buffer_ref()?.buffer,
|
self.index_buffer_ref()?.buffer,
|
||||||
0,
|
0,
|
||||||
vk::IndexType::UINT32,
|
vk::IndexType::UINT16,
|
||||||
);
|
);
|
||||||
let clip_from_world = matrix_bytes(self.camera.clip_from_world);
|
device
|
||||||
device.device().cmd_push_constants(
|
.device()
|
||||||
command_buffer,
|
.cmd_draw_indexed(command_buffer, 3, 1, 0, 0, 0);
|
||||||
resources.pipeline_layout,
|
|
||||||
vk::ShaderStageFlags::VERTEX,
|
|
||||||
0,
|
|
||||||
&clip_from_world,
|
|
||||||
);
|
|
||||||
for (range, &texture_index) in self.draw_ranges.iter().zip(&self.draw_texture_indices) {
|
|
||||||
let pipeline = resources.pipelines.get(&range.pipeline_key()).ok_or(
|
|
||||||
VulkanSmokeRendererError::InvariantViolation {
|
|
||||||
context: "static draw pipeline variant",
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
device.device().cmd_bind_pipeline(
|
|
||||||
command_buffer,
|
|
||||||
vk::PipelineBindPoint::GRAPHICS,
|
|
||||||
*pipeline,
|
|
||||||
);
|
|
||||||
let descriptor_set = resources.descriptor_sets.get(texture_index).ok_or(
|
|
||||||
VulkanSmokeRendererError::InvariantViolation {
|
|
||||||
context: "static material descriptor set",
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
device.device().cmd_bind_descriptor_sets(
|
|
||||||
command_buffer,
|
|
||||||
vk::PipelineBindPoint::GRAPHICS,
|
|
||||||
resources.pipeline_layout,
|
|
||||||
0,
|
|
||||||
std::slice::from_ref(descriptor_set),
|
|
||||||
&[],
|
|
||||||
);
|
|
||||||
let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes();
|
|
||||||
device.device().cmd_push_constants(
|
|
||||||
command_buffer,
|
|
||||||
resources.pipeline_layout,
|
|
||||||
vk::ShaderStageFlags::FRAGMENT,
|
|
||||||
64,
|
|
||||||
&alpha_cutoff,
|
|
||||||
);
|
|
||||||
device.device().cmd_draw_indexed(
|
|
||||||
command_buffer,
|
|
||||||
range.index_count,
|
|
||||||
1,
|
|
||||||
range.first_index,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
device.device().cmd_end_render_pass(command_buffer);
|
device.device().cmd_end_render_pass(command_buffer);
|
||||||
if let (Some(image), Some(readback)) = (
|
|
||||||
resources.images.get(image_index),
|
|
||||||
resources.readback_buffers.get(image_index),
|
|
||||||
) {
|
|
||||||
let range = super::color_subresource_range();
|
|
||||||
let to_transfer = vk::ImageMemoryBarrier::default()
|
|
||||||
.old_layout(vk::ImageLayout::PRESENT_SRC_KHR)
|
|
||||||
.new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
|
|
||||||
.src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
|
|
||||||
.dst_access_mask(vk::AccessFlags::TRANSFER_READ)
|
|
||||||
.image(*image)
|
|
||||||
.subresource_range(range);
|
|
||||||
let back_to_present = vk::ImageMemoryBarrier::default()
|
|
||||||
.old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
|
|
||||||
.new_layout(vk::ImageLayout::PRESENT_SRC_KHR)
|
|
||||||
.src_access_mask(vk::AccessFlags::TRANSFER_READ)
|
|
||||||
.dst_access_mask(vk::AccessFlags::empty())
|
|
||||||
.image(*image)
|
|
||||||
.subresource_range(range);
|
|
||||||
let region = vk::BufferImageCopy::default()
|
|
||||||
.image_subresource(
|
|
||||||
vk::ImageSubresourceLayers::default()
|
|
||||||
.aspect_mask(vk::ImageAspectFlags::COLOR)
|
|
||||||
.mip_level(0)
|
|
||||||
.base_array_layer(0)
|
|
||||||
.layer_count(1),
|
|
||||||
)
|
|
||||||
.image_extent(vk::Extent3D {
|
|
||||||
width: swapchain.report.plan.extent.0,
|
|
||||||
height: swapchain.report.plan.extent.1,
|
|
||||||
depth: 1,
|
|
||||||
});
|
|
||||||
device.device().cmd_pipeline_barrier(
|
|
||||||
command_buffer,
|
|
||||||
vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT,
|
|
||||||
vk::PipelineStageFlags::TRANSFER,
|
|
||||||
vk::DependencyFlags::empty(),
|
|
||||||
&[],
|
|
||||||
&[],
|
|
||||||
&[to_transfer],
|
|
||||||
);
|
|
||||||
device.device().cmd_copy_image_to_buffer(
|
|
||||||
command_buffer,
|
|
||||||
*image,
|
|
||||||
vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
|
|
||||||
readback.buffer,
|
|
||||||
&[region],
|
|
||||||
);
|
|
||||||
device.device().cmd_pipeline_barrier(
|
|
||||||
command_buffer,
|
|
||||||
vk::PipelineStageFlags::TRANSFER,
|
|
||||||
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
|
|
||||||
vk::DependencyFlags::empty(),
|
|
||||||
&[],
|
|
||||||
&[],
|
|
||||||
&[back_to_present],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: The render pass owns the attachment layout transitions for this clear-and-present path.
|
// SAFETY: The render pass owns the attachment layout transitions for this clear-and-present path.
|
||||||
@@ -852,9 +633,6 @@ impl VulkanSmokeRenderer {
|
|||||||
fn destroy_device_owned_resources(&mut self) {
|
fn destroy_device_owned_resources(&mut self) {
|
||||||
self.destroy_swapchain_resources();
|
self.destroy_swapchain_resources();
|
||||||
if let Some(device) = self.device.as_ref() {
|
if let Some(device) = self.device.as_ref() {
|
||||||
for texture in self.textures.drain(..) {
|
|
||||||
destroy_allocated_image(device, &texture);
|
|
||||||
}
|
|
||||||
if let Some(buffer) = self.index_buffer.take() {
|
if let Some(buffer) = self.index_buffer.take() {
|
||||||
// SAFETY: Buffer and memory belong to this device and are destroyed once after the device has been idled and frame work has been torn down.
|
// SAFETY: Buffer and memory belong to this device and are destroyed once after the device has been idled and frame work has been torn down.
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -880,7 +658,7 @@ impl VulkanSmokeRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn shutdown_inner(&mut self) -> Result<VulkanSmokeShutdownReport, VulkanSmokeRendererError> {
|
fn shutdown_inner(&mut self) -> Result<VulkanSmokeShutdownReport, VulkanSmokeRendererError> {
|
||||||
let completed_readback = if let Some(device) = self.device.as_ref() {
|
if let Some(device) = self.device.as_ref() {
|
||||||
// SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts.
|
// SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts.
|
||||||
unsafe { device.device().device_wait_idle() }.map_err(|error| {
|
unsafe { device.device().device_wait_idle() }.map_err(|error| {
|
||||||
VulkanSmokeRendererError::VulkanOperation {
|
VulkanSmokeRendererError::VulkanOperation {
|
||||||
@@ -888,122 +666,41 @@ impl VulkanSmokeRenderer {
|
|||||||
result: error,
|
result: error,
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
self.completed_readback(device)?
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let readback_artifact = completed_readback.map(|bytes| {
|
|
||||||
self.report.readback_byte_count = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
|
|
||||||
self.report.readback_fnv1a64 = fnv1a64(&bytes);
|
|
||||||
VulkanReadbackArtifact {
|
|
||||||
format: self
|
|
||||||
.swapchain_ref()
|
|
||||||
.map_or(0, |swapchain| swapchain.report.plan.format.format),
|
|
||||||
width: self.report.swapchain_extent.0,
|
|
||||||
height: self.report.swapchain_extent.1,
|
|
||||||
bytes,
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
self.destroy_device_owned_resources();
|
self.destroy_device_owned_resources();
|
||||||
let validation = take_runtime_children_with_validation_snapshot(
|
let validation = take_runtime_owners_with_validation_snapshot(
|
||||||
|
&mut self.instance,
|
||||||
|
&mut self.validation,
|
||||||
&mut self.surface,
|
&mut self.surface,
|
||||||
&mut self.device,
|
&mut self.device,
|
||||||
&mut self.swapchain,
|
&mut self.swapchain,
|
||||||
self.validation.as_ref(),
|
|
||||||
VulkanValidationMessenger::report,
|
VulkanValidationMessenger::report,
|
||||||
)
|
)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
self.validation.take();
|
|
||||||
self.instance.take();
|
|
||||||
Ok(VulkanSmokeShutdownReport {
|
Ok(VulkanSmokeShutdownReport {
|
||||||
renderer_report: self.report.clone(),
|
renderer_report: self.report.clone(),
|
||||||
readback_artifact,
|
|
||||||
swapchain_recreate_count: self.swapchain_recreate_count,
|
swapchain_recreate_count: self.swapchain_recreate_count,
|
||||||
validation,
|
validation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn completed_readback(
|
|
||||||
&self,
|
|
||||||
device: &VulkanLogicalDeviceProbe,
|
|
||||||
) -> Result<Option<Vec<u8>>, VulkanSmokeRendererError> {
|
|
||||||
let Some(resources) = self.swapchain_resources.as_ref() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let byte_len = usize::try_from(self.report.swapchain_extent.0)
|
|
||||||
.ok()
|
|
||||||
.and_then(|width| {
|
|
||||||
usize::try_from(self.report.swapchain_extent.1)
|
|
||||||
.ok()
|
|
||||||
.and_then(|height| width.checked_mul(height))
|
|
||||||
})
|
|
||||||
.and_then(|pixels| pixels.checked_mul(4))
|
|
||||||
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
|
|
||||||
context: "completed readback byte length",
|
|
||||||
})?;
|
|
||||||
if resources.readback_buffers.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let Some(image_index) = completed_readback_buffer_index(
|
|
||||||
self.last_readback_image_index,
|
|
||||||
resources.readback_buffers.len(),
|
|
||||||
)?
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let buffer = resources.readback_buffers.get(image_index).ok_or(
|
|
||||||
VulkanSmokeRendererError::InvariantViolation {
|
|
||||||
context: "last readback image index",
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
Ok(Some(readback_buffer_bytes(device, buffer, byte_len)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn teardown(&mut self) {
|
fn teardown(&mut self) {
|
||||||
if let Some(device) = self.device.as_ref() {
|
if let Some(device) = self.device.as_ref() {
|
||||||
// SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts.
|
// SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts.
|
||||||
let _ = unsafe { device.device().device_wait_idle() };
|
let _ = unsafe { device.device().device_wait_idle() };
|
||||||
}
|
}
|
||||||
self.destroy_device_owned_resources();
|
self.destroy_device_owned_resources();
|
||||||
let _ = take_runtime_children_with_validation_snapshot(
|
let _ = take_runtime_owners_with_validation_snapshot(
|
||||||
|
&mut self.instance,
|
||||||
|
&mut self.validation,
|
||||||
&mut self.surface,
|
&mut self.surface,
|
||||||
&mut self.device,
|
&mut self.device,
|
||||||
&mut self.swapchain,
|
&mut self.swapchain,
|
||||||
self.validation.as_ref(),
|
|
||||||
VulkanValidationMessenger::report,
|
VulkanValidationMessenger::report,
|
||||||
);
|
);
|
||||||
self.validation.take();
|
|
||||||
self.instance.take();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn completed_readback_buffer_index(
|
|
||||||
last_image_index: Option<usize>,
|
|
||||||
buffer_count: usize,
|
|
||||||
) -> Result<Option<usize>, VulkanSmokeRendererError> {
|
|
||||||
match last_image_index {
|
|
||||||
None => Ok(None),
|
|
||||||
Some(index) if index < buffer_count => Ok(Some(index)),
|
|
||||||
Some(_) => Err(VulkanSmokeRendererError::InvariantViolation {
|
|
||||||
context: "last readback image index",
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn matrix_bytes(matrix: [f32; 16]) -> [u8; 64] {
|
|
||||||
let mut bytes = [0; 64];
|
|
||||||
for (index, value) in matrix.into_iter().enumerate() {
|
|
||||||
bytes[index * 4..(index + 1) * 4].copy_from_slice(&value.to_ne_bytes());
|
|
||||||
}
|
|
||||||
bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fnv1a64(bytes: &[u8]) -> u64 {
|
|
||||||
bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
|
|
||||||
(hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for VulkanSmokeRenderer {
|
impl Drop for VulkanSmokeRenderer {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.teardown();
|
self.teardown();
|
||||||
@@ -1013,8 +710,8 @@ impl Drop for VulkanSmokeRenderer {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
completed_readback_buffer_index, take_runtime_children_with_validation_snapshot,
|
take_runtime_owners_in_dependency_order, take_runtime_owners_with_validation_snapshot,
|
||||||
take_runtime_owners_in_dependency_order, RollbackOnDrop,
|
RollbackOnDrop,
|
||||||
};
|
};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -1097,13 +794,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn completed_readback_selects_only_the_last_submitted_image() {
|
|
||||||
assert_eq!(completed_readback_buffer_index(None, 2), Ok(None));
|
|
||||||
assert_eq!(completed_readback_buffer_index(Some(1), 2), Ok(Some(1)));
|
|
||||||
assert!(completed_readback_buffer_index(Some(2), 2).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_owners_drop_remaining_children_after_partial_init_failures() {
|
fn runtime_owners_drop_remaining_children_after_partial_init_failures() {
|
||||||
let cases = [
|
let cases = [
|
||||||
@@ -1160,7 +850,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn final_validation_snapshot_is_captured_after_surface_device_swapchain_drop() {
|
fn final_validation_snapshot_is_captured_before_validation_drop() {
|
||||||
let log = Rc::new(RefCell::new(Vec::new()));
|
let log = Rc::new(RefCell::new(Vec::new()));
|
||||||
let mut instance = Some(tracker(TeardownStep::Instance, &log));
|
let mut instance = Some(tracker(TeardownStep::Instance, &log));
|
||||||
let mut validation = Some(tracker(TeardownStep::Validation, &log));
|
let mut validation = Some(tracker(TeardownStep::Validation, &log));
|
||||||
@@ -1168,18 +858,17 @@ mod tests {
|
|||||||
let mut device = Some(tracker(TeardownStep::Device, &log));
|
let mut device = Some(tracker(TeardownStep::Device, &log));
|
||||||
let mut swapchain = Some(tracker(TeardownStep::Swapchain, &log));
|
let mut swapchain = Some(tracker(TeardownStep::Swapchain, &log));
|
||||||
|
|
||||||
let snapshot = take_runtime_children_with_validation_snapshot(
|
let snapshot = take_runtime_owners_with_validation_snapshot(
|
||||||
|
&mut instance,
|
||||||
|
&mut validation,
|
||||||
&mut surface,
|
&mut surface,
|
||||||
&mut device,
|
&mut device,
|
||||||
&mut swapchain,
|
&mut swapchain,
|
||||||
validation.as_ref(),
|
|
||||||
|_| {
|
|_| {
|
||||||
log.borrow_mut().push(TeardownStep::Snapshot);
|
log.borrow_mut().push(TeardownStep::Snapshot);
|
||||||
TeardownStep::Validation
|
TeardownStep::Validation
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
validation.take();
|
|
||||||
instance.take();
|
|
||||||
|
|
||||||
assert_eq!(snapshot, Some(TeardownStep::Validation));
|
assert_eq!(snapshot, Some(TeardownStep::Validation));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1187,10 +876,10 @@ mod tests {
|
|||||||
.expect("all drop trackers released")
|
.expect("all drop trackers released")
|
||||||
.into_inner(),
|
.into_inner(),
|
||||||
vec![
|
vec![
|
||||||
|
TeardownStep::Snapshot,
|
||||||
TeardownStep::Swapchain,
|
TeardownStep::Swapchain,
|
||||||
TeardownStep::Device,
|
TeardownStep::Device,
|
||||||
TeardownStep::Surface,
|
TeardownStep::Surface,
|
||||||
TeardownStep::Snapshot,
|
|
||||||
TeardownStep::Validation,
|
TeardownStep::Validation,
|
||||||
TeardownStep::Instance,
|
TeardownStep::Instance,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
use ash::vk;
|
use ash::vk;
|
||||||
use fparkan_platform::{NativeWindowHandles, RenderRequest};
|
use fparkan_platform::{NativeWindowHandles, RenderRequest};
|
||||||
use fparkan_render::{LegacyD3d7Projection, LegacyPipelineState, PipelineKey, RawCameraTransform};
|
|
||||||
use std::sync::atomic::{AtomicU8, Ordering};
|
use std::sync::atomic::{AtomicU8, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanFrameSync, VulkanInstanceError,
|
VulkanAllocatedBuffer, VulkanFrameSync, VulkanInstanceError, VulkanInstanceProbe,
|
||||||
VulkanInstanceProbe, VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanSurfaceError,
|
VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanSurfaceError, VulkanSurfaceProbe,
|
||||||
VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainProbeError, VulkanSwapchainResources,
|
VulkanSwapchainProbe, VulkanSwapchainProbeError, VulkanSwapchainResources,
|
||||||
VulkanValidationMessenger,
|
VulkanValidationMessenger,
|
||||||
};
|
};
|
||||||
use crate::shader_manifest::VulkanShaderManifestError;
|
use crate::shader_manifest::VulkanShaderManifestError;
|
||||||
@@ -25,464 +24,10 @@ pub struct VulkanSmokeRendererCreateInfo {
|
|||||||
pub render_request: RenderRequest,
|
pub render_request: RenderRequest,
|
||||||
/// Whether validation layers must be enabled.
|
/// Whether validation layers must be enabled.
|
||||||
pub enable_validation: bool,
|
pub enable_validation: bool,
|
||||||
/// Static indexed geometry uploaded before the first live frame.
|
|
||||||
pub mesh: VulkanStaticMesh,
|
|
||||||
/// Camera contract for static geometry.
|
|
||||||
///
|
|
||||||
/// The default identity matrix retains the initial XY diagnostic viewer.
|
|
||||||
pub camera: VulkanStaticCamera,
|
|
||||||
/// Material textures uploaded before the first live frame.
|
|
||||||
///
|
|
||||||
/// An empty list retains the compatibility white fallback. A singleton list
|
|
||||||
/// is a deliberately explicit one-material compatibility mode; otherwise
|
|
||||||
/// every source batch selector must resolve to one entry in this list.
|
|
||||||
pub materials: Vec<VulkanStaticMaterial>,
|
|
||||||
/// Optional shared bootstrap progress tracker for failure evidence.
|
/// Optional shared bootstrap progress tracker for failure evidence.
|
||||||
pub bootstrap_progress: Option<Arc<VulkanSmokeBootstrapProgress>>,
|
pub bootstrap_progress: Option<Arc<VulkanSmokeBootstrapProgress>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One vertex accepted by the initial static Vulkan geometry path.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub struct VulkanStaticVertex {
|
|
||||||
/// World or clip position transformed by [`VulkanStaticCamera`].
|
|
||||||
pub position: [f32; 3],
|
|
||||||
/// Linear RGB vertex color.
|
|
||||||
pub color: [f32; 3],
|
|
||||||
/// Texture coordinate consumed by the static material bridge.
|
|
||||||
pub uv: [f32; 2],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A static geometry camera represented in the shader's matrix memory order.
|
|
||||||
///
|
|
||||||
/// `clip_from_world` contains row-major D3D7 data. GLSL's default column-major
|
|
||||||
/// `mat4` interpretation deliberately transposes that storage, making
|
|
||||||
/// `matrix * vec4(position, 1)` equivalent to D3D7's row-vector multiplication.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub struct VulkanStaticCamera {
|
|
||||||
/// Row-major clip-from-world transform consumed by the vertex shader.
|
|
||||||
pub clip_from_world: [f32; 16],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VulkanStaticCamera {
|
|
||||||
/// Builds a static camera from the recovered Ngi32 D3D7 camera contract.
|
|
||||||
#[must_use]
|
|
||||||
pub fn from_legacy_d3d7(
|
|
||||||
transform: RawCameraTransform,
|
|
||||||
projection: LegacyD3d7Projection,
|
|
||||||
) -> Option<Self> {
|
|
||||||
let view = transform.try_direct3d7_view_row_major()?;
|
|
||||||
let projection = projection.try_direct3d7_projection_row_major()?;
|
|
||||||
Self::from_row_major_view_projection(view, projection)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a camera from finite row-major view and projection matrices.
|
|
||||||
///
|
|
||||||
/// This is the runtime-facing form of the contract. It is intentionally
|
|
||||||
/// independent of the D3D7 recovery path so a future native camera
|
|
||||||
/// controller can submit its per-frame matrices without recreating Vulkan
|
|
||||||
/// resources.
|
|
||||||
#[must_use]
|
|
||||||
pub fn from_row_major_view_projection(view: [f32; 16], projection: [f32; 16]) -> Option<Self> {
|
|
||||||
view.iter()
|
|
||||||
.chain(projection.iter())
|
|
||||||
.all(|value| value.is_finite())
|
|
||||||
.then_some(Self {
|
|
||||||
clip_from_world: multiply_row_major(view, projection),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns whether every matrix element is finite.
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_finite(self) -> bool {
|
|
||||||
self.clip_from_world.iter().all(|value| value.is_finite())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for VulkanStaticCamera {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
clip_from_world: [
|
|
||||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
|
|
||||||
let mut result = [0.0; 16];
|
|
||||||
for row in 0..4 {
|
|
||||||
for column in 0..4 {
|
|
||||||
result[row * 4 + column] = (0..4)
|
|
||||||
.map(|inner| left[row * 4 + inner] * right[inner * 4 + column])
|
|
||||||
.sum();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Static indexed geometry uploaded to live Vulkan buffers.
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct VulkanStaticMesh {
|
|
||||||
/// Vertex data in pipeline order.
|
|
||||||
pub vertices: Vec<VulkanStaticVertex>,
|
|
||||||
/// Triangle-list indices into [`Self::vertices`].
|
|
||||||
pub indices: Vec<u32>,
|
|
||||||
/// Source-preserving triangle draw ranges in [`Self::indices`].
|
|
||||||
pub draw_ranges: Vec<VulkanStaticDrawRange>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One indexed triangle-list draw retained from an original mesh batch.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct VulkanStaticDrawRange {
|
|
||||||
/// First index in the shared index buffer.
|
|
||||||
pub first_index: u32,
|
|
||||||
/// Number of indices in this draw.
|
|
||||||
pub index_count: u32,
|
|
||||||
/// Original positional `Batch20.material_index` selector.
|
|
||||||
pub material_index: u16,
|
|
||||||
/// Backend-neutral fixed-function state for this source range.
|
|
||||||
pub pipeline_state: LegacyPipelineState,
|
|
||||||
/// Alpha-test reference in legacy 0..=255 units; dynamic material data.
|
|
||||||
pub alpha_test_reference: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VulkanStaticDrawRange {
|
|
||||||
/// Returns the canonical key used for Vulkan pipeline selection.
|
|
||||||
#[must_use]
|
|
||||||
pub fn pipeline_key(self) -> PipelineKey {
|
|
||||||
self.pipeline_state.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the normalized cutoff consumed by the fragment shader.
|
|
||||||
#[must_use]
|
|
||||||
pub fn alpha_test_cutoff(self) -> f32 {
|
|
||||||
if self.pipeline_state.alpha_test {
|
|
||||||
f32::from(self.alpha_test_reference) / 255.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One diffuse material texture keyed by an original MSH batch selector.
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct VulkanStaticMaterial {
|
|
||||||
/// Positional material selector used by one or more source batches.
|
|
||||||
pub material_index: u16,
|
|
||||||
/// Decoded RGBA8 diffuse texture for this selector.
|
|
||||||
pub texture: VulkanStaticTexture,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decoded RGBA8 image accepted by the initial Vulkan texture upload path.
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct VulkanStaticTexture {
|
|
||||||
/// Image width in texels.
|
|
||||||
pub width: u32,
|
|
||||||
/// Image height in texels.
|
|
||||||
pub height: u32,
|
|
||||||
/// Row-major RGBA8 pixels.
|
|
||||||
pub rgba8: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VulkanStaticTexture {
|
|
||||||
pub(super) fn validate(&self) -> Result<(), &'static str> {
|
|
||||||
let pixels = usize::try_from(self.width)
|
|
||||||
.ok()
|
|
||||||
.and_then(|width| {
|
|
||||||
usize::try_from(self.height)
|
|
||||||
.ok()
|
|
||||||
.and_then(|height| width.checked_mul(height))
|
|
||||||
})
|
|
||||||
.and_then(|pixels| pixels.checked_mul(4));
|
|
||||||
match pixels {
|
|
||||||
None => Err("static texture dimensions overflow address space"),
|
|
||||||
Some(0) => Err("static texture has zero extent"),
|
|
||||||
Some(expected) if expected != self.rgba8.len() => {
|
|
||||||
Err("static texture rgba8 byte count does not match extent")
|
|
||||||
}
|
|
||||||
Some(_) => Ok(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves each source range to its descriptor-set material index.
|
|
||||||
///
|
|
||||||
/// Empty material input selects the renderer's white fallback. A singleton is
|
|
||||||
/// the explicit direct-TEXM compatibility mode. Multiple materials must map
|
|
||||||
/// each `Batch20.material_index` exactly and never duplicate a selector.
|
|
||||||
pub(super) fn resolve_draw_texture_indices(
|
|
||||||
draw_ranges: &[VulkanStaticDrawRange],
|
|
||||||
materials: &[VulkanStaticMaterial],
|
|
||||||
) -> Result<Vec<usize>, &'static str> {
|
|
||||||
for (index, material) in materials.iter().enumerate() {
|
|
||||||
material.texture.validate()?;
|
|
||||||
if materials[..index]
|
|
||||||
.iter()
|
|
||||||
.any(|previous| previous.material_index == material.material_index)
|
|
||||||
{
|
|
||||||
return Err("static material selectors must be unique");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
draw_ranges
|
|
||||||
.iter()
|
|
||||||
.map(|range| {
|
|
||||||
if materials.len() <= 1 {
|
|
||||||
Ok(0)
|
|
||||||
} else {
|
|
||||||
materials
|
|
||||||
.iter()
|
|
||||||
.position(|material| material.material_index == range.material_index)
|
|
||||||
.ok_or("static mesh draw range has no material texture")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VulkanStaticMesh {
|
|
||||||
/// Returns the compatibility triangle used by the native Stage 0 smoke app.
|
|
||||||
#[must_use]
|
|
||||||
pub fn smoke_triangle() -> Self {
|
|
||||||
Self {
|
|
||||||
vertices: vec![
|
|
||||||
VulkanStaticVertex {
|
|
||||||
position: [0.0, -0.55, 0.0],
|
|
||||||
color: [1.0, 0.2, 0.2],
|
|
||||||
uv: [0.5, 0.0],
|
|
||||||
},
|
|
||||||
VulkanStaticVertex {
|
|
||||||
position: [0.55, 0.55, 0.0],
|
|
||||||
color: [0.2, 1.0, 0.2],
|
|
||||||
uv: [1.0, 1.0],
|
|
||||||
},
|
|
||||||
VulkanStaticVertex {
|
|
||||||
position: [-0.55, 0.55, 0.0],
|
|
||||||
color: [0.2, 0.4, 1.0],
|
|
||||||
uv: [0.0, 1.0],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
indices: vec![0, 1, 2],
|
|
||||||
draw_ranges: vec![VulkanStaticDrawRange {
|
|
||||||
first_index: 0,
|
|
||||||
index_count: 3,
|
|
||||||
material_index: 0,
|
|
||||||
pipeline_state: LegacyPipelineState::default(),
|
|
||||||
alpha_test_reference: 0,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn validate(&self) -> Result<(), &'static str> {
|
|
||||||
if self.vertices.is_empty() {
|
|
||||||
return Err("static mesh has no vertices");
|
|
||||||
}
|
|
||||||
if self.indices.is_empty() || !self.indices.len().is_multiple_of(3) {
|
|
||||||
return Err("static mesh indices must contain complete triangles");
|
|
||||||
}
|
|
||||||
if self.draw_ranges.is_empty() {
|
|
||||||
return Err("static mesh has no draw ranges");
|
|
||||||
}
|
|
||||||
let mut expected_first = 0_u32;
|
|
||||||
for range in &self.draw_ranges {
|
|
||||||
if range.first_index != expected_first
|
|
||||||
|| range.index_count == 0
|
|
||||||
|| !range.index_count.is_multiple_of(3)
|
|
||||||
{
|
|
||||||
return Err("static mesh draw ranges must be contiguous complete triangles");
|
|
||||||
}
|
|
||||||
expected_first = expected_first
|
|
||||||
.checked_add(range.index_count)
|
|
||||||
.ok_or("static mesh draw range exceeds index count")?;
|
|
||||||
}
|
|
||||||
if usize::try_from(expected_first).ok() != Some(self.indices.len()) {
|
|
||||||
return Err("static mesh draw ranges must cover all indices");
|
|
||||||
}
|
|
||||||
if self.indices.iter().any(|&index| {
|
|
||||||
usize::try_from(index)
|
|
||||||
.ok()
|
|
||||||
.is_none_or(|index| index >= self.vertices.len())
|
|
||||||
}) {
|
|
||||||
return Err("static mesh index exceeds vertex count");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod static_mesh_tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn smoke_triangle_is_valid_complete_geometry() {
|
|
||||||
let mesh = VulkanStaticMesh::smoke_triangle();
|
|
||||||
|
|
||||||
assert_eq!(mesh.indices, vec![0, 1, 2]);
|
|
||||||
assert_eq!(mesh.validate(), Ok(()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn static_camera_composes_recovered_d3d7_view_before_projection() {
|
|
||||||
let transform = RawCameraTransform {
|
|
||||||
words: [
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
(-1.0_f32).to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
10.0_f32.to_bits(),
|
|
||||||
1.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
20.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
1.0_f32.to_bits(),
|
|
||||||
30.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
1.0_f32.to_bits(),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
let projection = LegacyD3d7Projection {
|
|
||||||
viewport: [0, 0, 1024, 768],
|
|
||||||
near_plane: 0.5,
|
|
||||||
far_plane: 700.0,
|
|
||||||
field_of_view_radians: 1.3,
|
|
||||||
};
|
|
||||||
|
|
||||||
let camera = VulkanStaticCamera::from_legacy_d3d7(transform, projection)
|
|
||||||
.expect("recovered camera inputs are valid");
|
|
||||||
|
|
||||||
assert!(camera.is_finite());
|
|
||||||
assert_eq!(camera.clip_from_world[0], 0.65_f32.cos());
|
|
||||||
assert_eq!(camera.clip_from_world[6], 0.65_f32.sin() * 700.0 / 699.5);
|
|
||||||
assert_eq!(camera.clip_from_world[7], 0.65_f32.sin());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn static_camera_accepts_finite_runtime_matrices_and_rejects_nan() {
|
|
||||||
let identity = VulkanStaticCamera::default().clip_from_world;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
VulkanStaticCamera::from_row_major_view_projection(identity, identity),
|
|
||||||
Some(VulkanStaticCamera::default())
|
|
||||||
);
|
|
||||||
let mut invalid = identity;
|
|
||||||
invalid[5] = f32::NAN;
|
|
||||||
assert_eq!(
|
|
||||||
VulkanStaticCamera::from_row_major_view_projection(invalid, identity),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn static_mesh_rejects_bad_triangle_topology_and_indices() {
|
|
||||||
let no_vertices = VulkanStaticMesh {
|
|
||||||
vertices: Vec::new(),
|
|
||||||
indices: vec![0, 1, 2],
|
|
||||||
draw_ranges: VulkanStaticMesh::smoke_triangle().draw_ranges,
|
|
||||||
};
|
|
||||||
let incomplete_triangle = VulkanStaticMesh {
|
|
||||||
vertices: VulkanStaticMesh::smoke_triangle().vertices,
|
|
||||||
indices: vec![0, 1],
|
|
||||||
draw_ranges: vec![VulkanStaticDrawRange {
|
|
||||||
first_index: 0,
|
|
||||||
index_count: 2,
|
|
||||||
material_index: 0,
|
|
||||||
pipeline_state: LegacyPipelineState::default(),
|
|
||||||
alpha_test_reference: 0,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
let out_of_range_index = VulkanStaticMesh {
|
|
||||||
vertices: VulkanStaticMesh::smoke_triangle().vertices,
|
|
||||||
indices: vec![0, 1, 3],
|
|
||||||
draw_ranges: VulkanStaticMesh::smoke_triangle().draw_ranges,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(no_vertices.validate(), Err("static mesh has no vertices"));
|
|
||||||
assert_eq!(
|
|
||||||
incomplete_triangle.validate(),
|
|
||||||
Err("static mesh indices must contain complete triangles")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
out_of_range_index.validate(),
|
|
||||||
Err("static mesh index exceeds vertex count")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn material_descriptors_follow_source_batch_selectors() {
|
|
||||||
let ranges = [
|
|
||||||
VulkanStaticDrawRange {
|
|
||||||
first_index: 0,
|
|
||||||
index_count: 3,
|
|
||||||
material_index: 7,
|
|
||||||
pipeline_state: LegacyPipelineState::default(),
|
|
||||||
alpha_test_reference: 0,
|
|
||||||
},
|
|
||||||
VulkanStaticDrawRange {
|
|
||||||
first_index: 3,
|
|
||||||
index_count: 3,
|
|
||||||
material_index: 2,
|
|
||||||
pipeline_state: LegacyPipelineState::default(),
|
|
||||||
alpha_test_reference: 0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let texture = || VulkanStaticTexture {
|
|
||||||
width: 1,
|
|
||||||
height: 1,
|
|
||||||
rgba8: vec![255; 4],
|
|
||||||
};
|
|
||||||
let materials = [
|
|
||||||
VulkanStaticMaterial {
|
|
||||||
material_index: 2,
|
|
||||||
texture: texture(),
|
|
||||||
},
|
|
||||||
VulkanStaticMaterial {
|
|
||||||
material_index: 7,
|
|
||||||
texture: texture(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
resolve_draw_texture_indices(&ranges, &materials),
|
|
||||||
Ok(vec![1, 0])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn draw_range_pipeline_key_follows_backend_neutral_state() {
|
|
||||||
let base = VulkanStaticMesh::smoke_triangle().draw_ranges[0];
|
|
||||||
let blended = VulkanStaticDrawRange {
|
|
||||||
pipeline_state: LegacyPipelineState {
|
|
||||||
blend: fparkan_render::LegacyBlendMode::SourceAlpha,
|
|
||||||
..LegacyPipelineState::default()
|
|
||||||
},
|
|
||||||
..base
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(base.pipeline_key().packed(), 0);
|
|
||||||
assert_ne!(base.pipeline_key(), blended.pipeline_key());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn alpha_cutoff_is_dynamic_and_disabled_outside_alpha_test_pipeline() {
|
|
||||||
let base = VulkanStaticMesh::smoke_triangle().draw_ranges[0];
|
|
||||||
let disabled = VulkanStaticDrawRange {
|
|
||||||
alpha_test_reference: 200,
|
|
||||||
..base
|
|
||||||
};
|
|
||||||
let enabled = VulkanStaticDrawRange {
|
|
||||||
pipeline_state: LegacyPipelineState {
|
|
||||||
alpha_test: true,
|
|
||||||
..LegacyPipelineState::default()
|
|
||||||
},
|
|
||||||
alpha_test_reference: 128,
|
|
||||||
..base
|
|
||||||
};
|
|
||||||
assert_eq!(disabled.alpha_test_cutoff(), 0.0);
|
|
||||||
assert_eq!(enabled.alpha_test_cutoff(), 128.0 / 255.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared bootstrap progress used to report partial renderer startup evidence.
|
/// Shared bootstrap progress used to report partial renderer startup evidence.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct VulkanSmokeBootstrapProgress {
|
pub struct VulkanSmokeBootstrapProgress {
|
||||||
@@ -580,16 +125,6 @@ pub struct VulkanSmokeRendererReport {
|
|||||||
pub swapchain_extent: (u32, u32),
|
pub swapchain_extent: (u32, u32),
|
||||||
/// Current swapchain image count.
|
/// Current swapchain image count.
|
||||||
pub swapchain_image_count: u32,
|
pub swapchain_image_count: u32,
|
||||||
/// Current swapchain image format as a raw Vulkan enum value.
|
|
||||||
pub swapchain_image_format: i32,
|
|
||||||
/// Current swapchain image-usage flags as raw Vulkan bits.
|
|
||||||
pub swapchain_image_usage: u32,
|
|
||||||
/// Number of submitted swapchain image-to-buffer copy commands.
|
|
||||||
pub readback_copy_count: u64,
|
|
||||||
/// Byte length of the final completed readback artifact.
|
|
||||||
pub readback_byte_count: u64,
|
|
||||||
/// Stable FNV-1a hash of the final completed readback artifact.
|
|
||||||
pub readback_fnv1a64: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Measured validation counters from the live smoke loop.
|
/// Measured validation counters from the live smoke loop.
|
||||||
@@ -603,27 +138,11 @@ pub struct VulkanValidationReport {
|
|||||||
pub vuids: Vec<String>,
|
pub vuids: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CPU-owned copy of the final completed swapchain image readback.
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct VulkanReadbackArtifact {
|
|
||||||
/// Vulkan swapchain format as a raw enum value.
|
|
||||||
pub format: i32,
|
|
||||||
/// Width of each image in pixels.
|
|
||||||
pub width: u32,
|
|
||||||
/// Height of each image in pixels.
|
|
||||||
pub height: u32,
|
|
||||||
/// One raw four-byte-per-pixel image: the last successfully submitted
|
|
||||||
/// swapchain image before synchronized teardown.
|
|
||||||
pub bytes: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Final smoke renderer shutdown evidence captured after explicit teardown.
|
/// Final smoke renderer shutdown evidence captured after explicit teardown.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct VulkanSmokeShutdownReport {
|
pub struct VulkanSmokeShutdownReport {
|
||||||
/// Stable renderer bootstrap and swapchain report.
|
/// Stable renderer bootstrap and swapchain report.
|
||||||
pub renderer_report: VulkanSmokeRendererReport,
|
pub renderer_report: VulkanSmokeRendererReport,
|
||||||
/// Optional raw GPU readback retained after synchronization and before teardown.
|
|
||||||
pub readback_artifact: Option<VulkanReadbackArtifact>,
|
|
||||||
/// Measured swapchain recreation count for the completed smoke loop.
|
/// Measured swapchain recreation count for the completed smoke loop.
|
||||||
pub swapchain_recreate_count: u32,
|
pub swapchain_recreate_count: u32,
|
||||||
/// Final validation snapshot captured before the debug messenger is destroyed.
|
/// Final validation snapshot captured before the debug messenger is destroyed.
|
||||||
@@ -666,21 +185,6 @@ pub enum VulkanSmokeRendererError {
|
|||||||
/// Operation context.
|
/// Operation context.
|
||||||
context: &'static str,
|
context: &'static str,
|
||||||
},
|
},
|
||||||
/// The submitted static geometry cannot be represented by this path.
|
|
||||||
InvalidStaticMesh {
|
|
||||||
/// Validation failure detail.
|
|
||||||
context: &'static str,
|
|
||||||
},
|
|
||||||
/// The static camera matrix contains a non-finite element.
|
|
||||||
InvalidStaticCamera {
|
|
||||||
/// Validation failure detail.
|
|
||||||
context: &'static str,
|
|
||||||
},
|
|
||||||
/// The submitted static texture cannot be represented by this path.
|
|
||||||
InvalidStaticTexture {
|
|
||||||
/// Validation failure detail.
|
|
||||||
context: &'static str,
|
|
||||||
},
|
|
||||||
/// Internal smoke renderer state was unexpectedly absent.
|
/// Internal smoke renderer state was unexpectedly absent.
|
||||||
InvariantViolation {
|
InvariantViolation {
|
||||||
/// Missing state context.
|
/// Missing state context.
|
||||||
@@ -702,13 +206,6 @@ impl std::fmt::Display for VulkanSmokeRendererError {
|
|||||||
Self::MissingMemoryType { context } => {
|
Self::MissingMemoryType { context } => {
|
||||||
write!(f, "{context}: no compatible Vulkan memory type")
|
write!(f, "{context}: no compatible Vulkan memory type")
|
||||||
}
|
}
|
||||||
Self::InvalidStaticMesh { context } => write!(f, "invalid static mesh: {context}"),
|
|
||||||
Self::InvalidStaticCamera { context } => {
|
|
||||||
write!(f, "invalid static camera: {context}")
|
|
||||||
}
|
|
||||||
Self::InvalidStaticTexture { context } => {
|
|
||||||
write!(f, "invalid static texture: {context}")
|
|
||||||
}
|
|
||||||
Self::InvariantViolation { context } => {
|
Self::InvariantViolation { context } => {
|
||||||
write!(f, "renderer invariant violated: {context}")
|
write!(f, "renderer invariant violated: {context}")
|
||||||
}
|
}
|
||||||
@@ -729,18 +226,9 @@ pub struct VulkanSmokeRenderer {
|
|||||||
pub(super) swapchain_resources: Option<VulkanSwapchainResources>,
|
pub(super) swapchain_resources: Option<VulkanSwapchainResources>,
|
||||||
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
|
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
|
||||||
pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
|
pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
|
||||||
pub(super) textures: Vec<VulkanAllocatedImage>,
|
|
||||||
pub(super) draw_ranges: Vec<VulkanStaticDrawRange>,
|
|
||||||
pub(super) draw_texture_indices: Vec<usize>,
|
|
||||||
pub(super) camera: VulkanStaticCamera,
|
|
||||||
pub(super) frame_sync: Vec<VulkanFrameSync>,
|
pub(super) frame_sync: Vec<VulkanFrameSync>,
|
||||||
pub(super) images_in_flight: Vec<vk::Fence>,
|
pub(super) images_in_flight: Vec<vk::Fence>,
|
||||||
pub(super) current_frame: usize,
|
pub(super) current_frame: usize,
|
||||||
/// Last swapchain image whose color attachment was copied after a
|
|
||||||
/// successful graphics submission. Reset whenever swapchain resources are
|
|
||||||
/// replaced so a teardown never reads a buffer from an old swapchain.
|
|
||||||
pub(super) last_readback_image_index: Option<usize>,
|
|
||||||
pub(super) depth_request: fparkan_platform::DepthStencilSupport,
|
|
||||||
pub(super) pending_extent: Option<(u32, u32)>,
|
pub(super) pending_extent: Option<(u32, u32)>,
|
||||||
pub(super) swapchain_recreate_count: u32,
|
pub(super) swapchain_recreate_count: u32,
|
||||||
pub(super) report: VulkanSmokeRendererReport,
|
pub(super) report: VulkanSmokeRendererReport,
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ pub fn create_vulkan_swapchain_probe_for_extent(
|
|||||||
height: plan.extent.1,
|
height: plan.extent.1,
|
||||||
})
|
})
|
||||||
.image_array_layers(1)
|
.image_array_layers(1)
|
||||||
.image_usage(vk::ImageUsageFlags::from_raw(plan.image_usage))
|
.image_usage(vk::ImageUsageFlags::COLOR_ATTACHMENT)
|
||||||
.image_sharing_mode(sharing_mode)
|
.image_sharing_mode(sharing_mode)
|
||||||
.queue_family_indices(&queue_family_indices)
|
.queue_family_indices(&queue_family_indices)
|
||||||
.pre_transform(raw_capabilities.current_transform)
|
.pre_transform(raw_capabilities.current_transform)
|
||||||
|
|||||||
@@ -1,61 +1,37 @@
|
|||||||
#![allow(unsafe_code)]
|
#![allow(unsafe_code)]
|
||||||
|
|
||||||
use ash::vk;
|
use ash::vk;
|
||||||
use fparkan_platform::DepthStencilSupport;
|
|
||||||
use fparkan_render::{
|
|
||||||
LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState, PipelineKey,
|
|
||||||
};
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
color_subresource_range, create_depth_attachment, destroy_depth_attachment,
|
color_subresource_range, VulkanAllocatedBuffer, VulkanLogicalDeviceProbe,
|
||||||
VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment, VulkanInstanceProbe,
|
VulkanSmokeRendererError, VulkanSwapchainProbe, TRIANGLE_FRAGMENT_SHADER_WORDS,
|
||||||
VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe,
|
TRIANGLE_VERTEX_SHADER_WORDS,
|
||||||
TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(super) struct VulkanSwapchainResources {
|
pub(super) struct VulkanSwapchainResources {
|
||||||
pub(super) image_views: Vec<vk::ImageView>,
|
pub(super) image_views: Vec<vk::ImageView>,
|
||||||
pub(super) images: Vec<vk::Image>,
|
|
||||||
pub(super) readback_buffers: Vec<VulkanAllocatedBuffer>,
|
|
||||||
pub(super) depth_attachment: VulkanDepthAttachment,
|
|
||||||
pub(super) render_pass: vk::RenderPass,
|
pub(super) render_pass: vk::RenderPass,
|
||||||
pub(super) pipeline_layout: vk::PipelineLayout,
|
pub(super) pipeline_layout: vk::PipelineLayout,
|
||||||
pub(super) descriptor_set_layout: vk::DescriptorSetLayout,
|
pub(super) pipeline: vk::Pipeline,
|
||||||
pub(super) descriptor_pool: vk::DescriptorPool,
|
|
||||||
pub(super) descriptor_sets: Vec<vk::DescriptorSet>,
|
|
||||||
pub(super) sampler: vk::Sampler,
|
|
||||||
/// Live graphics pipelines indexed by canonical backend-neutral state.
|
|
||||||
pub(super) pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
|
|
||||||
pub(super) framebuffers: Vec<vk::Framebuffer>,
|
pub(super) framebuffers: Vec<vk::Framebuffer>,
|
||||||
pub(super) command_buffers: Vec<vk::CommandBuffer>,
|
pub(super) command_buffers: Vec<vk::CommandBuffer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PartialSwapchainResources {
|
struct PartialSwapchainResources {
|
||||||
image_views: Vec<vk::ImageView>,
|
image_views: Vec<vk::ImageView>,
|
||||||
readback_buffers: Vec<VulkanAllocatedBuffer>,
|
|
||||||
depth_attachment: Option<VulkanDepthAttachment>,
|
|
||||||
render_pass: Option<vk::RenderPass>,
|
render_pass: Option<vk::RenderPass>,
|
||||||
pipeline_layout: Option<vk::PipelineLayout>,
|
pipeline_layout: Option<vk::PipelineLayout>,
|
||||||
descriptor_set_layout: Option<vk::DescriptorSetLayout>,
|
pipeline: Option<vk::Pipeline>,
|
||||||
descriptor_pool: Option<vk::DescriptorPool>,
|
|
||||||
sampler: Option<vk::Sampler>,
|
|
||||||
pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
|
|
||||||
framebuffers: Vec<vk::Framebuffer>,
|
framebuffers: Vec<vk::Framebuffer>,
|
||||||
command_buffers: Vec<vk::CommandBuffer>,
|
command_buffers: Vec<vk::CommandBuffer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
|
||||||
pub(super) fn create_swapchain_resources(
|
pub(super) fn create_swapchain_resources(
|
||||||
instance: &VulkanInstanceProbe,
|
|
||||||
device: &VulkanLogicalDeviceProbe,
|
device: &VulkanLogicalDeviceProbe,
|
||||||
swapchain: &VulkanSwapchainProbe,
|
swapchain: &VulkanSwapchainProbe,
|
||||||
command_pool: vk::CommandPool,
|
command_pool: vk::CommandPool,
|
||||||
vertex_buffer: &VulkanAllocatedBuffer,
|
vertex_buffer: &VulkanAllocatedBuffer,
|
||||||
index_buffer: &VulkanAllocatedBuffer,
|
index_buffer: &VulkanAllocatedBuffer,
|
||||||
textures: &[VulkanAllocatedImage],
|
|
||||||
draw_ranges: &[super::VulkanStaticDrawRange],
|
|
||||||
depth_request: DepthStencilSupport,
|
|
||||||
reuse_command_pool: bool,
|
reuse_command_pool: bool,
|
||||||
) -> Result<VulkanSwapchainResources, VulkanSmokeRendererError> {
|
) -> Result<VulkanSwapchainResources, VulkanSmokeRendererError> {
|
||||||
// SAFETY: The swapchain is live and owned by this renderer for the duration of the query.
|
// SAFETY: The swapchain is live and owned by this renderer for the duration of the query.
|
||||||
@@ -74,34 +50,16 @@ pub(super) fn create_swapchain_resources(
|
|||||||
&images,
|
&images,
|
||||||
swapchain.report.plan.format.format,
|
swapchain.report.plan.format.format,
|
||||||
)?,
|
)?,
|
||||||
readback_buffers: Vec::new(),
|
|
||||||
depth_attachment: None,
|
|
||||||
render_pass: None,
|
render_pass: None,
|
||||||
pipeline_layout: None,
|
pipeline_layout: None,
|
||||||
descriptor_set_layout: None,
|
pipeline: None,
|
||||||
descriptor_pool: None,
|
|
||||||
sampler: None,
|
|
||||||
pipelines: BTreeMap::new(),
|
|
||||||
framebuffers: Vec::new(),
|
framebuffers: Vec::new(),
|
||||||
command_buffers: Vec::new(),
|
command_buffers: Vec::new(),
|
||||||
};
|
};
|
||||||
let (
|
let (render_pass, pipeline_layout, pipeline) = match create_swapchain_pipeline_bundle(
|
||||||
depth_attachment,
|
|
||||||
render_pass,
|
|
||||||
pipeline_layout,
|
|
||||||
pipelines,
|
|
||||||
descriptor_set_layout,
|
|
||||||
descriptor_pool,
|
|
||||||
descriptor_sets,
|
|
||||||
sampler,
|
|
||||||
) = match create_swapchain_pipeline_bundle(
|
|
||||||
instance,
|
|
||||||
device,
|
device,
|
||||||
swapchain.report.plan.format.format,
|
swapchain.report.plan.format.format,
|
||||||
swapchain.report.plan.extent,
|
swapchain.report.plan.extent,
|
||||||
textures,
|
|
||||||
draw_ranges,
|
|
||||||
depth_request,
|
|
||||||
) {
|
) {
|
||||||
Ok(bundle) => bundle,
|
Ok(bundle) => bundle,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -109,19 +67,13 @@ pub(super) fn create_swapchain_resources(
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let depth_view = depth_attachment.image.view;
|
|
||||||
partial.depth_attachment = Some(depth_attachment);
|
|
||||||
partial.render_pass = Some(render_pass);
|
partial.render_pass = Some(render_pass);
|
||||||
partial.pipeline_layout = Some(pipeline_layout);
|
partial.pipeline_layout = Some(pipeline_layout);
|
||||||
partial.descriptor_set_layout = Some(descriptor_set_layout);
|
partial.pipeline = Some(pipeline);
|
||||||
partial.descriptor_pool = Some(descriptor_pool);
|
|
||||||
partial.sampler = Some(sampler);
|
|
||||||
partial.pipelines = pipelines;
|
|
||||||
let framebuffers = match create_swapchain_framebuffers(
|
let framebuffers = match create_swapchain_framebuffers(
|
||||||
device,
|
device,
|
||||||
render_pass,
|
render_pass,
|
||||||
&partial.image_views,
|
&partial.image_views,
|
||||||
depth_view,
|
|
||||||
swapchain.report.plan.extent,
|
swapchain.report.plan.extent,
|
||||||
) {
|
) {
|
||||||
Ok(framebuffers) => framebuffers,
|
Ok(framebuffers) => framebuffers,
|
||||||
@@ -131,30 +83,6 @@ pub(super) fn create_swapchain_resources(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
partial.framebuffers = framebuffers;
|
partial.framebuffers = framebuffers;
|
||||||
if vk::ImageUsageFlags::from_raw(swapchain.report.plan.image_usage)
|
|
||||||
.contains(vk::ImageUsageFlags::TRANSFER_SRC)
|
|
||||||
{
|
|
||||||
let byte_len = usize::try_from(swapchain.report.plan.extent.0)
|
|
||||||
.ok()
|
|
||||||
.and_then(|width| {
|
|
||||||
usize::try_from(swapchain.report.plan.extent.1)
|
|
||||||
.ok()
|
|
||||||
.and_then(|height| width.checked_mul(height))
|
|
||||||
})
|
|
||||||
.and_then(|pixels| pixels.checked_mul(4))
|
|
||||||
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
|
|
||||||
context: "swapchain readback byte length",
|
|
||||||
})?;
|
|
||||||
for _ in &images {
|
|
||||||
match super::create_readback_buffer(instance, device, byte_len) {
|
|
||||||
Ok(buffer) => partial.readback_buffers.push(buffer),
|
|
||||||
Err(error) => {
|
|
||||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reset_reusable_command_pool(device, command_pool, reuse_command_pool)?;
|
reset_reusable_command_pool(device, command_pool, reuse_command_pool)?;
|
||||||
let command_buffers = match allocate_command_buffers(
|
let command_buffers = match allocate_command_buffers(
|
||||||
device,
|
device,
|
||||||
@@ -169,25 +97,11 @@ pub(super) fn create_swapchain_resources(
|
|||||||
};
|
};
|
||||||
partial.command_buffers = command_buffers;
|
partial.command_buffers = command_buffers;
|
||||||
let _ = (vertex_buffer, index_buffer);
|
let _ = (vertex_buffer, index_buffer);
|
||||||
let depth_attachment =
|
|
||||||
partial
|
|
||||||
.depth_attachment
|
|
||||||
.take()
|
|
||||||
.ok_or(VulkanSmokeRendererError::InvariantViolation {
|
|
||||||
context: "depth attachment ownership after swapchain setup",
|
|
||||||
})?;
|
|
||||||
Ok(VulkanSwapchainResources {
|
Ok(VulkanSwapchainResources {
|
||||||
image_views: partial.image_views,
|
image_views: partial.image_views,
|
||||||
images,
|
|
||||||
readback_buffers: partial.readback_buffers,
|
|
||||||
depth_attachment,
|
|
||||||
render_pass,
|
render_pass,
|
||||||
pipeline_layout,
|
pipeline_layout,
|
||||||
descriptor_set_layout,
|
pipeline,
|
||||||
descriptor_pool,
|
|
||||||
descriptor_sets,
|
|
||||||
sampler,
|
|
||||||
pipelines: partial.pipelines,
|
|
||||||
framebuffers: partial.framebuffers,
|
framebuffers: partial.framebuffers,
|
||||||
command_buffers: partial.command_buffers,
|
command_buffers: partial.command_buffers,
|
||||||
})
|
})
|
||||||
@@ -205,98 +119,38 @@ fn create_swapchain_image_views(
|
|||||||
Ok(image_views)
|
Ok(image_views)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
fn create_swapchain_pipeline_bundle(
|
fn create_swapchain_pipeline_bundle(
|
||||||
instance: &VulkanInstanceProbe,
|
|
||||||
device: &VulkanLogicalDeviceProbe,
|
device: &VulkanLogicalDeviceProbe,
|
||||||
format: i32,
|
format: i32,
|
||||||
extent: (u32, u32),
|
extent: (u32, u32),
|
||||||
textures: &[VulkanAllocatedImage],
|
) -> Result<(vk::RenderPass, vk::PipelineLayout, vk::Pipeline), VulkanSmokeRendererError> {
|
||||||
draw_ranges: &[super::VulkanStaticDrawRange],
|
let render_pass = create_render_pass(device, format)?;
|
||||||
depth_request: DepthStencilSupport,
|
let pipeline_layout = create_pipeline_layout(device).inspect_err(|_| {
|
||||||
) -> Result<
|
|
||||||
(
|
|
||||||
VulkanDepthAttachment,
|
|
||||||
vk::RenderPass,
|
|
||||||
vk::PipelineLayout,
|
|
||||||
BTreeMap<PipelineKey, vk::Pipeline>,
|
|
||||||
vk::DescriptorSetLayout,
|
|
||||||
vk::DescriptorPool,
|
|
||||||
Vec<vk::DescriptorSet>,
|
|
||||||
vk::Sampler,
|
|
||||||
),
|
|
||||||
VulkanSmokeRendererError,
|
|
||||||
> {
|
|
||||||
let depth_attachment = create_depth_attachment(instance, device, extent, depth_request)?;
|
|
||||||
let render_pass = match create_render_pass(device, format, depth_attachment.format) {
|
|
||||||
Ok(render_pass) => render_pass,
|
|
||||||
Err(error) => {
|
|
||||||
destroy_depth_attachment(device, &depth_attachment);
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let (descriptor_set_layout, descriptor_pool, descriptor_sets, sampler) =
|
|
||||||
create_texture_descriptor_bundle(device, textures).inspect_err(|_| {
|
|
||||||
// SAFETY: The render pass was created above on this live logical device and is destroyed on setup failure.
|
// SAFETY: The render pass was created above on this live logical device and is destroyed on setup failure.
|
||||||
unsafe { device.device().destroy_render_pass(render_pass, None) };
|
unsafe { device.device().destroy_render_pass(render_pass, None) };
|
||||||
destroy_depth_attachment(device, &depth_attachment);
|
|
||||||
})?;
|
})?;
|
||||||
let pipeline_layout =
|
let pipeline = create_graphics_pipeline(device, render_pass, pipeline_layout, extent)
|
||||||
create_pipeline_layout(device, descriptor_set_layout).inspect_err(|_| {
|
|
||||||
// SAFETY: The descriptor resources and render pass were created above and are rolled back on this device.
|
|
||||||
unsafe {
|
|
||||||
device.device().destroy_sampler(sampler, None);
|
|
||||||
device
|
|
||||||
.device()
|
|
||||||
.destroy_descriptor_pool(descriptor_pool, None);
|
|
||||||
device
|
|
||||||
.device()
|
|
||||||
.destroy_descriptor_set_layout(descriptor_set_layout, None);
|
|
||||||
device.device().destroy_render_pass(render_pass, None);
|
|
||||||
}
|
|
||||||
destroy_depth_attachment(device, &depth_attachment);
|
|
||||||
})?;
|
|
||||||
let pipelines =
|
|
||||||
create_graphics_pipeline_cache(device, render_pass, pipeline_layout, extent, draw_ranges)
|
|
||||||
.inspect_err(|_| {
|
.inspect_err(|_| {
|
||||||
// SAFETY: These objects were created above on this live logical device and are destroyed on setup failure.
|
// SAFETY: These objects were created above on this live logical device and are destroyed on setup failure.
|
||||||
unsafe {
|
unsafe {
|
||||||
device
|
device
|
||||||
.device()
|
.device()
|
||||||
.destroy_pipeline_layout(pipeline_layout, None);
|
.destroy_pipeline_layout(pipeline_layout, None);
|
||||||
device.device().destroy_sampler(sampler, None);
|
|
||||||
device
|
|
||||||
.device()
|
|
||||||
.destroy_descriptor_pool(descriptor_pool, None);
|
|
||||||
device
|
|
||||||
.device()
|
|
||||||
.destroy_descriptor_set_layout(descriptor_set_layout, None);
|
|
||||||
device.device().destroy_render_pass(render_pass, None);
|
device.device().destroy_render_pass(render_pass, None);
|
||||||
}
|
}
|
||||||
destroy_depth_attachment(device, &depth_attachment);
|
|
||||||
})?;
|
})?;
|
||||||
Ok((
|
Ok((render_pass, pipeline_layout, pipeline))
|
||||||
depth_attachment,
|
|
||||||
render_pass,
|
|
||||||
pipeline_layout,
|
|
||||||
pipelines,
|
|
||||||
descriptor_set_layout,
|
|
||||||
descriptor_pool,
|
|
||||||
descriptor_sets,
|
|
||||||
sampler,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_swapchain_framebuffers(
|
fn create_swapchain_framebuffers(
|
||||||
device: &VulkanLogicalDeviceProbe,
|
device: &VulkanLogicalDeviceProbe,
|
||||||
render_pass: vk::RenderPass,
|
render_pass: vk::RenderPass,
|
||||||
image_views: &[vk::ImageView],
|
image_views: &[vk::ImageView],
|
||||||
depth_view: vk::ImageView,
|
|
||||||
extent: (u32, u32),
|
extent: (u32, u32),
|
||||||
) -> Result<Vec<vk::Framebuffer>, VulkanSmokeRendererError> {
|
) -> Result<Vec<vk::Framebuffer>, VulkanSmokeRendererError> {
|
||||||
let mut framebuffers = Vec::with_capacity(image_views.len());
|
let mut framebuffers = Vec::with_capacity(image_views.len());
|
||||||
for image_view in image_views.iter().copied() {
|
for image_view in image_views.iter().copied() {
|
||||||
match create_framebuffer(device, render_pass, image_view, depth_view, extent) {
|
match create_framebuffer(device, render_pass, image_view, extent) {
|
||||||
Ok(framebuffer) => framebuffers.push(framebuffer),
|
Ok(framebuffer) => framebuffers.push(framebuffer),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
// SAFETY: These framebuffers were created above on this live logical device and are destroyed on setup failure.
|
// SAFETY: These framebuffers were created above on this live logical device and are destroyed on setup failure.
|
||||||
@@ -354,7 +208,6 @@ fn create_image_view(
|
|||||||
fn create_render_pass(
|
fn create_render_pass(
|
||||||
device: &VulkanLogicalDeviceProbe,
|
device: &VulkanLogicalDeviceProbe,
|
||||||
format: i32,
|
format: i32,
|
||||||
depth_format: vk::Format,
|
|
||||||
) -> Result<vk::RenderPass, VulkanSmokeRendererError> {
|
) -> Result<vk::RenderPass, VulkanSmokeRendererError> {
|
||||||
let color_attachment = vk::AttachmentDescription::default()
|
let color_attachment = vk::AttachmentDescription::default()
|
||||||
.format(vk::Format::from_raw(format))
|
.format(vk::Format::from_raw(format))
|
||||||
@@ -366,23 +219,10 @@ fn create_render_pass(
|
|||||||
let color_attachment_ref = vk::AttachmentReference::default()
|
let color_attachment_ref = vk::AttachmentReference::default()
|
||||||
.attachment(0)
|
.attachment(0)
|
||||||
.layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL);
|
.layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL);
|
||||||
let depth_attachment = vk::AttachmentDescription::default()
|
|
||||||
.format(depth_format)
|
|
||||||
.samples(vk::SampleCountFlags::TYPE_1)
|
|
||||||
.load_op(vk::AttachmentLoadOp::CLEAR)
|
|
||||||
.store_op(vk::AttachmentStoreOp::DONT_CARE)
|
|
||||||
.stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
|
|
||||||
.stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
|
|
||||||
.initial_layout(vk::ImageLayout::UNDEFINED)
|
|
||||||
.final_layout(vk::ImageLayout::DEPTH_STENCIL_ATTACHMENT_OPTIMAL);
|
|
||||||
let depth_attachment_ref = vk::AttachmentReference::default()
|
|
||||||
.attachment(1)
|
|
||||||
.layout(vk::ImageLayout::DEPTH_STENCIL_ATTACHMENT_OPTIMAL);
|
|
||||||
let color_attachments = [color_attachment_ref];
|
let color_attachments = [color_attachment_ref];
|
||||||
let subpass = vk::SubpassDescription::default()
|
let subpass = vk::SubpassDescription::default()
|
||||||
.pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
|
.pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
|
||||||
.color_attachments(&color_attachments)
|
.color_attachments(&color_attachments);
|
||||||
.depth_stencil_attachment(&depth_attachment_ref);
|
|
||||||
let dependency = vk::SubpassDependency::default()
|
let dependency = vk::SubpassDependency::default()
|
||||||
.src_subpass(vk::SUBPASS_EXTERNAL)
|
.src_subpass(vk::SUBPASS_EXTERNAL)
|
||||||
.dst_subpass(0)
|
.dst_subpass(0)
|
||||||
@@ -390,7 +230,7 @@ fn create_render_pass(
|
|||||||
.dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
|
.dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
|
||||||
.src_access_mask(vk::AccessFlags::empty())
|
.src_access_mask(vk::AccessFlags::empty())
|
||||||
.dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE);
|
.dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE);
|
||||||
let attachments = [color_attachment, depth_attachment];
|
let attachments = [color_attachment];
|
||||||
let subpasses = [subpass];
|
let subpasses = [subpass];
|
||||||
let dependencies = [dependency];
|
let dependencies = [dependency];
|
||||||
let create_info = vk::RenderPassCreateInfo::default()
|
let create_info = vk::RenderPassCreateInfo::default()
|
||||||
@@ -408,23 +248,9 @@ fn create_render_pass(
|
|||||||
|
|
||||||
fn create_pipeline_layout(
|
fn create_pipeline_layout(
|
||||||
device: &VulkanLogicalDeviceProbe,
|
device: &VulkanLogicalDeviceProbe,
|
||||||
descriptor_set_layout: vk::DescriptorSetLayout,
|
|
||||||
) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> {
|
) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> {
|
||||||
let set_layouts = [descriptor_set_layout];
|
let create_info = vk::PipelineLayoutCreateInfo::default();
|
||||||
let push_constant_ranges = [
|
// SAFETY: The pipeline layout has no descriptor sets or push constants in Stage 0 smoke.
|
||||||
vk::PushConstantRange::default()
|
|
||||||
.stage_flags(vk::ShaderStageFlags::VERTEX)
|
|
||||||
.offset(0)
|
|
||||||
.size(64),
|
|
||||||
vk::PushConstantRange::default()
|
|
||||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
|
|
||||||
.offset(64)
|
|
||||||
.size(u32::try_from(std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
|
|
||||||
];
|
|
||||||
let create_info = vk::PipelineLayoutCreateInfo::default()
|
|
||||||
.set_layouts(&set_layouts)
|
|
||||||
.push_constant_ranges(&push_constant_ranges);
|
|
||||||
// SAFETY: The descriptor-set layout belongs to this live logical device.
|
|
||||||
unsafe { device.device().create_pipeline_layout(&create_info, None) }.map_err(|error| {
|
unsafe { device.device().create_pipeline_layout(&create_info, None) }.map_err(|error| {
|
||||||
VulkanSmokeRendererError::VulkanOperation {
|
VulkanSmokeRendererError::VulkanOperation {
|
||||||
context: "vkCreatePipelineLayout",
|
context: "vkCreatePipelineLayout",
|
||||||
@@ -433,176 +259,17 @@ fn create_pipeline_layout(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
fn create_texture_descriptor_bundle(
|
|
||||||
device: &VulkanLogicalDeviceProbe,
|
|
||||||
textures: &[VulkanAllocatedImage],
|
|
||||||
) -> Result<
|
|
||||||
(
|
|
||||||
vk::DescriptorSetLayout,
|
|
||||||
vk::DescriptorPool,
|
|
||||||
Vec<vk::DescriptorSet>,
|
|
||||||
vk::Sampler,
|
|
||||||
),
|
|
||||||
VulkanSmokeRendererError,
|
|
||||||
> {
|
|
||||||
let binding = vk::DescriptorSetLayoutBinding::default()
|
|
||||||
.binding(0)
|
|
||||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
|
||||||
.descriptor_count(1)
|
|
||||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT);
|
|
||||||
let bindings = [binding];
|
|
||||||
let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings);
|
|
||||||
// SAFETY: The layout description is stack-owned and references no external memory.
|
|
||||||
let layout = unsafe {
|
|
||||||
device
|
|
||||||
.device()
|
|
||||||
.create_descriptor_set_layout(&layout_info, None)
|
|
||||||
}
|
|
||||||
.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkCreateDescriptorSetLayout",
|
|
||||||
result,
|
|
||||||
})?;
|
|
||||||
let texture_count = u32::try_from(textures.len()).map_err(|_| {
|
|
||||||
VulkanSmokeRendererError::InvariantViolation {
|
|
||||||
context: "static material texture count exceeds Vulkan descriptor limit",
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
if texture_count == 0 {
|
|
||||||
return Err(VulkanSmokeRendererError::InvariantViolation {
|
|
||||||
context: "static material texture list is empty",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let pool_size = vk::DescriptorPoolSize::default()
|
|
||||||
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
|
||||||
.descriptor_count(texture_count);
|
|
||||||
let pool_sizes = [pool_size];
|
|
||||||
let pool_info = vk::DescriptorPoolCreateInfo::default()
|
|
||||||
.pool_sizes(&pool_sizes)
|
|
||||||
.max_sets(texture_count);
|
|
||||||
let pool =
|
|
||||||
// SAFETY: The pool description is stack-owned and reserves exactly one descriptor.
|
|
||||||
unsafe { device.device().create_descriptor_pool(&pool_info, None) }.map_err(|result| {
|
|
||||||
// SAFETY: The layout was created above on this device and is rolled back once.
|
|
||||||
unsafe { device.device().destroy_descriptor_set_layout(layout, None) };
|
|
||||||
VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkCreateDescriptorPool",
|
|
||||||
result,
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let layouts = vec![layout; textures.len()];
|
|
||||||
let allocate_info = vk::DescriptorSetAllocateInfo::default()
|
|
||||||
.descriptor_pool(pool)
|
|
||||||
.set_layouts(&layouts);
|
|
||||||
// SAFETY: The pool and layout are live on this logical device.
|
|
||||||
let descriptor_sets = unsafe { device.device().allocate_descriptor_sets(&allocate_info) }
|
|
||||||
.map_err(|result| {
|
|
||||||
// SAFETY: Resources were created above on this device and are rolled back once.
|
|
||||||
unsafe {
|
|
||||||
device.device().destroy_descriptor_pool(pool, None);
|
|
||||||
device.device().destroy_descriptor_set_layout(layout, None);
|
|
||||||
};
|
|
||||||
VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkAllocateDescriptorSets",
|
|
||||||
result,
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let sampler_info = vk::SamplerCreateInfo::default()
|
|
||||||
.mag_filter(vk::Filter::LINEAR)
|
|
||||||
.min_filter(vk::Filter::LINEAR)
|
|
||||||
.mipmap_mode(vk::SamplerMipmapMode::LINEAR)
|
|
||||||
.address_mode_u(vk::SamplerAddressMode::REPEAT)
|
|
||||||
.address_mode_v(vk::SamplerAddressMode::REPEAT)
|
|
||||||
.address_mode_w(vk::SamplerAddressMode::REPEAT)
|
|
||||||
.max_lod(0.0);
|
|
||||||
let sampler =
|
|
||||||
// SAFETY: The sampler create info is stack-owned and has no unsupported optional features.
|
|
||||||
unsafe { device.device().create_sampler(&sampler_info, None) }.map_err(|result| {
|
|
||||||
// SAFETY: Resources were created above on this device and are rolled back once.
|
|
||||||
unsafe {
|
|
||||||
device.device().destroy_descriptor_pool(pool, None);
|
|
||||||
device.device().destroy_descriptor_set_layout(layout, None);
|
|
||||||
};
|
|
||||||
VulkanSmokeRendererError::VulkanOperation {
|
|
||||||
context: "vkCreateSampler",
|
|
||||||
result,
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let image_infos = textures
|
|
||||||
.iter()
|
|
||||||
.map(|texture| {
|
|
||||||
vk::DescriptorImageInfo::default()
|
|
||||||
.sampler(sampler)
|
|
||||||
.image_view(texture.view)
|
|
||||||
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let writes = descriptor_sets
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.zip(image_infos.iter())
|
|
||||||
.map(|(descriptor_set, image_info)| {
|
|
||||||
vk::WriteDescriptorSet::default()
|
|
||||||
.dst_set(descriptor_set)
|
|
||||||
.dst_binding(0)
|
|
||||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
|
||||||
.image_info(std::slice::from_ref(image_info))
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
// SAFETY: Descriptor sets, sampler and image views are live; every texture upload completed its shader-read transition.
|
|
||||||
unsafe { device.device().update_descriptor_sets(&writes, &[]) };
|
|
||||||
Ok((layout, pool, descriptor_sets, sampler))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::cast_precision_loss)]
|
#[allow(clippy::cast_precision_loss)]
|
||||||
fn extent_component_to_f32(value: u32) -> f32 {
|
fn extent_component_to_f32(value: u32) -> f32 {
|
||||||
value as f32
|
value as f32
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
fn create_graphics_pipeline_cache(
|
|
||||||
device: &VulkanLogicalDeviceProbe,
|
|
||||||
render_pass: vk::RenderPass,
|
|
||||||
pipeline_layout: vk::PipelineLayout,
|
|
||||||
extent: (u32, u32),
|
|
||||||
draw_ranges: &[super::VulkanStaticDrawRange],
|
|
||||||
) -> Result<BTreeMap<PipelineKey, vk::Pipeline>, VulkanSmokeRendererError> {
|
|
||||||
let mut pipelines = BTreeMap::new();
|
|
||||||
for range in draw_ranges {
|
|
||||||
let key = range.pipeline_key();
|
|
||||||
if pipelines.contains_key(&key) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let pipeline = match create_graphics_pipeline(
|
|
||||||
device,
|
|
||||||
render_pass,
|
|
||||||
pipeline_layout,
|
|
||||||
extent,
|
|
||||||
range.pipeline_state,
|
|
||||||
) {
|
|
||||||
Ok(pipeline) => pipeline,
|
|
||||||
Err(error) => {
|
|
||||||
// SAFETY: All previously created pipelines belong to this live device and are rolled back with their bundle.
|
|
||||||
unsafe {
|
|
||||||
for pipeline in pipelines.into_values() {
|
|
||||||
device.device().destroy_pipeline(pipeline, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
pipelines.insert(key, pipeline);
|
|
||||||
}
|
|
||||||
Ok(pipelines)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
fn create_graphics_pipeline(
|
fn create_graphics_pipeline(
|
||||||
device: &VulkanLogicalDeviceProbe,
|
device: &VulkanLogicalDeviceProbe,
|
||||||
render_pass: vk::RenderPass,
|
render_pass: vk::RenderPass,
|
||||||
pipeline_layout: vk::PipelineLayout,
|
pipeline_layout: vk::PipelineLayout,
|
||||||
extent: (u32, u32),
|
extent: (u32, u32),
|
||||||
state: LegacyPipelineState,
|
|
||||||
) -> Result<vk::Pipeline, VulkanSmokeRendererError> {
|
) -> Result<vk::Pipeline, VulkanSmokeRendererError> {
|
||||||
let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?;
|
let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?;
|
||||||
let fragment_shader = match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS) {
|
let fragment_shader = match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS) {
|
||||||
@@ -626,24 +293,19 @@ fn create_graphics_pipeline(
|
|||||||
];
|
];
|
||||||
let vertex_binding = vk::VertexInputBindingDescription::default()
|
let vertex_binding = vk::VertexInputBindingDescription::default()
|
||||||
.binding(0)
|
.binding(0)
|
||||||
.stride(u32::try_from(8 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX))
|
.stride(u32::try_from(5 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX))
|
||||||
.input_rate(vk::VertexInputRate::VERTEX);
|
.input_rate(vk::VertexInputRate::VERTEX);
|
||||||
let vertex_attributes = [
|
let vertex_attributes = [
|
||||||
vk::VertexInputAttributeDescription::default()
|
vk::VertexInputAttributeDescription::default()
|
||||||
.binding(0)
|
.binding(0)
|
||||||
.location(0)
|
.location(0)
|
||||||
.format(vk::Format::R32G32B32_SFLOAT)
|
.format(vk::Format::R32G32_SFLOAT)
|
||||||
.offset(0),
|
.offset(0),
|
||||||
vk::VertexInputAttributeDescription::default()
|
vk::VertexInputAttributeDescription::default()
|
||||||
.binding(0)
|
.binding(0)
|
||||||
.location(1)
|
.location(1)
|
||||||
.format(vk::Format::R32G32B32_SFLOAT)
|
.format(vk::Format::R32G32B32_SFLOAT)
|
||||||
.offset(u32::try_from(3 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
|
.offset(u32::try_from(2 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
|
||||||
vk::VertexInputAttributeDescription::default()
|
|
||||||
.binding(0)
|
|
||||||
.location(2)
|
|
||||||
.format(vk::Format::R32G32_SFLOAT)
|
|
||||||
.offset(u32::try_from(6 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
|
|
||||||
];
|
];
|
||||||
let vertex_bindings = [vertex_binding];
|
let vertex_bindings = [vertex_binding];
|
||||||
let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default()
|
let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default()
|
||||||
@@ -675,22 +337,12 @@ fn create_graphics_pipeline(
|
|||||||
.rasterizer_discard_enable(false)
|
.rasterizer_discard_enable(false)
|
||||||
.polygon_mode(vk::PolygonMode::FILL)
|
.polygon_mode(vk::PolygonMode::FILL)
|
||||||
.line_width(1.0)
|
.line_width(1.0)
|
||||||
.cull_mode(match state.cull {
|
.cull_mode(vk::CullModeFlags::BACK)
|
||||||
LegacyCullMode::Disabled => vk::CullModeFlags::NONE,
|
|
||||||
LegacyCullMode::BackFace => vk::CullModeFlags::BACK,
|
|
||||||
LegacyCullMode::FrontFace => vk::CullModeFlags::FRONT,
|
|
||||||
})
|
|
||||||
.front_face(vk::FrontFace::CLOCKWISE)
|
.front_face(vk::FrontFace::CLOCKWISE)
|
||||||
.depth_bias_enable(false);
|
.depth_bias_enable(false);
|
||||||
let multisample_state = vk::PipelineMultisampleStateCreateInfo::default()
|
let multisample_state = vk::PipelineMultisampleStateCreateInfo::default()
|
||||||
.sample_shading_enable(false)
|
.sample_shading_enable(false)
|
||||||
.rasterization_samples(vk::SampleCountFlags::TYPE_1);
|
.rasterization_samples(vk::SampleCountFlags::TYPE_1);
|
||||||
let depth_stencil_state = vk::PipelineDepthStencilStateCreateInfo::default()
|
|
||||||
.depth_test_enable(state.depth != LegacyDepthMode::Disabled)
|
|
||||||
.depth_write_enable(state.depth == LegacyDepthMode::TestWrite)
|
|
||||||
.depth_compare_op(vk::CompareOp::LESS_OR_EQUAL)
|
|
||||||
.depth_bounds_test_enable(false)
|
|
||||||
.stencil_test_enable(false);
|
|
||||||
let color_blend_attachment = vk::PipelineColorBlendAttachmentState::default()
|
let color_blend_attachment = vk::PipelineColorBlendAttachmentState::default()
|
||||||
.color_write_mask(
|
.color_write_mask(
|
||||||
vk::ColorComponentFlags::R
|
vk::ColorComponentFlags::R
|
||||||
@@ -698,13 +350,7 @@ fn create_graphics_pipeline(
|
|||||||
| vk::ColorComponentFlags::B
|
| vk::ColorComponentFlags::B
|
||||||
| vk::ColorComponentFlags::A,
|
| vk::ColorComponentFlags::A,
|
||||||
)
|
)
|
||||||
.blend_enable(state.blend == LegacyBlendMode::SourceAlpha)
|
.blend_enable(false);
|
||||||
.src_color_blend_factor(vk::BlendFactor::SRC_ALPHA)
|
|
||||||
.dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
|
|
||||||
.color_blend_op(vk::BlendOp::ADD)
|
|
||||||
.src_alpha_blend_factor(vk::BlendFactor::ONE)
|
|
||||||
.dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
|
|
||||||
.alpha_blend_op(vk::BlendOp::ADD);
|
|
||||||
let color_blend_attachments = [color_blend_attachment];
|
let color_blend_attachments = [color_blend_attachment];
|
||||||
let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default()
|
let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default()
|
||||||
.logic_op_enable(false)
|
.logic_op_enable(false)
|
||||||
@@ -716,7 +362,6 @@ fn create_graphics_pipeline(
|
|||||||
.viewport_state(&viewport_state)
|
.viewport_state(&viewport_state)
|
||||||
.rasterization_state(&rasterization_state)
|
.rasterization_state(&rasterization_state)
|
||||||
.multisample_state(&multisample_state)
|
.multisample_state(&multisample_state)
|
||||||
.depth_stencil_state(&depth_stencil_state)
|
|
||||||
.color_blend_state(&color_blend_state)
|
.color_blend_state(&color_blend_state)
|
||||||
.layout(pipeline_layout)
|
.layout(pipeline_layout)
|
||||||
.render_pass(render_pass)
|
.render_pass(render_pass)
|
||||||
@@ -759,10 +404,9 @@ fn create_framebuffer(
|
|||||||
device: &VulkanLogicalDeviceProbe,
|
device: &VulkanLogicalDeviceProbe,
|
||||||
render_pass: vk::RenderPass,
|
render_pass: vk::RenderPass,
|
||||||
image_view: vk::ImageView,
|
image_view: vk::ImageView,
|
||||||
depth_view: vk::ImageView,
|
|
||||||
extent: (u32, u32),
|
extent: (u32, u32),
|
||||||
) -> Result<vk::Framebuffer, VulkanSmokeRendererError> {
|
) -> Result<vk::Framebuffer, VulkanSmokeRendererError> {
|
||||||
let attachments = [image_view, depth_view];
|
let attachments = [image_view];
|
||||||
let create_info = vk::FramebufferCreateInfo::default()
|
let create_info = vk::FramebufferCreateInfo::default()
|
||||||
.render_pass(render_pass)
|
.render_pass(render_pass)
|
||||||
.attachments(&attachments)
|
.attachments(&attachments)
|
||||||
@@ -811,30 +455,16 @@ pub(super) fn destroy_swapchain_resources(
|
|||||||
for framebuffer in resources.framebuffers {
|
for framebuffer in resources.framebuffers {
|
||||||
device.device().destroy_framebuffer(framebuffer, None);
|
device.device().destroy_framebuffer(framebuffer, None);
|
||||||
}
|
}
|
||||||
for pipeline in resources.pipelines.into_values() {
|
device.device().destroy_pipeline(resources.pipeline, None);
|
||||||
device.device().destroy_pipeline(pipeline, None);
|
|
||||||
}
|
|
||||||
device
|
device
|
||||||
.device()
|
.device()
|
||||||
.destroy_pipeline_layout(resources.pipeline_layout, None);
|
.destroy_pipeline_layout(resources.pipeline_layout, None);
|
||||||
device.device().destroy_sampler(resources.sampler, None);
|
|
||||||
device
|
|
||||||
.device()
|
|
||||||
.destroy_descriptor_pool(resources.descriptor_pool, None);
|
|
||||||
device
|
|
||||||
.device()
|
|
||||||
.destroy_descriptor_set_layout(resources.descriptor_set_layout, None);
|
|
||||||
device
|
device
|
||||||
.device()
|
.device()
|
||||||
.destroy_render_pass(resources.render_pass, None);
|
.destroy_render_pass(resources.render_pass, None);
|
||||||
destroy_depth_attachment(device, &resources.depth_attachment);
|
|
||||||
for image_view in resources.image_views {
|
for image_view in resources.image_views {
|
||||||
device.device().destroy_image_view(image_view, None);
|
device.device().destroy_image_view(image_view, None);
|
||||||
}
|
}
|
||||||
for buffer in resources.readback_buffers {
|
|
||||||
device.device().destroy_buffer(buffer.buffer, None);
|
|
||||||
device.device().free_memory(buffer.memory, None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,7 +483,7 @@ fn destroy_partial_swapchain_resources(
|
|||||||
for framebuffer in partial.framebuffers {
|
for framebuffer in partial.framebuffers {
|
||||||
device.device().destroy_framebuffer(framebuffer, None);
|
device.device().destroy_framebuffer(framebuffer, None);
|
||||||
}
|
}
|
||||||
for pipeline in partial.pipelines.into_values() {
|
if let Some(pipeline) = partial.pipeline {
|
||||||
device.device().destroy_pipeline(pipeline, None);
|
device.device().destroy_pipeline(pipeline, None);
|
||||||
}
|
}
|
||||||
if let Some(pipeline_layout) = partial.pipeline_layout {
|
if let Some(pipeline_layout) = partial.pipeline_layout {
|
||||||
@@ -861,31 +491,11 @@ fn destroy_partial_swapchain_resources(
|
|||||||
.device()
|
.device()
|
||||||
.destroy_pipeline_layout(pipeline_layout, None);
|
.destroy_pipeline_layout(pipeline_layout, None);
|
||||||
}
|
}
|
||||||
if let Some(sampler) = partial.sampler {
|
|
||||||
device.device().destroy_sampler(sampler, None);
|
|
||||||
}
|
|
||||||
if let Some(descriptor_pool) = partial.descriptor_pool {
|
|
||||||
device
|
|
||||||
.device()
|
|
||||||
.destroy_descriptor_pool(descriptor_pool, None);
|
|
||||||
}
|
|
||||||
if let Some(descriptor_set_layout) = partial.descriptor_set_layout {
|
|
||||||
device
|
|
||||||
.device()
|
|
||||||
.destroy_descriptor_set_layout(descriptor_set_layout, None);
|
|
||||||
}
|
|
||||||
if let Some(render_pass) = partial.render_pass {
|
if let Some(render_pass) = partial.render_pass {
|
||||||
device.device().destroy_render_pass(render_pass, None);
|
device.device().destroy_render_pass(render_pass, None);
|
||||||
}
|
}
|
||||||
if let Some(depth_attachment) = partial.depth_attachment {
|
|
||||||
destroy_depth_attachment(device, &depth_attachment);
|
|
||||||
}
|
|
||||||
for image_view in partial.image_views {
|
for image_view in partial.image_views {
|
||||||
device.device().destroy_image_view(image_view, None);
|
device.device().destroy_image_view(image_view, None);
|
||||||
}
|
}
|
||||||
for buffer in partial.readback_buffers {
|
|
||||||
device.device().destroy_buffer(buffer.buffer, None);
|
|
||||||
device.device().free_memory(buffer.memory, None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ use crate::shader_manifest::{
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
use fparkan_platform::{DepthStencilSupport, RenderRequest};
|
use fparkan_platform::{DepthStencilSupport, RenderRequest};
|
||||||
use fparkan_render::{
|
use fparkan_render::{
|
||||||
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, LegacyPipelineState, RenderCommand,
|
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderCommand, RenderPhase,
|
||||||
RenderPhase,
|
|
||||||
};
|
};
|
||||||
use fparkan_render::{RenderBackend, RenderError};
|
use fparkan_render::{RenderBackend, RenderError};
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ fn planning_backend_tracks_render_request_and_simulated_present() -> Result<(),
|
|||||||
object_id: None,
|
object_id: None,
|
||||||
mesh: GpuMeshId(1),
|
mesh: GpuMeshId(1),
|
||||||
material: GpuMaterialId(2),
|
material: GpuMaterialId(2),
|
||||||
pipeline_key: LegacyPipelineState::default().into(),
|
|
||||||
transform: [1.0; 16],
|
transform: [1.0; 16],
|
||||||
range: IndexRange { start: 0, count: 3 },
|
range: IndexRange { start: 0, count: 3 },
|
||||||
stable_order: 7,
|
stable_order: 7,
|
||||||
@@ -77,7 +75,6 @@ fn frame_submission_plan_json_is_stable() -> Result<(), RenderError> {
|
|||||||
object_id: None,
|
object_id: None,
|
||||||
mesh: GpuMeshId(1),
|
mesh: GpuMeshId(1),
|
||||||
material: GpuMaterialId(2),
|
material: GpuMaterialId(2),
|
||||||
pipeline_key: LegacyPipelineState::default().into(),
|
|
||||||
transform: [1.0; 16],
|
transform: [1.0; 16],
|
||||||
range: IndexRange { start: 0, count: 3 },
|
range: IndexRange { start: 0, count: 3 },
|
||||||
stable_order: 7,
|
stable_order: 7,
|
||||||
@@ -94,7 +91,6 @@ fn frame_submission_plan_json_is_stable() -> Result<(), RenderError> {
|
|||||||
},
|
},
|
||||||
present_mode: vk::PresentModeKHR::FIFO.as_raw(),
|
present_mode: vk::PresentModeKHR::FIFO.as_raw(),
|
||||||
image_count: 3,
|
image_count: 3,
|
||||||
image_usage: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let plan = plan_vulkan_frame_submission(&swapchain, &commands)?;
|
let plan = plan_vulkan_frame_submission(&swapchain, &commands)?;
|
||||||
@@ -321,45 +317,6 @@ fn capability_gate_respects_request_specific_depth_profiles() {
|
|||||||
assert!(report.rejected_devices.is_empty());
|
assert!(report.rejected_devices.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn depth_attachment_selection_is_canonical_and_request_scoped() {
|
|
||||||
let supported = [
|
|
||||||
vk::Format::D32_SFLOAT_S8_UINT.as_raw(),
|
|
||||||
vk::Format::D24_UNORM_S8_UINT.as_raw(),
|
|
||||||
vk::Format::D32_SFLOAT.as_raw(),
|
|
||||||
];
|
|
||||||
assert_eq!(
|
|
||||||
select_depth_stencil_attachment_format(
|
|
||||||
&supported,
|
|
||||||
DepthStencilSupport {
|
|
||||||
depth_bits: 24,
|
|
||||||
stencil_bits: 8,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Some(vk::Format::D24_UNORM_S8_UINT.as_raw())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
select_depth_stencil_attachment_format(
|
|
||||||
&supported,
|
|
||||||
DepthStencilSupport {
|
|
||||||
depth_bits: 32,
|
|
||||||
stencil_bits: 0,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Some(vk::Format::D32_SFLOAT.as_raw())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
select_depth_stencil_attachment_format(
|
|
||||||
&supported,
|
|
||||||
DepthStencilSupport {
|
|
||||||
depth_bits: 0,
|
|
||||||
stencil_bits: 0,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn capability_report_preserves_informational_sampled_formats_and_limits() {
|
fn capability_report_preserves_informational_sampled_formats_and_limits() {
|
||||||
let report = select_physical_device(&[device(
|
let report = select_physical_device(&[device(
|
||||||
@@ -544,23 +501,6 @@ fn swapchain_plan_prefers_srgb_mailbox_and_clamps_extent() {
|
|||||||
assert_eq!(plan.present_mode, vk::PresentModeKHR::MAILBOX.as_raw());
|
assert_eq!(plan.present_mode, vk::PresentModeKHR::MAILBOX.as_raw());
|
||||||
assert_eq!(plan.extent, (1024, 720));
|
assert_eq!(plan.extent, (1024, 720));
|
||||||
assert_eq!(plan.image_count, 3);
|
assert_eq!(plan.image_count, 3);
|
||||||
assert_eq!(
|
|
||||||
plan.image_usage,
|
|
||||||
vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn swapchain_plan_enables_transfer_source_when_surface_supports_it() {
|
|
||||||
let mut request = swapchain_request();
|
|
||||||
request.capabilities.supported_usage_flags |= vk::ImageUsageFlags::TRANSFER_SRC.as_raw();
|
|
||||||
|
|
||||||
let plan = plan_vulkan_swapchain(&request).expect("swapchain plan");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
plan.image_usage,
|
|
||||||
(vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_SRC).as_raw()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -624,7 +564,7 @@ fn swapchain_plan_json_and_recreation_reports_are_stable() {
|
|||||||
let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan");
|
let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
render_swapchain_plan_json(&plan),
|
render_swapchain_plan_json(&plan),
|
||||||
"{\"schema\":1,\"extent\":[1024,720],\"format\":50,\"color_space\":0,\"present_mode\":1,\"image_count\":3,\"image_usage\":16}"
|
"{\"schema\":1,\"extent\":[1024,720],\"format\":50,\"color_space\":0,\"present_mode\":1,\"image_count\":3}"
|
||||||
);
|
);
|
||||||
|
|
||||||
let report = swapchain_recreation_report(
|
let report = swapchain_recreation_report(
|
||||||
@@ -669,13 +609,13 @@ fn triangle_shader_manifest_hashes_are_stable() {
|
|||||||
TRIANGLE_VERTEX_SOURCE_SHA256
|
TRIANGLE_VERTEX_SOURCE_SHA256
|
||||||
);
|
);
|
||||||
assert_eq!(report.modules[0].spirv_path, TRIANGLE_VERTEX_SPIRV_PATH);
|
assert_eq!(report.modules[0].spirv_path, TRIANGLE_VERTEX_SPIRV_PATH);
|
||||||
assert_eq!(report.modules[0].word_count, 358);
|
assert_eq!(report.modules[0].word_count, 253);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report.modules[0].sha256,
|
report.modules[0].sha256,
|
||||||
"4e2051ac43b933a57e5557e596bcc95952b4efbfcb45315180f89581e094398d"
|
"4d3ceca7b42ebc971d831b0a0d816457397bd9aeda47fb8d44c4b1aeaa5e7ba0"
|
||||||
);
|
);
|
||||||
assert_eq!(report.modules[0].descriptor_sets, 0);
|
assert_eq!(report.modules[0].descriptor_sets, 0);
|
||||||
assert_eq!(report.modules[0].push_constant_bytes, 64);
|
assert_eq!(report.modules[0].push_constant_bytes, 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report.modules[0].compile_command,
|
report.modules[0].compile_command,
|
||||||
TRIANGLE_VERTEX_COMPILE_COMMAND
|
TRIANGLE_VERTEX_COMPILE_COMMAND
|
||||||
@@ -687,11 +627,11 @@ fn triangle_shader_manifest_hashes_are_stable() {
|
|||||||
assert!(!report.modules[0].interface_hash.is_empty());
|
assert!(!report.modules[0].interface_hash.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report.modules[1].sha256,
|
report.modules[1].sha256,
|
||||||
"8ab7bf835e166892b04b10fa1100258daf355d2f693ad311d014dbee22de5c7b"
|
"5a7441be03cd3c25d557268b2e58d5aa50504c87bffcb4c3fd7cbcf007db0b96"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
report.manifest_hash,
|
report.manifest_hash,
|
||||||
"cf471972978ddb5c8919711ad64c93d0d64e18b1f590a89ce014f1e2a743cfb5"
|
"11e3feb65200ebd2ac87b7e776e9c6433a5da9d71a651bfadea89a51be17ff05"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// Vulkan backend migration readiness.
|
/// Vulkan backend migration readiness.
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum VulkanPlanningBackendState {
|
pub enum VulkanPlanningBackendState {
|
||||||
/// Planning facade is configured and able to accept command lists.
|
/// Planning facade is configured and able to accept command lists.
|
||||||
#[default]
|
|
||||||
Configured,
|
Configured,
|
||||||
/// Adapter is tracking a recoverable runtime surface/depth pipeline fault.
|
/// Adapter is tracking a recoverable runtime surface/depth pipeline fault.
|
||||||
Degraded,
|
Degraded,
|
||||||
@@ -21,6 +20,12 @@ pub enum VulkanPlanningBackendState {
|
|||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for VulkanPlanningBackendState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Configured
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Diagnostics for planning-facade request tracking.
|
/// Diagnostics for planning-facade request tracking.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct VulkanPlanningRequestReport {
|
pub struct VulkanPlanningRequestReport {
|
||||||
@@ -163,6 +168,5 @@ fn default_stage0_swapchain_plan() -> VulkanSwapchainPlan {
|
|||||||
},
|
},
|
||||||
present_mode: vk::PresentModeKHR::FIFO.as_raw(),
|
present_mode: vk::PresentModeKHR::FIFO.as_raw(),
|
||||||
image_count: 2,
|
image_count: 2,
|
||||||
image_usage: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,8 +96,6 @@ pub struct VulkanSwapchainPlan {
|
|||||||
pub present_mode: i32,
|
pub present_mode: i32,
|
||||||
/// Selected image count.
|
/// Selected image count.
|
||||||
pub image_count: u32,
|
pub image_count: u32,
|
||||||
/// Selected image-usage flags as raw Vulkan bits.
|
|
||||||
pub image_usage: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Swapchain planning error.
|
/// Swapchain planning error.
|
||||||
@@ -427,7 +425,6 @@ pub fn plan_vulkan_swapchain(
|
|||||||
format,
|
format,
|
||||||
present_mode,
|
present_mode,
|
||||||
image_count: select_image_count(request.capabilities),
|
image_count: select_image_count(request.capabilities),
|
||||||
image_usage: select_swapchain_image_usage(request.capabilities),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +521,6 @@ pub fn render_swapchain_plan_json(plan: &VulkanSwapchainPlan) -> String {
|
|||||||
color_space: i32,
|
color_space: i32,
|
||||||
present_mode: i32,
|
present_mode: i32,
|
||||||
image_count: u32,
|
image_count: u32,
|
||||||
image_usage: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize_json_or_fallback(
|
serialize_json_or_fallback(
|
||||||
@@ -535,9 +531,8 @@ pub fn render_swapchain_plan_json(plan: &VulkanSwapchainPlan) -> String {
|
|||||||
color_space: plan.format.color_space,
|
color_space: plan.format.color_space,
|
||||||
present_mode: plan.present_mode,
|
present_mode: plan.present_mode,
|
||||||
image_count: plan.image_count,
|
image_count: plan.image_count,
|
||||||
image_usage: plan.image_usage,
|
|
||||||
},
|
},
|
||||||
"{\"schema\":0,\"extent\":[0,0],\"format\":0,\"color_space\":0,\"present_mode\":0,\"image_count\":0,\"image_usage\":0}",
|
"{\"schema\":0,\"extent\":[0,0],\"format\":0,\"color_space\":0,\"present_mode\":0,\"image_count\":0}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,34 +806,11 @@ fn supports_depth_stencil_request(
|
|||||||
if depth.depth_bits == 0 && depth.stencil_bits == 0 {
|
if depth.depth_bits == 0 && depth.stencil_bits == 0 {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
select_depth_stencil_attachment_format(&device.supported_depth_stencil_formats, depth).is_some()
|
required_depth_stencil_formats(depth).iter().any(|format| {
|
||||||
}
|
device
|
||||||
|
.supported_depth_stencil_formats
|
||||||
fn select_swapchain_image_usage(capabilities: VulkanSwapchainSurfaceCapabilities) -> u32 {
|
.contains(&format.as_raw())
|
||||||
let mut usage = vk::ImageUsageFlags::COLOR_ATTACHMENT;
|
})
|
||||||
let supported = vk::ImageUsageFlags::from_raw(capabilities.supported_usage_flags);
|
|
||||||
if supported.contains(vk::ImageUsageFlags::TRANSFER_SRC) {
|
|
||||||
usage |= vk::ImageUsageFlags::TRANSFER_SRC;
|
|
||||||
}
|
|
||||||
usage.as_raw()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Selects the first canonical depth/stencil attachment format supported by a device.
|
|
||||||
///
|
|
||||||
/// The order is part of the Windows Vulkan compatibility contract and is shared
|
|
||||||
/// by device admission and future render-pass allocation.
|
|
||||||
#[must_use]
|
|
||||||
pub fn select_depth_stencil_attachment_format(
|
|
||||||
supported_formats: &[i32],
|
|
||||||
depth: DepthStencilSupport,
|
|
||||||
) -> Option<i32> {
|
|
||||||
if depth.depth_bits == 0 && depth.stencil_bits == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
required_depth_stencil_formats(depth)
|
|
||||||
.iter()
|
|
||||||
.map(|format| format.as_raw())
|
|
||||||
.find(|format| supported_formats.contains(format))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn informational_capabilities(
|
fn informational_capabilities(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pub(crate) const SPIRV_VALIDATOR_BINARY_SHA256: &str =
|
|||||||
pub(crate) const TRIANGLE_VERTEX_SOURCE_PATH: &str =
|
pub(crate) const TRIANGLE_VERTEX_SOURCE_PATH: &str =
|
||||||
"adapters/fparkan-render-vulkan/shaders/triangle.vert";
|
"adapters/fparkan-render-vulkan/shaders/triangle.vert";
|
||||||
pub(crate) const TRIANGLE_VERTEX_SOURCE_SHA256: &str =
|
pub(crate) const TRIANGLE_VERTEX_SOURCE_SHA256: &str =
|
||||||
"fe3721202477220d2d9677b6572d7f3baecb374e94f04eea9d7286ed5e98b6c9";
|
"1e57f14d193fc61457c0749081c452ad25669998913107df12f3ccc3c33e0341";
|
||||||
pub(crate) const TRIANGLE_VERTEX_SPIRV_PATH: &str =
|
pub(crate) const TRIANGLE_VERTEX_SPIRV_PATH: &str =
|
||||||
"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
|
"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
|
||||||
pub(crate) const TRIANGLE_VERTEX_COMPILE_COMMAND: &str =
|
pub(crate) const TRIANGLE_VERTEX_COMPILE_COMMAND: &str =
|
||||||
@@ -29,7 +29,7 @@ pub(crate) const TRIANGLE_VERTEX_VALIDATE_COMMAND: &str =
|
|||||||
"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
|
"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
|
||||||
const TRIANGLE_FRAGMENT_SOURCE_PATH: &str = "adapters/fparkan-render-vulkan/shaders/triangle.frag";
|
const TRIANGLE_FRAGMENT_SOURCE_PATH: &str = "adapters/fparkan-render-vulkan/shaders/triangle.frag";
|
||||||
const TRIANGLE_FRAGMENT_SOURCE_SHA256: &str =
|
const TRIANGLE_FRAGMENT_SOURCE_SHA256: &str =
|
||||||
"fb3ed435af48e4fb4a817f160b467f7a561c7c547c87b6f0c1e89f5f58af7329";
|
"f19e74d001d07fb537d4b0f9e621f9b8bc40eeb68816130220853abea6bd4445";
|
||||||
const TRIANGLE_FRAGMENT_SPIRV_PATH: &str =
|
const TRIANGLE_FRAGMENT_SPIRV_PATH: &str =
|
||||||
"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
|
"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
|
||||||
const TRIANGLE_FRAGMENT_COMPILE_COMMAND: &str =
|
const TRIANGLE_FRAGMENT_COMPILE_COMMAND: &str =
|
||||||
@@ -213,7 +213,7 @@ pub fn triangle_shader_manifest() -> Vec<VulkanShaderModuleManifest> {
|
|||||||
stage: VulkanShaderStage::Vertex,
|
stage: VulkanShaderStage::Vertex,
|
||||||
entry_point: "main",
|
entry_point: "main",
|
||||||
descriptor_sets: 0,
|
descriptor_sets: 0,
|
||||||
push_constant_bytes: 64,
|
push_constant_bytes: 0,
|
||||||
source_path: TRIANGLE_VERTEX_SOURCE_PATH,
|
source_path: TRIANGLE_VERTEX_SOURCE_PATH,
|
||||||
source_sha256: TRIANGLE_VERTEX_SOURCE_SHA256,
|
source_sha256: TRIANGLE_VERTEX_SOURCE_SHA256,
|
||||||
spirv_path: TRIANGLE_VERTEX_SPIRV_PATH,
|
spirv_path: TRIANGLE_VERTEX_SPIRV_PATH,
|
||||||
@@ -225,8 +225,8 @@ pub fn triangle_shader_manifest() -> Vec<VulkanShaderModuleManifest> {
|
|||||||
name: "triangle.frag",
|
name: "triangle.frag",
|
||||||
stage: VulkanShaderStage::Fragment,
|
stage: VulkanShaderStage::Fragment,
|
||||||
entry_point: "main",
|
entry_point: "main",
|
||||||
descriptor_sets: 1,
|
descriptor_sets: 0,
|
||||||
push_constant_bytes: 68,
|
push_constant_bytes: 0,
|
||||||
source_path: TRIANGLE_FRAGMENT_SOURCE_PATH,
|
source_path: TRIANGLE_FRAGMENT_SOURCE_PATH,
|
||||||
source_sha256: TRIANGLE_FRAGMENT_SOURCE_SHA256,
|
source_sha256: TRIANGLE_FRAGMENT_SOURCE_SHA256,
|
||||||
spirv_path: TRIANGLE_FRAGMENT_SPIRV_PATH,
|
spirv_path: TRIANGLE_FRAGMENT_SPIRV_PATH,
|
||||||
|
|||||||
@@ -6,17 +6,13 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
|
fparkan-assets = { path = "../../crates/fparkan-assets" }
|
||||||
fparkan-corpus = { path = "../../crates/fparkan-corpus", version = "0.1.0" }
|
fparkan-corpus = { path = "../../crates/fparkan-corpus" }
|
||||||
fparkan-prototype = { path = "../../crates/fparkan-prototype", version = "0.1.0" }
|
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
|
||||||
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
|
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
|
||||||
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
|
fparkan-resource = { path = "../../crates/fparkan-resource" }
|
||||||
fparkan-resource = { path = "../../crates/fparkan-resource", version = "0.1.0" }
|
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||||
fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
|
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||||
fparkan-script = { path = "../../crates/fparkan-script", version = "0.1.0" }
|
|
||||||
fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
+74
-781
@@ -21,241 +21,20 @@
|
|||||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||||
//! `FParkan` command-line tools.
|
//! `FParkan` command-line tools.
|
||||||
|
|
||||||
use fparkan_assets::{
|
use fparkan_assets::extend_graph_report_with_visual_dependencies;
|
||||||
decode_mission_payload, extend_graph_report_with_visual_dependencies, TmaProfile,
|
|
||||||
};
|
|
||||||
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
|
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
|
||||||
use fparkan_inspection::{
|
use fparkan_inspection::inspect_archive_file;
|
||||||
inspect_archive_file, inspect_land_msh_bounds_file, inspect_model_from_root,
|
use fparkan_inspection::ArchiveInspection;
|
||||||
inspect_wear_from_root, load_land_msh_from_path, ArchiveInspection, ModelInspection,
|
|
||||||
};
|
|
||||||
use fparkan_path::{normalize_relative, PathPolicy};
|
|
||||||
use fparkan_prototype::build_prototype_graph_report;
|
use fparkan_prototype::build_prototype_graph_report;
|
||||||
use fparkan_resource::{resource_name, CachedResourceRepository};
|
use fparkan_resource::{resource_name, CachedResourceRepository};
|
||||||
use fparkan_runtime::{
|
use fparkan_runtime::{
|
||||||
create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
||||||
};
|
};
|
||||||
use fparkan_vfs::{DirectoryVfs, Vfs};
|
use fparkan_vfs::DirectoryVfs;
|
||||||
use serde::Serialize;
|
use std::fmt::Write;
|
||||||
use std::fmt::Write as _;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
const ARCHIVE_INSPECT_SCHEMA: &str = "fparkan-archive-inspect-v1";
|
|
||||||
const PROTOTYPE_INSPECT_SCHEMA: &str = "fparkan-prototype-inspect-v2";
|
|
||||||
const MISSION_GRAPH_SCHEMA: &str = "fparkan-mission-graph-v1";
|
|
||||||
const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1";
|
|
||||||
const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1";
|
|
||||||
const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1";
|
|
||||||
const WEAR_INSPECT_SCHEMA: &str = "fparkan-wear-inspect-v1";
|
|
||||||
const SCRIPT_INSPECT_SCHEMA: &str = "fparkan-script-inspect-v2";
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct ArchiveInspectOutput<'a> {
|
|
||||||
schema_version: &'static str,
|
|
||||||
path: &'a str,
|
|
||||||
kind: &'a str,
|
|
||||||
entries: usize,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
lookup_order_valid: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct PrototypeInspectOutput {
|
|
||||||
schema_version: &'static str,
|
|
||||||
key: String,
|
|
||||||
roots: usize,
|
|
||||||
node_count: usize,
|
|
||||||
edge_count: usize,
|
|
||||||
unit_component_records: Vec<UnitComponentInspectOutput>,
|
|
||||||
edges: Vec<PrototypeGraphEdgeInspectOutput>,
|
|
||||||
prototype_requests: usize,
|
|
||||||
resolved: usize,
|
|
||||||
unit_references: usize,
|
|
||||||
unit_components: usize,
|
|
||||||
direct_references: usize,
|
|
||||||
wear_requests: usize,
|
|
||||||
wear: usize,
|
|
||||||
materials: usize,
|
|
||||||
textures: usize,
|
|
||||||
lightmaps: usize,
|
|
||||||
is_success: bool,
|
|
||||||
failures: Vec<GraphFailureOutput>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct MissionGraphOutput {
|
|
||||||
schema_version: &'static str,
|
|
||||||
mission: String,
|
|
||||||
objects: usize,
|
|
||||||
paths: usize,
|
|
||||||
clans: usize,
|
|
||||||
extras: usize,
|
|
||||||
roots: usize,
|
|
||||||
node_count: usize,
|
|
||||||
edge_count: usize,
|
|
||||||
direct_references: usize,
|
|
||||||
unit_references: usize,
|
|
||||||
unit_components: usize,
|
|
||||||
prototype_requests: usize,
|
|
||||||
wear_requests: usize,
|
|
||||||
wear: usize,
|
|
||||||
materials: usize,
|
|
||||||
textures: usize,
|
|
||||||
lightmaps: usize,
|
|
||||||
is_success: bool,
|
|
||||||
failures: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct MissionInspectOutput {
|
|
||||||
schema_version: &'static str,
|
|
||||||
mission: String,
|
|
||||||
objects: Vec<MissionObjectInspectOutput>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct MissionObjectInspectOutput {
|
|
||||||
index: usize,
|
|
||||||
resource: String,
|
|
||||||
position: [f32; 3],
|
|
||||||
orientation_raw: [f32; 3],
|
|
||||||
scale: [f32; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct TerrainInspectOutput {
|
|
||||||
schema_version: &'static str,
|
|
||||||
path: String,
|
|
||||||
positions: usize,
|
|
||||||
min: [f32; 3],
|
|
||||||
max: [f32; 3],
|
|
||||||
faces: usize,
|
|
||||||
slots: usize,
|
|
||||||
material_tags: Vec<TerrainMaterialTagCount>,
|
|
||||||
shade_pairs: usize,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
shade_lookup_key_min: Option<u16>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
shade_lookup_key_max: Option<u16>,
|
|
||||||
shade_batch_boundaries: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct TerrainMaterialTagCount {
|
|
||||||
tag: u16,
|
|
||||||
faces: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct WearInspectOutput {
|
|
||||||
schema_version: &'static str,
|
|
||||||
archive: String,
|
|
||||||
resource: String,
|
|
||||||
materials: usize,
|
|
||||||
lightmaps: usize,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
first_material: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
last_material: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct ModelInspectOutput<'a> {
|
|
||||||
schema_version: &'static str,
|
|
||||||
archive: &'a str,
|
|
||||||
resource: &'a str,
|
|
||||||
streams: usize,
|
|
||||||
nodes: usize,
|
|
||||||
node_stride: usize,
|
|
||||||
slots: usize,
|
|
||||||
positions: usize,
|
|
||||||
indices: usize,
|
|
||||||
batches: usize,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
animation_keys: Option<usize>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
animation_frame_count: Option<u32>,
|
|
||||||
node38: Vec<ModelNodeInspectOutput>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct ModelNodeInspectOutput {
|
|
||||||
index: usize,
|
|
||||||
parent_or_link_raw: u16,
|
|
||||||
anim_map_start: u16,
|
|
||||||
fallback_key: u16,
|
|
||||||
has_lod0_group0: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct GraphFailureOutput {
|
|
||||||
root_index: usize,
|
|
||||||
edge: &'static str,
|
|
||||||
requiredness: &'static str,
|
|
||||||
message: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
archive: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
resource: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct UnitComponentInspectOutput {
|
|
||||||
root_index: usize,
|
|
||||||
component_index: usize,
|
|
||||||
archive_raw_hex: String,
|
|
||||||
resource_raw_hex: String,
|
|
||||||
kind: u32,
|
|
||||||
parent_or_link: i32,
|
|
||||||
description_raw_hex: String,
|
|
||||||
tail0: u32,
|
|
||||||
tail1: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct PrototypeGraphEdgeInspectOutput {
|
|
||||||
id: u32,
|
|
||||||
from: u32,
|
|
||||||
to: u32,
|
|
||||||
kind: &'static str,
|
|
||||||
requiredness: &'static str,
|
|
||||||
root_index: Option<usize>,
|
|
||||||
parent_edge: Option<u32>,
|
|
||||||
unit_component_index: Option<usize>,
|
|
||||||
archive: Option<String>,
|
|
||||||
resource_raw_hex: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct ScriptInspectOutput<'a> {
|
|
||||||
schema_version: &'static str,
|
|
||||||
path: &'a str,
|
|
||||||
opcode_handler_count: u32,
|
|
||||||
events: usize,
|
|
||||||
instructions: usize,
|
|
||||||
references: usize,
|
|
||||||
trailing_bytes: usize,
|
|
||||||
first_header_word_candidates: Vec<ScriptHeaderWordCount>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct ScriptHeaderWordCount {
|
|
||||||
value: u32,
|
|
||||||
instructions: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct VarSetInspectOutput<'a> {
|
|
||||||
schema_version: &'static str,
|
|
||||||
path: &'a str,
|
|
||||||
declarations: usize,
|
|
||||||
float_defaults: usize,
|
|
||||||
dword_defaults: usize,
|
|
||||||
first_name: Option<&'a str>,
|
|
||||||
last_name: Option<&'a str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
let result = run(&args);
|
let result = run(&args);
|
||||||
@@ -304,115 +83,10 @@ fn run(args: &[String]) -> Result<(), String> {
|
|||||||
let rest = strip_format_json(rest)?;
|
let rest = strip_format_json(rest)?;
|
||||||
graph_mission(&rest)
|
graph_mission(&rest)
|
||||||
}
|
}
|
||||||
[domain, command, rest @ ..] if domain == "mission" && command == "inspect" => {
|
|
||||||
let rest = strip_format_json(rest)?;
|
|
||||||
inspect_mission(&rest)
|
|
||||||
}
|
|
||||||
[domain, command, rest @ ..] if domain == "terrain" && command == "inspect" => {
|
|
||||||
let rest = strip_format_json(rest)?;
|
|
||||||
inspect_terrain(&rest)
|
|
||||||
}
|
|
||||||
[domain, command, rest @ ..] if domain == "wear" && command == "inspect" => {
|
|
||||||
let rest = strip_format_json(rest)?;
|
|
||||||
inspect_wear(&rest)
|
|
||||||
}
|
|
||||||
[domain, command, rest @ ..] if domain == "model" && command == "inspect" => {
|
|
||||||
let rest = strip_format_json(rest)?;
|
|
||||||
inspect_model(&rest)
|
|
||||||
}
|
|
||||||
[domain, command, rest @ ..] if domain == "script" && command == "inspect" => {
|
|
||||||
let rest = strip_format_json(rest)?;
|
|
||||||
inspect_script(&rest)
|
|
||||||
}
|
|
||||||
[domain, command, rest @ ..] if domain == "varset" && command == "inspect" => {
|
|
||||||
let rest = strip_format_json(rest)?;
|
|
||||||
inspect_varset(&rest)
|
|
||||||
}
|
|
||||||
_ => Err(usage()),
|
_ => Err(usage()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inspect_script(args: &[String]) -> Result<(), String> {
|
|
||||||
let path = parse_file_path(args, "script inspect")?;
|
|
||||||
let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
|
|
||||||
let package =
|
|
||||||
fparkan_script::decode(&bytes).map_err(|err| format!("{}: {err}", path.display()))?;
|
|
||||||
let display_path = path.display().to_string();
|
|
||||||
println!("{}", script_inspect_json(&display_path, &package)?);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn script_inspect_json(
|
|
||||||
path: &str,
|
|
||||||
package: &fparkan_script::ScriptPackage,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let instructions = package
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.map(|event| event.instructions.len())
|
|
||||||
.sum();
|
|
||||||
let references = package
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.flat_map(|event| &event.instructions)
|
|
||||||
.map(|instruction| instruction.references.len())
|
|
||||||
.sum();
|
|
||||||
let mut candidates = std::collections::BTreeMap::<u32, usize>::new();
|
|
||||||
for instruction in package.events.iter().flat_map(|event| &event.instructions) {
|
|
||||||
*candidates.entry(instruction.header_words[0]).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
serialize_json(&ScriptInspectOutput {
|
|
||||||
schema_version: SCRIPT_INSPECT_SCHEMA,
|
|
||||||
path,
|
|
||||||
opcode_handler_count: package.opcode_handler_count,
|
|
||||||
events: package.events.len(),
|
|
||||||
instructions,
|
|
||||||
references,
|
|
||||||
trailing_bytes: package.trailing_bytes.len(),
|
|
||||||
first_header_word_candidates: candidates
|
|
||||||
.into_iter()
|
|
||||||
.map(|(value, instructions)| ScriptHeaderWordCount {
|
|
||||||
value,
|
|
||||||
instructions,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inspect_varset(args: &[String]) -> Result<(), String> {
|
|
||||||
let path = parse_file_path(args, "varset inspect")?;
|
|
||||||
let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
|
|
||||||
let varset =
|
|
||||||
fparkan_script::parse_varset(&bytes).map_err(|err| format!("{}: {err}", path.display()))?;
|
|
||||||
let display_path = path.display().to_string();
|
|
||||||
println!("{}", varset_inspect_json(&display_path, &varset)?);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn varset_inspect_json(path: &str, varset: &fparkan_script::VarSet) -> Result<String, String> {
|
|
||||||
let float_defaults = varset
|
|
||||||
.declarations
|
|
||||||
.iter()
|
|
||||||
.filter(|declaration| declaration.type_name == fparkan_script::VarSetType::Float)
|
|
||||||
.count();
|
|
||||||
let dword_defaults = varset.declarations.len() - float_defaults;
|
|
||||||
serialize_json(&VarSetInspectOutput {
|
|
||||||
schema_version: "fparkan-varset-inspect-v1",
|
|
||||||
path,
|
|
||||||
declarations: varset.declarations.len(),
|
|
||||||
float_defaults,
|
|
||||||
dword_defaults,
|
|
||||||
first_name: varset
|
|
||||||
.declarations
|
|
||||||
.first()
|
|
||||||
.map(|declaration| declaration.name.as_str()),
|
|
||||||
last_name: varset
|
|
||||||
.declarations
|
|
||||||
.last()
|
|
||||||
.map(|declaration| declaration.name.as_str()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn exit_code(result: &Result<(), String>) -> i32 {
|
fn exit_code(result: &Result<(), String>) -> i32 {
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
0
|
0
|
||||||
@@ -478,13 +152,10 @@ fn inspect_prototype(args: &[String]) -> Result<(), String> {
|
|||||||
let vfs = Arc::new(DirectoryVfs::new(root));
|
let vfs = Arc::new(DirectoryVfs::new(root));
|
||||||
let repository = CachedResourceRepository::new(vfs.clone());
|
let repository = CachedResourceRepository::new(vfs.clone());
|
||||||
let roots = [resource_name(key.as_bytes())];
|
let roots = [resource_name(key.as_bytes())];
|
||||||
let (mut graph, resolved, mut report) =
|
let (graph, resolved, mut report) =
|
||||||
build_prototype_graph_report(&repository, vfs.as_ref(), &roots);
|
build_prototype_graph_report(&repository, vfs.as_ref(), &roots);
|
||||||
extend_graph_report_with_visual_dependencies(&repository, &mut report, &mut graph, &resolved);
|
extend_graph_report_with_visual_dependencies(&repository, &mut report, &graph, &resolved);
|
||||||
println!(
|
println!("{}", prototype_inspect_json(&key, &graph, &report));
|
||||||
"{}",
|
|
||||||
prototype_inspect_json(&key, &graph, &report).map_err(|err| err.to_string())?
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,83 +163,22 @@ fn prototype_inspect_json(
|
|||||||
key: &str,
|
key: &str,
|
||||||
graph: &fparkan_prototype::PrototypeGraph,
|
graph: &fparkan_prototype::PrototypeGraph,
|
||||||
report: &fparkan_prototype::PrototypeGraphReport,
|
report: &fparkan_prototype::PrototypeGraphReport,
|
||||||
) -> Result<String, serde_json::Error> {
|
) -> String {
|
||||||
serde_json::to_string(&PrototypeInspectOutput {
|
format!(
|
||||||
schema_version: PROTOTYPE_INSPECT_SCHEMA,
|
"{{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":{},\"roots\":{},\"prototype_requests\":{},\"resolved\":{},\"unit_references\":{},\"unit_components\":{},\"direct_references\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}",
|
||||||
key: key.to_string(),
|
json_string(key),
|
||||||
roots: report.root_count,
|
report.root_count,
|
||||||
node_count: graph.nodes.len(),
|
graph.prototype_requests.len(),
|
||||||
edge_count: graph.edges.len(),
|
report.resolved_count,
|
||||||
unit_component_records: graph
|
report.unit_reference_count,
|
||||||
.root_unit_components
|
report.unit_component_count,
|
||||||
.iter()
|
report.direct_reference_count,
|
||||||
.enumerate()
|
report.wear_resolved_count,
|
||||||
.flat_map(|(root_index, records)| {
|
report.material_resolved_count,
|
||||||
records
|
report.texture_resolved_count,
|
||||||
.iter()
|
report.lightmap_resolved_count,
|
||||||
.enumerate()
|
report.failures.len()
|
||||||
.map(
|
|
||||||
move |(component_index, record)| UnitComponentInspectOutput {
|
|
||||||
root_index,
|
|
||||||
component_index,
|
|
||||||
archive_raw_hex: hex_bytes(&record.archive_raw),
|
|
||||||
resource_raw_hex: hex_bytes(&record.resource_raw),
|
|
||||||
kind: record.kind,
|
|
||||||
parent_or_link: record.parent_or_link,
|
|
||||||
description_raw_hex: hex_bytes(&record.description_raw),
|
|
||||||
tail0: record.tail0,
|
|
||||||
tail1: record.tail1,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
edges: graph
|
|
||||||
.edges
|
|
||||||
.iter()
|
|
||||||
.map(|edge| PrototypeGraphEdgeInspectOutput {
|
|
||||||
id: edge.id.0,
|
|
||||||
from: edge.from.0,
|
|
||||||
to: edge.to.0,
|
|
||||||
kind: prototype_graph_edge_kind_label(edge.kind),
|
|
||||||
requiredness: prototype_graph_requiredness_label(edge.requiredness),
|
|
||||||
root_index: edge.provenance.as_ref().map(|value| value.root_index),
|
|
||||||
parent_edge: edge
|
|
||||||
.provenance
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|value| value.parent_edge.map(|parent| parent.0)),
|
|
||||||
unit_component_index: edge
|
|
||||||
.provenance
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|value| value.unit_component_index),
|
|
||||||
archive: edge
|
|
||||||
.provenance
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|value| value.archive.clone()),
|
|
||||||
resource_raw_hex: edge
|
|
||||||
.provenance
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|value| value.resource.as_ref())
|
|
||||||
.map(|raw| hex_bytes(raw)),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
prototype_requests: graph.prototype_requests.len(),
|
|
||||||
resolved: report.resolved_count,
|
|
||||||
unit_references: report.unit_reference_count,
|
|
||||||
unit_components: report.unit_component_count,
|
|
||||||
direct_references: report.direct_reference_count,
|
|
||||||
wear_requests: report.wear_request_count,
|
|
||||||
wear: report.wear_resolved_count,
|
|
||||||
materials: report.material_resolved_count,
|
|
||||||
textures: report.texture_resolved_count,
|
|
||||||
lightmaps: report.lightmap_resolved_count,
|
|
||||||
is_success: report.is_success(),
|
|
||||||
failures: report
|
|
||||||
.failures
|
|
||||||
.iter()
|
|
||||||
.take(16)
|
|
||||||
.map(graph_failure_output)
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn graph_mission(args: &[String]) -> Result<(), String> {
|
fn graph_mission(args: &[String]) -> Result<(), String> {
|
||||||
@@ -590,68 +200,29 @@ fn graph_mission(args: &[String]) -> Result<(), String> {
|
|||||||
)
|
)
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":{},\"objects\":{},\"paths\":{},\"clans\":{},\"extras\":{},\"roots\":{},\"direct_references\":{},\"unit_references\":{},\"unit_components\":{},\"prototype_requests\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}",
|
||||||
serialize_json(&MissionGraphOutput {
|
json_string(&mission),
|
||||||
schema_version: MISSION_GRAPH_SCHEMA,
|
loaded.object_count,
|
||||||
mission: mission.clone(),
|
loaded.path_count,
|
||||||
objects: loaded.object_count,
|
loaded.clan_count,
|
||||||
paths: loaded.path_count,
|
loaded.extra_count,
|
||||||
clans: loaded.clan_count,
|
loaded.graph_root_count,
|
||||||
extras: loaded.extra_count,
|
loaded.graph_direct_reference_count,
|
||||||
roots: loaded.graph_root_count,
|
loaded.graph_unit_reference_count,
|
||||||
node_count: loaded.graph_node_count,
|
loaded.graph_unit_component_count,
|
||||||
edge_count: loaded.graph_edge_count,
|
loaded.graph_resolved_count,
|
||||||
direct_references: loaded.graph_direct_reference_count,
|
loaded.graph_wear_resolved_count,
|
||||||
unit_references: loaded.graph_unit_reference_count,
|
loaded.graph_material_resolved_count,
|
||||||
unit_components: loaded.graph_unit_component_count,
|
loaded.graph_texture_resolved_count,
|
||||||
prototype_requests: loaded.graph_resolved_count,
|
loaded.graph_lightmap_resolved_count,
|
||||||
wear_requests: loaded.graph_wear_request_count,
|
loaded.graph_failure_count
|
||||||
wear: loaded.graph_wear_resolved_count,
|
|
||||||
materials: loaded.graph_material_resolved_count,
|
|
||||||
textures: loaded.graph_texture_resolved_count,
|
|
||||||
lightmaps: loaded.graph_lightmap_resolved_count,
|
|
||||||
is_success: loaded.graph_failure_count == 0,
|
|
||||||
failures: loaded.graph_failure_count,
|
|
||||||
})?
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inspect_mission(args: &[String]) -> Result<(), String> {
|
|
||||||
let root = parse_root_alias(args)?;
|
|
||||||
let mission = parse_required(args, &["--mission"], "--mission")?;
|
|
||||||
let mission_path = normalize_relative(mission.as_bytes(), PathPolicy::StrictLegacy)
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
let vfs = DirectoryVfs::new(root);
|
|
||||||
let bytes = vfs.read(&mission_path).map_err(|err| err.to_string())?;
|
|
||||||
let document =
|
|
||||||
decode_mission_payload(bytes, TmaProfile::Strict).map_err(|err| err.to_string())?;
|
|
||||||
let objects = document
|
|
||||||
.objects
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, object)| MissionObjectInspectOutput {
|
|
||||||
index,
|
|
||||||
resource: String::from_utf8_lossy(&object.resource_name.raw).into_owned(),
|
|
||||||
position: object.position,
|
|
||||||
orientation_raw: object.orientation,
|
|
||||||
scale: object.scale,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
serialize_json(&MissionInspectOutput {
|
|
||||||
schema_version: MISSION_INSPECT_SCHEMA,
|
|
||||||
mission,
|
|
||||||
objects,
|
|
||||||
})?
|
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inspect_archive(args: &[String]) -> Result<(), String> {
|
fn inspect_archive(args: &[String]) -> Result<(), String> {
|
||||||
let path = parse_archive_path(args)?;
|
let path = parse_archive_path(args)?;
|
||||||
let inspection = inspect_archive_file(&path, 0).map_err(|err| err.clone())?;
|
let inspection = inspect_archive_file(&path, 0).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
match inspection {
|
match inspection {
|
||||||
ArchiveInspection::Nres {
|
ArchiveInspection::Nres {
|
||||||
@@ -666,14 +237,14 @@ fn inspect_archive(args: &[String]) -> Result<(), String> {
|
|||||||
"NRes",
|
"NRes",
|
||||||
entries,
|
entries,
|
||||||
Some(lookup_order_valid),
|
Some(lookup_order_valid),
|
||||||
)?
|
)
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
ArchiveInspection::Rsli { entries } => {
|
ArchiveInspection::Rsli { entries } => {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
archive_inspect_json(&path.display().to_string(), "RsLi", entries, None)?
|
archive_inspect_json(&path.display().to_string(), "RsLi", entries, None)
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -683,212 +254,55 @@ fn inspect_archive(args: &[String]) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inspect_terrain(args: &[String]) -> Result<(), String> {
|
|
||||||
let path = parse_file_path(args, "terrain inspect")?;
|
|
||||||
let bounds = inspect_land_msh_bounds_file(&path)?;
|
|
||||||
let terrain = load_land_msh_from_path(&path)?;
|
|
||||||
let mut material_tags = terrain
|
|
||||||
.faces
|
|
||||||
.iter()
|
|
||||||
.fold(std::collections::BTreeMap::new(), |mut counts, face| {
|
|
||||||
*counts.entry(face.material_tag).or_insert(0usize) += 1;
|
|
||||||
counts
|
|
||||||
})
|
|
||||||
.into_iter()
|
|
||||||
.map(|(tag, faces)| TerrainMaterialTagCount { tag, faces })
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
material_tags.sort_unstable_by_key(|entry| entry.tag);
|
|
||||||
let shade_pairs = (0..terrain.slots.slots_raw.len())
|
|
||||||
.filter_map(|slot_index| terrain.slot_material_pairs(slot_index))
|
|
||||||
.flatten()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let shade_lookup_key_min = shade_pairs.iter().map(|pair| pair.shade_lookup_key()).min();
|
|
||||||
let shade_lookup_key_max = shade_pairs.iter().map(|pair| pair.shade_lookup_key()).max();
|
|
||||||
let shade_batch_boundaries = shade_pairs
|
|
||||||
.iter()
|
|
||||||
.filter(|pair| pair.flags & 0x0010 != 0)
|
|
||||||
.count();
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
serialize_json(&TerrainInspectOutput {
|
|
||||||
schema_version: TERRAIN_INSPECT_SCHEMA,
|
|
||||||
path: path.display().to_string(),
|
|
||||||
positions: bounds.positions,
|
|
||||||
min: bounds.min,
|
|
||||||
max: bounds.max,
|
|
||||||
faces: terrain.faces.len(),
|
|
||||||
slots: terrain.slots.slots_raw.len(),
|
|
||||||
material_tags,
|
|
||||||
shade_pairs: shade_pairs.len(),
|
|
||||||
shade_lookup_key_min,
|
|
||||||
shade_lookup_key_max,
|
|
||||||
shade_batch_boundaries,
|
|
||||||
})?
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inspect_wear(args: &[String]) -> Result<(), String> {
|
|
||||||
let root = parse_root_alias(args)?;
|
|
||||||
let archive = parse_required(args, &["--archive"], "--archive")?;
|
|
||||||
let resource = parse_required(args, &["--resource"], "--resource")?;
|
|
||||||
let inspection = inspect_wear_from_root(&root, &archive, &resource)?;
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
serialize_json(&WearInspectOutput {
|
|
||||||
schema_version: WEAR_INSPECT_SCHEMA,
|
|
||||||
archive,
|
|
||||||
resource,
|
|
||||||
materials: inspection.materials,
|
|
||||||
lightmaps: inspection.lightmaps,
|
|
||||||
first_material: inspection.first_material,
|
|
||||||
last_material: inspection.last_material,
|
|
||||||
})?
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inspect_model(args: &[String]) -> Result<(), String> {
|
|
||||||
let root = parse_root_alias(args)?;
|
|
||||||
let archive = parse_required(args, &["--archive"], "--archive")?;
|
|
||||||
let resource = parse_required(args, &["--resource"], "--resource")?;
|
|
||||||
let inspection = inspect_model_from_root(&root, &archive, &resource)?;
|
|
||||||
println!("{}", model_inspect_json(&archive, &resource, &inspection)?);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn model_inspect_json(
|
|
||||||
archive: &str,
|
|
||||||
resource: &str,
|
|
||||||
inspection: &ModelInspection,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
serialize_json(&ModelInspectOutput {
|
|
||||||
schema_version: MODEL_INSPECT_SCHEMA,
|
|
||||||
archive,
|
|
||||||
resource,
|
|
||||||
streams: inspection.streams,
|
|
||||||
nodes: inspection.nodes,
|
|
||||||
node_stride: inspection.node_stride,
|
|
||||||
slots: inspection.slots,
|
|
||||||
positions: inspection.positions,
|
|
||||||
indices: inspection.indices,
|
|
||||||
batches: inspection.batches,
|
|
||||||
animation_keys: inspection.animation_keys,
|
|
||||||
animation_frame_count: inspection.animation_frame_count,
|
|
||||||
node38: inspection
|
|
||||||
.node38
|
|
||||||
.iter()
|
|
||||||
.map(|node| ModelNodeInspectOutput {
|
|
||||||
index: node.index,
|
|
||||||
parent_or_link_raw: node.parent_or_link_raw,
|
|
||||||
anim_map_start: node.anim_map_start,
|
|
||||||
fallback_key: node.fallback_key,
|
|
||||||
has_lod0_group0: node.has_lod0_group0,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn archive_inspect_json(
|
fn archive_inspect_json(
|
||||||
path: &str,
|
path: &str,
|
||||||
kind: &str,
|
kind: &str,
|
||||||
entries: usize,
|
entries: usize,
|
||||||
lookup_order_valid: Option<bool>,
|
lookup_order_valid: Option<bool>,
|
||||||
) -> Result<String, String> {
|
) -> String {
|
||||||
serialize_json(&ArchiveInspectOutput {
|
let mut out = format!(
|
||||||
schema_version: ARCHIVE_INSPECT_SCHEMA,
|
"{{\"schema_version\":\"fparkan-archive-inspect-v1\",\"path\":{},\"kind\":{},\"entries\":{}",
|
||||||
path,
|
json_string(path),
|
||||||
kind,
|
json_string(kind),
|
||||||
entries,
|
entries
|
||||||
lookup_order_valid,
|
);
|
||||||
})
|
if let Some(valid) = lookup_order_valid {
|
||||||
|
let _ = write!(out, ",\"lookup_order_valid\":{valid}");
|
||||||
|
}
|
||||||
|
out.push('}');
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> {
|
fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> {
|
||||||
parse_file_path(args, "archive inspect")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_file_path(args: &[String], command: &str) -> Result<PathBuf, String> {
|
|
||||||
match args {
|
match args {
|
||||||
[path] => Ok(PathBuf::from(path)),
|
[path] => Ok(PathBuf::from(path)),
|
||||||
[flag, path] if flag == "--file" => Ok(PathBuf::from(path)),
|
[flag, path] if flag == "--file" => Ok(PathBuf::from(path)),
|
||||||
_ => Err(format!("{command} requires <file> or --file <file>")),
|
_ => Err("archive inspect requires <file> or --file <file>".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn serialize_json<T: Serialize>(value: &T) -> Result<String, String> {
|
fn json_string(value: &str) -> String {
|
||||||
serde_json::to_string(value).map_err(|err| err.to_string())
|
let mut out = String::with_capacity(value.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
c if c.is_control() => {
|
||||||
|
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||||
}
|
}
|
||||||
|
c => out.push(c),
|
||||||
fn graph_failure_output(failure: &fparkan_prototype::PrototypeGraphFailure) -> GraphFailureOutput {
|
|
||||||
GraphFailureOutput {
|
|
||||||
root_index: failure.root_index,
|
|
||||||
edge: prototype_graph_edge_label(failure.edge),
|
|
||||||
requiredness: prototype_graph_requiredness_label(failure.requiredness),
|
|
||||||
message: failure.message.clone(),
|
|
||||||
archive: failure
|
|
||||||
.provenance
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|provenance| provenance.archive.clone()),
|
|
||||||
resource: failure.provenance.as_ref().and_then(|provenance| {
|
|
||||||
provenance
|
|
||||||
.resource
|
|
||||||
.as_ref()
|
|
||||||
.map(|raw| String::from_utf8_lossy(raw).into_owned())
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out.push('"');
|
||||||
fn hex_bytes(raw: &[u8]) -> String {
|
out
|
||||||
let mut output = String::with_capacity(raw.len().saturating_mul(2));
|
|
||||||
for byte in raw {
|
|
||||||
#[allow(clippy::expect_used)]
|
|
||||||
write!(&mut output, "{byte:02x}").expect("writing into String cannot fail");
|
|
||||||
}
|
|
||||||
output
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prototype_graph_edge_kind_label(
|
|
||||||
edge: fparkan_prototype::PrototypeGraphEdgeKind,
|
|
||||||
) -> &'static str {
|
|
||||||
match edge {
|
|
||||||
fparkan_prototype::PrototypeGraphEdgeKind::MissionToRoot => "mission_to_root",
|
|
||||||
fparkan_prototype::PrototypeGraphEdgeKind::UnitDatToComponent => "unit_dat_to_component",
|
|
||||||
fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh => "prototype_to_mesh",
|
|
||||||
fparkan_prototype::PrototypeGraphEdgeKind::MeshToWear => "mesh_to_wear",
|
|
||||||
fparkan_prototype::PrototypeGraphEdgeKind::WearToMaterial => "wear_to_material",
|
|
||||||
fparkan_prototype::PrototypeGraphEdgeKind::MaterialToTexture => "material_to_texture",
|
|
||||||
fparkan_prototype::PrototypeGraphEdgeKind::WearToLightmap => "wear_to_lightmap",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prototype_graph_edge_label(edge: fparkan_prototype::PrototypeGraphEdge) -> &'static str {
|
|
||||||
match edge {
|
|
||||||
fparkan_prototype::PrototypeGraphEdge::MissionToUnitDat => "mission_to_unit_dat",
|
|
||||||
fparkan_prototype::PrototypeGraphEdge::MissionToObjectsRegistry => {
|
|
||||||
"mission_to_objects_registry"
|
|
||||||
}
|
|
||||||
fparkan_prototype::PrototypeGraphEdge::UnitDatToComponent => "unit_dat_to_component",
|
|
||||||
fparkan_prototype::PrototypeGraphEdge::PrototypeToMesh => "prototype_to_mesh",
|
|
||||||
fparkan_prototype::PrototypeGraphEdge::MeshToWear => "mesh_to_wear",
|
|
||||||
fparkan_prototype::PrototypeGraphEdge::WearToMaterial => "wear_to_material",
|
|
||||||
fparkan_prototype::PrototypeGraphEdge::MaterialToTexture => "material_to_texture",
|
|
||||||
fparkan_prototype::PrototypeGraphEdge::WearToLightmap => "wear_to_lightmap",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prototype_graph_requiredness_label(
|
|
||||||
requiredness: fparkan_prototype::PrototypeGraphRequiredness,
|
|
||||||
) -> &'static str {
|
|
||||||
match requiredness {
|
|
||||||
fparkan_prototype::PrototypeGraphRequiredness::Required => "required",
|
|
||||||
fparkan_prototype::PrototypeGraphRequiredness::Optional => "optional",
|
|
||||||
fparkan_prototype::PrototypeGraphRequiredness::Fallback => "fallback",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> String {
|
fn usage() -> String {
|
||||||
"usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | terrain inspect <Land.msh> [--format json] | wear inspect --root <path> --archive <archive> --resource <wear.wea> [--format json] | model inspect --root <path> --archive <archive> --resource <model.msh> [--format json] | script inspect <file> [--format json] | varset inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph|inspect --root <path> --mission <path> [--format json]".to_string()
|
"usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph --root <path> --mission <path> [--format json]".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -917,31 +331,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn script_inspect_json_has_canonical_field_order() {
|
|
||||||
let package =
|
|
||||||
fparkan_script::decode(&[73, 0, 0, 0, 0, 0, 0, 0]).expect("minimal script package");
|
|
||||||
assert_eq!(
|
|
||||||
script_inspect_json("script.scr", &package),
|
|
||||||
Ok("{\"schema_version\":\"fparkan-script-inspect-v2\",\"path\":\"script.scr\",\"opcode_handler_count\":73,\"events\":0,\"instructions\":0,\"references\":0,\"trailing_bytes\":0,\"first_header_word_candidates\":[]}".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn varset_inspect_json_has_canonical_field_order() {
|
|
||||||
let varset =
|
|
||||||
fparkan_script::parse_varset(b"VAR( float, f0, 0)\nVAR( DWORD, d0, 0xffffffff)\n")
|
|
||||||
.expect("minimal varset");
|
|
||||||
assert_eq!(
|
|
||||||
varset_inspect_json("varset.var", &varset),
|
|
||||||
Ok("{\"schema_version\":\"fparkan-varset-inspect-v1\",\"path\":\"varset.var\",\"declarations\":2,\"float_defaults\":1,\"dword_defaults\":1,\"first_name\":\"f0\",\"last_name\":\"d0\"}".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn archive_json_has_schema_version() {
|
fn archive_json_has_schema_version() {
|
||||||
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true))
|
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true));
|
||||||
.expect("serialize archive inspection");
|
|
||||||
|
|
||||||
assert!(json.contains("\"schema_version\":\"fparkan-archive-inspect-v1\""));
|
assert!(json.contains("\"schema_version\":\"fparkan-archive-inspect-v1\""));
|
||||||
assert!(json.contains("\"kind\":\"NRes\""));
|
assert!(json.contains("\"kind\":\"NRes\""));
|
||||||
@@ -961,110 +353,11 @@ mod tests {
|
|||||||
..fparkan_prototype::PrototypeGraphReport::default()
|
..fparkan_prototype::PrototypeGraphReport::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = prototype_inspect_json("root", &graph, &report).expect("serialize");
|
let json = prototype_inspect_json("root", &graph, &report);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
json,
|
json,
|
||||||
"{\"schema_version\":\"fparkan-prototype-inspect-v2\",\"key\":\"root\",\"roots\":1,\"node_count\":0,\"edge_count\":0,\"unit_component_records\":[],\"edges\":[],\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear_requests\":0,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"is_success\":true,\"failures\":[]}"
|
"{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":\"root\",\"roots\":1,\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"failures\":0}"
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mission_graph_json_has_canonical_field_order() {
|
|
||||||
let json = serialize_json(&MissionGraphOutput {
|
|
||||||
schema_version: MISSION_GRAPH_SCHEMA,
|
|
||||||
mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
||||||
objects: 2,
|
|
||||||
paths: 3,
|
|
||||||
clans: 4,
|
|
||||||
extras: 5,
|
|
||||||
roots: 6,
|
|
||||||
node_count: 7,
|
|
||||||
edge_count: 8,
|
|
||||||
direct_references: 9,
|
|
||||||
unit_references: 10,
|
|
||||||
unit_components: 11,
|
|
||||||
prototype_requests: 12,
|
|
||||||
wear_requests: 13,
|
|
||||||
wear: 14,
|
|
||||||
materials: 15,
|
|
||||||
textures: 16,
|
|
||||||
lightmaps: 17,
|
|
||||||
is_success: true,
|
|
||||||
failures: 0,
|
|
||||||
})
|
|
||||||
.expect("serialize");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
json,
|
|
||||||
"{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":\"MISSIONS/Autodemo.00/data.tma\",\"objects\":2,\"paths\":3,\"clans\":4,\"extras\":5,\"roots\":6,\"node_count\":7,\"edge_count\":8,\"direct_references\":9,\"unit_references\":10,\"unit_components\":11,\"prototype_requests\":12,\"wear_requests\":13,\"wear\":14,\"materials\":15,\"textures\":16,\"lightmaps\":17,\"is_success\":true,\"failures\":0}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mission_inspect_output_retains_raw_transform_fields() {
|
|
||||||
let json = serialize_json(&MissionInspectOutput {
|
|
||||||
schema_version: MISSION_INSPECT_SCHEMA,
|
|
||||||
mission: "MISSIONS/test/data.tma".to_string(),
|
|
||||||
objects: vec![MissionObjectInspectOutput {
|
|
||||||
index: 1,
|
|
||||||
resource: "unit.dat".to_string(),
|
|
||||||
position: [1.0, 2.0, 3.0],
|
|
||||||
orientation_raw: [4.0, 5.0, 6.0],
|
|
||||||
scale: [7.0, 8.0, 9.0],
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
.expect("serialize mission inspection");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
json,
|
|
||||||
"{\"schema_version\":\"fparkan-mission-inspect-v1\",\"mission\":\"MISSIONS/test/data.tma\",\"objects\":[{\"index\":1,\"resource\":\"unit.dat\",\"position\":[1.0,2.0,3.0],\"orientation_raw\":[4.0,5.0,6.0],\"scale\":[7.0,8.0,9.0]}]}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn terrain_inspect_output_retains_axis_bounds() {
|
|
||||||
let json = serialize_json(&TerrainInspectOutput {
|
|
||||||
schema_version: TERRAIN_INSPECT_SCHEMA,
|
|
||||||
path: "DATA/MAPS/AutoMAP/Land.msh".to_string(),
|
|
||||||
positions: 3,
|
|
||||||
min: [-1.0, -2.0, -3.0],
|
|
||||||
max: [4.0, 5.0, 6.0],
|
|
||||||
faces: 2,
|
|
||||||
slots: 4,
|
|
||||||
material_tags: vec![
|
|
||||||
TerrainMaterialTagCount { tag: 0, faces: 1 },
|
|
||||||
TerrainMaterialTagCount { tag: 3, faces: 1 },
|
|
||||||
],
|
|
||||||
shade_pairs: 2,
|
|
||||||
shade_lookup_key_min: Some(1),
|
|
||||||
shade_lookup_key_max: Some(2),
|
|
||||||
shade_batch_boundaries: 1,
|
|
||||||
})
|
|
||||||
.expect("serialize terrain inspection");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
json,
|
|
||||||
"{\"schema_version\":\"fparkan-terrain-inspect-v1\",\"path\":\"DATA/MAPS/AutoMAP/Land.msh\",\"positions\":3,\"min\":[-1.0,-2.0,-3.0],\"max\":[4.0,5.0,6.0],\"faces\":2,\"slots\":4,\"material_tags\":[{\"tag\":0,\"faces\":1},{\"tag\":3,\"faces\":1}],\"shade_pairs\":2,\"shade_lookup_key_min\":1,\"shade_lookup_key_max\":2,\"shade_batch_boundaries\":1}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wear_inspect_output_retains_material_bounds() {
|
|
||||||
let json = serialize_json(&WearInspectOutput {
|
|
||||||
schema_version: WEAR_INSPECT_SCHEMA,
|
|
||||||
archive: "system.rlb".to_string(),
|
|
||||||
resource: "SHADE.WEA".to_string(),
|
|
||||||
materials: 1,
|
|
||||||
lightmaps: 0,
|
|
||||||
first_material: Some("LIGHT1".to_string()),
|
|
||||||
last_material: Some("LIGHT1".to_string()),
|
|
||||||
})
|
|
||||||
.expect("serialize wear inspection");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
json,
|
|
||||||
"{\"schema_version\":\"fparkan-wear-inspect-v1\",\"archive\":\"system.rlb\",\"resource\":\"SHADE.WEA\",\"materials\":1,\"lightmaps\":0,\"first_material\":\"LIGHT1\",\"last_material\":\"LIGHT1\"}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,20 +6,14 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
|
fparkan-assets = { path = "../../crates/fparkan-assets" }
|
||||||
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
|
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||||
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
|
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||||
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
|
||||||
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
|
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
|
||||||
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
|
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||||
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version = "0.1.0" }
|
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||||
fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
|
fparkan-world = { path = "../../crates/fparkan-world" }
|
||||||
fparkan-terrain = { path = "../../crates/fparkan-terrain", version = "0.1.0" }
|
|
||||||
fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
|
|
||||||
fparkan-world = { path = "../../crates/fparkan-world", version = "0.1.0" }
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
+48
-1698
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,9 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
|
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||||
fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
|
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||||
fparkan-world = { path = "../../crates/fparkan-world", version = "0.1.0" }
|
fparkan-world = { path = "../../crates/fparkan-world" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -22,11 +22,10 @@
|
|||||||
//! `FParkan` headless runtime entrypoint.
|
//! `FParkan` headless runtime entrypoint.
|
||||||
|
|
||||||
use fparkan_runtime::{
|
use fparkan_runtime::{
|
||||||
advance_reference_movement, create, load_mission, step_headless, EngineConfig, EngineMode,
|
create, load_mission, step_headless, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
||||||
EngineServices, MissionRequest,
|
|
||||||
};
|
};
|
||||||
use fparkan_vfs::DirectoryVfs;
|
use fparkan_vfs::DirectoryVfs;
|
||||||
use fparkan_world::{InputSnapshot, OriginalObjectId};
|
use fparkan_world::InputSnapshot;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -56,7 +55,7 @@ fn run() -> Result<(), String> {
|
|||||||
let loaded = load_mission(&mut engine, MissionRequest { key: mission })
|
let loaded = load_mission(&mut engine, MissionRequest { key: mission })
|
||||||
.map_err(|err| format!("{err}"))?;
|
.map_err(|err| format!("{err}"))?;
|
||||||
println!(
|
println!(
|
||||||
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} script_varset_declarations={} script_init_states={} script_varset_states={} graph_failures={}",
|
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} graph_failures={}",
|
||||||
loaded.object_count,
|
loaded.object_count,
|
||||||
loaded.areal_count,
|
loaded.areal_count,
|
||||||
loaded.surface_count,
|
loaded.surface_count,
|
||||||
@@ -66,27 +65,9 @@ fn run() -> Result<(), String> {
|
|||||||
loaded.graph_material_resolved_count,
|
loaded.graph_material_resolved_count,
|
||||||
loaded.graph_texture_resolved_count,
|
loaded.graph_texture_resolved_count,
|
||||||
loaded.graph_lightmap_resolved_count,
|
loaded.graph_lightmap_resolved_count,
|
||||||
loaded.script_bundle_count,
|
|
||||||
loaded.script_event_count,
|
|
||||||
loaded.script_varset_declaration_count,
|
|
||||||
loaded.script_init_state_count,
|
|
||||||
loaded.script_varset_state_count,
|
|
||||||
loaded.graph_failure_count
|
loaded.graph_failure_count
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(movement) = args.reference_movement {
|
|
||||||
let reached = advance_reference_movement(
|
|
||||||
&mut engine,
|
|
||||||
OriginalObjectId(movement.original_id),
|
|
||||||
movement.target_xy,
|
|
||||||
movement.max_step,
|
|
||||||
)
|
|
||||||
.map_err(|err| format!("{err}"))?;
|
|
||||||
println!(
|
|
||||||
"reference_movement original_id={} target_xy=[{},{}] max_step={} reached={reached}",
|
|
||||||
movement.original_id, movement.target_xy[0], movement.target_xy[1], movement.max_step,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let mut last = None;
|
let mut last = None;
|
||||||
for _ in 0..args.ticks {
|
for _ in 0..args.ticks {
|
||||||
last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?);
|
last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?);
|
||||||
@@ -100,19 +81,10 @@ fn run() -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Args {
|
struct Args {
|
||||||
root: Option<PathBuf>,
|
root: Option<PathBuf>,
|
||||||
mission: Option<String>,
|
mission: Option<String>,
|
||||||
ticks: u64,
|
ticks: u64,
|
||||||
reference_movement: Option<ReferenceMovement>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
struct ReferenceMovement {
|
|
||||||
original_id: u32,
|
|
||||||
target_xy: [f32; 2],
|
|
||||||
max_step: f32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Args {
|
impl Args {
|
||||||
@@ -121,7 +93,6 @@ impl Args {
|
|||||||
root: None,
|
root: None,
|
||||||
mission: None,
|
mission: None,
|
||||||
ticks: 1,
|
ticks: 1,
|
||||||
reference_movement: None,
|
|
||||||
};
|
};
|
||||||
let mut iter = args.iter();
|
let mut iter = args.iter();
|
||||||
while let Some(arg) = iter.next() {
|
while let Some(arg) = iter.next() {
|
||||||
@@ -147,121 +118,16 @@ impl Args {
|
|||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| "--ticks must be an integer".to_string())?;
|
.map_err(|_| "--ticks must be an integer".to_string())?;
|
||||||
}
|
}
|
||||||
"--move-object" => {
|
|
||||||
if parsed.reference_movement.is_some() {
|
|
||||||
return Err("--move-object may be specified once".to_string());
|
|
||||||
}
|
|
||||||
let original_id = iter
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| "--move-object requires an original object id".to_string())?
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| {
|
|
||||||
"--move-object object id must be an unsigned integer".to_string()
|
|
||||||
})?;
|
|
||||||
let x = parse_finite_argument(
|
|
||||||
iter.next(),
|
|
||||||
"--move-object requires a finite X target",
|
|
||||||
)?;
|
|
||||||
let y = parse_finite_argument(
|
|
||||||
iter.next(),
|
|
||||||
"--move-object requires a finite Y target",
|
|
||||||
)?;
|
|
||||||
let max_step = parse_finite_argument(
|
|
||||||
iter.next(),
|
|
||||||
"--move-object requires a finite positive maximum step",
|
|
||||||
)?;
|
|
||||||
if max_step <= 0.0 {
|
|
||||||
return Err("--move-object maximum step must be positive".to_string());
|
|
||||||
}
|
|
||||||
parsed.reference_movement = Some(ReferenceMovement {
|
|
||||||
original_id,
|
|
||||||
target_xy: [x, y],
|
|
||||||
max_step,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => return Err(usage()),
|
_ => return Err(usage()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if parsed.mission.is_some() && parsed.root.is_none() {
|
if parsed.mission.is_some() && parsed.root.is_none() {
|
||||||
return Err("--mission requires --root".to_string());
|
return Err("--mission requires --root".to_string());
|
||||||
}
|
}
|
||||||
if parsed.reference_movement.is_some() && parsed.mission.is_none() {
|
|
||||||
return Err("--move-object requires --mission".to_string());
|
|
||||||
}
|
|
||||||
Ok(parsed)
|
Ok(parsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_finite_argument(value: Option<&String>, error: &str) -> Result<f32, String> {
|
|
||||||
let value: f32 = value
|
|
||||||
.ok_or_else(|| error.to_string())?
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| error.to_string())?;
|
|
||||||
value
|
|
||||||
.is_finite()
|
|
||||||
.then_some(value)
|
|
||||||
.ok_or_else(|| error.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn usage() -> String {
|
fn usage() -> String {
|
||||||
"usage: fparkan-headless [--root <path> --mission <path>] [--move-object <original-id> <x> <y> <max-step>] [--ticks <n>]".to_string()
|
"usage: fparkan-headless [--root <path> --mission <path>] [--ticks <n>]".to_string()
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn args(values: &[&str]) -> Vec<String> {
|
|
||||||
values.iter().map(ToString::to_string).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn move_object_parses_a_single_finite_reference_command() {
|
|
||||||
let parsed = Args::parse(&args(&[
|
|
||||||
"--root",
|
|
||||||
"C:/game",
|
|
||||||
"--mission",
|
|
||||||
"MISSIONS/Autodemo.00/data.tma",
|
|
||||||
"--move-object",
|
|
||||||
"7",
|
|
||||||
"12.5",
|
|
||||||
"-3",
|
|
||||||
"0.25",
|
|
||||||
"--ticks",
|
|
||||||
"2",
|
|
||||||
]))
|
|
||||||
.expect("args");
|
|
||||||
assert_eq!(parsed.ticks, 2);
|
|
||||||
assert_eq!(
|
|
||||||
parsed.reference_movement,
|
|
||||||
Some(ReferenceMovement {
|
|
||||||
original_id: 7,
|
|
||||||
target_xy: [12.5, -3.0],
|
|
||||||
max_step: 0.25,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn move_object_requires_a_loaded_mission_and_valid_step() {
|
|
||||||
assert_eq!(
|
|
||||||
Args::parse(&args(&["--move-object", "7", "1", "2", "1"])).expect_err("mission"),
|
|
||||||
"--move-object requires --mission"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Args::parse(&args(&[
|
|
||||||
"--root",
|
|
||||||
"C:/game",
|
|
||||||
"--mission",
|
|
||||||
"M/data.tma",
|
|
||||||
"--move-object",
|
|
||||||
"7",
|
|
||||||
"1",
|
|
||||||
"2",
|
|
||||||
"0",
|
|
||||||
]))
|
|
||||||
.expect_err("step"),
|
|
||||||
"--move-object maximum step must be positive"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
|
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
|
||||||
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
|
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||||
//! `FParkan` asset inspection composition root.
|
//! `FParkan` asset viewer composition root.
|
||||||
|
|
||||||
use fparkan_inspection::{
|
use fparkan_inspection::{
|
||||||
inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection,
|
inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection,
|
||||||
@@ -68,14 +68,14 @@ fn inspect_archive(args: &[String]) -> Result<String, String> {
|
|||||||
lookup_order_valid,
|
lookup_order_valid,
|
||||||
sample,
|
sample,
|
||||||
} => Ok(format!(
|
} => Ok(format!(
|
||||||
"{{\"report_kind\":\"archive-inspection\",\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
|
"{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
|
||||||
json_string(&file.display().to_string()),
|
json_string(&file.display().to_string()),
|
||||||
entries,
|
entries,
|
||||||
lookup_order_valid,
|
lookup_order_valid,
|
||||||
render_nres_entries(&sample)
|
render_nres_entries(&sample)
|
||||||
)),
|
)),
|
||||||
ArchiveInspection::Rsli { entries } => Ok(format!(
|
ArchiveInspection::Rsli { entries } => Ok(format!(
|
||||||
"{{\"report_kind\":\"archive-inspection\",\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
|
"{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
|
||||||
json_string(&file.display().to_string()),
|
json_string(&file.display().to_string()),
|
||||||
entries
|
entries
|
||||||
)),
|
)),
|
||||||
@@ -92,7 +92,7 @@ fn inspect_model(args: &[String]) -> Result<String, String> {
|
|||||||
let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?;
|
let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?;
|
||||||
|
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
|
"{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
|
||||||
json_string(&query.archive),
|
json_string(&query.archive),
|
||||||
json_string(&query.name),
|
json_string(&query.name),
|
||||||
inspection.streams,
|
inspection.streams,
|
||||||
@@ -122,7 +122,6 @@ impl ViewerModelService {
|
|||||||
mesh: GpuMeshId(1),
|
mesh: GpuMeshId(1),
|
||||||
material_slots: vec![GpuMaterialId(7)],
|
material_slots: vec![GpuMaterialId(7)],
|
||||||
material_index: 0,
|
material_index: 0,
|
||||||
pipeline_state: fparkan_render::LegacyPipelineState::default(),
|
|
||||||
transform: identity_transform(),
|
transform: identity_transform(),
|
||||||
range: IndexRange { start: 0, count: 3 },
|
range: IndexRange { start: 0, count: 3 },
|
||||||
stable_order: 0,
|
stable_order: 0,
|
||||||
@@ -137,7 +136,7 @@ impl ViewerModelService {
|
|||||||
.count();
|
.count();
|
||||||
|
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model-inspection\",\"draw_commands\":{draw_commands}}}",
|
"{{\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model\",\"draw_commands\":{draw_commands}}}",
|
||||||
json_string(fixture)
|
json_string(fixture)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -148,7 +147,7 @@ fn inspect_texture(args: &[String]) -> Result<String, String> {
|
|||||||
let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?;
|
let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?;
|
||||||
|
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{{\"report_kind\":\"texture-inspection\",\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
|
"{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
|
||||||
json_string(&query.archive),
|
json_string(&query.archive),
|
||||||
json_string(&query.name),
|
json_string(&query.name),
|
||||||
inspection.width,
|
inspection.width,
|
||||||
@@ -181,7 +180,7 @@ fn inspect_map(args: &[String]) -> Result<String, String> {
|
|||||||
fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String {
|
fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String {
|
||||||
match kind {
|
match kind {
|
||||||
"land-msh" => format!(
|
"land-msh" => format!(
|
||||||
"{{\"report_kind\":\"map-inspection\",\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
|
"{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
|
||||||
json_string(path),
|
json_string(path),
|
||||||
inspection.streams,
|
inspection.streams,
|
||||||
inspection.positions,
|
inspection.positions,
|
||||||
@@ -189,7 +188,7 @@ fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection
|
|||||||
inspection.slots
|
inspection.slots
|
||||||
),
|
),
|
||||||
"land-map" => format!(
|
"land-map" => format!(
|
||||||
"{{\"report_kind\":\"map-inspection\",\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
|
"{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
|
||||||
json_string(path),
|
json_string(path),
|
||||||
inspection.areals,
|
inspection.areals,
|
||||||
inspection.declared_areals,
|
inspection.declared_areals,
|
||||||
@@ -343,7 +342,7 @@ mod tests {
|
|||||||
fn model_fixture_uses_viewer_service_and_render_commands() -> Result<(), String> {
|
fn model_fixture_uses_viewer_service_and_render_commands() -> Result<(), String> {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
run(&strings(&["model", "--fixture", "synthetic/model-basic"]))?,
|
run(&strings(&["model", "--fixture", "synthetic/model-basic"]))?,
|
||||||
"{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"fixture\":\"synthetic/model-basic\",\"service\":\"synthetic-model-inspection\",\"draw_commands\":1}"
|
"{\"kind\":\"model\",\"fixture\":\"synthetic/model-basic\",\"service\":\"synthetic-model\",\"draw_commands\":1}"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ repository.workspace = true
|
|||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||||
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
|
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
|
||||||
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version = "0.1.0" }
|
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
|
||||||
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
|
winit = "0.30"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
|||||||
allow(
|
allow(
|
||||||
clippy::cast_possible_truncation,
|
clippy::cast_possible_truncation,
|
||||||
clippy::cast_possible_wrap,
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
clippy::expect_used,
|
clippy::expect_used,
|
||||||
clippy::float_cmp,
|
clippy::float_cmp,
|
||||||
clippy::identity_op,
|
clippy::identity_op,
|
||||||
@@ -781,29 +782,16 @@ fn write_f32_bits(out: &mut Vec<u8>, value: f32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn compose_pose(parent: Pose, child: Pose) -> Result<Pose, AnimationError> {
|
fn compose_pose(parent: Pose, child: Pose) -> Result<Pose, AnimationError> {
|
||||||
let translated = rotate_point(parent.rotation, child.translation);
|
|
||||||
Ok(Pose {
|
Ok(Pose {
|
||||||
translation: [
|
translation: [
|
||||||
parent.translation[0] + translated[0],
|
parent.translation[0] + child.translation[0],
|
||||||
parent.translation[1] + translated[1],
|
parent.translation[1] + child.translation[1],
|
||||||
parent.translation[2] + translated[2],
|
parent.translation[2] + child.translation[2],
|
||||||
],
|
],
|
||||||
rotation: normalize_quat(mul_quat(parent.rotation, child.rotation))?,
|
rotation: normalize_quat(mul_quat(parent.rotation, child.rotation))?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rotate_point(rotation: [f32; 4], point: [f32; 3]) -> [f32; 3] {
|
|
||||||
let [x, y, z, w] = rotation;
|
|
||||||
let tx = 2.0 * (y * point[2] - z * point[1]);
|
|
||||||
let ty = 2.0 * (z * point[0] - x * point[2]);
|
|
||||||
let tz = 2.0 * (x * point[1] - y * point[0]);
|
|
||||||
[
|
|
||||||
point[0] + w * tx + (y * tz - z * ty),
|
|
||||||
point[1] + w * ty + (z * tx - x * tz),
|
|
||||||
point[2] + w * tz + (x * ty - y * tx),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mul_quat(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
|
fn mul_quat(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
|
||||||
let [lx, ly, lz, lw] = left;
|
let [lx, ly, lz, lw] = left;
|
||||||
let [rx, ry, rz, rw] = right;
|
let [rx, ry, rz, rw] = right;
|
||||||
@@ -1187,26 +1175,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hierarchy_rotates_child_translation_by_parent_orientation() {
|
|
||||||
let half = std::f32::consts::FRAC_1_SQRT_2;
|
|
||||||
let local = [
|
|
||||||
Pose {
|
|
||||||
translation: [1.0, 0.0, 0.0],
|
|
||||||
rotation: [0.0, 0.0, half, half],
|
|
||||||
},
|
|
||||||
Pose {
|
|
||||||
translation: [2.0, 0.0, 0.0],
|
|
||||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let buffer = evaluate_hierarchy(&[ParentIndex(None), ParentIndex(Some(0))], &local)
|
|
||||||
.expect("hierarchy");
|
|
||||||
assert!((buffer.poses[1].translation[0] - 1.0).abs() < 0.0001);
|
|
||||||
assert!((buffer.poses[1].translation[1] - 2.0).abs() < 0.0001);
|
|
||||||
assert!(buffer.poses[1].translation[2].abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_valid_quaternions_remain_finite() {
|
fn generated_valid_quaternions_remain_finite() {
|
||||||
for index in 1..64_u16 {
|
for index in 1..64_u16 {
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
|
fparkan-material = { path = "../fparkan-material" }
|
||||||
fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
|
fparkan-msh = { path = "../fparkan-msh" }
|
||||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
fparkan-mission-format = { path = "../fparkan-mission-format", version = "0.1.0" }
|
fparkan-mission-format = { path = "../fparkan-mission-format" }
|
||||||
fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
|
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||||
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
|
fparkan-resource = { path = "../fparkan-resource" }
|
||||||
fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" }
|
fparkan-texm = { path = "../fparkan-texm" }
|
||||||
fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
|
fparkan-terrain = { path = "../fparkan-terrain" }
|
||||||
fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
|
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
+142
-2447
File diff suppressed because it is too large
Load Diff
@@ -6,17 +6,17 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
fparkan-fx = { path = "../fparkan-fx", version = "0.1.0" }
|
fparkan-fx = { path = "../fparkan-fx" }
|
||||||
fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
|
fparkan-material = { path = "../fparkan-material" }
|
||||||
fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
|
fparkan-msh = { path = "../fparkan-msh" }
|
||||||
fparkan-mission-format = { path = "../fparkan-mission-format", version = "0.1.0" }
|
fparkan-mission-format = { path = "../fparkan-mission-format" }
|
||||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
|
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
|
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||||
fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" }
|
fparkan-texm = { path = "../fparkan-texm" }
|
||||||
fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
|
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::ffi::OsStrExt;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -71,8 +69,6 @@ pub struct DiscoverOptions {
|
|||||||
pub struct ManifestEntry {
|
pub struct ManifestEntry {
|
||||||
/// Normalized relative path.
|
/// Normalized relative path.
|
||||||
pub path: String,
|
pub path: String,
|
||||||
/// Byte-exact relative host path used for reopening corpus files.
|
|
||||||
pub host_rel_path: PathBuf,
|
|
||||||
/// File size in bytes.
|
/// File size in bytes.
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
/// SHA-256 content fingerprint.
|
/// SHA-256 content fingerprint.
|
||||||
@@ -192,7 +188,7 @@ pub fn discover(root: &Path, options: DiscoverOptions) -> Result<CorpusManifest,
|
|||||||
}
|
}
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
walk(root, root, options, &mut files)?;
|
walk(root, root, options, &mut files)?;
|
||||||
files.sort_by(|a, b| a.host_rel_path.cmp(&b.host_rel_path));
|
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
|
||||||
let kind = classify(root, &files);
|
let kind = classify(root, &files);
|
||||||
let casefold_collisions = detect_casefold_collisions(&files);
|
let casefold_collisions = detect_casefold_collisions(&files);
|
||||||
@@ -247,22 +243,17 @@ fn walk(
|
|||||||
let rel = path
|
let rel = path
|
||||||
.strip_prefix(root)
|
.strip_prefix(root)
|
||||||
.map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?;
|
.map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?;
|
||||||
#[cfg(unix)]
|
let rel_text = rel
|
||||||
let rel_bytes = rel.as_os_str().as_bytes();
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
let rel_bytes = rel
|
|
||||||
.to_str()
|
.to_str()
|
||||||
.ok_or_else(|| CorpusError::InvalidPath(path.display().to_string()))?
|
.ok_or_else(|| CorpusError::InvalidPath(path.display().to_string()))?;
|
||||||
.as_bytes();
|
let normalized = normalize_relative(rel_text.as_bytes(), PathPolicy::HostCompatible)
|
||||||
let normalized = normalize_relative(rel_bytes, PathPolicy::HostCompatible)
|
.map_err(|_| CorpusError::InvalidPath(rel_text.to_string()))?;
|
||||||
.map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?;
|
|
||||||
let bytes = fs::read(&path).map_err(|source| CorpusError::Io {
|
let bytes = fs::read(&path).map_err(|source| CorpusError::Io {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
source,
|
source,
|
||||||
})?;
|
})?;
|
||||||
out.push(ManifestEntry {
|
out.push(ManifestEntry {
|
||||||
path: normalized.display_lossy().to_string(),
|
path: normalized.as_str().to_string(),
|
||||||
host_rel_path: rel.to_path_buf(),
|
|
||||||
size: metadata.len(),
|
size: metadata.len(),
|
||||||
hash: sha256(&bytes),
|
hash: sha256(&bytes),
|
||||||
});
|
});
|
||||||
@@ -294,7 +285,7 @@ fn detect_casefold_collisions(files: &[ManifestEntry]) -> Vec<Vec<String>> {
|
|||||||
let mut grouped: BTreeMap<Vec<u8>, BTreeSet<String>> = BTreeMap::new();
|
let mut grouped: BTreeMap<Vec<u8>, BTreeSet<String>> = BTreeMap::new();
|
||||||
for file in files {
|
for file in files {
|
||||||
grouped
|
grouped
|
||||||
.entry(ascii_lookup_key(path_identity_bytes(&file.host_rel_path)).0)
|
.entry(ascii_lookup_key(file.path.as_bytes()).0)
|
||||||
.or_default()
|
.or_default()
|
||||||
.insert(file.path.clone());
|
.insert(file.path.clone());
|
||||||
}
|
}
|
||||||
@@ -362,7 +353,7 @@ fn inspect_report_file(
|
|||||||
) -> CorpusFileRecord {
|
) -> CorpusFileRecord {
|
||||||
let lower = entry.path.to_ascii_lowercase();
|
let lower = entry.path.to_ascii_lowercase();
|
||||||
let mut variant = inspect_path_metrics(&lower, metrics);
|
let mut variant = inspect_path_metrics(&lower, metrics);
|
||||||
let path = root.join(&entry.host_rel_path);
|
let path = root.join(&entry.path);
|
||||||
let bytes = match fs::read(&path) {
|
let bytes = match fs::read(&path) {
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
Err(source) => {
|
Err(source) => {
|
||||||
@@ -448,17 +439,6 @@ fn inspect_report_file(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn path_identity_bytes(path: &Path) -> &[u8] {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
path.as_os_str().as_bytes()
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
path.to_str().unwrap_or_default().as_bytes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> String {
|
fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> String {
|
||||||
let mut variant = "file";
|
let mut variant = "file";
|
||||||
if lower.ends_with("data.tma") {
|
if lower.ends_with("data.tma") {
|
||||||
@@ -787,7 +767,11 @@ mod tests {
|
|||||||
fn report_json_contains_metrics_and_hashes_not_paths_or_payloads() {
|
fn report_json_contains_metrics_and_hashes_not_paths_or_payloads() {
|
||||||
let manifest = CorpusManifest {
|
let manifest = CorpusManifest {
|
||||||
kind: CorpusKind::Part1,
|
kind: CorpusKind::Part1,
|
||||||
files: vec![manifest_entry("secret/payload.bin", 4, sha256(b"DATA"))],
|
files: vec![ManifestEntry {
|
||||||
|
path: "secret/payload.bin".to_string(),
|
||||||
|
size: 4,
|
||||||
|
hash: sha256(b"DATA"),
|
||||||
|
}],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
};
|
};
|
||||||
let report = report(Path::new("."), &manifest).expect("report");
|
let report = report(Path::new("."), &manifest).expect("report");
|
||||||
@@ -807,7 +791,11 @@ mod tests {
|
|||||||
let root = temp_dir("report-missing");
|
let root = temp_dir("report-missing");
|
||||||
let manifest = CorpusManifest {
|
let manifest = CorpusManifest {
|
||||||
kind: CorpusKind::Unknown,
|
kind: CorpusKind::Unknown,
|
||||||
files: vec![manifest_entry("missing.lib", 1, sha256(b"missing"))],
|
files: vec![ManifestEntry {
|
||||||
|
path: "missing.lib".to_string(),
|
||||||
|
size: 1,
|
||||||
|
hash: sha256(b"missing"),
|
||||||
|
}],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -826,7 +814,11 @@ mod tests {
|
|||||||
fs::write(root.join("bad.lib"), b"NRes").expect("bad nres");
|
fs::write(root.join("bad.lib"), b"NRes").expect("bad nres");
|
||||||
let manifest = CorpusManifest {
|
let manifest = CorpusManifest {
|
||||||
kind: CorpusKind::Unknown,
|
kind: CorpusKind::Unknown,
|
||||||
files: vec![manifest_entry("bad.lib", 4, sha256(b"NRes"))],
|
files: vec![ManifestEntry {
|
||||||
|
path: "bad.lib".to_string(),
|
||||||
|
size: 4,
|
||||||
|
hash: sha256(b"NRes"),
|
||||||
|
}],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -865,11 +857,11 @@ mod tests {
|
|||||||
fs::write(root.join("archive.lib"), &archive).expect("archive");
|
fs::write(root.join("archive.lib"), &archive).expect("archive");
|
||||||
let manifest = CorpusManifest {
|
let manifest = CorpusManifest {
|
||||||
kind: CorpusKind::Unknown,
|
kind: CorpusKind::Unknown,
|
||||||
files: vec![manifest_entry(
|
files: vec![ManifestEntry {
|
||||||
"archive.lib",
|
path: "archive.lib".to_string(),
|
||||||
u64::try_from(archive.len()).expect("archive size"),
|
size: u64::try_from(archive.len()).expect("archive size"),
|
||||||
sha256(&archive),
|
hash: sha256(&archive),
|
||||||
)],
|
}],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -894,11 +886,11 @@ mod tests {
|
|||||||
fs::write(root.join("WORLD/MAP/land.map"), build_nres(&[])).expect("land map");
|
fs::write(root.join("WORLD/MAP/land.map"), build_nres(&[])).expect("land map");
|
||||||
let manifest = CorpusManifest {
|
let manifest = CorpusManifest {
|
||||||
kind: CorpusKind::Unknown,
|
kind: CorpusKind::Unknown,
|
||||||
files: vec![manifest_entry(
|
files: vec![ManifestEntry {
|
||||||
"WORLD/MAP/land.map",
|
path: "WORLD/MAP/land.map".to_string(),
|
||||||
16,
|
size: 16,
|
||||||
sha256(b"land.map"),
|
hash: sha256(b"land.map"),
|
||||||
)],
|
}],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -917,11 +909,11 @@ mod tests {
|
|||||||
fs::write(root.join("WORLD/MAP/land.msh"), build_nres(&[])).expect("land msh");
|
fs::write(root.join("WORLD/MAP/land.msh"), build_nres(&[])).expect("land msh");
|
||||||
let manifest = CorpusManifest {
|
let manifest = CorpusManifest {
|
||||||
kind: CorpusKind::Unknown,
|
kind: CorpusKind::Unknown,
|
||||||
files: vec![manifest_entry(
|
files: vec![ManifestEntry {
|
||||||
"WORLD/MAP/land.msh",
|
path: "WORLD/MAP/land.msh".to_string(),
|
||||||
16,
|
size: 16,
|
||||||
sha256(b"land.msh"),
|
hash: sha256(b"land.msh"),
|
||||||
)],
|
}],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -940,11 +932,11 @@ mod tests {
|
|||||||
fs::write(root.join("MISSIONS/test/data.tma"), b"malformed tma").expect("tma");
|
fs::write(root.join("MISSIONS/test/data.tma"), b"malformed tma").expect("tma");
|
||||||
let manifest = CorpusManifest {
|
let manifest = CorpusManifest {
|
||||||
kind: CorpusKind::Unknown,
|
kind: CorpusKind::Unknown,
|
||||||
files: vec![manifest_entry(
|
files: vec![ManifestEntry {
|
||||||
"MISSIONS/test/data.tma",
|
path: "MISSIONS/test/data.tma".to_string(),
|
||||||
12,
|
size: 12,
|
||||||
sha256(b"malformed tma"),
|
hash: sha256(b"malformed tma"),
|
||||||
)],
|
}],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -963,7 +955,11 @@ mod tests {
|
|||||||
fs::write(root.join("units/unit.dat"), vec![0u8; 120]).expect("unit");
|
fs::write(root.join("units/unit.dat"), vec![0u8; 120]).expect("unit");
|
||||||
let manifest = CorpusManifest {
|
let manifest = CorpusManifest {
|
||||||
kind: CorpusKind::Unknown,
|
kind: CorpusKind::Unknown,
|
||||||
files: vec![manifest_entry("units/unit.dat", 120, sha256(&[0u8; 120]))],
|
files: vec![ManifestEntry {
|
||||||
|
path: "units/unit.dat".to_string(),
|
||||||
|
size: 120,
|
||||||
|
hash: sha256(&[0u8; 120]),
|
||||||
|
}],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -981,7 +977,11 @@ mod tests {
|
|||||||
fs::write(root.join("patch.nl"), b"NL malformed").expect("rsli");
|
fs::write(root.join("patch.nl"), b"NL malformed").expect("rsli");
|
||||||
let manifest = CorpusManifest {
|
let manifest = CorpusManifest {
|
||||||
kind: CorpusKind::Unknown,
|
kind: CorpusKind::Unknown,
|
||||||
files: vec![manifest_entry("patch.nl", 12, sha256(b"NL malformed"))],
|
files: vec![ManifestEntry {
|
||||||
|
path: "patch.nl".to_string(),
|
||||||
|
size: 12,
|
||||||
|
hash: sha256(b"NL malformed"),
|
||||||
|
}],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1052,8 +1052,16 @@ mod tests {
|
|||||||
let manifest = CorpusManifest {
|
let manifest = CorpusManifest {
|
||||||
kind: CorpusKind::Unknown,
|
kind: CorpusKind::Unknown,
|
||||||
files: vec![
|
files: vec![
|
||||||
manifest_entry("Textures/Foo.TEX", 1, sha256(b"first")),
|
ManifestEntry {
|
||||||
manifest_entry("textures/foo.tex", 1, sha256(b"second")),
|
path: "Textures/Foo.TEX".to_string(),
|
||||||
|
size: 1,
|
||||||
|
hash: sha256(b"first"),
|
||||||
|
},
|
||||||
|
ManifestEntry {
|
||||||
|
path: "textures/foo.tex".to_string(),
|
||||||
|
size: 1,
|
||||||
|
hash: sha256(b"second"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -1073,7 +1081,11 @@ mod tests {
|
|||||||
fn fingerprint_changes() {
|
fn fingerprint_changes() {
|
||||||
let mut manifest = CorpusManifest {
|
let mut manifest = CorpusManifest {
|
||||||
kind: CorpusKind::Unknown,
|
kind: CorpusKind::Unknown,
|
||||||
files: vec![manifest_entry("a", 1, sha256(b"before"))],
|
files: vec![ManifestEntry {
|
||||||
|
path: "a".to_string(),
|
||||||
|
size: 1,
|
||||||
|
hash: sha256(b"before"),
|
||||||
|
}],
|
||||||
casefold_collisions: Vec::new(),
|
casefold_collisions: Vec::new(),
|
||||||
};
|
};
|
||||||
let a = fingerprint(&manifest);
|
let a = fingerprint(&manifest);
|
||||||
@@ -1106,29 +1118,6 @@ mod tests {
|
|||||||
let _ = fs::remove_file(tmp);
|
let _ = fs::remove_file(tmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn discover_supports_non_utf8_host_paths() {
|
|
||||||
use std::ffi::OsString;
|
|
||||||
use std::os::unix::ffi::OsStringExt;
|
|
||||||
|
|
||||||
let root = temp_dir("non-utf8");
|
|
||||||
let file_name = OsString::from_vec(vec![0xFF, b'.', b'b', b'i', b'n']);
|
|
||||||
let file_path = root.join(&file_name);
|
|
||||||
if let Err(err) = fs::write(&file_path, b"raw") {
|
|
||||||
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
|
|
||||||
let _ = fs::remove_dir_all(root);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let manifest = discover(&root, DiscoverOptions::default()).expect("manifest");
|
|
||||||
|
|
||||||
assert_eq!(manifest.files.len(), 1);
|
|
||||||
assert_eq!(manifest.files[0].path, "\u{FFFD}.bin");
|
|
||||||
assert_eq!(manifest.files[0].host_rel_path, PathBuf::from(&file_name));
|
|
||||||
let _ = fs::remove_dir_all(root);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TestNresEntry<'a> {
|
struct TestNresEntry<'a> {
|
||||||
name: &'a str,
|
name: &'a str,
|
||||||
type_id: u32,
|
type_id: u32,
|
||||||
@@ -1175,15 +1164,6 @@ mod tests {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn manifest_entry(path: &str, size: u64, hash: Sha256Digest) -> ManifestEntry {
|
|
||||||
ManifestEntry {
|
|
||||||
path: path.to_string(),
|
|
||||||
host_rel_path: PathBuf::from(path),
|
|
||||||
size,
|
|
||||||
hash,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
||||||
out.extend_from_slice(&value.to_le_bytes());
|
out.extend_from_slice(&value.to_le_bytes());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -6,16 +6,13 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-diagnostics = { path = "../fparkan-diagnostics", version = "0.1.0" }
|
fparkan-msh = { path = "../fparkan-msh" }
|
||||||
fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
|
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
fparkan-resource = { path = "../fparkan-resource" }
|
||||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||||
fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
|
fparkan-texm = { path = "../fparkan-texm" }
|
||||||
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
|
|
||||||
fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" }
|
|
||||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -20,23 +20,14 @@
|
|||||||
)]
|
)]
|
||||||
//! Shared inspection helpers for format-backed tooling.
|
//! Shared inspection helpers for format-backed tooling.
|
||||||
|
|
||||||
use fparkan_diagnostics::{
|
use fparkan_msh::{decode_msh, validate_msh};
|
||||||
diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase, SourceSpan,
|
|
||||||
};
|
|
||||||
use fparkan_material::{decode_wear, resolve_material, MaterialFallback};
|
|
||||||
use fparkan_msh::{
|
|
||||||
decode_msh, node38_metadata, selected_slot, validate_msh, Group, Lod, ModelAsset, NodeId,
|
|
||||||
};
|
|
||||||
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
|
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
|
||||||
use fparkan_path::{normalize_relative, PathPolicy};
|
|
||||||
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
|
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
|
||||||
use fparkan_rsli::decode as decode_rsli;
|
use fparkan_rsli::decode as decode_rsli;
|
||||||
use fparkan_terrain_format::{decode_land_map, decode_land_msh, LandMeshDocument};
|
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
|
||||||
use fparkan_texm::decode_texm;
|
use fparkan_texm::decode_texm;
|
||||||
use fparkan_vfs::{DirectoryVfs, Vfs};
|
use fparkan_vfs::DirectoryVfs;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::ffi::OsStrExt;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -87,29 +78,6 @@ pub struct ModelInspection {
|
|||||||
pub indices: usize,
|
pub indices: usize,
|
||||||
/// Batch count.
|
/// Batch count.
|
||||||
pub batches: usize,
|
pub batches: usize,
|
||||||
/// Original node record stride.
|
|
||||||
pub node_stride: usize,
|
|
||||||
/// Standard-node metadata in source order, when the model uses `Node38`.
|
|
||||||
pub node38: Vec<Node38Inspection>,
|
|
||||||
/// Number of decoded type-8 animation keys, when available.
|
|
||||||
pub animation_keys: Option<usize>,
|
|
||||||
/// Declared type-19 animation frame count, when available.
|
|
||||||
pub animation_frame_count: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inspection view of a standard 38-byte model node.
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct Node38Inspection {
|
|
||||||
/// Source node index.
|
|
||||||
pub index: usize,
|
|
||||||
/// Opaque source field at byte offset two.
|
|
||||||
pub parent_or_link_raw: u16,
|
|
||||||
/// Type-19 frame-map offset, or `0xFFFF`.
|
|
||||||
pub anim_map_start: u16,
|
|
||||||
/// Type-8 fallback key index.
|
|
||||||
pub fallback_key: u16,
|
|
||||||
/// Whether LOD zero/group zero selects a geometry slot.
|
|
||||||
pub has_lod0_group0: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Texture inspection payload.
|
/// Texture inspection payload.
|
||||||
@@ -127,38 +95,6 @@ pub struct TextureInspection {
|
|||||||
pub pages: usize,
|
pub pages: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Diffuse TEXM selected through an original WEAR and MAT0 material chain.
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct WearMaterialTexture {
|
|
||||||
/// Source WEAR archive.
|
|
||||||
pub wear_archive: String,
|
|
||||||
/// Source WEAR resource.
|
|
||||||
pub wear_resource: String,
|
|
||||||
/// Positional WEAR selector used by an MSH batch.
|
|
||||||
pub material_index: u16,
|
|
||||||
/// Resolved MAT0 resource name after the original fallback chain.
|
|
||||||
pub material_name: String,
|
|
||||||
/// Fallback route used while resolving the MAT0 resource.
|
|
||||||
pub material_fallback: MaterialFallback,
|
|
||||||
/// Texture name selected from phase zero of the resolved MAT0 document.
|
|
||||||
pub texture_name: String,
|
|
||||||
/// Decoded RGBA8 mip zero suitable for the Vulkan upload boundary.
|
|
||||||
pub image: fparkan_texm::RgbaImage,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compact inspection summary of a WEAR resource stored in an archive.
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct WearInspection {
|
|
||||||
/// Number of material rows.
|
|
||||||
pub materials: usize,
|
|
||||||
/// Number of lightmap rows.
|
|
||||||
pub lightmaps: usize,
|
|
||||||
/// First material resource name, when present.
|
|
||||||
pub first_material: Option<String>,
|
|
||||||
/// Last material resource name, when present.
|
|
||||||
pub last_material: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Land map/msh inspection payload.
|
/// Land map/msh inspection payload.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct MapInspection {
|
pub struct MapInspection {
|
||||||
@@ -180,17 +116,6 @@ pub struct MapInspection {
|
|||||||
pub grid_height: u32,
|
pub grid_height: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Axis-aligned position bounds of a decoded `Land.msh`.
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct LandMeshBoundsInspection {
|
|
||||||
/// Number of source positions covered by the bounds.
|
|
||||||
pub positions: usize,
|
|
||||||
/// Per-axis inclusive minimum position.
|
|
||||||
pub min: [f32; 3],
|
|
||||||
/// Per-axis inclusive maximum position.
|
|
||||||
pub max: [f32; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Supported land file kinds.
|
/// Supported land file kinds.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum LandFileKind {
|
pub enum LandFileKind {
|
||||||
@@ -206,92 +131,22 @@ pub enum LandFileKind {
|
|||||||
///
|
///
|
||||||
/// Returns a string error when the archive cannot be read or decoded.
|
/// Returns a string error when the archive cannot be read or decoded.
|
||||||
pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
|
pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
|
||||||
inspect_archive_file_diagnostic(path, sample_limit)
|
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||||
.map_err(|diagnostic| render_human(&diagnostic))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inspects a format archive and returns a structured diagnostic on failure.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns a [`Diagnostic`] when the archive cannot be read or decoded.
|
|
||||||
// Diagnostic is deliberately returned by value as the public structured-error contract.
|
|
||||||
#[allow(clippy::result_large_err)]
|
|
||||||
pub fn inspect_archive_file_diagnostic(
|
|
||||||
path: &Path,
|
|
||||||
sample_limit: usize,
|
|
||||||
) -> Result<ArchiveInspection, Diagnostic> {
|
|
||||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
|
||||||
let file_name = path.file_name().ok_or_else(|| {
|
|
||||||
diagnostic(
|
|
||||||
DiagnosticCode("S1.VFS.PATH"),
|
|
||||||
format!("{}: archive path has no file name", path.display()),
|
|
||||||
)
|
|
||||||
.with_context(DiagnosticContext {
|
|
||||||
phase: Some(Phase::Read),
|
|
||||||
path: Some(path.display().to_string()),
|
|
||||||
..DiagnosticContext::default()
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
#[cfg(unix)]
|
|
||||||
let raw_name = file_name.as_bytes();
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
let raw_name = file_name
|
|
||||||
.to_str()
|
|
||||||
.ok_or_else(|| {
|
|
||||||
diagnostic(
|
|
||||||
DiagnosticCode("S1.VFS.PATH"),
|
|
||||||
format!("{}: archive file name is not valid text", path.display()),
|
|
||||||
)
|
|
||||||
.with_context(DiagnosticContext {
|
|
||||||
phase: Some(Phase::Read),
|
|
||||||
path: Some(path.display().to_string()),
|
|
||||||
..DiagnosticContext::default()
|
|
||||||
})
|
|
||||||
})?
|
|
||||||
.as_bytes();
|
|
||||||
let normalized = normalize_relative(raw_name, PathPolicy::HostCompatible).map_err(|err| {
|
|
||||||
diagnostic(
|
|
||||||
DiagnosticCode("S1.VFS.PATH"),
|
|
||||||
format!("{}: {err}", path.display()),
|
|
||||||
)
|
|
||||||
.with_context(DiagnosticContext {
|
|
||||||
phase: Some(Phase::Read),
|
|
||||||
path: Some(path.display().to_string()),
|
|
||||||
..DiagnosticContext::default()
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
let vfs = DirectoryVfs::new(parent);
|
|
||||||
let bytes = vfs.read(&normalized).map_err(|err| {
|
|
||||||
diagnostic(
|
|
||||||
DiagnosticCode("S1.VFS.READ"),
|
|
||||||
format!("{}: {err}", path.display()),
|
|
||||||
)
|
|
||||||
.with_context(DiagnosticContext {
|
|
||||||
phase: Some(Phase::Read),
|
|
||||||
path: Some(path.display().to_string()),
|
|
||||||
..DiagnosticContext::default()
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
inspect_archive_bytes(&bytes, sample_limit, Some(path))
|
inspect_archive_bytes(&bytes, sample_limit, Some(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inspects archive bytes and returns a typed summary.
|
/// Inspects archive bytes and returns a typed summary.
|
||||||
// Keeps the internal diagnostic flow aligned with the public structured-error contract.
|
|
||||||
#[allow(clippy::result_large_err)]
|
|
||||||
fn inspect_archive_bytes(
|
fn inspect_archive_bytes(
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
sample_limit: usize,
|
sample_limit: usize,
|
||||||
source: Option<&Path>,
|
source: Option<&Path>,
|
||||||
) -> Result<ArchiveInspection, Diagnostic> {
|
) -> Result<ArchiveInspection, String> {
|
||||||
if bytes.starts_with(b"NRes") {
|
if bytes.starts_with(b"NRes") {
|
||||||
let document = decode_nres(
|
let document = decode_nres(
|
||||||
Arc::from(bytes.to_vec().into_boxed_slice()),
|
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||||
ReadProfile::Compatible,
|
ReadProfile::Compatible,
|
||||||
)
|
)
|
||||||
.map_err(|err| {
|
.map_err(|err| err.to_string())?;
|
||||||
archive_parse_diagnostic("S1.NRES.DECODE", source, bytes, err.to_string())
|
|
||||||
})?;
|
|
||||||
let mut sample = Vec::new();
|
let mut sample = Vec::new();
|
||||||
for entry in document.entries().iter().take(sample_limit) {
|
for entry in document.entries().iter().take(sample_limit) {
|
||||||
sample.push(NresEntrySummary {
|
sample.push(NresEntrySummary {
|
||||||
@@ -310,19 +165,15 @@ fn inspect_archive_bytes(
|
|||||||
Arc::from(bytes.to_vec().into_boxed_slice()),
|
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||||
fparkan_rsli::ReadProfile::Compatible,
|
fparkan_rsli::ReadProfile::Compatible,
|
||||||
)
|
)
|
||||||
.map_err(|err| {
|
.map_err(|err| err.to_string())?;
|
||||||
archive_parse_diagnostic("S1.RSLI.DECODE", source, bytes, err.to_string())
|
|
||||||
})?;
|
|
||||||
Ok(ArchiveInspection::Rsli {
|
Ok(ArchiveInspection::Rsli {
|
||||||
entries: document.entries().len(),
|
entries: document.entries().len(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Err(archive_parse_diagnostic(
|
match source {
|
||||||
"S1.RESOURCE.UNSUPPORTED_ARCHIVE",
|
Some(path) => Err(format!("{}: unsupported archive magic", path.display())),
|
||||||
source,
|
None => Err("unsupported archive magic".to_string()),
|
||||||
bytes,
|
}
|
||||||
"unsupported archive magic".to_string(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,36 +188,10 @@ pub fn inspect_model_from_root(
|
|||||||
archive: &str,
|
archive: &str,
|
||||||
resource: &str,
|
resource: &str,
|
||||||
) -> Result<ModelInspection, String> {
|
) -> Result<ModelInspection, String> {
|
||||||
let bytes = read_resource_bytes_diagnostic(root, archive, resource)
|
let bytes = read_resource_bytes(root, archive, resource)?;
|
||||||
.map_err(|err| render_human(&err))?;
|
let document = decode_nres(bytes, ReadProfile::Compatible).map_err(|err| err.to_string())?;
|
||||||
let document = decode_nres(bytes.clone(), ReadProfile::Compatible).map_err(|err| {
|
|
||||||
render_human(&resource_parse_diagnostic(
|
|
||||||
"S1.NRES.DECODE",
|
|
||||||
archive,
|
|
||||||
resource,
|
|
||||||
&bytes,
|
|
||||||
err.to_string(),
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let msh = decode_msh(&document).map_err(|err| err.to_string())?;
|
let msh = decode_msh(&document).map_err(|err| err.to_string())?;
|
||||||
let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
|
let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
|
||||||
let node38 = if validated.node_stride == 38 {
|
|
||||||
(0..validated.node_count)
|
|
||||||
.filter_map(|index| {
|
|
||||||
let node = NodeId(u32::try_from(index).ok()?);
|
|
||||||
let metadata = node38_metadata(&validated, node)?;
|
|
||||||
Some(Node38Inspection {
|
|
||||||
index,
|
|
||||||
parent_or_link_raw: metadata.parent_or_link_raw,
|
|
||||||
anim_map_start: metadata.anim_map_start,
|
|
||||||
fallback_key: metadata.fallback_key,
|
|
||||||
has_lod0_group0: selected_slot(&validated, node, Lod(0), Group(0)).is_some(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
Ok(ModelInspection {
|
Ok(ModelInspection {
|
||||||
streams: msh.streams().len(),
|
streams: msh.streams().len(),
|
||||||
nodes: validated.node_count,
|
nodes: validated.node_count,
|
||||||
@@ -374,64 +199,9 @@ pub fn inspect_model_from_root(
|
|||||||
positions: validated.positions.len(),
|
positions: validated.positions.len(),
|
||||||
indices: validated.indices.len(),
|
indices: validated.indices.len(),
|
||||||
batches: validated.batches.len(),
|
batches: validated.batches.len(),
|
||||||
node_stride: validated.node_stride,
|
|
||||||
node38,
|
|
||||||
animation_keys: validated
|
|
||||||
.animation
|
|
||||||
.as_ref()
|
|
||||||
.map(|animation| animation.keys.len()),
|
|
||||||
animation_frame_count: validated
|
|
||||||
.animation
|
|
||||||
.as_ref()
|
|
||||||
.map(|animation| animation.frame_count),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inspects a WEAR resource through repository-backed lookup.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns a string error when the resource cannot be resolved or parsed as a
|
|
||||||
/// valid WEAR payload.
|
|
||||||
pub fn inspect_wear_from_root(
|
|
||||||
root: &Path,
|
|
||||||
archive: &str,
|
|
||||||
resource: &str,
|
|
||||||
) -> Result<WearInspection, String> {
|
|
||||||
let bytes = read_resource_bytes_diagnostic(root, archive, resource)
|
|
||||||
.map_err(|err| render_human(&err))?;
|
|
||||||
let wear = decode_wear(&bytes).map_err(|err| err.to_string())?;
|
|
||||||
Ok(WearInspection {
|
|
||||||
materials: wear.entries.len(),
|
|
||||||
lightmaps: wear.lightmaps.len(),
|
|
||||||
first_material: wear
|
|
||||||
.entries
|
|
||||||
.first()
|
|
||||||
.map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()),
|
|
||||||
last_material: wear
|
|
||||||
.entries
|
|
||||||
.last()
|
|
||||||
.map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads and validates a model resource through repository-backed lookup.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns a string error when the resource cannot be resolved or parsed as a
|
|
||||||
/// valid model payload.
|
|
||||||
pub fn load_model_from_root(
|
|
||||||
root: &Path,
|
|
||||||
archive: &str,
|
|
||||||
resource: &str,
|
|
||||||
) -> Result<ModelAsset, String> {
|
|
||||||
let document = load_model_document_from_root_diagnostic(root, archive, resource)
|
|
||||||
.map_err(|err| render_human(&err))?;
|
|
||||||
let msh = decode_msh(&document).map_err(|err| err.to_string())?;
|
|
||||||
validate_msh(&msh).map_err(|err| err.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inspects a texture through repository-backed resource lookup.
|
/// Inspects a texture through repository-backed resource lookup.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -443,8 +213,7 @@ pub fn inspect_texture_from_root(
|
|||||||
archive: &str,
|
archive: &str,
|
||||||
resource: &str,
|
resource: &str,
|
||||||
) -> Result<TextureInspection, String> {
|
) -> Result<TextureInspection, String> {
|
||||||
let bytes = read_resource_bytes_diagnostic(root, archive, resource)
|
let bytes = read_resource_bytes(root, archive, resource)?;
|
||||||
.map_err(|err| render_human(&err))?;
|
|
||||||
let document = decode_texm(bytes).map_err(|err| err.to_string())?;
|
let document = decode_texm(bytes).map_err(|err| err.to_string())?;
|
||||||
Ok(TextureInspection {
|
Ok(TextureInspection {
|
||||||
width: document.width(),
|
width: document.width(),
|
||||||
@@ -455,113 +224,6 @@ pub fn inspect_texture_from_root(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads a decoded TEXM document through repository-backed lookup.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns a string error when the resource cannot be resolved or parsed as a
|
|
||||||
/// valid TEXM payload.
|
|
||||||
pub fn load_texture_from_root(
|
|
||||||
root: &Path,
|
|
||||||
archive: &str,
|
|
||||||
resource: &str,
|
|
||||||
) -> Result<fparkan_texm::TexmDocument, String> {
|
|
||||||
let bytes = read_resource_bytes_diagnostic(root, archive, resource)
|
|
||||||
.map_err(|err| render_human(&err))?;
|
|
||||||
decode_texm(bytes).map_err(|err| err.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads and decodes TEXM mip 0 as RGBA8 through repository-backed lookup.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns a string error when the resource cannot be resolved, decoded, or
|
|
||||||
/// converted to the shared RGBA8 upload representation.
|
|
||||||
pub fn load_texture_mip0_rgba8_from_root(
|
|
||||||
root: &Path,
|
|
||||||
archive: &str,
|
|
||||||
resource: &str,
|
|
||||||
) -> Result<fparkan_texm::RgbaImage, String> {
|
|
||||||
let document = load_texture_from_root(root, archive, resource)?;
|
|
||||||
fparkan_texm::decode_mip_rgba8(&document, 0).map_err(|err| err.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves phase-zero diffuse TEXM through `WEAR → MAT0 → Textures.lib`.
|
|
||||||
///
|
|
||||||
/// The selector is the positional `Batch20.material_index`, not WEAR's legacy
|
|
||||||
/// text id. MAT0 fallback remains owned by `fparkan-material`: exact requested
|
|
||||||
/// entry, then `DEFAULT`, then the first material entry.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns a string error if WEAR/MAT0 resolution, phase selection, or TEXM
|
|
||||||
/// decoding fails. An empty phase-zero texture name is an intentional
|
|
||||||
/// untextured material and is reported rather than substituted with a texture.
|
|
||||||
pub fn load_wear_material_texture_mip0_rgba8_from_root(
|
|
||||||
root: &Path,
|
|
||||||
wear_archive: &str,
|
|
||||||
wear_resource: &str,
|
|
||||||
material_index: u16,
|
|
||||||
) -> Result<WearMaterialTexture, String> {
|
|
||||||
let wear_bytes = read_resource_bytes_diagnostic(root, wear_archive, wear_resource)
|
|
||||||
.map_err(|err| render_human(&err))?;
|
|
||||||
let wear = decode_wear(&wear_bytes).map_err(|err| err.to_string())?;
|
|
||||||
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
|
||||||
let material =
|
|
||||||
resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?;
|
|
||||||
let texture = material.document.primary_texture().ok_or_else(|| {
|
|
||||||
"MAT0 phase zero declares an intentionally untextured material".to_string()
|
|
||||||
})?;
|
|
||||||
let texture_name = String::from_utf8_lossy(&texture.0).into_owned();
|
|
||||||
let image = load_texture_mip0_rgba8_from_root(root, "Textures.lib", &texture_name)?;
|
|
||||||
Ok(WearMaterialTexture {
|
|
||||||
wear_archive: wear_archive.to_string(),
|
|
||||||
wear_resource: wear_resource.to_string(),
|
|
||||||
material_index,
|
|
||||||
material_name: String::from_utf8_lossy(&material.name.0).into_owned(),
|
|
||||||
material_fallback: material.fallback,
|
|
||||||
texture_name,
|
|
||||||
image,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves phase-zero diffuse TEXM through a standalone map-local WEAR file.
|
|
||||||
///
|
|
||||||
/// Map terrain keeps its two WEAR tables adjacent to `Land.msh`, while their
|
|
||||||
/// MAT0 and TEXM resources remain in the game root's normal archives. This
|
|
||||||
/// preserves that split instead of treating the sidecar as an archive entry.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns a string error if the standalone WEAR file, global material
|
|
||||||
/// resources, or selected diffuse TEXM cannot be decoded.
|
|
||||||
pub fn load_standalone_wear_material_texture_mip0_rgba8_from_root(
|
|
||||||
root: &Path,
|
|
||||||
wear_path: &Path,
|
|
||||||
material_index: u16,
|
|
||||||
) -> Result<WearMaterialTexture, String> {
|
|
||||||
let wear_bytes =
|
|
||||||
fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?;
|
|
||||||
let wear = decode_wear(&wear_bytes).map_err(|err| err.to_string())?;
|
|
||||||
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
|
||||||
let material =
|
|
||||||
resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?;
|
|
||||||
let texture = material.document.primary_texture().ok_or_else(|| {
|
|
||||||
"MAT0 phase zero declares an intentionally untextured material".to_string()
|
|
||||||
})?;
|
|
||||||
let texture_name = String::from_utf8_lossy(&texture.0).into_owned();
|
|
||||||
let image = load_texture_mip0_rgba8_from_root(root, "Textures.lib", &texture_name)?;
|
|
||||||
Ok(WearMaterialTexture {
|
|
||||||
wear_archive: "<standalone>".to_string(),
|
|
||||||
wear_resource: wear_path.display().to_string(),
|
|
||||||
material_index,
|
|
||||||
material_name: String::from_utf8_lossy(&material.name.0).into_owned(),
|
|
||||||
material_fallback: material.fallback,
|
|
||||||
texture_name,
|
|
||||||
image,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inspects a terrain land file by path.
|
/// Inspects a terrain land file by path.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -578,59 +240,6 @@ pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspectio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads and validates a standalone `Land.msh` file.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns a human-readable error if the file cannot be read, decoded as `NRes`,
|
|
||||||
/// or decoded as the specialized terrain mesh format.
|
|
||||||
pub fn load_land_msh_from_path(path: &Path) -> Result<LandMeshDocument, String> {
|
|
||||||
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
|
||||||
let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
decode_land_msh(&document).map_err(|err| err.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inspects the source-coordinate bounds of a standalone `Land.msh` file.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns a string error when the file cannot be read, decoded, or contains
|
|
||||||
/// no finite source positions.
|
|
||||||
pub fn inspect_land_msh_bounds_file(path: &Path) -> Result<LandMeshBoundsInspection, String> {
|
|
||||||
let mesh = load_land_msh_from_path(path)?;
|
|
||||||
inspect_land_msh_bounds(&mesh)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Computes source-coordinate bounds for an already validated `Land.msh`.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns a string error when the mesh has no finite source positions.
|
|
||||||
pub fn inspect_land_msh_bounds(
|
|
||||||
mesh: &LandMeshDocument,
|
|
||||||
) -> Result<LandMeshBoundsInspection, String> {
|
|
||||||
let mut min = [f32::INFINITY; 3];
|
|
||||||
let mut max = [f32::NEG_INFINITY; 3];
|
|
||||||
for position in &mesh.positions {
|
|
||||||
if !position.iter().all(|value| value.is_finite()) {
|
|
||||||
return Err("Land.msh contains a non-finite source position".to_string());
|
|
||||||
}
|
|
||||||
for axis in 0..3 {
|
|
||||||
min[axis] = min[axis].min(position[axis]);
|
|
||||||
max[axis] = max[axis].max(position[axis]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if mesh.positions.is_empty() {
|
|
||||||
return Err("Land.msh contains no source positions".to_string());
|
|
||||||
}
|
|
||||||
Ok(LandMeshBoundsInspection {
|
|
||||||
positions: mesh.positions.len(),
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> {
|
fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> {
|
||||||
let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?;
|
let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?;
|
||||||
Ok(MapInspection {
|
Ok(MapInspection {
|
||||||
@@ -659,135 +268,32 @@ fn inspect_land_map(document: &NresDocument) -> Result<MapInspection, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserves the shared structured-error type without changing callers to boxed errors.
|
fn read_resource_bytes(root: &Path, archive: &str, name: &str) -> Result<Arc<[u8]>, String> {
|
||||||
#[allow(clippy::result_large_err)]
|
|
||||||
fn read_resource_bytes_diagnostic(
|
|
||||||
root: &Path,
|
|
||||||
archive: &str,
|
|
||||||
name: &str,
|
|
||||||
) -> Result<Arc<[u8]>, Diagnostic> {
|
|
||||||
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
||||||
let archive_path = archive_path(archive.as_bytes()).map_err(|err| {
|
let archive_path = archive_path(archive.as_bytes()).map_err(|err| err.to_string())?;
|
||||||
diagnostic(DiagnosticCode("S1.PATH.ARCHIVE"), err.to_string()).with_context(
|
|
||||||
DiagnosticContext {
|
|
||||||
phase: Some(Phase::Resolve),
|
|
||||||
path: Some(archive.to_string()),
|
|
||||||
archive_entry: Some(name.to_string()),
|
|
||||||
..DiagnosticContext::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let resource_name = resource_name(name.as_bytes());
|
let resource_name = resource_name(name.as_bytes());
|
||||||
let archive_handle = repository.open_archive(&archive_path).map_err(|err| {
|
let archive_handle = repository
|
||||||
diagnostic(DiagnosticCode("S1.RESOURCE.OPEN_ARCHIVE"), err.to_string()).with_context(
|
.open_archive(&archive_path)
|
||||||
DiagnosticContext {
|
.map_err(|err| format!("{err}"))?;
|
||||||
phase: Some(Phase::Read),
|
|
||||||
path: Some(archive.to_string()),
|
|
||||||
archive_entry: Some(name.to_string()),
|
|
||||||
..DiagnosticContext::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let Some(handle) = repository
|
let Some(handle) = repository
|
||||||
.find(archive_handle, &resource_name)
|
.find(archive_handle, &resource_name)
|
||||||
.map_err(|err| {
|
.map_err(|err| format!("{err}"))?
|
||||||
diagnostic(DiagnosticCode("S1.RESOURCE.FIND"), err.to_string()).with_context(
|
|
||||||
DiagnosticContext {
|
|
||||||
phase: Some(Phase::Resolve),
|
|
||||||
path: Some(archive.to_string()),
|
|
||||||
archive_entry: Some(name.to_string()),
|
|
||||||
..DiagnosticContext::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
else {
|
else {
|
||||||
return Err(diagnostic(
|
return Err(format!(
|
||||||
DiagnosticCode("S1.RESOURCE.MISSING_ENTRY"),
|
|
||||||
format!(
|
|
||||||
"resource not found: {archive}/{}",
|
"resource not found: {archive}/{}",
|
||||||
String::from_utf8_lossy(name.as_bytes())
|
String::from_utf8_lossy(name.as_bytes())
|
||||||
),
|
));
|
||||||
)
|
|
||||||
.with_context(DiagnosticContext {
|
|
||||||
phase: Some(Phase::Resolve),
|
|
||||||
path: Some(archive.to_string()),
|
|
||||||
archive_entry: Some(name.to_string()),
|
|
||||||
..DiagnosticContext::default()
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
let bytes = repository.read(handle).map_err(|err| {
|
let bytes = repository.read(handle).map_err(|err| format!("{err}"))?;
|
||||||
diagnostic(DiagnosticCode("S1.RESOURCE.READ"), err.to_string()).with_context(
|
|
||||||
DiagnosticContext {
|
|
||||||
phase: Some(Phase::Read),
|
|
||||||
path: Some(archive.to_string()),
|
|
||||||
archive_entry: Some(name.to_string()),
|
|
||||||
..DiagnosticContext::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(Arc::from(bytes.into_owned()))
|
Ok(Arc::from(bytes.into_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserves the shared structured-error type without changing callers to boxed errors.
|
|
||||||
#[allow(clippy::result_large_err)]
|
|
||||||
fn load_model_document_from_root_diagnostic(
|
|
||||||
root: &Path,
|
|
||||||
archive: &str,
|
|
||||||
resource: &str,
|
|
||||||
) -> Result<NresDocument, Diagnostic> {
|
|
||||||
let bytes = read_resource_bytes_diagnostic(root, archive, resource)?;
|
|
||||||
decode_nres(bytes.clone(), ReadProfile::Compatible).map_err(|err| {
|
|
||||||
resource_parse_diagnostic("S1.NRES.DECODE", archive, resource, &bytes, err.to_string())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn archive_parse_diagnostic(
|
|
||||||
code: &'static str,
|
|
||||||
source: Option<&Path>,
|
|
||||||
bytes: &[u8],
|
|
||||||
message: String,
|
|
||||||
) -> Diagnostic {
|
|
||||||
diagnostic(DiagnosticCode(code), message).with_context(DiagnosticContext {
|
|
||||||
phase: Some(Phase::Parse),
|
|
||||||
path: source.map(|path| path.display().to_string()),
|
|
||||||
span: Some(SourceSpan {
|
|
||||||
offset: 0,
|
|
||||||
length: u64::try_from(bytes.len().min(4)).unwrap_or(4),
|
|
||||||
}),
|
|
||||||
..DiagnosticContext::default()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resource_parse_diagnostic(
|
|
||||||
code: &'static str,
|
|
||||||
archive: &str,
|
|
||||||
resource: &str,
|
|
||||||
bytes: &[u8],
|
|
||||||
message: String,
|
|
||||||
) -> Diagnostic {
|
|
||||||
diagnostic(DiagnosticCode(code), message).with_context(DiagnosticContext {
|
|
||||||
phase: Some(Phase::Parse),
|
|
||||||
path: Some(archive.to_string()),
|
|
||||||
archive_entry: Some(resource.to_string()),
|
|
||||||
span: Some(SourceSpan {
|
|
||||||
offset: 0,
|
|
||||||
length: u64::try_from(bytes.len().min(4)).unwrap_or(4),
|
|
||||||
}),
|
|
||||||
..DiagnosticContext::default()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use fparkan_terrain_format::TerrainSlotTable;
|
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
const TEST_NRES_HEADER_LEN: usize = 16;
|
|
||||||
const TEST_NRES_NAME_LEN: usize = 36;
|
|
||||||
const TEST_NRES_VERSION_0100: u32 = 0x100;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn inspect_rsli_rejects_malformed_archive() {
|
fn inspect_rsli_rejects_malformed_archive() {
|
||||||
let dir = temp_dir("inspect");
|
let dir = temp_dir("inspect");
|
||||||
@@ -800,30 +306,6 @@ mod tests {
|
|||||||
assert!(error.contains("entry table out of bounds"));
|
assert!(error.contains("entry table out of bounds"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn archive_diagnostic_preserves_source_path_phase_and_span() {
|
|
||||||
let dir = temp_dir("inspect-diagnostic");
|
|
||||||
let path = dir.join("broken.nres");
|
|
||||||
fs::write(&path, b"NRes").expect("broken nres");
|
|
||||||
|
|
||||||
let diagnostic = inspect_archive_file_diagnostic(&path, 0).expect_err("diagnostic failure");
|
|
||||||
|
|
||||||
assert_eq!(diagnostic.code.0, "S1.NRES.DECODE");
|
|
||||||
let expected_path = path.display().to_string();
|
|
||||||
assert_eq!(
|
|
||||||
diagnostic.context.path.as_deref(),
|
|
||||||
Some(expected_path.as_str())
|
|
||||||
);
|
|
||||||
assert_eq!(diagnostic.context.phase, Some(Phase::Parse));
|
|
||||||
assert_eq!(
|
|
||||||
diagnostic.context.span,
|
|
||||||
Some(SourceSpan {
|
|
||||||
offset: 0,
|
|
||||||
length: 4
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nres_entry_summary_fields_are_readable() {
|
fn nres_entry_summary_fields_are_readable() {
|
||||||
let dir = temp_dir("inspect-nres");
|
let dir = temp_dir("inspect-nres");
|
||||||
@@ -834,117 +316,6 @@ mod tests {
|
|||||||
let _ = inspect_archive_file(&archive, 2);
|
let _ = inspect_archive_file(&archive, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn model_archive_diagnostic_preserves_archive_entry_context() {
|
|
||||||
let dir = temp_dir("inspect-model-diagnostic");
|
|
||||||
let archive = dir.join("models.rlb");
|
|
||||||
fs::write(&archive, build_single_entry_nres(b"BROKEN.MSH", b"NRes")).expect("archive");
|
|
||||||
|
|
||||||
let diagnostic = load_model_document_from_root_diagnostic(&dir, "models.rlb", "BROKEN.MSH")
|
|
||||||
.expect_err("nested diagnostic failure");
|
|
||||||
|
|
||||||
assert_eq!(diagnostic.code.0, "S1.NRES.DECODE");
|
|
||||||
assert_eq!(diagnostic.context.phase, Some(Phase::Parse));
|
|
||||||
assert_eq!(diagnostic.context.path.as_deref(), Some("models.rlb"));
|
|
||||||
assert_eq!(
|
|
||||||
diagnostic.context.archive_entry.as_deref(),
|
|
||||||
Some("BROKEN.MSH")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
diagnostic.context.span,
|
|
||||||
Some(SourceSpan {
|
|
||||||
offset: 0,
|
|
||||||
length: 4
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wear_material_texture_loader_preserves_original_selection_provenance() {
|
|
||||||
let dir = temp_dir("wear-material-texture");
|
|
||||||
fs::write(
|
|
||||||
dir.join("wear.rlb"),
|
|
||||||
build_single_entry_nres(b"MODEL.WEA", b"1\n0 MAT\n"),
|
|
||||||
)
|
|
||||||
.expect("wear archive");
|
|
||||||
let mut mat0 = vec![0; 4 + 34];
|
|
||||||
mat0[0..2].copy_from_slice(&1_u16.to_le_bytes());
|
|
||||||
mat0[22..25].copy_from_slice(b"TEX");
|
|
||||||
fs::write(
|
|
||||||
dir.join("material.lib"),
|
|
||||||
build_single_entry_nres_with_meta(b"MAT", fparkan_material::MAT0_KIND, 0, &mat0),
|
|
||||||
)
|
|
||||||
.expect("material archive");
|
|
||||||
fs::write(
|
|
||||||
dir.join("Textures.lib"),
|
|
||||||
build_single_entry_nres(b"TEX", &texm_argb8888_pixel([0x40, 0x11, 0x22, 0x33])),
|
|
||||||
)
|
|
||||||
.expect("texture archive");
|
|
||||||
|
|
||||||
let selected =
|
|
||||||
load_wear_material_texture_mip0_rgba8_from_root(&dir, "wear.rlb", "MODEL.WEA", 0)
|
|
||||||
.expect("resolved material texture");
|
|
||||||
|
|
||||||
assert_eq!(selected.material_name, "MAT");
|
|
||||||
assert_eq!(selected.material_fallback, MaterialFallback::Exact);
|
|
||||||
assert_eq!(selected.texture_name, "TEX");
|
|
||||||
assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn standalone_wear_material_texture_loader_keeps_map_sidecar_separate() {
|
|
||||||
let dir = temp_dir("standalone-wear-material-texture");
|
|
||||||
let wear_path = dir.join("Land2.wea");
|
|
||||||
fs::write(&wear_path, b"1\n0 MAT\n").expect("standalone wear");
|
|
||||||
let mut mat0 = vec![0; 4 + 34];
|
|
||||||
mat0[0..2].copy_from_slice(&1_u16.to_le_bytes());
|
|
||||||
mat0[22..25].copy_from_slice(b"TEX");
|
|
||||||
fs::write(
|
|
||||||
dir.join("material.lib"),
|
|
||||||
build_single_entry_nres_with_meta(b"MAT", fparkan_material::MAT0_KIND, 0, &mat0),
|
|
||||||
)
|
|
||||||
.expect("material archive");
|
|
||||||
fs::write(
|
|
||||||
dir.join("Textures.lib"),
|
|
||||||
build_single_entry_nres(b"TEX", &texm_argb8888_pixel([0x40, 0x11, 0x22, 0x33])),
|
|
||||||
)
|
|
||||||
.expect("texture archive");
|
|
||||||
|
|
||||||
let selected =
|
|
||||||
load_standalone_wear_material_texture_mip0_rgba8_from_root(&dir, &wear_path, 0)
|
|
||||||
.expect("resolved material texture");
|
|
||||||
|
|
||||||
assert_eq!(selected.wear_archive, "<standalone>");
|
|
||||||
assert_eq!(selected.wear_resource, wear_path.display().to_string());
|
|
||||||
assert_eq!(selected.texture_name, "TEX");
|
|
||||||
assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn land_mesh_bounds_preserve_each_source_axis() {
|
|
||||||
let mesh = LandMeshDocument {
|
|
||||||
streams: Vec::new(),
|
|
||||||
nodes_raw: Vec::new(),
|
|
||||||
slots: TerrainSlotTable {
|
|
||||||
header_raw: Vec::new(),
|
|
||||||
slots_raw: Vec::new(),
|
|
||||||
},
|
|
||||||
positions: vec![[4.0, -2.0, 8.0], [-3.0, 6.0, 1.0]],
|
|
||||||
normals: Vec::new(),
|
|
||||||
uv0: Vec::new(),
|
|
||||||
accelerator: Vec::new(),
|
|
||||||
aux14: Vec::new(),
|
|
||||||
aux18: Vec::new(),
|
|
||||||
faces: Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let bounds = inspect_land_msh_bounds(&mesh).expect("bounds");
|
|
||||||
|
|
||||||
assert_eq!(bounds.positions, 2);
|
|
||||||
assert_eq!(bounds.min, [-3.0, -2.0, 1.0]);
|
|
||||||
assert_eq!(bounds.max, [4.0, 6.0, 8.0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn temp_dir(name: &str) -> PathBuf {
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
let base = PathBuf::from("/tmp")
|
let base = PathBuf::from("/tmp")
|
||||||
.join("fparkan-inspection-tests")
|
.join("fparkan-inspection-tests")
|
||||||
@@ -953,57 +324,4 @@ mod tests {
|
|||||||
fs::create_dir_all(&base).expect("tmp dir");
|
fs::create_dir_all(&base).expect("tmp dir");
|
||||||
base
|
base
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_single_entry_nres(name: &[u8], payload: &[u8]) -> Vec<u8> {
|
|
||||||
build_single_entry_nres_with_meta(name, 1, 0, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_single_entry_nres_with_meta(
|
|
||||||
name: &[u8],
|
|
||||||
type_id: u32,
|
|
||||||
attr2: u32,
|
|
||||||
payload: &[u8],
|
|
||||||
) -> Vec<u8> {
|
|
||||||
let mut out = vec![0; TEST_NRES_HEADER_LEN];
|
|
||||||
let payload_offset = u32::try_from(out.len()).expect("payload offset");
|
|
||||||
out.extend_from_slice(payload);
|
|
||||||
let padding = (8 - (out.len() % 8)) % 8;
|
|
||||||
out.resize(out.len() + padding, 0);
|
|
||||||
|
|
||||||
push_u32(&mut out, type_id);
|
|
||||||
push_u32(&mut out, 0);
|
|
||||||
push_u32(&mut out, attr2);
|
|
||||||
push_u32(&mut out, u32::try_from(payload.len()).expect("payload len"));
|
|
||||||
push_u32(&mut out, 0);
|
|
||||||
let mut raw_name = [0; TEST_NRES_NAME_LEN];
|
|
||||||
raw_name[..name.len()].copy_from_slice(name);
|
|
||||||
out.extend_from_slice(&raw_name);
|
|
||||||
push_u32(&mut out, payload_offset);
|
|
||||||
push_u32(&mut out, 0);
|
|
||||||
|
|
||||||
out[0..4].copy_from_slice(b"NRes");
|
|
||||||
out[4..8].copy_from_slice(&TEST_NRES_VERSION_0100.to_le_bytes());
|
|
||||||
out[8..12].copy_from_slice(&1_u32.to_le_bytes());
|
|
||||||
let total_size = u32::try_from(out.len()).expect("total size");
|
|
||||||
out[12..16].copy_from_slice(&total_size.to_le_bytes());
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
|
||||||
out.extend_from_slice(&value.to_le_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn texm_argb8888_pixel(pixel: [u8; 4]) -> Vec<u8> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
out.extend_from_slice(&0x6D78_6554_u32.to_le_bytes());
|
|
||||||
out.extend_from_slice(&1_u32.to_le_bytes());
|
|
||||||
out.extend_from_slice(&1_u32.to_le_bytes());
|
|
||||||
out.extend_from_slice(&1_u32.to_le_bytes());
|
|
||||||
out.extend_from_slice(&0_u32.to_le_bytes());
|
|
||||||
out.extend_from_slice(&0_u32.to_le_bytes());
|
|
||||||
out.extend_from_slice(&0_u32.to_le_bytes());
|
|
||||||
out.extend_from_slice(&8888_u32.to_le_bytes());
|
|
||||||
out.extend_from_slice(&pixel);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
encoding_rs = "0.8"
|
encoding_rs = "0.8"
|
||||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
|
fparkan-resource = { path = "../fparkan-resource" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
encoding_rs = "0.8"
|
encoding_rs = "0.8"
|
||||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
encoding_rs = "0.8"
|
encoding_rs = "0.8"
|
||||||
fparkan-animation = { path = "../fparkan-animation", version = "0.1.0" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
|
||||||
|
[dev-dependencies]
|
||||||
|
fparkan-animation = { path = "../fparkan-animation" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -21,10 +21,6 @@
|
|||||||
//! Stage-3 MSH asset contract.
|
//! Stage-3 MSH asset contract.
|
||||||
|
|
||||||
use encoding_rs::WINDOWS_1251;
|
use encoding_rs::WINDOWS_1251;
|
||||||
use fparkan_animation::{
|
|
||||||
evaluate_hierarchy, AnimKey24, AnimationTime, NodePoseBuffer, ParentIndex, Pose, TimedPoseKey,
|
|
||||||
TimedPoseTrack,
|
|
||||||
};
|
|
||||||
use fparkan_nres::{EntryMeta, NresDocument, NresError};
|
use fparkan_nres::{EntryMeta, NresDocument, NresError};
|
||||||
|
|
||||||
/// Node table stream.
|
/// Node table stream.
|
||||||
@@ -114,22 +110,6 @@ pub struct ModelAsset {
|
|||||||
pub batches: Vec<Batch>,
|
pub batches: Vec<Batch>,
|
||||||
/// Optional decoded node names.
|
/// Optional decoded node names.
|
||||||
pub node_names: Option<Vec<Option<String>>>,
|
pub node_names: Option<Vec<Option<String>>>,
|
||||||
/// Optional decoded node-animation streams.
|
|
||||||
pub animation: Option<ModelAnimation>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decoded MSH node-animation streams.
|
|
||||||
///
|
|
||||||
/// This preserves the exact type-8/type-19 boundary used by `Node38` without
|
|
||||||
/// assigning runtime-state ownership to a static asset.
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct ModelAnimation {
|
|
||||||
/// Type-8 animation keys in source order.
|
|
||||||
pub keys: Vec<AnimKey24>,
|
|
||||||
/// Type-19 frame-to-key mapping words in source order.
|
|
||||||
pub frame_map: Vec<u16>,
|
|
||||||
/// Declared frame count from type-19 `attr2`.
|
|
||||||
pub frame_count: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Node id.
|
/// Node id.
|
||||||
@@ -147,17 +127,6 @@ pub struct Node {
|
|||||||
pub raw: Vec<u8>,
|
pub raw: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Raw fields of the standard 38-byte node layout that precede slot selection.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct Node38Metadata {
|
|
||||||
/// Unassigned source link value at byte offset two.
|
|
||||||
pub parent_or_link_raw: u16,
|
|
||||||
/// Offset into the type-19 frame map, or `0xFFFF` for no map.
|
|
||||||
pub anim_map_start: u16,
|
|
||||||
/// Fallback type-8 key index.
|
|
||||||
pub fallback_key: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Slot descriptor.
|
/// Slot descriptor.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct Slot {
|
pub struct Slot {
|
||||||
@@ -418,151 +387,6 @@ pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -
|
|||||||
(slot < model.slots.len()).then_some(SlotId(u32::from(raw)))
|
(slot < model.slots.len()).then_some(SlotId(u32::from(raw)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the undecorated metadata of a standard 38-byte node.
|
|
||||||
#[must_use]
|
|
||||||
pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadata> {
|
|
||||||
if model.node_stride != 38 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let node_index = usize::try_from(node.0).ok()?;
|
|
||||||
if node_index >= model.node_count {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let offset = node_index.checked_mul(model.node_stride)?;
|
|
||||||
Some(Node38Metadata {
|
|
||||||
parent_or_link_raw: read_u16(&model.nodes_raw, offset + 2)?,
|
|
||||||
anim_map_start: read_u16(&model.nodes_raw, offset + 4)?,
|
|
||||||
fallback_key: read_u16(&model.nodes_raw, offset + 6)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the source fallback pose of a standard node.
|
|
||||||
///
|
|
||||||
/// The legacy sampler uses this key when a node has no frame map or its current
|
|
||||||
/// frame falls outside that map. It is a static-pose input only; callers must
|
|
||||||
/// still recover animation state and the meaning of `parent_or_link_raw` before
|
|
||||||
/// assembling a runtime hierarchy.
|
|
||||||
#[must_use]
|
|
||||||
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
|
|
||||||
let metadata = node38_metadata(model, node)?;
|
|
||||||
model
|
|
||||||
.animation
|
|
||||||
.as_ref()?
|
|
||||||
.keys
|
|
||||||
.get(usize::from(metadata.fallback_key))
|
|
||||||
.map(AnimKey24::sampling_pose)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evaluates the static fallback pose hierarchy of a standard `Node38` model.
|
|
||||||
///
|
|
||||||
/// `0xFFFF` denotes a root. Every non-root source link must name an earlier
|
|
||||||
/// node, which preserves the producer's parent-before-child ordering. Models
|
|
||||||
/// that do not meet this established `Node38` contract return `None` so callers
|
|
||||||
/// can retain an explicitly unhierarchical diagnostic path.
|
|
||||||
#[must_use]
|
|
||||||
pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
|
|
||||||
if model.node_stride != 38 || model.node_count == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut parents = Vec::with_capacity(model.node_count);
|
|
||||||
let mut poses = Vec::with_capacity(model.node_count);
|
|
||||||
for index in 0..model.node_count {
|
|
||||||
let node = NodeId(u32::try_from(index).ok()?);
|
|
||||||
let metadata = node38_metadata(model, node)?;
|
|
||||||
let parent = if metadata.parent_or_link_raw == u16::MAX {
|
|
||||||
ParentIndex(None)
|
|
||||||
} else {
|
|
||||||
let parent = usize::from(metadata.parent_or_link_raw);
|
|
||||||
(parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))?
|
|
||||||
};
|
|
||||||
parents.push(parent);
|
|
||||||
poses.push(node38_fallback_pose(model, node)?);
|
|
||||||
}
|
|
||||||
evaluate_hierarchy(&parents, &poses).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
|
|
||||||
/// model at one explicitly supplied logical frame.
|
|
||||||
///
|
|
||||||
/// A node without a usable type-19 map, or a requested frame outside the
|
|
||||||
/// declared map length, retains its exact fallback key. Mapped keys are
|
|
||||||
/// sampled against their immediate successor using the decoded key times.
|
|
||||||
/// This is an offline asset-sampling contract; it does not claim ownership of
|
|
||||||
/// the original runtime's animation clock or x87 numeric profile.
|
|
||||||
///
|
|
||||||
/// Returns `None` when the model lacks a complete standard-node animation
|
|
||||||
/// representation or the map cannot be safely resolved.
|
|
||||||
#[must_use]
|
|
||||||
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
|
|
||||||
if model.node_stride != 38 || model.node_count == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let animation = model.animation.as_ref()?;
|
|
||||||
let mut parents = Vec::with_capacity(model.node_count);
|
|
||||||
let mut poses = Vec::with_capacity(model.node_count);
|
|
||||||
for index in 0..model.node_count {
|
|
||||||
let node = NodeId(u32::try_from(index).ok()?);
|
|
||||||
let metadata = node38_metadata(model, node)?;
|
|
||||||
let parent = if metadata.parent_or_link_raw == u16::MAX {
|
|
||||||
ParentIndex(None)
|
|
||||||
} else {
|
|
||||||
let parent = usize::from(metadata.parent_or_link_raw);
|
|
||||||
(parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))?
|
|
||||||
};
|
|
||||||
let fallback_index = usize::from(metadata.fallback_key);
|
|
||||||
let _fallback = animation.keys.get(fallback_index)?;
|
|
||||||
let key_index =
|
|
||||||
if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count {
|
|
||||||
fallback_index
|
|
||||||
} else {
|
|
||||||
let mapped_index =
|
|
||||||
usize::from(*animation.frame_map.get(
|
|
||||||
usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?,
|
|
||||||
)?);
|
|
||||||
if mapped_index < fallback_index {
|
|
||||||
mapped_index
|
|
||||||
} else {
|
|
||||||
fallback_index
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?;
|
|
||||||
parents.push(parent);
|
|
||||||
poses.push(pose);
|
|
||||||
}
|
|
||||||
evaluate_hierarchy(&parents, &poses).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sample_node38_key_pair(
|
|
||||||
keys: &[AnimKey24],
|
|
||||||
key_index: usize,
|
|
||||||
fallback_index: usize,
|
|
||||||
frame: u16,
|
|
||||||
) -> Option<Pose> {
|
|
||||||
let key = *keys.get(key_index)?;
|
|
||||||
if key_index == fallback_index {
|
|
||||||
return Some(key.sampling_pose());
|
|
||||||
}
|
|
||||||
let next = *keys.get(key_index.checked_add(1)?)?;
|
|
||||||
if next.time.0 <= key.time.0 {
|
|
||||||
return Some(key.sampling_pose());
|
|
||||||
}
|
|
||||||
let track = TimedPoseTrack::new(
|
|
||||||
key.sampling_pose(),
|
|
||||||
vec![
|
|
||||||
TimedPoseKey {
|
|
||||||
time: key.time,
|
|
||||||
pose: key.sampling_pose(),
|
|
||||||
},
|
|
||||||
TimedPoseKey {
|
|
||||||
time: next.time,
|
|
||||||
pose: next.sampling_pose(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.ok()?;
|
|
||||||
track.sample(AnimationTime(f32::from(frame))).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns draw batches for a validated slot.
|
/// Returns draw batches for a validated slot.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -702,7 +526,6 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
|
|||||||
let node_names = read_optional_stream(document, STREAM_NAMES)?
|
let node_names = read_optional_stream(document, STREAM_NAMES)?
|
||||||
.map(|raw| parse_res10_names(&raw.bytes, node_count))
|
.map(|raw| parse_res10_names(&raw.bytes, node_count))
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
let animation = parse_optional_animation(document)?;
|
|
||||||
|
|
||||||
Ok(ModelAsset {
|
Ok(ModelAsset {
|
||||||
node_stride,
|
node_stride,
|
||||||
@@ -715,37 +538,9 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
|
|||||||
indices,
|
indices,
|
||||||
batches,
|
batches,
|
||||||
node_names,
|
node_names,
|
||||||
animation,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_optional_animation(document: &NresDocument) -> Result<Option<ModelAnimation>, MshError> {
|
|
||||||
let keys = read_optional_stream(document, STREAM_ANIMATION_KEYS)?;
|
|
||||||
let frame_map = read_optional_stream(document, STREAM_ANIMATION_FRAME_MAP)?;
|
|
||||||
let (Some(keys), Some(frame_map)) = (keys, frame_map) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
if !keys.bytes.len().is_multiple_of(24) {
|
|
||||||
return Err(invalid_resource_size("Res8", keys.bytes.len(), 24));
|
|
||||||
}
|
|
||||||
if !frame_map.bytes.len().is_multiple_of(2) {
|
|
||||||
return Err(invalid_resource_size("Res19", frame_map.bytes.len(), 2));
|
|
||||||
}
|
|
||||||
let keys = keys
|
|
||||||
.bytes
|
|
||||||
.chunks_exact(24)
|
|
||||||
.map(AnimKey24::decode)
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.map_err(|err| MshError::InvalidGeometry(format!("invalid Res8 animation key: {err}")))?;
|
|
||||||
let frame_count = frame_map.attributes.attr2;
|
|
||||||
let frame_map = parse_u16_array(&frame_map.bytes, "Res19")?;
|
|
||||||
Ok(Some(ModelAnimation {
|
|
||||||
keys,
|
|
||||||
frame_map,
|
|
||||||
frame_count,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RawStream {
|
struct RawStream {
|
||||||
attributes: EntryAttributes,
|
attributes: EntryAttributes,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
@@ -1268,151 +1063,6 @@ mod tests {
|
|||||||
assert_eq!(selected_slot(&model, NodeId(0), Lod(2), Group(4)), None);
|
assert_eq!(selected_slot(&model, NodeId(0), Lod(2), Group(4)), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn standard_node_exposes_fallback_pose_and_unassigned_link() {
|
|
||||||
let mut node = node38([u16::MAX; 15]);
|
|
||||||
node[2..4].copy_from_slice(&7_u16.to_le_bytes());
|
|
||||||
node[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
|
|
||||||
node[6..8].copy_from_slice(&0_u16.to_le_bytes());
|
|
||||||
let mut key = Vec::new();
|
|
||||||
push_f32(&mut key, 1.0);
|
|
||||||
push_f32(&mut key, 2.0);
|
|
||||||
push_f32(&mut key, 3.0);
|
|
||||||
push_f32(&mut key, 0.0);
|
|
||||||
push_u16(&mut key, 0);
|
|
||||||
push_u16(&mut key, 0);
|
|
||||||
push_u16(&mut key, 0);
|
|
||||||
push_u16(&mut key, 32_767);
|
|
||||||
let document = decode_nested(&build_nres(&[
|
|
||||||
stream(STREAM_NODE_TABLE, 38, b"Res1", &node),
|
|
||||||
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
|
|
||||||
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
|
|
||||||
stream(STREAM_INDICES, 0, b"Res6", &[]),
|
|
||||||
stream(STREAM_ANIMATION_KEYS, 0, b"Res8", &key),
|
|
||||||
stream(STREAM_BATCHES, 0, b"Res13", &[]),
|
|
||||||
stream(STREAM_ANIMATION_FRAME_MAP, 0, b"Res19", &[]),
|
|
||||||
]))
|
|
||||||
.expect("nested NRes");
|
|
||||||
let model =
|
|
||||||
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
node38_metadata(&model, NodeId(0)),
|
|
||||||
Some(Node38Metadata {
|
|
||||||
parent_or_link_raw: 7,
|
|
||||||
anim_map_start: u16::MAX,
|
|
||||||
fallback_key: 0,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
node38_fallback_pose(&model, NodeId(0)).map(|pose| pose.translation),
|
|
||||||
Some([1.0, 2.0, 3.0])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model
|
|
||||||
.animation
|
|
||||||
.as_ref()
|
|
||||||
.map(|animation| animation.keys.len()),
|
|
||||||
Some(1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn standard_nodes_evaluate_fallback_parent_hierarchy() {
|
|
||||||
let mut root = node38([u16::MAX; 15]);
|
|
||||||
root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
|
|
||||||
root[6..8].copy_from_slice(&0_u16.to_le_bytes());
|
|
||||||
let mut child = node38([u16::MAX; 15]);
|
|
||||||
child[2..4].copy_from_slice(&0_u16.to_le_bytes());
|
|
||||||
child[6..8].copy_from_slice(&1_u16.to_le_bytes());
|
|
||||||
let mut nodes = root;
|
|
||||||
nodes.extend(child);
|
|
||||||
let mut keys = Vec::new();
|
|
||||||
for (x, y, z, qz, qw) in [
|
|
||||||
(1.0, 0.0, 0.0, 23_170_i16, 23_170_i16),
|
|
||||||
(2.0, 0.0, 0.0, 0_i16, 32_767_i16),
|
|
||||||
] {
|
|
||||||
push_f32(&mut keys, x);
|
|
||||||
push_f32(&mut keys, y);
|
|
||||||
push_f32(&mut keys, z);
|
|
||||||
push_f32(&mut keys, 0.0);
|
|
||||||
push_u16(&mut keys, 0);
|
|
||||||
push_u16(&mut keys, 0);
|
|
||||||
push_u16(&mut keys, qz.cast_unsigned());
|
|
||||||
push_u16(&mut keys, qw.cast_unsigned());
|
|
||||||
}
|
|
||||||
let document = decode_nested(&build_nres(&[
|
|
||||||
stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes),
|
|
||||||
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
|
|
||||||
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
|
|
||||||
stream(STREAM_INDICES, 0, b"Res6", &[]),
|
|
||||||
stream(STREAM_ANIMATION_KEYS, 0, b"Res8", &keys),
|
|
||||||
stream(STREAM_BATCHES, 0, b"Res13", &[]),
|
|
||||||
stream(STREAM_ANIMATION_FRAME_MAP, 0, b"Res19", &[]),
|
|
||||||
]))
|
|
||||||
.expect("nested NRes");
|
|
||||||
let model =
|
|
||||||
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
|
|
||||||
let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy");
|
|
||||||
assert!((hierarchy.poses[1].translation[0] - 1.0).abs() < 0.001);
|
|
||||||
assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn standard_nodes_sample_mapped_key_before_fallback_key() {
|
|
||||||
let mut node = node38([u16::MAX; 15]);
|
|
||||||
node[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
|
|
||||||
node[4..6].copy_from_slice(&0_u16.to_le_bytes());
|
|
||||||
node[6..8].copy_from_slice(&2_u16.to_le_bytes());
|
|
||||||
let model = ModelAsset {
|
|
||||||
node_stride: 38,
|
|
||||||
node_count: 1,
|
|
||||||
nodes_raw: node,
|
|
||||||
slots: Vec::new(),
|
|
||||||
positions: Vec::new(),
|
|
||||||
normals: None,
|
|
||||||
uv0: None,
|
|
||||||
indices: Vec::new(),
|
|
||||||
batches: Vec::new(),
|
|
||||||
node_names: None,
|
|
||||||
animation: Some(ModelAnimation {
|
|
||||||
keys: vec![
|
|
||||||
AnimKey24 {
|
|
||||||
time: AnimationTime(0.0),
|
|
||||||
pose: Pose {
|
|
||||||
translation: [4.0, 0.0, 0.0],
|
|
||||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AnimKey24 {
|
|
||||||
time: AnimationTime(2.0),
|
|
||||||
pose: Pose {
|
|
||||||
translation: [8.0, 0.0, 0.0],
|
|
||||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AnimKey24 {
|
|
||||||
time: AnimationTime(9.0),
|
|
||||||
pose: Pose::default(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
frame_map: vec![0, 0, 0],
|
|
||||||
frame_count: 3,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
let pose = node38_sampled_hierarchy(&model, 1)
|
|
||||||
.expect("mapped hierarchy")
|
|
||||||
.poses[0];
|
|
||||||
assert!((pose.translation[0] - 6.0).abs() < f32::EPSILON);
|
|
||||||
assert_eq!(
|
|
||||||
node38_sampled_hierarchy(&model, 9)
|
|
||||||
.expect("fallback hierarchy")
|
|
||||||
.poses[0],
|
|
||||||
Pose::default()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn type2_header_and_slot_tail_framing_are_exact() {
|
fn type2_header_and_slot_tail_framing_are_exact() {
|
||||||
let too_small = decode_nested(&build_nres(&[
|
let too_small = decode_nested(&build_nres(&[
|
||||||
@@ -1705,13 +1355,6 @@ mod tests {
|
|||||||
let model = validate_msh(&msh).unwrap_or_else(|err| {
|
let model = validate_msh(&msh).unwrap_or_else(|err| {
|
||||||
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
|
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
|
||||||
});
|
});
|
||||||
if model.node_stride == 38 {
|
|
||||||
assert!(
|
|
||||||
node38_fallback_hierarchy(&model).is_some(),
|
|
||||||
"{corpus} {path:?} {:?}: Node38 hierarchy is not parent-before-child",
|
|
||||||
entry.name_bytes()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let preserved = msh.preserved_streams().unwrap_or_else(|err| {
|
let preserved = msh.preserved_streams().unwrap_or_else(|err| {
|
||||||
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
|
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
+20
-223
@@ -20,7 +20,7 @@
|
|||||||
)]
|
)]
|
||||||
//! Strict and lossless `NRes` archive support.
|
//! Strict and lossless `NRes` archive support.
|
||||||
|
|
||||||
use fparkan_binary::{checked_allocation_len, Cursor, DecodeError};
|
use fparkan_binary::{Cursor, DecodeError};
|
||||||
use fparkan_path::{ascii_lookup_key, LookupKey};
|
use fparkan_path::{ascii_lookup_key, LookupKey};
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -51,33 +51,6 @@ pub enum WriteProfile {
|
|||||||
CanonicalCompact,
|
CanonicalCompact,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode-time archive limits.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct DecodeLimits {
|
|
||||||
/// Maximum accepted source archive bytes.
|
|
||||||
pub max_input_bytes: u64,
|
|
||||||
/// Maximum accepted entry count.
|
|
||||||
pub max_entries: u32,
|
|
||||||
/// Maximum accepted single payload byte length.
|
|
||||||
pub max_decoded_entry_bytes: u64,
|
|
||||||
/// Maximum accepted cumulative payload bytes.
|
|
||||||
pub max_total_decoded_bytes: u64,
|
|
||||||
/// Maximum accepted preserved-region bytes.
|
|
||||||
pub max_preserved_bytes: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DecodeLimits {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
max_input_bytes: 256 * 1024 * 1024,
|
|
||||||
max_entries: 1_000_000,
|
|
||||||
max_decoded_entry_bytes: 256 * 1024 * 1024,
|
|
||||||
max_total_decoded_bytes: 512 * 1024 * 1024,
|
|
||||||
max_preserved_bytes: 256 * 1024 * 1024,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NRes` archive header.
|
/// `NRes` archive header.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct NresHeader {
|
pub struct NresHeader {
|
||||||
@@ -370,31 +343,16 @@ impl From<DecodeError> for NresError {
|
|||||||
/// Returns [`NresError`] when the header, directory, payload ranges, or strict
|
/// Returns [`NresError`] when the header, directory, payload ranges, or strict
|
||||||
/// lookup permutation are malformed for the selected [`ReadProfile`].
|
/// lookup permutation are malformed for the selected [`ReadProfile`].
|
||||||
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<NresDocument, NresError> {
|
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<NresDocument, NresError> {
|
||||||
decode_with_limits(bytes, profile, DecodeLimits::default())
|
let header = parse_header(&bytes)?;
|
||||||
}
|
let entries = parse_entries(&bytes, &header)?;
|
||||||
|
|
||||||
/// Decodes `NRes` bytes with explicit archive limits.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns [`NresError`] when the input exceeds configured limits, the header,
|
|
||||||
/// directory, payload ranges, or strict lookup permutation are malformed.
|
|
||||||
pub fn decode_with_limits(
|
|
||||||
bytes: Arc<[u8]>,
|
|
||||||
profile: ReadProfile,
|
|
||||||
limits: DecodeLimits,
|
|
||||||
) -> Result<NresDocument, NresError> {
|
|
||||||
let header = parse_header(&bytes, limits)?;
|
|
||||||
let entries = parse_entries(&bytes, &header, limits)?;
|
|
||||||
validate_names(&entries)?;
|
validate_names(&entries)?;
|
||||||
validate_payload_ranges(&entries, limits)?;
|
validate_payload_ranges(&entries)?;
|
||||||
let lookup_order_valid = match validate_lookup_order(&entries) {
|
let lookup_order_valid = match validate_lookup_order(&entries) {
|
||||||
Ok(()) => true,
|
Ok(valid) => valid,
|
||||||
Err(err) if profile == ReadProfile::Strict => return Err(err),
|
Err(err) if profile == ReadProfile::Strict => return Err(err),
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
};
|
};
|
||||||
let preserved_regions =
|
let preserved_regions = find_preserved_regions(&bytes, &entries, header.directory_offset)?;
|
||||||
find_preserved_regions(&bytes, &entries, header.directory_offset, limits)?;
|
|
||||||
Ok(NresDocument {
|
Ok(NresDocument {
|
||||||
bytes,
|
bytes,
|
||||||
header,
|
header,
|
||||||
@@ -479,23 +437,6 @@ impl NresDocument {
|
|||||||
Ok(&self.bytes[entry.data_range.clone()])
|
Ok(&self.bytes[entry.data_range.clone()])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns an owned view of an entry payload without copying its bytes.
|
|
||||||
///
|
|
||||||
/// The returned owner is the immutable archive buffer. Consumers may retain
|
|
||||||
/// the view after dropping this document.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns [`NresError::EntryIdOutOfRange`] when `id` is not present in
|
|
||||||
/// this document.
|
|
||||||
pub fn payload_view(&self, id: EntryId) -> Result<(Arc<[u8]>, Range<usize>), NresError> {
|
|
||||||
let entry = self.entry(id).ok_or_else(|| NresError::EntryIdOutOfRange {
|
|
||||||
id: id.0,
|
|
||||||
entry_count: saturating_u32_len(self.entries.len()),
|
|
||||||
})?;
|
|
||||||
Ok((Arc::clone(&self.bytes), entry.data_range.clone()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes the document according to the selected write profile.
|
/// Encodes the document according to the selected write profile.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn encode(&self, profile: WriteProfile) -> Vec<u8> {
|
pub fn encode(&self, profile: WriteProfile) -> Vec<u8> {
|
||||||
@@ -743,11 +684,7 @@ impl NresEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_header(bytes: &[u8], limits: DecodeLimits) -> Result<NresHeader, NresError> {
|
fn parse_header(bytes: &[u8]) -> Result<NresHeader, NresError> {
|
||||||
enforce_limit(
|
|
||||||
u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?,
|
|
||||||
limits.max_input_bytes,
|
|
||||||
)?;
|
|
||||||
if bytes.len() < HEADER_LEN {
|
if bytes.len() < HEADER_LEN {
|
||||||
let mut got = [0; 4];
|
let mut got = [0; 4];
|
||||||
let copy_len = bytes.len().min(4);
|
let copy_len = bytes.len().min(4);
|
||||||
@@ -774,7 +711,6 @@ fn parse_header(bytes: &[u8], limits: DecodeLimits) -> Result<NresHeader, NresEr
|
|||||||
}
|
}
|
||||||
let entry_count =
|
let entry_count =
|
||||||
u32::try_from(entry_count_signed).map_err(|_| DecodeError::IntegerOverflow)?;
|
u32::try_from(entry_count_signed).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||||
enforce_limit(u64::from(entry_count), u64::from(limits.max_entries))?;
|
|
||||||
let total_size = cursor.read_u32_le()?;
|
let total_size = cursor.read_u32_le()?;
|
||||||
let actual = u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?;
|
let actual = u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||||
if u64::from(total_size) != actual {
|
if u64::from(total_size) != actual {
|
||||||
@@ -814,14 +750,8 @@ fn parse_header(bytes: &[u8], limits: DecodeLimits) -> Result<NresHeader, NresEr
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_entries(
|
fn parse_entries(bytes: &[u8], header: &NresHeader) -> Result<Vec<NresEntry>, NresError> {
|
||||||
bytes: &[u8],
|
let mut entries = Vec::with_capacity(header.entry_count as usize);
|
||||||
header: &NresHeader,
|
|
||||||
limits: DecodeLimits,
|
|
||||||
) -> Result<Vec<NresEntry>, NresError> {
|
|
||||||
let capacity =
|
|
||||||
checked_allocation_len(u64::from(header.entry_count), u64::from(limits.max_entries))?;
|
|
||||||
let mut entries = Vec::with_capacity(capacity);
|
|
||||||
let directory_offset =
|
let directory_offset =
|
||||||
usize::try_from(header.directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
|
usize::try_from(header.directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||||
for index in 0..header.entry_count {
|
for index in 0..header.entry_count {
|
||||||
@@ -902,7 +832,7 @@ fn parse_entry(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_payload_ranges(entries: &[NresEntry], limits: DecodeLimits) -> Result<(), NresError> {
|
fn validate_payload_ranges(entries: &[NresEntry]) -> Result<(), NresError> {
|
||||||
let mut ranges: Vec<(u32, Range<usize>)> = entries
|
let mut ranges: Vec<(u32, Range<usize>)> = entries
|
||||||
.iter()
|
.iter()
|
||||||
.map(|entry| (entry.id.0, entry.data_range.clone()))
|
.map(|entry| (entry.id.0, entry.data_range.clone()))
|
||||||
@@ -913,15 +843,6 @@ fn validate_payload_ranges(entries: &[NresEntry], limits: DecodeLimits) -> Resul
|
|||||||
.cmp(&right.1.start)
|
.cmp(&right.1.start)
|
||||||
.then_with(|| left.1.end.cmp(&right.1.end))
|
.then_with(|| left.1.end.cmp(&right.1.end))
|
||||||
});
|
});
|
||||||
let mut total_payload_bytes = 0_u64;
|
|
||||||
for entry in entries {
|
|
||||||
let payload_len = u64::from(entry.meta.data_size);
|
|
||||||
enforce_limit(payload_len, limits.max_decoded_entry_bytes)?;
|
|
||||||
total_payload_bytes = total_payload_bytes
|
|
||||||
.checked_add(payload_len)
|
|
||||||
.ok_or(DecodeError::IntegerOverflow)?;
|
|
||||||
enforce_limit(total_payload_bytes, limits.max_total_decoded_bytes)?;
|
|
||||||
}
|
|
||||||
for pair in ranges.windows(2) {
|
for pair in ranges.windows(2) {
|
||||||
if pair[0].1.end > pair[1].1.start {
|
if pair[0].1.end > pair[1].1.start {
|
||||||
return Err(NresError::EntryDataOverlap {
|
return Err(NresError::EntryDataOverlap {
|
||||||
@@ -942,7 +863,7 @@ fn validate_names(entries: &[NresEntry]) -> Result<(), NresError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_lookup_order(entries: &[NresEntry]) -> Result<(), NresError> {
|
fn validate_lookup_order(entries: &[NresEntry]) -> Result<bool, NresError> {
|
||||||
let entry_count = saturating_u32_len(entries.len());
|
let entry_count = saturating_u32_len(entries.len());
|
||||||
let mut seen = vec![false; entries.len()];
|
let mut seen = vec![false; entries.len()];
|
||||||
for (position, entry) in entries.iter().enumerate() {
|
for (position, entry) in entries.iter().enumerate() {
|
||||||
@@ -960,7 +881,7 @@ fn validate_lookup_order(entries: &[NresEntry]) -> Result<(), NresError> {
|
|||||||
}
|
}
|
||||||
seen[index_usize] = true;
|
seen[index_usize] = true;
|
||||||
}
|
}
|
||||||
for (position, pair) in entries.windows(2).enumerate() {
|
for pair in entries.windows(2) {
|
||||||
let left_index =
|
let left_index =
|
||||||
usize::try_from(pair[0].meta.sort_index).map_err(|_| DecodeError::IntegerOverflow)?;
|
usize::try_from(pair[0].meta.sort_index).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||||
let right_index =
|
let right_index =
|
||||||
@@ -968,19 +889,16 @@ fn validate_lookup_order(entries: &[NresEntry]) -> Result<(), NresError> {
|
|||||||
let left = entries[left_index].name_bytes();
|
let left = entries[left_index].name_bytes();
|
||||||
let right = entries[right_index].name_bytes();
|
let right = entries[right_index].name_bytes();
|
||||||
if cmp_ascii_casefold(left, right) == Ordering::Greater {
|
if cmp_ascii_casefold(left, right) == Ordering::Greater {
|
||||||
return Err(NresError::SortOrderMismatch {
|
return Ok(false);
|
||||||
position: saturating_u32_len(position.saturating_add(1)),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_preserved_regions(
|
fn find_preserved_regions(
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
entries: &[NresEntry],
|
entries: &[NresEntry],
|
||||||
directory_offset: u32,
|
directory_offset: u32,
|
||||||
limits: DecodeLimits,
|
|
||||||
) -> Result<Vec<PreservedRegion>, NresError> {
|
) -> Result<Vec<PreservedRegion>, NresError> {
|
||||||
let mut ranges: Vec<Range<usize>> = entries
|
let mut ranges: Vec<Range<usize>> = entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -996,44 +914,18 @@ fn find_preserved_regions(
|
|||||||
let directory_offset =
|
let directory_offset =
|
||||||
usize::try_from(directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
|
usize::try_from(directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||||
let mut preserved = Vec::new();
|
let mut preserved = Vec::new();
|
||||||
let mut preserved_bytes = 0_u64;
|
|
||||||
for range in ranges {
|
for range in ranges {
|
||||||
if cursor < range.start {
|
if cursor < range.start {
|
||||||
preserved_bytes = preserved_bytes
|
|
||||||
.checked_add(
|
|
||||||
u64::try_from(range.start - cursor)
|
|
||||||
.map_err(|_| DecodeError::IntegerOverflow)?,
|
|
||||||
)
|
|
||||||
.ok_or(DecodeError::IntegerOverflow)?;
|
|
||||||
enforce_limit(preserved_bytes, limits.max_preserved_bytes)?;
|
|
||||||
preserved.push(make_preserved_region(bytes, cursor..range.start)?);
|
preserved.push(make_preserved_region(bytes, cursor..range.start)?);
|
||||||
}
|
}
|
||||||
cursor = cursor.max(range.end);
|
cursor = cursor.max(range.end);
|
||||||
}
|
}
|
||||||
if cursor < directory_offset {
|
if cursor < directory_offset {
|
||||||
preserved_bytes = preserved_bytes
|
|
||||||
.checked_add(
|
|
||||||
u64::try_from(directory_offset - cursor)
|
|
||||||
.map_err(|_| DecodeError::IntegerOverflow)?,
|
|
||||||
)
|
|
||||||
.ok_or(DecodeError::IntegerOverflow)?;
|
|
||||||
enforce_limit(preserved_bytes, limits.max_preserved_bytes)?;
|
|
||||||
preserved.push(make_preserved_region(bytes, cursor..directory_offset)?);
|
preserved.push(make_preserved_region(bytes, cursor..directory_offset)?);
|
||||||
}
|
}
|
||||||
Ok(preserved)
|
Ok(preserved)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enforce_limit(value: u64, limit: u64) -> Result<(), NresError> {
|
|
||||||
if value > limit {
|
|
||||||
return Err(DecodeError::LimitExceeded {
|
|
||||||
count: value,
|
|
||||||
limit,
|
|
||||||
}
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_preserved_region(bytes: &[u8], range: Range<usize>) -> Result<PreservedRegion, NresError> {
|
fn make_preserved_region(bytes: &[u8], range: Range<usize>) -> Result<PreservedRegion, NresError> {
|
||||||
let all_zero = bytes[range.clone()].iter().all(|byte| *byte == 0);
|
let all_zero = bytes[range.clone()].iter().all(|byte| *byte == 0);
|
||||||
Ok(PreservedRegion {
|
Ok(PreservedRegion {
|
||||||
@@ -1230,8 +1122,6 @@ mod tests {
|
|||||||
assert_eq!(entry.data_range().end, HEADER_LEN + 1);
|
assert_eq!(entry.data_range().end, HEADER_LEN + 1);
|
||||||
assert_eq!(doc.header().directory_offset % 8, 0);
|
assert_eq!(doc.header().directory_offset % 8, 0);
|
||||||
assert_eq!(doc.payload(EntryId(0)).expect("payload"), b"x");
|
assert_eq!(doc.payload(EntryId(0)).expect("payload"), b"x");
|
||||||
let (owner, range) = doc.payload_view(EntryId(0)).expect("payload view");
|
|
||||||
assert_eq!(&owner[range], b"x");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1286,10 +1176,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
decode(arc(bytes), ReadProfile::Strict),
|
decode(arc(bytes), ReadProfile::Strict),
|
||||||
Err(NresError::Binary(DecodeError::LimitExceeded {
|
Err(NresError::DirectoryOutOfBounds { .. })
|
||||||
count,
|
|
||||||
limit
|
|
||||||
})) if count == i32::MAX as u64 && limit == u64::from(DecodeLimits::default().max_entries)
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1582,7 +1469,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn strict_rejects_unsorted_lookup_table() {
|
fn unsorted_lookup_table_falls_back_to_linear_lookup() {
|
||||||
let mut bytes = build_archive(&[
|
let mut bytes = build_archive(&[
|
||||||
SyntheticEntry {
|
SyntheticEntry {
|
||||||
type_id: 1,
|
type_id: 1,
|
||||||
@@ -1610,99 +1497,9 @@ mod tests {
|
|||||||
bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64]
|
bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64]
|
||||||
.copy_from_slice(&1_u32.to_le_bytes());
|
.copy_from_slice(&1_u32.to_le_bytes());
|
||||||
|
|
||||||
assert!(matches!(
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("strict nres");
|
||||||
decode(arc(bytes), ReadProfile::Strict),
|
assert!(!doc.lookup_order_valid());
|
||||||
Err(NresError::SortOrderMismatch { position: 1 })
|
assert_eq!(doc.find("A"), Some(EntryId(1)));
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_rejects_entry_count_above_limit() {
|
|
||||||
let bytes = build_archive(&[
|
|
||||||
SyntheticEntry {
|
|
||||||
type_id: 1,
|
|
||||||
attr1: 0,
|
|
||||||
attr2: 0,
|
|
||||||
attr3: 0,
|
|
||||||
name: "a",
|
|
||||||
payload: b"a",
|
|
||||||
},
|
|
||||||
SyntheticEntry {
|
|
||||||
type_id: 2,
|
|
||||||
attr1: 0,
|
|
||||||
attr2: 0,
|
|
||||||
attr3: 0,
|
|
||||||
name: "b",
|
|
||||||
payload: b"b",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
decode_with_limits(
|
|
||||||
arc(bytes),
|
|
||||||
ReadProfile::Strict,
|
|
||||||
DecodeLimits {
|
|
||||||
max_entries: 1,
|
|
||||||
..DecodeLimits::default()
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Err(NresError::Binary(DecodeError::LimitExceeded {
|
|
||||||
count: 2,
|
|
||||||
limit: 1
|
|
||||||
}))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_rejects_input_bytes_above_limit() {
|
|
||||||
let bytes = build_archive(&[SyntheticEntry {
|
|
||||||
type_id: 1,
|
|
||||||
attr1: 0,
|
|
||||||
attr2: 0,
|
|
||||||
attr3: 0,
|
|
||||||
name: "payload",
|
|
||||||
payload: b"data",
|
|
||||||
}]);
|
|
||||||
let exact_size = u64::try_from(bytes.len()).expect("archive size");
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
decode_with_limits(
|
|
||||||
arc(bytes),
|
|
||||||
ReadProfile::Strict,
|
|
||||||
DecodeLimits {
|
|
||||||
max_input_bytes: exact_size - 1,
|
|
||||||
..DecodeLimits::default()
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Err(NresError::Binary(DecodeError::LimitExceeded {
|
|
||||||
count,
|
|
||||||
limit
|
|
||||||
})) if count == exact_size && limit == exact_size - 1
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_rejects_preserved_bytes_above_limit() {
|
|
||||||
let bytes = build_archive_with_nonzero_prefix_gap(&[SyntheticEntry {
|
|
||||||
type_id: 1,
|
|
||||||
attr1: 0,
|
|
||||||
attr2: 0,
|
|
||||||
attr3: 0,
|
|
||||||
name: "payload",
|
|
||||||
payload: b"data",
|
|
||||||
}]);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
decode_with_limits(
|
|
||||||
arc(bytes),
|
|
||||||
ReadProfile::Strict,
|
|
||||||
DecodeLimits {
|
|
||||||
max_preserved_bytes: 4,
|
|
||||||
..DecodeLimits::default()
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Err(NresError::Binary(DecodeError::LimitExceeded { .. }))
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2192,7 +1989,7 @@ mod tests {
|
|||||||
let mut has_nonzero_preserved_region = false;
|
let mut has_nonzero_preserved_region = false;
|
||||||
for path in &files {
|
for path in &files {
|
||||||
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||||
let doc = decode(arc(bytes.clone()), ReadProfile::Compatible)
|
let doc = decode(arc(bytes.clone()), ReadProfile::Strict)
|
||||||
.map_err(|err| format!("{}: {err}", path.display()))?;
|
.map_err(|err| format!("{}: {err}", path.display()))?;
|
||||||
total_entries = total_entries
|
total_entries = total_entries
|
||||||
.checked_add(doc.entry_count())
|
.checked_add(doc.entry_count())
|
||||||
|
|||||||
@@ -20,9 +20,7 @@
|
|||||||
)]
|
)]
|
||||||
//! Legacy path normalization and ASCII lookup semantics.
|
//! Legacy path normalization and ASCII lookup semantics.
|
||||||
|
|
||||||
use std::cmp::Ordering;
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Original bytes.
|
/// Original bytes.
|
||||||
@@ -44,41 +42,23 @@ impl OriginalPathBytes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Normalized relative path.
|
/// Normalized relative path.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
pub struct NormalizedPath {
|
pub struct NormalizedPath {
|
||||||
raw: Vec<u8>,
|
raw: Vec<u8>,
|
||||||
display: String,
|
display: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NormalizedPath {
|
impl NormalizedPath {
|
||||||
/// Returns normalized byte view used for identity, ordering, and hashing.
|
/// Returns string view.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn identity_bytes(&self) -> &[u8] {
|
pub fn as_str(&self) -> &str {
|
||||||
&self.raw
|
&self.display
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns an ASCII-only lookup key for case-insensitive archive matching.
|
|
||||||
#[must_use]
|
|
||||||
pub fn lookup_key(&self) -> LookupKey {
|
|
||||||
ascii_lookup_key(&self.raw)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns normalized byte view.
|
/// Returns normalized byte view.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn as_bytes(&self) -> &[u8] {
|
pub fn as_bytes(&self) -> &[u8] {
|
||||||
self.identity_bytes()
|
&self.raw
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a lossy display representation.
|
|
||||||
#[must_use]
|
|
||||||
pub fn display_lossy(&self) -> &str {
|
|
||||||
&self.display
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a lossy string view for UI and diagnostics only.
|
|
||||||
#[must_use]
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
self.display_lossy()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns an OS path owned path buffer.
|
/// Returns an OS path owned path buffer.
|
||||||
@@ -88,32 +68,6 @@ impl NormalizedPath {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq for NormalizedPath {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
|
||||||
self.raw == other.raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Eq for NormalizedPath {}
|
|
||||||
|
|
||||||
impl PartialOrd for NormalizedPath {
|
|
||||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
|
||||||
Some(self.cmp(other))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Ord for NormalizedPath {
|
|
||||||
fn cmp(&self, other: &Self) -> Ordering {
|
|
||||||
self.raw.cmp(&other.raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Hash for NormalizedPath {
|
|
||||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
|
||||||
self.raw.hash(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalized path paired with its original byte image.
|
/// Normalized path paired with its original byte image.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct NormalizedPathWithOriginal {
|
pub struct NormalizedPathWithOriginal {
|
||||||
@@ -399,8 +353,7 @@ mod tests {
|
|||||||
let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
||||||
.expect("raw legacy bytes");
|
.expect("raw legacy bytes");
|
||||||
|
|
||||||
assert_eq!(path.display_lossy(), "DATA/\u{FFFD}.bin");
|
assert_eq!(path.as_str(), "DATA/\u{FFFD}.bin");
|
||||||
assert_eq!(path.identity_bytes(), b"DATA/\xFF.bin");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -411,18 +364,4 @@ mod tests {
|
|||||||
assert_eq!(path.normalized().as_str(), "DATA/Maps/Intro/Land.msh");
|
assert_eq!(path.normalized().as_str(), "DATA/Maps/Intro/Land.msh");
|
||||||
assert_eq!(path.original().as_bytes(), raw);
|
assert_eq!(path.original().as_bytes(), raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lossy_display_does_not_affect_identity_or_ordering() {
|
|
||||||
let first = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
|
||||||
.expect("first raw path");
|
|
||||||
let second = normalize_relative(b"DATA/\xFE.bin", PathPolicy::HostCompatible)
|
|
||||||
.expect("second raw path");
|
|
||||||
|
|
||||||
assert_eq!(first.display_lossy(), second.display_lossy());
|
|
||||||
assert_ne!(first, second);
|
|
||||||
assert_ne!(first.identity_bytes(), second.identity_bytes());
|
|
||||||
assert_ne!(first.cmp(&second), Ordering::Equal);
|
|
||||||
assert_ne!(first.lookup_key(), second.lookup_key());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
encoding_rs = "0.8"
|
encoding_rs = "0.8"
|
||||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
|
fparkan-resource = { path = "../fparkan-resource" }
|
||||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
|||||||
@@ -128,15 +128,6 @@ pub struct PrototypeGraph {
|
|||||||
pub prototype_requests: Vec<PrototypeKey>,
|
pub prototype_requests: Vec<PrototypeKey>,
|
||||||
/// Mission object-local spans of effective prototype requests.
|
/// Mission object-local spans of effective prototype requests.
|
||||||
pub root_prototype_request_spans: Vec<std::ops::Range<usize>>,
|
pub root_prototype_request_spans: Vec<std::ops::Range<usize>>,
|
||||||
/// Ordered raw unit DAT component records for each mission root.
|
|
||||||
///
|
|
||||||
/// This vector is aligned with [`Self::roots`]. Direct roots and legacy
|
|
||||||
/// unit bindings have an empty record list. Records are preserved without
|
|
||||||
/// assigning runtime semantics to their `kind`, links, descriptions, or
|
|
||||||
/// opaque tails.
|
|
||||||
pub root_unit_components: Vec<Vec<UnitComponentRecord>>,
|
|
||||||
/// Whether visual dependency expansion has already been applied.
|
|
||||||
pub visual_dependencies_expanded: bool,
|
|
||||||
/// Materialized prototype dependency graph nodes.
|
/// Materialized prototype dependency graph nodes.
|
||||||
pub nodes: Vec<PrototypeGraphNode>,
|
pub nodes: Vec<PrototypeGraphNode>,
|
||||||
/// Materialized prototype dependency graph edges.
|
/// Materialized prototype dependency graph edges.
|
||||||
@@ -169,12 +160,6 @@ pub struct PrototypeGraphProvenance {
|
|||||||
pub root_index: usize,
|
pub root_index: usize,
|
||||||
/// Immediate parent edge that discovered this edge.
|
/// Immediate parent edge that discovered this edge.
|
||||||
pub parent_edge: Option<PrototypeGraphEdgeId>,
|
pub parent_edge: Option<PrototypeGraphEdgeId>,
|
||||||
/// Ordered Unit DAT record selected for this path, when the root is a
|
|
||||||
/// decoded multi-component unit.
|
|
||||||
///
|
|
||||||
/// The index identifies source provenance only; it does not classify the
|
|
||||||
/// component as physics, Control, animation, or any other subsystem.
|
|
||||||
pub unit_component_index: Option<usize>,
|
|
||||||
/// Source archive when available.
|
/// Source archive when available.
|
||||||
pub archive: Option<String>,
|
pub archive: Option<String>,
|
||||||
/// Source resource key when available.
|
/// Source resource key when available.
|
||||||
@@ -194,14 +179,6 @@ pub enum PrototypeGraphNodeKind {
|
|||||||
Prototype,
|
Prototype,
|
||||||
/// Mesh dependency.
|
/// Mesh dependency.
|
||||||
MeshResource,
|
MeshResource,
|
||||||
/// WEAR dependency.
|
|
||||||
WearResource,
|
|
||||||
/// MAT0 dependency.
|
|
||||||
MaterialResource,
|
|
||||||
/// TEXM texture dependency.
|
|
||||||
TextureResource,
|
|
||||||
/// TEXM lightmap dependency.
|
|
||||||
LightmapResource,
|
|
||||||
/// Non-geometric prototype.
|
/// Non-geometric prototype.
|
||||||
NonGeometric,
|
NonGeometric,
|
||||||
}
|
}
|
||||||
@@ -220,21 +197,6 @@ pub struct PrototypeGraphNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PrototypeGraphNode {
|
impl PrototypeGraphNode {
|
||||||
/// Creates a typed resource node.
|
|
||||||
#[must_use]
|
|
||||||
pub fn resource(
|
|
||||||
kind: PrototypeGraphNodeKind,
|
|
||||||
resource: ResourceKey,
|
|
||||||
id: PrototypeGraphNodeId,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
kind,
|
|
||||||
key: None,
|
|
||||||
resource: Some(resource),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a mesh resource node.
|
/// Creates a mesh resource node.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn mesh(resource: ResourceKey, id: PrototypeGraphNodeId) -> Self {
|
pub const fn mesh(resource: ResourceKey, id: PrototypeGraphNodeId) -> Self {
|
||||||
@@ -282,14 +244,6 @@ pub enum PrototypeGraphEdgeKind {
|
|||||||
UnitDatToComponent,
|
UnitDatToComponent,
|
||||||
/// Prototype to mesh dependency.
|
/// Prototype to mesh dependency.
|
||||||
PrototypeToMesh,
|
PrototypeToMesh,
|
||||||
/// Mesh resource to WEAR table.
|
|
||||||
MeshToWear,
|
|
||||||
/// WEAR table to MAT0 material.
|
|
||||||
WearToMaterial,
|
|
||||||
/// MAT0 material to TEXM texture.
|
|
||||||
MaterialToTexture,
|
|
||||||
/// WEAR table to TEXM lightmap.
|
|
||||||
WearToLightmap,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prototype graph edge record.
|
/// Prototype graph edge record.
|
||||||
@@ -650,7 +604,6 @@ fn resolve_direct_prototype(
|
|||||||
struct ResolvedPrototypeRequests {
|
struct ResolvedPrototypeRequests {
|
||||||
expected_count: usize,
|
expected_count: usize,
|
||||||
prototypes: Vec<EffectivePrototype>,
|
prototypes: Vec<EffectivePrototype>,
|
||||||
unit_components: Vec<UnitComponentRecord>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_prototype_requests(
|
fn resolve_prototype_requests(
|
||||||
@@ -666,7 +619,6 @@ fn resolve_prototype_requests(
|
|||||||
Ok(ResolvedPrototypeRequests {
|
Ok(ResolvedPrototypeRequests {
|
||||||
expected_count: 1,
|
expected_count: 1,
|
||||||
prototypes: prototype.into_iter().collect(),
|
prototypes: prototype.into_iter().collect(),
|
||||||
unit_components: Vec::new(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,6 +640,7 @@ fn resolve_unit_dat_prototype_requests(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Ok(unit) = decode_unit_dat(&bytes) {
|
if let Ok(unit) = decode_unit_dat(&bytes) {
|
||||||
|
if !unit.records.is_empty() {
|
||||||
let mut prototypes = Vec::with_capacity(unit.records.len());
|
let mut prototypes = Vec::with_capacity(unit.records.len());
|
||||||
for record in &unit.records {
|
for record in &unit.records {
|
||||||
let prototype = resolve_unit_component(repository, record)?.ok_or_else(|| {
|
let prototype = resolve_unit_component(repository, record)?.ok_or_else(|| {
|
||||||
@@ -701,9 +654,9 @@ fn resolve_unit_dat_prototype_requests(
|
|||||||
return Ok(ResolvedPrototypeRequests {
|
return Ok(ResolvedPrototypeRequests {
|
||||||
expected_count: unit.records.len(),
|
expected_count: unit.records.len(),
|
||||||
prototypes,
|
prototypes,
|
||||||
unit_components: unit.records,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let binding = decode_unit_dat_binding(&bytes)?;
|
let binding = decode_unit_dat_binding(&bytes)?;
|
||||||
let archive =
|
let archive =
|
||||||
@@ -713,7 +666,6 @@ fn resolve_unit_dat_prototype_requests(
|
|||||||
Ok(ResolvedPrototypeRequests {
|
Ok(ResolvedPrototypeRequests {
|
||||||
expected_count: 1,
|
expected_count: 1,
|
||||||
prototypes: prototype.into_iter().collect(),
|
prototypes: prototype.into_iter().collect(),
|
||||||
unit_components: Vec::new(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,15 +710,8 @@ pub fn build_prototype_graph(
|
|||||||
graph.roots.push(key);
|
graph.roots.push(key);
|
||||||
let start = graph.prototype_requests.len();
|
let start = graph.prototype_requests.len();
|
||||||
let expansion = resolve_prototype_requests(repository, vfs, root)?;
|
let expansion = resolve_prototype_requests(repository, vfs, root)?;
|
||||||
graph
|
let root_provenance = provenance_for_root(root_index, root);
|
||||||
.root_unit_components
|
for prototype in expansion.prototypes {
|
||||||
.push(expansion.unit_components.clone());
|
|
||||||
let unit_component_count = expansion.unit_components.len();
|
|
||||||
for (prototype_index, prototype) in expansion.prototypes.into_iter().enumerate() {
|
|
||||||
let unit_component_index = is_unit_dat_root
|
|
||||||
.then_some(prototype_index)
|
|
||||||
.filter(|index| *index < unit_component_count);
|
|
||||||
let root_provenance = provenance_for_root(root_index, root, unit_component_index);
|
|
||||||
let prototype_node = PrototypeGraphNode::prototype(
|
let prototype_node = PrototypeGraphNode::prototype(
|
||||||
prototype.key.clone(),
|
prototype.key.clone(),
|
||||||
PrototypeGraphNodeId(next_node),
|
PrototypeGraphNodeId(next_node),
|
||||||
@@ -805,7 +750,6 @@ pub fn build_prototype_graph(
|
|||||||
provenance: Some(provenance_for_mesh(
|
provenance: Some(provenance_for_mesh(
|
||||||
root_index,
|
root_index,
|
||||||
root_to_prototype_edge_id,
|
root_to_prototype_edge_id,
|
||||||
unit_component_index,
|
|
||||||
dependency,
|
dependency,
|
||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
@@ -861,23 +805,16 @@ pub fn build_prototype_graph_report(
|
|||||||
root_node,
|
root_node,
|
||||||
));
|
));
|
||||||
let start = graph.prototype_requests.len();
|
let start = graph.prototype_requests.len();
|
||||||
|
let root_provenance = provenance_for_root(root_index, root);
|
||||||
|
|
||||||
match resolve_prototype_requests(repository, vfs, root) {
|
match resolve_prototype_requests(repository, vfs, root) {
|
||||||
Ok(expansion) => {
|
Ok(expansion) => {
|
||||||
let expected = expansion.expected_count;
|
let expected = expansion.expected_count;
|
||||||
graph
|
|
||||||
.root_unit_components
|
|
||||||
.push(expansion.unit_components.clone());
|
|
||||||
if edge == PrototypeGraphEdge::MissionToUnitDat {
|
if edge == PrototypeGraphEdge::MissionToUnitDat {
|
||||||
report.unit_component_count += expected;
|
report.unit_component_count += expected;
|
||||||
}
|
}
|
||||||
let actual = expansion.prototypes.len();
|
let actual = expansion.prototypes.len();
|
||||||
let unit_component_count = expansion.unit_components.len();
|
for prototype in expansion.prototypes {
|
||||||
for (prototype_index, prototype) in expansion.prototypes.into_iter().enumerate() {
|
|
||||||
let unit_component_index = is_unit_dat_root
|
|
||||||
.then_some(prototype_index)
|
|
||||||
.filter(|index| *index < unit_component_count);
|
|
||||||
let root_provenance =
|
|
||||||
provenance_for_root(root_index, root, unit_component_index);
|
|
||||||
let prototype_node = PrototypeGraphNode::prototype(
|
let prototype_node = PrototypeGraphNode::prototype(
|
||||||
prototype.key.clone(),
|
prototype.key.clone(),
|
||||||
PrototypeGraphNodeId(next_node),
|
PrototypeGraphNodeId(next_node),
|
||||||
@@ -918,7 +855,6 @@ pub fn build_prototype_graph_report(
|
|||||||
provenance: Some(provenance_for_mesh(
|
provenance: Some(provenance_for_mesh(
|
||||||
root_index,
|
root_index,
|
||||||
root_to_prototype_edge_id,
|
root_to_prototype_edge_id,
|
||||||
unit_component_index,
|
|
||||||
dependency,
|
dependency,
|
||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
@@ -940,7 +876,6 @@ pub fn build_prototype_graph_report(
|
|||||||
provenance: Some(PrototypeGraphProvenance {
|
provenance: Some(PrototypeGraphProvenance {
|
||||||
root_index,
|
root_index,
|
||||||
parent_edge: None,
|
parent_edge: None,
|
||||||
unit_component_index: None,
|
|
||||||
archive: None,
|
archive: None,
|
||||||
resource: Some(root.0.clone()),
|
resource: Some(root.0.clone()),
|
||||||
span: None,
|
span: None,
|
||||||
@@ -957,16 +892,12 @@ pub fn build_prototype_graph_report(
|
|||||||
provenance: Some(PrototypeGraphProvenance {
|
provenance: Some(PrototypeGraphProvenance {
|
||||||
root_index,
|
root_index,
|
||||||
parent_edge: None,
|
parent_edge: None,
|
||||||
unit_component_index: None,
|
|
||||||
archive: None,
|
archive: None,
|
||||||
resource: Some(root.0.clone()),
|
resource: Some(root.0.clone()),
|
||||||
span: None,
|
span: None,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
if graph.root_unit_components.len() <= root_index {
|
|
||||||
graph.root_unit_components.push(Vec::new());
|
|
||||||
}
|
|
||||||
let end = graph.prototype_requests.len();
|
let end = graph.prototype_requests.len();
|
||||||
graph.root_prototype_request_spans.push(start..end);
|
graph.root_prototype_request_spans.push(start..end);
|
||||||
}
|
}
|
||||||
@@ -975,42 +906,14 @@ pub fn build_prototype_graph_report(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn graph_error_edge(edge: PrototypeGraphEdge, err: &PrototypeError) -> PrototypeGraphEdge {
|
fn graph_error_edge(edge: PrototypeGraphEdge, err: &PrototypeError) -> PrototypeGraphEdge {
|
||||||
match err {
|
let _ = err;
|
||||||
PrototypeError::Decode(_)
|
edge
|
||||||
| PrototypeError::InvalidSize
|
|
||||||
| PrototypeError::InvalidUnitDatMagic(_)
|
|
||||||
if edge == PrototypeGraphEdge::MissionToUnitDat =>
|
|
||||||
{
|
|
||||||
PrototypeGraphEdge::MissionToUnitDat
|
|
||||||
}
|
|
||||||
PrototypeError::Resource(ResourceError::Format(message))
|
|
||||||
if message.starts_with("missing unit DAT:") =>
|
|
||||||
{
|
|
||||||
PrototypeGraphEdge::MissionToUnitDat
|
|
||||||
}
|
|
||||||
PrototypeError::Resource(ResourceError::Format(message))
|
|
||||||
if message.starts_with("unit component ") =>
|
|
||||||
{
|
|
||||||
PrototypeGraphEdge::UnitDatToComponent
|
|
||||||
}
|
|
||||||
PrototypeError::Resource(ResourceError::Format(message))
|
|
||||||
if message.contains("explicit mesh reference missing:") =>
|
|
||||||
{
|
|
||||||
PrototypeGraphEdge::PrototypeToMesh
|
|
||||||
}
|
|
||||||
_ => edge,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn provenance_for_root(
|
fn provenance_for_root(root_index: usize, root: &ResourceName) -> PrototypeGraphProvenance {
|
||||||
root_index: usize,
|
|
||||||
root: &ResourceName,
|
|
||||||
unit_component_index: Option<usize>,
|
|
||||||
) -> PrototypeGraphProvenance {
|
|
||||||
PrototypeGraphProvenance {
|
PrototypeGraphProvenance {
|
||||||
root_index,
|
root_index,
|
||||||
parent_edge: None,
|
parent_edge: None,
|
||||||
unit_component_index,
|
|
||||||
archive: None,
|
archive: None,
|
||||||
resource: Some(root.0.clone()),
|
resource: Some(root.0.clone()),
|
||||||
span: None,
|
span: None,
|
||||||
@@ -1020,13 +923,11 @@ fn provenance_for_root(
|
|||||||
fn provenance_for_mesh(
|
fn provenance_for_mesh(
|
||||||
root_index: usize,
|
root_index: usize,
|
||||||
parent_edge: PrototypeGraphEdgeId,
|
parent_edge: PrototypeGraphEdgeId,
|
||||||
unit_component_index: Option<usize>,
|
|
||||||
dependency: &ResourceKey,
|
dependency: &ResourceKey,
|
||||||
) -> PrototypeGraphProvenance {
|
) -> PrototypeGraphProvenance {
|
||||||
PrototypeGraphProvenance {
|
PrototypeGraphProvenance {
|
||||||
root_index,
|
root_index,
|
||||||
parent_edge: Some(parent_edge),
|
parent_edge: Some(parent_edge),
|
||||||
unit_component_index,
|
|
||||||
archive: Some(dependency.archive.as_str().to_string()),
|
archive: Some(dependency.archive.as_str().to_string()),
|
||||||
resource: Some(dependency.name.0.clone()),
|
resource: Some(dependency.name.0.clone()),
|
||||||
span: None,
|
span: None,
|
||||||
@@ -1111,7 +1012,7 @@ fn collect_registry_refs(
|
|||||||
}
|
}
|
||||||
let archive_id = match repository.open_archive(registry_archive) {
|
let archive_id = match repository.open_archive(registry_archive) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(ResourceError::MissingArchive { .. }) => return Ok(None),
|
Err(ResourceError::MissingArchive) => return Ok(None),
|
||||||
Err(err) => return Err(err.into()),
|
Err(err) => return Err(err.into()),
|
||||||
};
|
};
|
||||||
let Some((registry_entry, _matched_name)) =
|
let Some((registry_entry, _matched_name)) =
|
||||||
@@ -1181,7 +1082,7 @@ fn find_mesh_resource(
|
|||||||
) -> Result<Option<ResourceKey>, PrototypeError> {
|
) -> Result<Option<ResourceKey>, PrototypeError> {
|
||||||
let archive_id = match repository.open_archive(archive) {
|
let archive_id = match repository.open_archive(archive) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(ResourceError::MissingArchive { .. }) => return Ok(None),
|
Err(ResourceError::MissingArchive) => return Ok(None),
|
||||||
Err(err) => return Err(err.into()),
|
Err(err) => return Err(err.into()),
|
||||||
};
|
};
|
||||||
let candidates = mesh_name_candidates(&model_key.0);
|
let candidates = mesh_name_candidates(&model_key.0);
|
||||||
@@ -1680,81 +1581,6 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("missing unit DAT"));
|
assert!(err.to_string().contains("missing unit DAT"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_unit_dat_resolves_as_zero_component_root() {
|
|
||||||
let mut vfs = MemoryVfs::default();
|
|
||||||
let dat_path = resource_archive_path(b"UNITS/AUTO/empty.dat").expect("dat path");
|
|
||||||
vfs.insert(dat_path, Arc::from(vec![0_u8; 8].into_boxed_slice()));
|
|
||||||
let vfs = Arc::new(vfs);
|
|
||||||
let repo = CachedResourceRepository::new(vfs.clone());
|
|
||||||
let roots = [resource_name(b"UNITS/AUTO/empty.dat")];
|
|
||||||
|
|
||||||
let (graph, resolved, report) = build_prototype_graph_report(&repo, vfs.as_ref(), &roots);
|
|
||||||
|
|
||||||
assert_eq!(graph.roots.len(), 1);
|
|
||||||
assert!(graph.prototype_requests.is_empty());
|
|
||||||
assert!(resolved.is_empty());
|
|
||||||
assert_eq!(report.unit_reference_count, 1);
|
|
||||||
assert_eq!(report.unit_component_count, 0);
|
|
||||||
assert_eq!(report.resolved_count, 0);
|
|
||||||
assert!(report.failures.is_empty());
|
|
||||||
assert!(report.is_success());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_unit_dat_failure_is_classified_on_unit_edge() {
|
|
||||||
let mut vfs = MemoryVfs::default();
|
|
||||||
let dat_path = resource_archive_path(b"UNITS/AUTO/bad.dat").expect("dat path");
|
|
||||||
vfs.insert(
|
|
||||||
dat_path,
|
|
||||||
Arc::from([1_u8, 2, 3].to_vec().into_boxed_slice()),
|
|
||||||
);
|
|
||||||
let vfs = Arc::new(vfs);
|
|
||||||
let repo = CachedResourceRepository::new(vfs.clone());
|
|
||||||
|
|
||||||
let (_graph, _resolved, report) = build_prototype_graph_report(
|
|
||||||
&repo,
|
|
||||||
vfs.as_ref(),
|
|
||||||
&[resource_name(b"UNITS/AUTO/bad.dat")],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(report.failures.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
report.failures[0].edge,
|
|
||||||
PrototypeGraphEdge::MissionToUnitDat
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_unit_component_failure_is_classified_on_component_edge() {
|
|
||||||
let mut vfs = MemoryVfs::default();
|
|
||||||
let dat_path = resource_archive_path(b"UNITS/AUTO/compound.dat").expect("dat path");
|
|
||||||
let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
|
|
||||||
vfs.insert(
|
|
||||||
dat_path,
|
|
||||||
Arc::from(
|
|
||||||
build_unit_dat(&[(b"objects.rlb".as_slice(), b"missing_component".as_slice())])
|
|
||||||
.into_boxed_slice(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
vfs.insert(objects_path, Arc::from(build_nres(&[]).into_boxed_slice()));
|
|
||||||
let vfs = Arc::new(vfs);
|
|
||||||
let repo = CachedResourceRepository::new(vfs.clone());
|
|
||||||
|
|
||||||
let (_graph, _resolved, report) = build_prototype_graph_report(
|
|
||||||
&repo,
|
|
||||||
vfs.as_ref(),
|
|
||||||
&[resource_name(b"UNITS/AUTO/compound.dat")],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(report.failures.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
report.failures[0].edge,
|
|
||||||
PrototypeGraphEdge::UnitDatToComponent
|
|
||||||
);
|
|
||||||
assert!(report.failures[0].message.contains("missing_component"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_dat_expands_components_in_order() {
|
fn unit_dat_expands_components_in_order() {
|
||||||
let mut vfs = MemoryVfs::default();
|
let mut vfs = MemoryVfs::default();
|
||||||
@@ -1815,45 +1641,6 @@ mod tests {
|
|||||||
assert_eq!(graph.prototype_requests.len(), 2);
|
assert_eq!(graph.prototype_requests.len(), 2);
|
||||||
assert_eq!(graph.prototype_requests[0].0 .0, b"component_a");
|
assert_eq!(graph.prototype_requests[0].0 .0, b"component_a");
|
||||||
assert_eq!(graph.prototype_requests[1].0 .0, b"component_b");
|
assert_eq!(graph.prototype_requests[1].0 .0, b"component_b");
|
||||||
assert_eq!(graph.root_unit_components.len(), 1);
|
|
||||||
assert_eq!(graph.root_unit_components[0].len(), 2);
|
|
||||||
assert_eq!(
|
|
||||||
cstr_bytes(&graph.root_unit_components[0][0].resource_raw),
|
|
||||||
b"component_a"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
cstr_bytes(&graph.root_unit_components[0][1].resource_raw),
|
|
||||||
b"component_b"
|
|
||||||
);
|
|
||||||
let component_edges: Vec<_> = graph
|
|
||||||
.edges
|
|
||||||
.iter()
|
|
||||||
.filter(|edge| edge.kind == PrototypeGraphEdgeKind::UnitDatToComponent)
|
|
||||||
.collect();
|
|
||||||
assert_eq!(component_edges.len(), 2);
|
|
||||||
assert_eq!(
|
|
||||||
component_edges[0]
|
|
||||||
.provenance
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|provenance| provenance.unit_component_index),
|
|
||||||
Some(0)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
component_edges[1]
|
|
||||||
.provenance
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|provenance| provenance.unit_component_index),
|
|
||||||
Some(1)
|
|
||||||
);
|
|
||||||
assert!(graph
|
|
||||||
.edges
|
|
||||||
.iter()
|
|
||||||
.filter(|edge| edge.kind == PrototypeGraphEdgeKind::PrototypeToMesh)
|
|
||||||
.all(|edge| edge
|
|
||||||
.provenance
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|value| value.unit_component_index)
|
|
||||||
.is_some()));
|
|
||||||
assert_eq!(resolved.len(), 2);
|
assert_eq!(resolved.len(), 2);
|
||||||
assert_eq!(report.unit_reference_count, 1);
|
assert_eq!(report.unit_reference_count, 1);
|
||||||
assert_eq!(report.unit_component_count, 2);
|
assert_eq!(report.unit_component_count, 2);
|
||||||
@@ -2107,7 +1894,10 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(report.failures.len(), 1);
|
assert_eq!(report.failures.len(), 1);
|
||||||
assert_eq!(report.failures[0].resource_raw, b"broken");
|
assert_eq!(report.failures[0].resource_raw, b"broken");
|
||||||
assert_eq!(report.failures[0].edge, PrototypeGraphEdge::PrototypeToMesh);
|
assert_eq!(
|
||||||
|
report.failures[0].edge,
|
||||||
|
PrototypeGraphEdge::MissionToObjectsRegistry
|
||||||
|
);
|
||||||
assert!(report.failures[0].message.contains("broken"));
|
assert!(report.failures[0].message.contains("broken"));
|
||||||
assert!(report.failures[0]
|
assert!(report.failures[0]
|
||||||
.message
|
.message
|
||||||
@@ -2139,7 +1929,6 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(report.failures.len(), 1);
|
assert_eq!(report.failures.len(), 1);
|
||||||
assert_eq!(report.failures[0].resource_raw, b"broken");
|
assert_eq!(report.failures[0].resource_raw, b"broken");
|
||||||
assert_eq!(report.failures[0].edge, PrototypeGraphEdge::PrototypeToMesh);
|
|
||||||
assert!(report.failures[0]
|
assert!(report.failures[0]
|
||||||
.message
|
.message
|
||||||
.contains("static.rlb:missing.msh"));
|
.contains("static.rlb:missing.msh"));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
|
fparkan-world = { path = "../fparkan-world" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -22,366 +22,6 @@
|
|||||||
|
|
||||||
use fparkan_world::OriginalObjectId;
|
use fparkan_world::OriginalObjectId;
|
||||||
|
|
||||||
/// A 64-byte transform block returned by the original Terrain camera ABI.
|
|
||||||
///
|
|
||||||
/// The original uses two selector-dependent transform pointers. Their exact
|
|
||||||
/// matrix convention is still under recovery, so words are deliberately kept
|
|
||||||
/// losslessly rather than treated as a renderer-ready matrix. The three
|
|
||||||
/// translation words have been confirmed at indices 3, 7, and 11.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct RawCameraTransform {
|
|
||||||
/// The exact 16 little-endian dwords returned by the legacy camera.
|
|
||||||
pub words: [u32; 16],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RawCameraTransform {
|
|
||||||
/// Indices of the confirmed X, Y, and Z translation floats.
|
|
||||||
pub const TRANSLATION_WORD_INDICES: [usize; 3] = [3, 7, 11];
|
|
||||||
|
|
||||||
/// Returns the confirmed legacy world position (X, Y, Z).
|
|
||||||
#[must_use]
|
|
||||||
pub fn translation(self) -> [f32; 3] {
|
|
||||||
Self::TRANSLATION_WORD_INDICES.map(|index| f32::from_bits(self.words[index]))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inverts a finite row-major affine transform without assigning it a
|
|
||||||
/// camera-space meaning.
|
|
||||||
///
|
|
||||||
/// The legacy SIMD dispatch multiplies these blocks as ordinary row-major
|
|
||||||
/// matrices and the confirmed camera samples have translation in the last
|
|
||||||
/// column. `Some` therefore means only that this block has a non-singular
|
|
||||||
/// affine inverse. Callers must still establish whether it is a
|
|
||||||
/// camera-to-world transform before using the result as a view matrix.
|
|
||||||
#[must_use]
|
|
||||||
pub fn try_inverse_affine_row_major(self) -> Option<[f32; 16]> {
|
|
||||||
let matrix = self.words.map(f32::from_bits);
|
|
||||||
if !matrix.iter().all(|value| value.is_finite())
|
|
||||||
|| matrix[12].abs() > f32::EPSILON
|
|
||||||
|| matrix[13].abs() > f32::EPSILON
|
|
||||||
|| matrix[14].abs() > f32::EPSILON
|
|
||||||
|| (matrix[15] - 1.0).abs() > f32::EPSILON
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let [m00, m01, m02, _, m10, m11, m12, _, m20, m21, m22, _, _, _, _, _] = matrix;
|
|
||||||
let cofactor00 = m11.mul_add(m22, -(m12 * m21));
|
|
||||||
let cofactor01 = m02.mul_add(m21, -(m01 * m22));
|
|
||||||
let cofactor02 = m01.mul_add(m12, -(m02 * m11));
|
|
||||||
let determinant = m00.mul_add(cofactor00, m10.mul_add(cofactor01, m20 * cofactor02));
|
|
||||||
if !determinant.is_finite() || determinant == 0.0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let inverse_determinant = determinant.recip();
|
|
||||||
let inverse = [
|
|
||||||
cofactor00 * inverse_determinant,
|
|
||||||
m02.mul_add(m21, -(m01 * m22)) * inverse_determinant,
|
|
||||||
cofactor02 * inverse_determinant,
|
|
||||||
0.0,
|
|
||||||
m12.mul_add(m20, -(m10 * m22)) * inverse_determinant,
|
|
||||||
m00.mul_add(m22, -(m02 * m20)) * inverse_determinant,
|
|
||||||
m02.mul_add(m10, -(m00 * m12)) * inverse_determinant,
|
|
||||||
0.0,
|
|
||||||
m10.mul_add(m21, -(m11 * m20)) * inverse_determinant,
|
|
||||||
m01.mul_add(m20, -(m00 * m21)) * inverse_determinant,
|
|
||||||
m00.mul_add(m11, -(m01 * m10)) * inverse_determinant,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
1.0,
|
|
||||||
];
|
|
||||||
let [translation_x, translation_y, translation_z] = self.translation();
|
|
||||||
let translation = [
|
|
||||||
-(inverse[0].mul_add(
|
|
||||||
translation_x,
|
|
||||||
inverse[1].mul_add(translation_y, inverse[2] * translation_z),
|
|
||||||
)),
|
|
||||||
-(inverse[4].mul_add(
|
|
||||||
translation_x,
|
|
||||||
inverse[5].mul_add(translation_y, inverse[6] * translation_z),
|
|
||||||
)),
|
|
||||||
-(inverse[8].mul_add(
|
|
||||||
translation_x,
|
|
||||||
inverse[9].mul_add(translation_y, inverse[10] * translation_z),
|
|
||||||
)),
|
|
||||||
];
|
|
||||||
|
|
||||||
Some([
|
|
||||||
inverse[0],
|
|
||||||
inverse[1],
|
|
||||||
inverse[2],
|
|
||||||
translation[0],
|
|
||||||
inverse[4],
|
|
||||||
inverse[5],
|
|
||||||
inverse[6],
|
|
||||||
translation[1],
|
|
||||||
inverse[8],
|
|
||||||
inverse[9],
|
|
||||||
inverse[10],
|
|
||||||
translation[2],
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
1.0,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reproduces Ngi32's `Direct3D7` view-matrix conversion for this transform.
|
|
||||||
///
|
|
||||||
/// The legacy renderer copies selector-0 into its camera state, then maps
|
|
||||||
/// its axes and translation in this exact order before calling
|
|
||||||
/// `IDirect3DDevice7::SetTransform(D3DTRANSFORMSTATE_VIEW, ...)`. This is
|
|
||||||
/// deliberately distinct from [`Self::try_inverse_affine_row_major`]: it
|
|
||||||
/// includes the original renderer's coordinate-system conversion.
|
|
||||||
///
|
|
||||||
/// The returned matrix is row-major D3D7 data, not yet a Vulkan view
|
|
||||||
/// matrix. A later adapter must explicitly account for clip-space and
|
|
||||||
/// shader-vector conventions.
|
|
||||||
#[must_use]
|
|
||||||
pub fn try_direct3d7_view_row_major(self) -> Option<[f32; 16]> {
|
|
||||||
let matrix = self.words.map(f32::from_bits);
|
|
||||||
if !matrix.iter().all(|value| value.is_finite())
|
|
||||||
|| matrix[12].abs() > f32::EPSILON
|
|
||||||
|| matrix[13].abs() > f32::EPSILON
|
|
||||||
|| matrix[14].abs() > f32::EPSILON
|
|
||||||
|| (matrix[15] - 1.0).abs() > f32::EPSILON
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let [m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, _, _, _, _] = matrix;
|
|
||||||
Some([
|
|
||||||
-m01,
|
|
||||||
m02,
|
|
||||||
m00,
|
|
||||||
0.0,
|
|
||||||
-m11,
|
|
||||||
m12,
|
|
||||||
m10,
|
|
||||||
0.0,
|
|
||||||
-m21,
|
|
||||||
m22,
|
|
||||||
m20,
|
|
||||||
0.0,
|
|
||||||
m23.mul_add(m21, m13.mul_add(m11, m03 * m01)),
|
|
||||||
-(m23.mul_add(m22, m13.mul_add(m12, m03 * m02))),
|
|
||||||
-(m00.mul_add(m03, m23.mul_add(m20, m13 * m10))),
|
|
||||||
1.0,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parameters consumed by Ngi32's `Direct3D7` projection-matrix builder.
|
|
||||||
///
|
|
||||||
/// These are a separate legacy-renderer boundary from
|
|
||||||
/// [`RawCameraProjection`]. The latter preserves Terrain's source ABI, while
|
|
||||||
/// this type records the already-resolved Ngi32 values submitted to `Direct3D7`.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub struct LegacyD3d7Projection {
|
|
||||||
/// Viewport rectangle as `(left, top, right, bottom)`.
|
|
||||||
pub viewport: [i32; 4],
|
|
||||||
/// Positive camera near-plane distance.
|
|
||||||
pub near_plane: f32,
|
|
||||||
/// Camera far-plane distance, greater than [`Self::near_plane`].
|
|
||||||
pub far_plane: f32,
|
|
||||||
/// Full field-of-view angle in radians.
|
|
||||||
pub field_of_view_radians: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LegacyD3d7Projection {
|
|
||||||
/// Reconstructs the exact row-major matrix passed to D3D7 projection state.
|
|
||||||
///
|
|
||||||
/// Ngi32 uses the viewport's `width / height`, writes `cos(fov / 2)` to
|
|
||||||
/// the diagonal and `sin(fov / 2)` to both the depth scale and
|
|
||||||
/// homogeneous-W term. The ratio after D3D's perspective divide is
|
|
||||||
/// therefore the expected cotangent scale. This remains legacy D3D7 data
|
|
||||||
/// rather than a Vulkan projection.
|
|
||||||
#[must_use]
|
|
||||||
pub fn try_direct3d7_projection_row_major(self) -> Option<[f32; 16]> {
|
|
||||||
let width = self.viewport[2].checked_sub(self.viewport[0])?;
|
|
||||||
let height = self.viewport[3].checked_sub(self.viewport[1])?;
|
|
||||||
if width <= 0
|
|
||||||
|| height <= 0
|
|
||||||
|| !self.near_plane.is_finite()
|
|
||||||
|| !self.far_plane.is_finite()
|
|
||||||
|| !self.field_of_view_radians.is_finite()
|
|
||||||
|| self.near_plane <= 0.0
|
|
||||||
|| self.far_plane <= self.near_plane
|
|
||||||
|| self.field_of_view_radians <= 0.0
|
|
||||||
|| self.field_of_view_radians >= std::f32::consts::PI
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ngi32 converts these signed viewport dimensions into single-precision
|
|
||||||
// arithmetic before building the legacy matrix.
|
|
||||||
#[allow(clippy::cast_precision_loss)]
|
|
||||||
let aspect = (width as f32) / (height as f32);
|
|
||||||
let half_fov = self.field_of_view_radians * 0.5;
|
|
||||||
let cosine = half_fov.cos();
|
|
||||||
let sine = half_fov.sin();
|
|
||||||
let depth_scale = sine / (1.0 - self.near_plane / self.far_plane);
|
|
||||||
[aspect, cosine, sine, depth_scale]
|
|
||||||
.iter()
|
|
||||||
.all(|value| value.is_finite())
|
|
||||||
.then_some([
|
|
||||||
cosine,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
aspect * cosine,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
depth_scale,
|
|
||||||
sine,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
-(depth_scale * self.near_plane),
|
|
||||||
0.0,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Affine placement inputs consumed by `Iron3D`'s recovered Euler-matrix builder.
|
|
||||||
///
|
|
||||||
/// This records the exact matrix construction at `iron3d.dll` RVA `0x36610`.
|
|
||||||
/// It is intentionally distinct from a mission placement contract: the static
|
|
||||||
/// trace has not yet proved that TMA's three raw orientation floats reach this
|
|
||||||
/// builder unchanged for every object category.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub struct LegacyIron3dEulerTransform {
|
|
||||||
/// World-space translation passed to the builder.
|
|
||||||
pub translation: [f32; 3],
|
|
||||||
/// Three angles in the builder's `(x, y, z)` input order, in radians.
|
|
||||||
pub orientation_radians: [f32; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LegacyIron3dEulerTransform {
|
|
||||||
/// Reconstructs the builder's finite row-major affine matrix.
|
|
||||||
///
|
|
||||||
/// The recovered x87 code evaluates `Rz(z) * Ry(y) * Rx(x)` and writes
|
|
||||||
/// translation into the last column. This uses portable `f32` trigonometry;
|
|
||||||
/// any future x87-compatibility path must be separately capture-validated.
|
|
||||||
#[must_use]
|
|
||||||
pub fn try_row_major(self) -> Option<[f32; 16]> {
|
|
||||||
if !self
|
|
||||||
.translation
|
|
||||||
.iter()
|
|
||||||
.chain(self.orientation_radians.iter())
|
|
||||||
.all(|value| value.is_finite())
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let [x, y, z] = self.orientation_radians;
|
|
||||||
let (sin_x, cos_x) = x.sin_cos();
|
|
||||||
let (sin_y, cos_y) = y.sin_cos();
|
|
||||||
let (sin_z, cos_z) = z.sin_cos();
|
|
||||||
let [translation_x, translation_y, translation_z] = self.translation;
|
|
||||||
let matrix = [
|
|
||||||
cos_z * cos_y,
|
|
||||||
cos_z * sin_y * sin_x - sin_z * cos_x,
|
|
||||||
cos_z * sin_y * cos_x + sin_z * sin_x,
|
|
||||||
translation_x,
|
|
||||||
sin_z * cos_y,
|
|
||||||
sin_z * sin_y * sin_x + cos_z * cos_x,
|
|
||||||
sin_z * sin_y * cos_x - cos_z * sin_x,
|
|
||||||
translation_y,
|
|
||||||
-sin_y,
|
|
||||||
cos_y * sin_x,
|
|
||||||
cos_y * cos_x,
|
|
||||||
translation_z,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
1.0,
|
|
||||||
];
|
|
||||||
matrix
|
|
||||||
.iter()
|
|
||||||
.all(|value| value.is_finite())
|
|
||||||
.then_some(matrix)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Applies the recovered rotation after a component-wise local scale.
|
|
||||||
///
|
|
||||||
/// This is the affine `R * (scale * point) + translation` convention used
|
|
||||||
/// by the static source-world bridge. The original dynamic transform path
|
|
||||||
/// needs its own capture evidence before it may replace this static path.
|
|
||||||
#[must_use]
|
|
||||||
pub fn try_transform_scaled_point(self, point: [f32; 3], scale: [f32; 3]) -> Option<[f32; 3]> {
|
|
||||||
if !point
|
|
||||||
.iter()
|
|
||||||
.chain(scale.iter())
|
|
||||||
.all(|value| value.is_finite())
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let matrix = self.try_row_major()?;
|
|
||||||
let scaled = [
|
|
||||||
point[0] * scale[0],
|
|
||||||
point[1] * scale[1],
|
|
||||||
point[2] * scale[2],
|
|
||||||
];
|
|
||||||
let transformed = [
|
|
||||||
matrix[0] * scaled[0] + matrix[1] * scaled[1] + matrix[2] * scaled[2] + matrix[3],
|
|
||||||
matrix[4] * scaled[0] + matrix[5] * scaled[1] + matrix[6] * scaled[2] + matrix[7],
|
|
||||||
matrix[8] * scaled[0] + matrix[9] * scaled[1] + matrix[10] * scaled[2] + matrix[11],
|
|
||||||
];
|
|
||||||
transformed
|
|
||||||
.iter()
|
|
||||||
.all(|value| value.is_finite())
|
|
||||||
.then_some(transformed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Raw camera state observed through the original Terrain camera interface.
|
|
||||||
///
|
|
||||||
/// Selector 0 supplies the currently active transform and selector 2 supplies
|
|
||||||
/// its paired transform. Keeping both lets a later compatibility layer derive
|
|
||||||
/// a view/projection convention without re-reading the legacy process.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct RawCameraPose {
|
|
||||||
/// Transform returned by selector 0.
|
|
||||||
pub selector0: RawCameraTransform,
|
|
||||||
/// Transform returned by selector 2.
|
|
||||||
pub selector2: RawCameraTransform,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Raw projection state observed through the original `CBufferingCamera` ABI.
|
|
||||||
///
|
|
||||||
/// The five-float context block is intentionally represented as original words:
|
|
||||||
/// its indices are observed, but their semantic labels have not yet all been
|
|
||||||
/// recovered. The field-of-view value is the type-0 input to `tan(fov / 2)`.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct RawCameraProjection {
|
|
||||||
/// Viewport rectangle as `(left, top, right, bottom)`.
|
|
||||||
pub viewport: [i32; 4],
|
|
||||||
/// Original projection selector.
|
|
||||||
pub projection_type: u32,
|
|
||||||
/// Original IEEE-754 FOV value in radians.
|
|
||||||
pub field_of_view_radians_bits: u32,
|
|
||||||
/// Exact five-float context block returned by the primary renderer.
|
|
||||||
pub context_words: [u32; 5],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RawCameraProjection {
|
|
||||||
/// Returns the type-0 FOV input in radians.
|
|
||||||
#[must_use]
|
|
||||||
pub fn field_of_view_radians(self) -> f32 {
|
|
||||||
f32::from_bits(self.field_of_view_radians_bits)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the five context values without assigning semantic labels.
|
|
||||||
#[must_use]
|
|
||||||
pub fn context_values(self) -> [f32; 5] {
|
|
||||||
self.context_words.map(f32::from_bits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Immutable camera data visible to command generation.
|
/// Immutable camera data visible to command generation.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct CameraSnapshot {
|
pub struct CameraSnapshot {
|
||||||
@@ -389,17 +29,6 @@ pub struct CameraSnapshot {
|
|||||||
pub view: [f32; 16],
|
pub view: [f32; 16],
|
||||||
/// Projection matrix, row-major.
|
/// Projection matrix, row-major.
|
||||||
pub projection: [f32; 16],
|
pub projection: [f32; 16],
|
||||||
/// Optional unconverted source-camera state.
|
|
||||||
///
|
|
||||||
/// This does not alter rendering until the original matrix and projection
|
|
||||||
/// conventions have been recovered; it preserves the ABI boundary for the
|
|
||||||
/// runtime adapter and deterministic captures.
|
|
||||||
pub raw_pose: Option<RawCameraPose>,
|
|
||||||
/// Optional unconverted source-projection state.
|
|
||||||
///
|
|
||||||
/// This is retained independently of `projection`, because its legacy
|
|
||||||
/// context words have not yet been mapped to a Vulkan clip convention.
|
|
||||||
pub raw_projection: Option<RawCameraProjection>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CameraSnapshot {
|
impl Default for CameraSnapshot {
|
||||||
@@ -407,8 +36,6 @@ impl Default for CameraSnapshot {
|
|||||||
Self {
|
Self {
|
||||||
view: identity_transform(),
|
view: identity_transform(),
|
||||||
projection: identity_transform(),
|
projection: identity_transform(),
|
||||||
raw_pose: None,
|
|
||||||
raw_projection: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,93 +71,6 @@ pub enum RenderPhase {
|
|||||||
Ui,
|
Ui,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fixed-function blend behaviour represented without a graphics API type.
|
|
||||||
///
|
|
||||||
/// This is a compatibility contract, not yet a decoded MAT0 mapping.
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
||||||
pub enum LegacyBlendMode {
|
|
||||||
/// Do not blend the fragment with the existing colour.
|
|
||||||
#[default]
|
|
||||||
Opaque,
|
|
||||||
/// Blend using source alpha.
|
|
||||||
SourceAlpha,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Depth-buffer behaviour represented without a graphics API type.
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
||||||
pub enum LegacyDepthMode {
|
|
||||||
/// No depth attachment is used.
|
|
||||||
#[default]
|
|
||||||
Disabled,
|
|
||||||
/// Test depth and write passing fragments.
|
|
||||||
TestWrite,
|
|
||||||
/// Test depth without modifying it.
|
|
||||||
TestReadOnly,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Triangle culling behaviour represented without a graphics API type.
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
||||||
pub enum LegacyCullMode {
|
|
||||||
/// Keep both front- and back-facing triangles.
|
|
||||||
#[default]
|
|
||||||
Disabled,
|
|
||||||
/// Cull back-facing triangles.
|
|
||||||
BackFace,
|
|
||||||
/// Cull front-facing triangles.
|
|
||||||
FrontFace,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Legacy fixed-function state that changes graphics-pipeline structure.
|
|
||||||
///
|
|
||||||
/// Alpha reference is deliberately not present: it is dynamic material data,
|
|
||||||
/// whereas this state records only whether an alpha-test shader variant is used.
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
||||||
pub struct LegacyPipelineState {
|
|
||||||
/// Colour blend mode.
|
|
||||||
pub blend: LegacyBlendMode,
|
|
||||||
/// Depth test/write mode.
|
|
||||||
pub depth: LegacyDepthMode,
|
|
||||||
/// Face culling mode.
|
|
||||||
pub cull: LegacyCullMode,
|
|
||||||
/// Whether alpha-test shader logic is enabled.
|
|
||||||
pub alpha_test: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Canonical, backend-neutral key for a graphics-pipeline variant.
|
|
||||||
///
|
|
||||||
/// The value is explicitly packed rather than hashed, so captures and caches
|
|
||||||
/// remain stable across processes and Rust toolchain updates.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
|
||||||
pub struct PipelineKey(u8);
|
|
||||||
|
|
||||||
impl PipelineKey {
|
|
||||||
/// Returns the canonical packed representation.
|
|
||||||
#[must_use]
|
|
||||||
pub const fn packed(self) -> u8 {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<LegacyPipelineState> for PipelineKey {
|
|
||||||
fn from(state: LegacyPipelineState) -> Self {
|
|
||||||
let blend = match state.blend {
|
|
||||||
LegacyBlendMode::Opaque => 0,
|
|
||||||
LegacyBlendMode::SourceAlpha => 1,
|
|
||||||
};
|
|
||||||
let depth = match state.depth {
|
|
||||||
LegacyDepthMode::Disabled => 0,
|
|
||||||
LegacyDepthMode::TestWrite => 1,
|
|
||||||
LegacyDepthMode::TestReadOnly => 2,
|
|
||||||
};
|
|
||||||
let cull = match state.cull {
|
|
||||||
LegacyCullMode::Disabled => 0,
|
|
||||||
LegacyCullMode::BackFace => 1,
|
|
||||||
LegacyCullMode::FrontFace => 2,
|
|
||||||
};
|
|
||||||
Self(blend | (depth << 1) | (cull << 3) | (u8::from(state.alpha_test) << 5))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Index range.
|
/// Index range.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct IndexRange {
|
pub struct IndexRange {
|
||||||
@@ -555,8 +95,6 @@ pub struct RenderSnapshotDraw {
|
|||||||
pub material_slots: Vec<GpuMaterialId>,
|
pub material_slots: Vec<GpuMaterialId>,
|
||||||
/// Batch material index into [`Self::material_slots`].
|
/// Batch material index into [`Self::material_slots`].
|
||||||
pub material_index: u16,
|
pub material_index: u16,
|
||||||
/// Fixed-function state resolved for this draw.
|
|
||||||
pub pipeline_state: LegacyPipelineState,
|
|
||||||
/// Node transform matrix, row-major.
|
/// Node transform matrix, row-major.
|
||||||
pub transform: [f32; 16],
|
pub transform: [f32; 16],
|
||||||
/// Index range.
|
/// Index range.
|
||||||
@@ -594,8 +132,6 @@ pub struct DrawCommand {
|
|||||||
pub mesh: GpuMeshId,
|
pub mesh: GpuMeshId,
|
||||||
/// Material.
|
/// Material.
|
||||||
pub material: GpuMaterialId,
|
pub material: GpuMaterialId,
|
||||||
/// Canonical graphics-pipeline variant.
|
|
||||||
pub pipeline_key: PipelineKey,
|
|
||||||
/// Transform matrix, row-major.
|
/// Transform matrix, row-major.
|
||||||
pub transform: [f32; 16],
|
pub transform: [f32; 16],
|
||||||
/// Index range.
|
/// Index range.
|
||||||
@@ -833,7 +369,6 @@ pub fn build_commands(
|
|||||||
object_id: draw.object_id,
|
object_id: draw.object_id,
|
||||||
mesh: draw.mesh,
|
mesh: draw.mesh,
|
||||||
material,
|
material,
|
||||||
pipeline_key: draw.pipeline_state.into(),
|
|
||||||
transform: draw.transform,
|
transform: draw.transform,
|
||||||
range: draw.range,
|
range: draw.range,
|
||||||
stable_order: draw.stable_order,
|
stable_order: draw.stable_order,
|
||||||
@@ -854,11 +389,6 @@ pub trait RenderBackend {
|
|||||||
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError>;
|
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marker trait for backends that execute draws against a live GPU.
|
|
||||||
///
|
|
||||||
/// Planning and capture-only backends must not implement this trait.
|
|
||||||
pub trait GpuRenderBackend: RenderBackend {}
|
|
||||||
|
|
||||||
/// Backend that validates commands and intentionally produces no pixels.
|
/// Backend that validates commands and intentionally produces no pixels.
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct NullBackend;
|
pub struct NullBackend;
|
||||||
@@ -918,13 +448,8 @@ pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, Render
|
|||||||
RenderCommand::Draw(draw) => {
|
RenderCommand::Draw(draw) => {
|
||||||
out.extend_from_slice(
|
out.extend_from_slice(
|
||||||
format!(
|
format!(
|
||||||
"D,{:?},{},{},{},{},{}\n",
|
"D,{:?},{},{},{},{}\n",
|
||||||
draw.phase,
|
draw.phase, draw.id.0, draw.mesh.0, draw.material.0, draw.stable_order
|
||||||
draw.id.0,
|
|
||||||
draw.mesh.0,
|
|
||||||
draw.material.0,
|
|
||||||
draw.pipeline_key.packed(),
|
|
||||||
draw.stable_order
|
|
||||||
)
|
)
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
);
|
);
|
||||||
@@ -1075,191 +600,6 @@ fn identity_transform() -> [f32; 16] {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
|
|
||||||
let mut result = [0.0; 16];
|
|
||||||
for row in 0..4 {
|
|
||||||
for column in 0..4 {
|
|
||||||
result[row * 4 + column] = (0..4)
|
|
||||||
.map(|index| left[row * 4 + index] * right[index * 4 + column])
|
|
||||||
.sum();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_matrix_approximately_identity(matrix: [f32; 16]) {
|
|
||||||
for (index, value) in matrix.into_iter().enumerate() {
|
|
||||||
let expected = if index / 4 == index % 4 { 1.0 } else { 0.0 };
|
|
||||||
assert!(
|
|
||||||
(value - expected).abs() < 0.000_02,
|
|
||||||
"matrix element {index}: expected {expected}, got {value}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn raw_camera_pose_preserves_words_and_extracts_confirmed_translation() {
|
|
||||||
let mut active = [0_u32; 16];
|
|
||||||
active[0] = 0x7FC0_0001;
|
|
||||||
active[3] = 491.562_5_f32.to_bits();
|
|
||||||
active[7] = 761.550_8_f32.to_bits();
|
|
||||||
active[11] = 7.361_0_f32.to_bits();
|
|
||||||
let paired = [0xA5A5_5A5A; 16];
|
|
||||||
|
|
||||||
let pose = RawCameraPose {
|
|
||||||
selector0: RawCameraTransform { words: active },
|
|
||||||
selector2: RawCameraTransform { words: paired },
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(pose.selector0.words, active);
|
|
||||||
assert_eq!(pose.selector2.words, paired);
|
|
||||||
assert_eq!(
|
|
||||||
pose.selector0.translation(),
|
|
||||||
[491.562_5, 761.550_8, 7.361_0]
|
|
||||||
);
|
|
||||||
assert_eq!(CameraSnapshot::default().raw_pose, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn raw_camera_projection_preserves_live_context_words_without_labeling_them() {
|
|
||||||
let projection = RawCameraProjection {
|
|
||||||
viewport: [0, 0, 1024, 768],
|
|
||||||
projection_type: 0,
|
|
||||||
field_of_view_radians_bits: 1.04_f32.to_bits(),
|
|
||||||
context_words: [
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
0.5_f32.to_bits(),
|
|
||||||
700.0_f32.to_bits(),
|
|
||||||
0.1_f32.to_bits(),
|
|
||||||
0.99_f32.to_bits(),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(projection.field_of_view_radians(), 1.04);
|
|
||||||
assert_eq!(projection.context_values(), [0.0, 0.5, 700.0, 0.1, 0.99]);
|
|
||||||
assert_eq!(CameraSnapshot::default().raw_projection, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn raw_camera_transform_inverts_only_non_singular_affine_blocks() {
|
|
||||||
let source = [
|
|
||||||
0.0, -1.0, 0.0, 433.544_7, 0.948_985, 0.0, 0.315_322, 652.292_5, -0.315_322, 0.0,
|
|
||||||
0.948_985, 10.673_42, 0.0, 0.0, 0.0, 1.0,
|
|
||||||
];
|
|
||||||
let transform = RawCameraTransform {
|
|
||||||
words: source.map(f32::to_bits),
|
|
||||||
};
|
|
||||||
let inverse = transform
|
|
||||||
.try_inverse_affine_row_major()
|
|
||||||
.expect("observed affine transform is invertible");
|
|
||||||
|
|
||||||
assert_matrix_approximately_identity(multiply_row_major(source, inverse));
|
|
||||||
assert_matrix_approximately_identity(multiply_row_major(inverse, source));
|
|
||||||
|
|
||||||
let singular = RawCameraTransform { words: [0_u32; 16] };
|
|
||||||
assert_eq!(singular.try_inverse_affine_row_major(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn raw_camera_transform_reproduces_direct3d7_view_axis_conversion() {
|
|
||||||
let transform = RawCameraTransform {
|
|
||||||
words: [
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
(-1.0_f32).to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
10.0_f32.to_bits(),
|
|
||||||
1.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
20.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
1.0_f32.to_bits(),
|
|
||||||
30.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
0.0_f32.to_bits(),
|
|
||||||
1.0_f32.to_bits(),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
transform.try_direct3d7_view_row_major(),
|
|
||||||
Some([
|
|
||||||
1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, -10.0, -30.0, -20.0,
|
|
||||||
1.0,
|
|
||||||
])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
RawCameraTransform { words: [0_u32; 16] }.try_direct3d7_view_row_major(),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_d3d7_projection_matches_recovered_camera_formula() {
|
|
||||||
let projection = LegacyD3d7Projection {
|
|
||||||
viewport: [0, 0, 1024, 768],
|
|
||||||
near_plane: 0.5,
|
|
||||||
far_plane: 700.0,
|
|
||||||
field_of_view_radians: 1.3,
|
|
||||||
};
|
|
||||||
let matrix = projection
|
|
||||||
.try_direct3d7_projection_row_major()
|
|
||||||
.expect("live Ngi32 projection parameters are valid");
|
|
||||||
let half_fov = 0.65_f32;
|
|
||||||
let depth_scale = half_fov.sin() * 700.0_f32 / 699.5;
|
|
||||||
|
|
||||||
assert_eq!(matrix[0], half_fov.cos());
|
|
||||||
assert_eq!(matrix[5], (4.0 / 3.0) * half_fov.cos());
|
|
||||||
assert_eq!(matrix[10], depth_scale);
|
|
||||||
assert_eq!(matrix[11], half_fov.sin());
|
|
||||||
assert_eq!(matrix[14], -(depth_scale * 0.5));
|
|
||||||
assert_eq!(
|
|
||||||
LegacyD3d7Projection {
|
|
||||||
viewport: [0, 0, 0, 768],
|
|
||||||
..projection
|
|
||||||
}
|
|
||||||
.try_direct3d7_projection_row_major(),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn iron3d_euler_builder_uses_rz_ry_rx_and_last_column_translation() {
|
|
||||||
let matrix = LegacyIron3dEulerTransform {
|
|
||||||
translation: [418.103_18, 717.433, 3.040_938_9],
|
|
||||||
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
|
|
||||||
}
|
|
||||||
.try_row_major()
|
|
||||||
.expect("finite recovered builder inputs");
|
|
||||||
|
|
||||||
assert!((matrix[0]).abs() < 0.000_001);
|
|
||||||
assert!((matrix[1] + 1.0).abs() < 0.000_001);
|
|
||||||
assert!((matrix[4] - 1.0).abs() < 0.000_001);
|
|
||||||
assert!((matrix[5]).abs() < 0.000_001);
|
|
||||||
assert_eq!(matrix[3], 418.103_18);
|
|
||||||
assert_eq!(matrix[7], 717.433);
|
|
||||||
assert_eq!(matrix[11], 3.040_938_9);
|
|
||||||
assert_eq!(matrix[15], 1.0);
|
|
||||||
assert_eq!(
|
|
||||||
LegacyIron3dEulerTransform {
|
|
||||||
translation: [10.0, 20.0, 30.0],
|
|
||||||
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
|
|
||||||
}
|
|
||||||
.try_transform_scaled_point([2.0, 3.0, 4.0], [2.0, 1.0, 0.5]),
|
|
||||||
Some([7.0, 24.0, 32.0])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
LegacyIron3dEulerTransform {
|
|
||||||
translation: [0.0, 0.0, 0.0],
|
|
||||||
orientation_radians: [f32::NAN, 0.0, 0.0],
|
|
||||||
}
|
|
||||||
.try_row_major(),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot_draw(
|
fn snapshot_draw(
|
||||||
id: u64,
|
id: u64,
|
||||||
phase: RenderPhase,
|
phase: RenderPhase,
|
||||||
@@ -1273,7 +613,6 @@ mod tests {
|
|||||||
mesh: GpuMeshId(10 + id),
|
mesh: GpuMeshId(10 + id),
|
||||||
material_slots: vec![GpuMaterialId(31), GpuMaterialId(37)],
|
material_slots: vec![GpuMaterialId(31), GpuMaterialId(37)],
|
||||||
material_index,
|
material_index,
|
||||||
pipeline_state: LegacyPipelineState::default(),
|
|
||||||
transform: identity_transform(),
|
transform: identity_transform(),
|
||||||
range: IndexRange { start: 0, count: 3 },
|
range: IndexRange { start: 0, count: 3 },
|
||||||
stable_order,
|
stable_order,
|
||||||
@@ -1291,7 +630,6 @@ mod tests {
|
|||||||
object_id: None,
|
object_id: None,
|
||||||
mesh: GpuMeshId(2),
|
mesh: GpuMeshId(2),
|
||||||
material: GpuMaterialId(3),
|
material: GpuMaterialId(3),
|
||||||
pipeline_key: LegacyPipelineState::default().into(),
|
|
||||||
transform: [0.0; 16],
|
transform: [0.0; 16],
|
||||||
range: IndexRange { start: 0, count: 3 },
|
range: IndexRange { start: 0, count: 3 },
|
||||||
stable_order: 4,
|
stable_order: 4,
|
||||||
@@ -1301,35 +639,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
canonical_capture(&list).expect("capture"),
|
canonical_capture(&list).expect("capture"),
|
||||||
b"B\nD,Opaque,1,2,3,0,4\nE\n"
|
b"B\nD,Opaque,1,2,3,4\nE\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pipeline_key_is_explicit_stable_and_sensitive_to_pipeline_structure() {
|
|
||||||
let base = LegacyPipelineState::default();
|
|
||||||
assert_eq!(PipelineKey::from(base).packed(), 0);
|
|
||||||
|
|
||||||
let variant = LegacyPipelineState {
|
|
||||||
blend: LegacyBlendMode::SourceAlpha,
|
|
||||||
depth: LegacyDepthMode::TestReadOnly,
|
|
||||||
cull: LegacyCullMode::BackFace,
|
|
||||||
alpha_test: true,
|
|
||||||
};
|
|
||||||
assert_eq!(PipelineKey::from(variant).packed(), 0b00_101_101);
|
|
||||||
assert_ne!(PipelineKey::from(base), PipelineKey::from(variant));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn alpha_test_flag_changes_key_without_encoding_material_threshold() {
|
|
||||||
let opaque = LegacyPipelineState::default();
|
|
||||||
let alpha_test = LegacyPipelineState {
|
|
||||||
alpha_test: true,
|
|
||||||
..opaque
|
|
||||||
};
|
|
||||||
assert_eq!(PipelineKey::from(alpha_test).packed(), 0b10_0000);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn null_backend_validates_without_capture() {
|
fn null_backend_validates_without_capture() {
|
||||||
let mut backend = NullBackend;
|
let mut backend = NullBackend;
|
||||||
@@ -1342,7 +655,6 @@ mod tests {
|
|||||||
object_id: None,
|
object_id: None,
|
||||||
mesh: GpuMeshId(2),
|
mesh: GpuMeshId(2),
|
||||||
material: GpuMaterialId(3),
|
material: GpuMaterialId(3),
|
||||||
pipeline_key: LegacyPipelineState::default().into(),
|
|
||||||
transform: [0.0; 16],
|
transform: [0.0; 16],
|
||||||
range: IndexRange { start: 0, count: 0 },
|
range: IndexRange { start: 0, count: 0 },
|
||||||
stable_order: 4,
|
stable_order: 4,
|
||||||
@@ -1445,7 +757,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
capture,
|
capture,
|
||||||
b"B\nD,Opaque,1,11,31,0,10\nD,Opaque,2,12,31,0,20\nD,Transparent,3,13,31,0,0\nE\n"
|
b"B\nD,Opaque,1,11,31,10\nD,Opaque,2,12,31,20\nD,Transparent,3,13,31,0\nE\n"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1551,7 +863,6 @@ mod tests {
|
|||||||
object_id: None,
|
object_id: None,
|
||||||
mesh: GpuMeshId(2),
|
mesh: GpuMeshId(2),
|
||||||
material: GpuMaterialId(3),
|
material: GpuMaterialId(3),
|
||||||
pipeline_key: LegacyPipelineState::default().into(),
|
|
||||||
transform: identity_transform(),
|
transform: identity_transform(),
|
||||||
range: IndexRange {
|
range: IndexRange {
|
||||||
start: u32::MAX,
|
start: u32::MAX,
|
||||||
@@ -1606,7 +917,6 @@ mod tests {
|
|||||||
object_id: None,
|
object_id: None,
|
||||||
mesh: GpuMeshId(2),
|
mesh: GpuMeshId(2),
|
||||||
material: GpuMaterialId(3),
|
material: GpuMaterialId(3),
|
||||||
pipeline_key: LegacyPipelineState::default().into(),
|
|
||||||
transform: identity_transform(),
|
transform: identity_transform(),
|
||||||
range: IndexRange {
|
range: IndexRange {
|
||||||
start: 14,
|
start: 14,
|
||||||
@@ -1641,7 +951,6 @@ mod tests {
|
|||||||
object_id: None,
|
object_id: None,
|
||||||
mesh: GpuMeshId(1),
|
mesh: GpuMeshId(1),
|
||||||
material: GpuMaterialId(1),
|
material: GpuMaterialId(1),
|
||||||
pipeline_key: LegacyPipelineState::default().into(),
|
|
||||||
transform: identity_transform(),
|
transform: identity_transform(),
|
||||||
range: IndexRange { start: 0, count: 3 },
|
range: IndexRange { start: 0, count: 3 },
|
||||||
stable_order: 0,
|
stable_order: 0,
|
||||||
@@ -1652,7 +961,6 @@ mod tests {
|
|||||||
object_id: None,
|
object_id: None,
|
||||||
mesh: GpuMeshId(1),
|
mesh: GpuMeshId(1),
|
||||||
material: GpuMaterialId(1),
|
material: GpuMaterialId(1),
|
||||||
pipeline_key: LegacyPipelineState::default().into(),
|
|
||||||
transform: identity_transform(),
|
transform: identity_transform(),
|
||||||
range: IndexRange { start: 0, count: 3 },
|
range: IndexRange { start: 0, count: 3 },
|
||||||
stable_order: 0,
|
stable_order: 0,
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
|
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
|
||||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
+17
-347
@@ -20,7 +20,6 @@
|
|||||||
)]
|
)]
|
||||||
//! Stage-1 `RsLi` archive contract.
|
//! Stage-1 `RsLi` archive contract.
|
||||||
|
|
||||||
use fparkan_binary::DecodeError;
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -79,33 +78,6 @@ pub enum WriteProfile {
|
|||||||
Lossless,
|
Lossless,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode and payload loading limits.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct DecodeLimits {
|
|
||||||
/// Maximum accepted source archive bytes.
|
|
||||||
pub max_input_bytes: u64,
|
|
||||||
/// Maximum accepted entry count.
|
|
||||||
pub max_entries: u32,
|
|
||||||
/// Maximum accepted packed entry bytes.
|
|
||||||
pub max_packed_entry_bytes: u64,
|
|
||||||
/// Maximum accepted decoded entry bytes.
|
|
||||||
pub max_decoded_entry_bytes: u64,
|
|
||||||
/// Maximum accepted cumulative decoded bytes for a single load operation.
|
|
||||||
pub max_total_decoded_bytes: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DecodeLimits {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
max_input_bytes: 256 * 1024 * 1024,
|
|
||||||
max_entries: 1_000_000,
|
|
||||||
max_packed_entry_bytes: 64 * 1024 * 1024,
|
|
||||||
max_decoded_entry_bytes: 128 * 1024 * 1024,
|
|
||||||
max_total_decoded_bytes: 128 * 1024 * 1024,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error returned when mutable editing is attempted.
|
/// Error returned when mutable editing is attempted.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum RsliMutationError {
|
pub enum RsliMutationError {
|
||||||
@@ -133,11 +105,6 @@ pub enum RsliMutationError {
|
|||||||
/// Format maximum (`u32::MAX`).
|
/// Format maximum (`u32::MAX`).
|
||||||
max: usize,
|
max: usize,
|
||||||
},
|
},
|
||||||
/// Method cannot be represented by the on-disk flags field.
|
|
||||||
UnsupportedMethod {
|
|
||||||
/// Requested method.
|
|
||||||
method: RsliMethod,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for RsliMutationError {
|
impl std::fmt::Display for RsliMutationError {
|
||||||
@@ -153,9 +120,6 @@ impl std::fmt::Display for RsliMutationError {
|
|||||||
Self::PackedPayloadTooLarge { size, max } => {
|
Self::PackedPayloadTooLarge { size, max } => {
|
||||||
write!(f, "packed payload is too large: {size} > {max}")
|
write!(f, "packed payload is too large: {size} > {max}")
|
||||||
}
|
}
|
||||||
Self::UnsupportedMethod { method } => {
|
|
||||||
write!(f, "unsupported authoring method: {method:?}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,8 +374,6 @@ pub enum RsliError {
|
|||||||
},
|
},
|
||||||
/// Integer conversion or arithmetic overflow.
|
/// Integer conversion or arithmetic overflow.
|
||||||
IntegerOverflow,
|
IntegerOverflow,
|
||||||
/// Shared bounded decode failure.
|
|
||||||
Binary(DecodeError),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for RsliError {
|
impl fmt::Display for RsliError {
|
||||||
@@ -470,25 +432,11 @@ impl fmt::Display for RsliError {
|
|||||||
write!(f, "output size mismatch: expected={expected}, got={got}")
|
write!(f, "output size mismatch: expected={expected}, got={got}")
|
||||||
}
|
}
|
||||||
Self::IntegerOverflow => write!(f, "integer overflow"),
|
Self::IntegerOverflow => write!(f, "integer overflow"),
|
||||||
Self::Binary(source) => write!(f, "{source}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for RsliError {
|
impl std::error::Error for RsliError {}
|
||||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
||||||
match self {
|
|
||||||
Self::Binary(source) => Some(source),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<DecodeError> for RsliError {
|
|
||||||
fn from(value: DecodeError) -> Self {
|
|
||||||
Self::Binary(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes an `RsLi` document.
|
/// Decodes an `RsLi` document.
|
||||||
///
|
///
|
||||||
@@ -498,21 +446,7 @@ impl From<DecodeError> for RsliError {
|
|||||||
/// compatibility quirks, or packed payloads are invalid for the selected
|
/// compatibility quirks, or packed payloads are invalid for the selected
|
||||||
/// profile.
|
/// profile.
|
||||||
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> {
|
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> {
|
||||||
decode_with_limits(bytes, profile, DecodeLimits::default())
|
decode_with_profile(bytes, profile.into())
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes an `RsLi` document with explicit archive limits.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns [`RsliError`] when the input exceeds configured limits or the
|
|
||||||
/// archive is malformed for the selected profile.
|
|
||||||
pub fn decode_with_limits(
|
|
||||||
bytes: Arc<[u8]>,
|
|
||||||
profile: ReadProfile,
|
|
||||||
limits: DecodeLimits,
|
|
||||||
) -> Result<RsliDocument, RsliError> {
|
|
||||||
decode_with_profile_and_limits(bytes, profile.into(), limits)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes an `RsLi` document with explicit compatibility switches.
|
/// Decodes an `RsLi` document with explicit compatibility switches.
|
||||||
@@ -525,33 +459,17 @@ pub fn decode_with_limits(
|
|||||||
pub fn decode_with_profile(
|
pub fn decode_with_profile(
|
||||||
bytes: Arc<[u8]>,
|
bytes: Arc<[u8]>,
|
||||||
profile: RsliReadProfile,
|
profile: RsliReadProfile,
|
||||||
) -> Result<RsliDocument, RsliError> {
|
|
||||||
decode_with_profile_and_limits(bytes, profile, DecodeLimits::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes an `RsLi` document with explicit profile and archive limits.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns [`RsliError`] when the input exceeds configured limits or the
|
|
||||||
/// archive is malformed for the selected profile.
|
|
||||||
pub fn decode_with_profile_and_limits(
|
|
||||||
bytes: Arc<[u8]>,
|
|
||||||
profile: RsliReadProfile,
|
|
||||||
limits: DecodeLimits,
|
|
||||||
) -> Result<RsliDocument, RsliError> {
|
) -> Result<RsliDocument, RsliError> {
|
||||||
let options = match profile {
|
let options = match profile {
|
||||||
RsliReadProfile::Strict => ParseOptions {
|
RsliReadProfile::Strict => ParseOptions {
|
||||||
allow_ao_trailer: false,
|
allow_ao_trailer: false,
|
||||||
allow_deflate_eof_plus_one: false,
|
allow_deflate_eof_plus_one: false,
|
||||||
allow_invalid_presorted_fallback: false,
|
allow_invalid_presorted_fallback: false,
|
||||||
limits,
|
|
||||||
},
|
},
|
||||||
RsliReadProfile::Compatible(profile) => ParseOptions {
|
RsliReadProfile::Compatible(profile) => ParseOptions {
|
||||||
allow_ao_trailer: profile.allow_ao_trailer,
|
allow_ao_trailer: profile.allow_ao_trailer,
|
||||||
allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one,
|
allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one,
|
||||||
allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback,
|
allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback,
|
||||||
limits,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let ParsedRsli {
|
let ParsedRsli {
|
||||||
@@ -627,20 +545,6 @@ impl RsliDocument {
|
|||||||
/// Returns [`RsliError`] when `id` is invalid or the packed payload cannot
|
/// Returns [`RsliError`] when `id` is invalid or the packed payload cannot
|
||||||
/// be decoded to the declared size.
|
/// be decoded to the declared size.
|
||||||
pub fn load(&self, id: EntryId) -> Result<Vec<u8>, RsliError> {
|
pub fn load(&self, id: EntryId) -> Result<Vec<u8>, RsliError> {
|
||||||
self.load_with_limits(id, DecodeLimits::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads and unpacks an entry with explicit decode limits.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns [`RsliError`] when the packed payload exceeds configured
|
|
||||||
/// limits, `id` is invalid, or the payload cannot be decoded.
|
|
||||||
pub fn load_with_limits(
|
|
||||||
&self,
|
|
||||||
id: EntryId,
|
|
||||||
limits: DecodeLimits,
|
|
||||||
) -> Result<Vec<u8>, RsliError> {
|
|
||||||
let record = self.record_by_id(id)?;
|
let record = self.record_by_id(id)?;
|
||||||
let packed = self.packed_slice(id, record)?;
|
let packed = self.packed_slice(id, record)?;
|
||||||
decode_payload(
|
decode_payload(
|
||||||
@@ -648,7 +552,6 @@ impl RsliDocument {
|
|||||||
record.meta.method,
|
record.meta.method,
|
||||||
record.key16,
|
record.key16,
|
||||||
record.meta.unpacked_size,
|
record.meta.unpacked_size,
|
||||||
limits,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,7 +651,6 @@ impl RsliEditor {
|
|||||||
/// Returns [`RsliMutationError`] when the entry id is unknown.
|
/// Returns [`RsliMutationError`] when the entry id is unknown.
|
||||||
pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> {
|
pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> {
|
||||||
let entry = self.entry_mut(id)?;
|
let entry = self.entry_mut(id)?;
|
||||||
entry.meta.flags = flags_with_method(entry.meta.flags, method)?;
|
|
||||||
entry.meta.method = method;
|
entry.meta.method = method;
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -935,7 +837,6 @@ struct ParseOptions {
|
|||||||
allow_ao_trailer: bool,
|
allow_ao_trailer: bool,
|
||||||
allow_deflate_eof_plus_one: bool,
|
allow_deflate_eof_plus_one: bool,
|
||||||
allow_invalid_presorted_fallback: bool,
|
allow_invalid_presorted_fallback: bool,
|
||||||
limits: DecodeLimits,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -956,10 +857,6 @@ struct EntryRecord {
|
|||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliError> {
|
fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliError> {
|
||||||
enforce_limit(
|
|
||||||
u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
|
|
||||||
options.limits.max_input_bytes,
|
|
||||||
)?;
|
|
||||||
if bytes.len() < 32 {
|
if bytes.len() < 32 {
|
||||||
return Err(RsliError::EntryTableOutOfBounds {
|
return Err(RsliError::EntryTableOutOfBounds {
|
||||||
table_offset: 32,
|
table_offset: 32,
|
||||||
@@ -995,10 +892,6 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliErr
|
|||||||
if count > usize::try_from(u32::MAX).map_err(|_| RsliError::IntegerOverflow)? {
|
if count > usize::try_from(u32::MAX).map_err(|_| RsliError::IntegerOverflow)? {
|
||||||
return Err(RsliError::TooManyEntries { got: count });
|
return Err(RsliError::TooManyEntries { got: count });
|
||||||
}
|
}
|
||||||
enforce_limit(
|
|
||||||
u64::try_from(count).map_err(|_| RsliError::IntegerOverflow)?,
|
|
||||||
u64::from(options.limits.max_entries),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
|
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
|
||||||
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||||
@@ -1042,14 +935,6 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliErr
|
|||||||
let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]);
|
let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]);
|
||||||
let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]);
|
let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]);
|
||||||
let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]);
|
let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]);
|
||||||
enforce_limit(
|
|
||||||
u64::from(packed_size_declared),
|
|
||||||
options.limits.max_packed_entry_bytes,
|
|
||||||
)?;
|
|
||||||
enforce_limit(
|
|
||||||
u64::from(unpacked_size),
|
|
||||||
options.limits.max_decoded_entry_bytes,
|
|
||||||
)?;
|
|
||||||
let method_raw = u32::from(flags_signed.cast_unsigned()) & 0x1E0;
|
let method_raw = u32::from(flags_signed.cast_unsigned()) & 0x1E0;
|
||||||
let method = parse_method(method_raw);
|
let method = parse_method(method_raw);
|
||||||
|
|
||||||
@@ -1124,12 +1009,9 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliErr
|
|||||||
}
|
}
|
||||||
|
|
||||||
if presorted_flag == 0xABBA {
|
if presorted_flag == 0xABBA {
|
||||||
let permutation = validate_permutation(&records);
|
if validate_permutation(&records).is_err() {
|
||||||
let order = validate_lookup_order(&records);
|
|
||||||
if permutation.is_err() || order.is_err() {
|
|
||||||
if !options.allow_invalid_presorted_fallback {
|
if !options.allow_invalid_presorted_fallback {
|
||||||
permutation?;
|
validate_permutation(&records)?;
|
||||||
order?;
|
|
||||||
}
|
}
|
||||||
rebuild_sorted_mapping(&mut records)?;
|
rebuild_sorted_mapping(&mut records)?;
|
||||||
}
|
}
|
||||||
@@ -1204,35 +1086,6 @@ fn validate_permutation(records: &[EntryRecord]) -> Result<(), RsliError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_lookup_order(records: &[EntryRecord]) -> Result<(), RsliError> {
|
|
||||||
for pair in records.windows(2) {
|
|
||||||
let left_original = usize::try_from(i32::from(pair[0].meta.sort_to_original))
|
|
||||||
.map_err(|_| RsliError::IntegerOverflow)?;
|
|
||||||
let right_original = usize::try_from(i32::from(pair[1].meta.sort_to_original))
|
|
||||||
.map_err(|_| RsliError::IntegerOverflow)?;
|
|
||||||
let left = records
|
|
||||||
.get(left_original)
|
|
||||||
.ok_or(RsliError::CorruptEntryTable(
|
|
||||||
"sort_to_original is not a permutation",
|
|
||||||
))?;
|
|
||||||
let right = records
|
|
||||||
.get(right_original)
|
|
||||||
.ok_or(RsliError::CorruptEntryTable(
|
|
||||||
"sort_to_original is not a permutation",
|
|
||||||
))?;
|
|
||||||
if cmp_c_string(
|
|
||||||
c_name_bytes(&left.meta.name_raw),
|
|
||||||
c_name_bytes(&right.meta.name_raw),
|
|
||||||
) == std::cmp::Ordering::Greater
|
|
||||||
{
|
|
||||||
return Err(RsliError::CorruptEntryTable(
|
|
||||||
"presorted lookup names are not sorted",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_method(raw: u32) -> RsliMethod {
|
fn parse_method(raw: u32) -> RsliMethod {
|
||||||
match raw {
|
match raw {
|
||||||
0x000 => RsliMethod::Stored,
|
0x000 => RsliMethod::Stored,
|
||||||
@@ -1294,39 +1147,32 @@ fn decode_payload(
|
|||||||
method: RsliMethod,
|
method: RsliMethod,
|
||||||
key16: u16,
|
key16: u16,
|
||||||
unpacked_size: u32,
|
unpacked_size: u32,
|
||||||
limits: DecodeLimits,
|
|
||||||
) -> Result<Vec<u8>, RsliError> {
|
) -> Result<Vec<u8>, RsliError> {
|
||||||
enforce_limit(
|
|
||||||
u64::try_from(packed.len()).map_err(|_| RsliError::IntegerOverflow)?,
|
|
||||||
limits.max_packed_entry_bytes,
|
|
||||||
)?;
|
|
||||||
enforce_limit(u64::from(unpacked_size), limits.max_decoded_entry_bytes)?;
|
|
||||||
enforce_limit(u64::from(unpacked_size), limits.max_total_decoded_bytes)?;
|
|
||||||
let expected = usize::try_from(unpacked_size).map_err(|_| RsliError::IntegerOverflow)?;
|
let expected = usize::try_from(unpacked_size).map_err(|_| RsliError::IntegerOverflow)?;
|
||||||
let out = match method {
|
let out = match method {
|
||||||
RsliMethod::Stored => {
|
RsliMethod::Stored => {
|
||||||
if packed.len() != expected {
|
if packed.len() < expected {
|
||||||
return Err(RsliError::OutputSizeMismatch {
|
return Err(RsliError::OutputSizeMismatch {
|
||||||
expected: unpacked_size,
|
expected: unpacked_size,
|
||||||
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
packed.to_vec()
|
packed[..expected].to_vec()
|
||||||
}
|
}
|
||||||
RsliMethod::XorOnly => {
|
RsliMethod::XorOnly => {
|
||||||
if packed.len() != expected {
|
if packed.len() < expected {
|
||||||
return Err(RsliError::OutputSizeMismatch {
|
return Err(RsliError::OutputSizeMismatch {
|
||||||
expected: unpacked_size,
|
expected: unpacked_size,
|
||||||
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
xor_stream(packed, key16)
|
xor_stream(&packed[..expected], key16)
|
||||||
}
|
}
|
||||||
RsliMethod::Lzss => lzss_decompress_simple(packed, expected, None)?,
|
RsliMethod::Lzss => lzss_decompress_simple(packed, expected, None)?,
|
||||||
RsliMethod::XorLzss => lzss_decompress_simple(packed, expected, Some(key16))?,
|
RsliMethod::XorLzss => lzss_decompress_simple(packed, expected, Some(key16))?,
|
||||||
RsliMethod::AdaptiveLzss => lzss_huffman_decompress(packed, expected, None)?,
|
RsliMethod::AdaptiveLzss => lzss_huffman_decompress(packed, expected, None)?,
|
||||||
RsliMethod::XorAdaptiveLzss => lzss_huffman_decompress(packed, expected, Some(key16))?,
|
RsliMethod::XorAdaptiveLzss => lzss_huffman_decompress(packed, expected, Some(key16))?,
|
||||||
RsliMethod::RawDeflate => decode_deflate(packed, expected)?,
|
RsliMethod::RawDeflate => decode_deflate(packed)?,
|
||||||
RsliMethod::Unknown(raw) => return Err(RsliError::UnsupportedMethod { raw }),
|
RsliMethod::Unknown(raw) => return Err(RsliError::UnsupportedMethod { raw }),
|
||||||
};
|
};
|
||||||
if out.len() != expected {
|
if out.len() != expected {
|
||||||
@@ -1430,61 +1276,15 @@ fn read_packed_byte(data: &[u8], pos: usize, state: &mut Option<XorState>) -> Op
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_deflate(packed: &[u8], expected_size: usize) -> Result<Vec<u8>, RsliError> {
|
fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>, RsliError> {
|
||||||
let mut out = Vec::with_capacity(expected_size);
|
let mut out = Vec::new();
|
||||||
let mut chunk = [0u8; 4096];
|
|
||||||
let mut decoder = flate2::read::DeflateDecoder::new(packed);
|
let mut decoder = flate2::read::DeflateDecoder::new(packed);
|
||||||
loop {
|
decoder
|
||||||
let read = decoder
|
.read_to_end(&mut out)
|
||||||
.read(&mut chunk)
|
|
||||||
.map_err(|_| RsliError::DecompressionFailed("deflate"))?;
|
.map_err(|_| RsliError::DecompressionFailed("deflate"))?;
|
||||||
if read == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let next_len = out
|
|
||||||
.len()
|
|
||||||
.checked_add(read)
|
|
||||||
.ok_or(RsliError::IntegerOverflow)?;
|
|
||||||
if next_len > expected_size {
|
|
||||||
return Err(RsliError::OutputSizeMismatch {
|
|
||||||
expected: u32::try_from(expected_size).unwrap_or(u32::MAX),
|
|
||||||
got: u32::try_from(next_len).unwrap_or(u32::MAX),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
out.extend_from_slice(&chunk[..read]);
|
|
||||||
}
|
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn method_bits(method: RsliMethod) -> Result<u16, RsliMutationError> {
|
|
||||||
match method {
|
|
||||||
RsliMethod::Stored => Ok(0x000),
|
|
||||||
RsliMethod::XorOnly => Ok(0x020),
|
|
||||||
RsliMethod::Lzss => Ok(0x040),
|
|
||||||
RsliMethod::XorLzss => Ok(0x060),
|
|
||||||
RsliMethod::AdaptiveLzss => Ok(0x080),
|
|
||||||
RsliMethod::XorAdaptiveLzss => Ok(0x0A0),
|
|
||||||
RsliMethod::RawDeflate => Ok(0x100),
|
|
||||||
RsliMethod::Unknown(_) => Err(RsliMutationError::UnsupportedMethod { method }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flags_with_method(flags: i32, method: RsliMethod) -> Result<i32, RsliMutationError> {
|
|
||||||
let method = i32::from(method_bits(method)?);
|
|
||||||
Ok((flags & !0x1E0) | method)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enforce_limit(value: u64, limit: u64) -> Result<(), RsliError> {
|
|
||||||
if value > limit {
|
|
||||||
return Err(DecodeError::LimitExceeded {
|
|
||||||
count: value,
|
|
||||||
limit,
|
|
||||||
}
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
const LZH_N: usize = 4096;
|
const LZH_N: usize = 4096;
|
||||||
const LZH_F: usize = 60;
|
const LZH_F: usize = 60;
|
||||||
const LZH_THRESHOLD: usize = 2;
|
const LZH_THRESHOLD: usize = 2;
|
||||||
@@ -1902,30 +1702,6 @@ mod tests {
|
|||||||
assert_eq!(doc.find("B"), Some(EntryId(0)));
|
assert_eq!(doc.find("B"), Some(EntryId(0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn strict_rejects_unsorted_presorted_mapping() {
|
|
||||||
let bytes = synthetic_rsli(
|
|
||||||
&[
|
|
||||||
SyntheticEntry::stored(b"B", 0, b"bee"),
|
|
||||||
SyntheticEntry::stored(b"A", 1, b"aye"),
|
|
||||||
],
|
|
||||||
true,
|
|
||||||
0x0103,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
decode(arc(bytes.clone()), ReadProfile::Strict),
|
|
||||||
Err(RsliError::CorruptEntryTable(
|
|
||||||
"presorted lookup names are not sorted"
|
|
||||||
))
|
|
||||||
));
|
|
||||||
|
|
||||||
let doc = decode(arc(bytes), ReadProfile::Compatible).expect("compatible fallback");
|
|
||||||
assert_eq!(doc.find("A"), Some(EntryId(1)));
|
|
||||||
assert_eq!(doc.find("B"), Some(EntryId(0)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_profile_controls_invalid_presorted_fallback() {
|
fn explicit_profile_controls_invalid_presorted_fallback() {
|
||||||
let bytes = synthetic_rsli(
|
let bytes = synthetic_rsli(
|
||||||
@@ -1980,8 +1756,8 @@ mod tests {
|
|||||||
let packed = xor_stream(&plain, 1);
|
let packed = xor_stream(&plain, 1);
|
||||||
let bytes = synthetic_rsli(
|
let bytes = synthetic_rsli(
|
||||||
&[
|
&[
|
||||||
SyntheticEntry::with_payload(b"B", 0x020, 1, &plain, packed),
|
SyntheticEntry::with_payload(b"A", 0x020, 1, &plain, packed),
|
||||||
SyntheticEntry::stored(b"A", 0, b"plain"),
|
SyntheticEntry::stored(b"B", 0, b"plain"),
|
||||||
],
|
],
|
||||||
true,
|
true,
|
||||||
0x2222,
|
0x2222,
|
||||||
@@ -2365,9 +2141,8 @@ mod tests {
|
|||||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
|
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
|
||||||
let mut editor = doc.editor().expect("editor");
|
let mut editor = doc.editor().expect("editor");
|
||||||
editor.set_name(EntryId(1), b"ZETA").expect("edit name");
|
editor.set_name(EntryId(1), b"ZETA").expect("edit name");
|
||||||
let repacked = deflate_bytes(b"repacked-alpha");
|
|
||||||
editor
|
editor
|
||||||
.set_packed_payload(EntryId(0), repacked, 14)
|
.set_packed_payload(EntryId(0), b"repacked-alpha", 14)
|
||||||
.expect("edit packed payload");
|
.expect("edit packed payload");
|
||||||
editor
|
editor
|
||||||
.set_method(EntryId(0), RsliMethod::RawDeflate)
|
.set_method(EntryId(0), RsliMethod::RawDeflate)
|
||||||
@@ -2388,106 +2163,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
doc.entries()[original.0 as usize].method,
|
doc.entries()[original.0 as usize].method,
|
||||||
RsliMethod::RawDeflate
|
RsliMethod::Stored
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_method_rejects_unknown_authoring_method() {
|
|
||||||
let bytes = synthetic_rsli(
|
|
||||||
&[SyntheticEntry::stored(b"A", 0, b"alpha")],
|
|
||||||
true,
|
|
||||||
0x7780,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
|
|
||||||
let mut editor = doc.editor().expect("editor");
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
editor.set_method(EntryId(0), RsliMethod::Unknown(0x1E0)),
|
|
||||||
Err(RsliMutationError::UnsupportedMethod { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_rejects_entry_count_above_limit() {
|
|
||||||
let bytes = synthetic_rsli(
|
|
||||||
&[
|
|
||||||
SyntheticEntry::stored(b"A", 0, b"alpha"),
|
|
||||||
SyntheticEntry::stored(b"B", 1, b"beta"),
|
|
||||||
],
|
|
||||||
true,
|
|
||||||
0x7781,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
decode_with_limits(
|
|
||||||
arc(bytes),
|
|
||||||
ReadProfile::Strict,
|
|
||||||
DecodeLimits {
|
|
||||||
max_entries: 1,
|
|
||||||
..DecodeLimits::default()
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Err(RsliError::Binary(DecodeError::LimitExceeded {
|
|
||||||
count: 2,
|
|
||||||
limit: 1
|
|
||||||
}))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stored_entries_require_exact_packed_size() {
|
|
||||||
let bytes = synthetic_rsli(
|
|
||||||
&[SyntheticEntry::with_payload(
|
|
||||||
b"A",
|
|
||||||
0x000,
|
|
||||||
0,
|
|
||||||
b"ok",
|
|
||||||
b"ok!".to_vec(),
|
|
||||||
)],
|
|
||||||
true,
|
|
||||||
0x7782,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("stored archive");
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
doc.load(EntryId(0)),
|
|
||||||
Err(RsliError::OutputSizeMismatch {
|
|
||||||
expected: 2,
|
|
||||||
got: 3
|
|
||||||
})
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_rejects_unpacked_size_above_limit_before_allocation() {
|
|
||||||
let bytes = synthetic_rsli(
|
|
||||||
&[SyntheticEntry::stored(b"A", 0, b"alpha")],
|
|
||||||
true,
|
|
||||||
0x7783,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("stored archive");
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
doc.load_with_limits(
|
|
||||||
EntryId(0),
|
|
||||||
DecodeLimits {
|
|
||||||
max_decoded_entry_bytes: 4,
|
|
||||||
max_total_decoded_bytes: 4,
|
|
||||||
..DecodeLimits::default()
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Err(RsliError::Binary(DecodeError::LimitExceeded {
|
|
||||||
count: 5,
|
|
||||||
limit: 4
|
|
||||||
}))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn editor_rejects_unknown_entry_id_and_invalid_name() {
|
fn editor_rejects_unknown_entry_id_and_invalid_name() {
|
||||||
let bytes = synthetic_rsli(
|
let bytes = synthetic_rsli(
|
||||||
@@ -2958,15 +2637,6 @@ mod tests {
|
|||||||
bytes
|
bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deflate_bytes(plain: &[u8]) -> Vec<u8> {
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
let mut encoder =
|
|
||||||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast());
|
|
||||||
encoder.write_all(plain).expect("deflate write");
|
|
||||||
encoder.finish().expect("deflate finish")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn two_plain_rows_for_transform_test() -> Vec<[u8; 32]> {
|
fn two_plain_rows_for_transform_test() -> Vec<[u8; 32]> {
|
||||||
let mut a = [0u8; 32];
|
let mut a = [0u8; 32];
|
||||||
let mut b = [0u8; 32];
|
let mut b = [0u8; 32];
|
||||||
|
|||||||
@@ -6,16 +6,14 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-assets = { path = "../fparkan-assets", version = "0.1.0" }
|
fparkan-assets = { path = "../fparkan-assets" }
|
||||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
fparkan-platform = { path = "../fparkan-platform", version = "0.1.0" }
|
fparkan-platform = { path = "../fparkan-platform" }
|
||||||
fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
|
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||||
fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
|
fparkan-render = { path = "../fparkan-render" }
|
||||||
fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
|
fparkan-resource = { path = "../fparkan-resource" }
|
||||||
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
fparkan-script = { path = "../fparkan-script", version = "0.1.0" }
|
fparkan-world = { path = "../fparkan-world" }
|
||||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
|
||||||
fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
+28
-1358
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "fparkan-script"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -62,69 +62,6 @@ pub struct CompactSurfaceMask(pub u16);
|
|||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct MaterialClassMask(pub u8);
|
pub struct MaterialClassMask(pub u8);
|
||||||
|
|
||||||
/// The two positional material-table selectors packed into a terrain face tag.
|
|
||||||
///
|
|
||||||
/// The high byte selects from map-local `Land1.wea`; `0xff` is the observed
|
|
||||||
/// no-selection sentinel. The low byte selects from `Land2.wea`.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct TerrainMaterialLayers {
|
|
||||||
/// Optional high-byte selector for `Land1.wea`.
|
|
||||||
pub land1_selector: Option<u8>,
|
|
||||||
/// Low-byte selector for `Land2.wea`.
|
|
||||||
pub land2_selector: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One `World3D` material-manager lookup key.
|
|
||||||
///
|
|
||||||
/// The original manager selects a WEAR table in the upper 16 bits and its
|
|
||||||
/// positional material row in the lower 16 bits.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct TerrainMaterialSelection {
|
|
||||||
/// Zero-based material-manager table index.
|
|
||||||
pub table_index: u16,
|
|
||||||
/// Positional material row in that WEAR table.
|
|
||||||
pub material_index: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TerrainMaterialSelection {
|
|
||||||
/// Encodes the original material-manager phase lookup key.
|
|
||||||
#[must_use]
|
|
||||||
pub fn phase_key(self) -> u32 {
|
|
||||||
(u32::from(self.table_index) << 16) | u32::from(self.material_index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TerrainMaterialLayers {
|
|
||||||
/// Constructs the decoded pair from its on-disk packed tag.
|
|
||||||
#[must_use]
|
|
||||||
pub const fn from_packed_tag(material_tag: u16) -> Self {
|
|
||||||
let [land2_selector, land1] = material_tag.to_le_bytes();
|
|
||||||
Self {
|
|
||||||
land1_selector: if land1 == u8::MAX { None } else { Some(land1) },
|
|
||||||
land2_selector,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the table-zero `Land1.wea` selection when it exists.
|
|
||||||
#[must_use]
|
|
||||||
pub fn land1_selection(self) -> Option<TerrainMaterialSelection> {
|
|
||||||
self.land1_selector
|
|
||||||
.map(|material_index| TerrainMaterialSelection {
|
|
||||||
table_index: 0,
|
|
||||||
material_index: u16::from(material_index),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the table-one `Land2.wea` selection.
|
|
||||||
#[must_use]
|
|
||||||
pub fn land2_selection(self) -> TerrainMaterialSelection {
|
|
||||||
TerrainMaterialSelection {
|
|
||||||
table_index: 1,
|
|
||||||
material_index: u16::from(self.land2_selector),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Terrain face with 28-byte source layout.
|
/// Terrain face with 28-byte source layout.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct TerrainFace28 {
|
pub struct TerrainFace28 {
|
||||||
@@ -144,14 +81,6 @@ pub struct TerrainFace28 {
|
|||||||
pub raw: [u8; 28],
|
pub raw: [u8; 28],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TerrainFace28 {
|
|
||||||
/// Decodes the proven two-table selector layout of [`Self::material_tag`].
|
|
||||||
#[must_use]
|
|
||||||
pub const fn material_layers(&self) -> TerrainMaterialLayers {
|
|
||||||
TerrainMaterialLayers::from_packed_tag(self.material_tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Terrain stream descriptor.
|
/// Terrain stream descriptor.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct TerrainStream {
|
pub struct TerrainStream {
|
||||||
@@ -183,59 +112,6 @@ pub struct TerrainSlotTable {
|
|||||||
pub slots_raw: Vec<[u8; SLOT_STRIDE]>,
|
pub slots_raw: Vec<[u8; SLOT_STRIDE]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The proven front fields of one 68-byte terrain slot record.
|
|
||||||
///
|
|
||||||
/// `Terrain.dll!CLandscape` supplies these fields to `GetShade`'s terrain
|
|
||||||
/// batch dispatcher: `pair_table_index` selects the pointer-table entry and
|
|
||||||
/// `pair_count` is the number of adjacent `u16` pairs to process. The pair
|
|
||||||
/// payload and the later material/blend interpretation remain external to the
|
|
||||||
/// disk record.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct TerrainSlotRenderDispatch {
|
|
||||||
/// Index into the runtime pair-pointer table.
|
|
||||||
pub pair_table_index: u16,
|
|
||||||
/// Number of adjacent `u16` pairs in that selected payload.
|
|
||||||
pub pair_count: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One pair consumed by the original terrain material-batch dispatcher.
|
|
||||||
///
|
|
||||||
/// These four bytes live in the type-11 stream. `material_lookup` is handed to
|
|
||||||
/// `GetShade`'s material lookup, while bit `0x10` of `flags` creates a batch
|
|
||||||
/// boundary. Other flag bits remain raw.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct TerrainMaterialPair {
|
|
||||||
/// Lookup id supplied to the shade material manager.
|
|
||||||
pub material_lookup: u16,
|
|
||||||
/// Opaque pair flags; bit `0x10` is a proven batch boundary.
|
|
||||||
pub flags: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TerrainMaterialPair {
|
|
||||||
/// Returns the opaque lookup key supplied to `GetShade`.
|
|
||||||
///
|
|
||||||
/// This is deliberately separate from the `Land1.wea`/`Land2.wea`
|
|
||||||
/// selectors carried by [`TerrainMaterialLayers`]. Although `GetShade`
|
|
||||||
/// owns a manager loaded from `Shade.wea`, this key is not a WEAR row:
|
|
||||||
/// observed map values exceed that table's row count.
|
|
||||||
#[must_use]
|
|
||||||
pub const fn shade_lookup_key(self) -> u16 {
|
|
||||||
self.material_lookup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TerrainSlotTable {
|
|
||||||
/// Returns the render-dispatch header for one decoded slot record.
|
|
||||||
#[must_use]
|
|
||||||
pub fn render_dispatch(&self, slot_index: usize) -> Option<TerrainSlotRenderDispatch> {
|
|
||||||
let raw = self.slots_raw.get(slot_index)?;
|
|
||||||
Some(TerrainSlotRenderDispatch {
|
|
||||||
pair_table_index: u16::from_le_bytes([raw[0], raw[1]]),
|
|
||||||
pair_count: u16::from_le_bytes([raw[2], raw[3]]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Land mesh document.
|
/// Land mesh document.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct LandMeshDocument {
|
pub struct LandMeshDocument {
|
||||||
@@ -255,38 +131,12 @@ pub struct LandMeshDocument {
|
|||||||
pub accelerator: Vec<[u8; 4]>,
|
pub accelerator: Vec<[u8; 4]>,
|
||||||
/// Type 14 auxiliary words.
|
/// Type 14 auxiliary words.
|
||||||
pub aux14: Vec<[u8; 4]>,
|
pub aux14: Vec<[u8; 4]>,
|
||||||
/// Type 18 microtexture mapping words.
|
/// Type 18 auxiliary words.
|
||||||
///
|
|
||||||
/// `Terrain.dll!CLandscape` requires this stream and retains it as a
|
|
||||||
/// four-byte-per-entry mapping chunk.
|
|
||||||
pub aux18: Vec<[u8; 4]>,
|
pub aux18: Vec<[u8; 4]>,
|
||||||
/// Faces.
|
/// Faces.
|
||||||
pub faces: Vec<TerrainFace28>,
|
pub faces: Vec<TerrainFace28>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LandMeshDocument {
|
|
||||||
/// Resolves the type-11 material pairs addressed by one 68-byte slot.
|
|
||||||
///
|
|
||||||
/// Returns `None` when the slot is absent or its pair range does not fit
|
|
||||||
/// the retained type-11 stream.
|
|
||||||
#[must_use]
|
|
||||||
pub fn slot_material_pairs(&self, slot_index: usize) -> Option<Vec<TerrainMaterialPair>> {
|
|
||||||
let dispatch = self.slots.render_dispatch(slot_index)?;
|
|
||||||
let start = usize::from(dispatch.pair_table_index);
|
|
||||||
let end = start.checked_add(usize::from(dispatch.pair_count))?;
|
|
||||||
let pairs = self.accelerator.get(start..end)?;
|
|
||||||
Some(
|
|
||||||
pairs
|
|
||||||
.iter()
|
|
||||||
.map(|raw| TerrainMaterialPair {
|
|
||||||
material_lookup: u16::from_le_bytes([raw[0], raw[1]]),
|
|
||||||
flags: u16::from_le_bytes([raw[2], raw[3]]),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decoded `Land.map` document.
|
/// Decoded `Land.map` document.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct LandMapDocument {
|
pub struct LandMapDocument {
|
||||||
@@ -1536,111 +1386,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn slot_render_dispatch_retains_pair_table_index_and_count() {
|
|
||||||
let mut raw = [0_u8; SLOT_STRIDE];
|
|
||||||
raw[..2].copy_from_slice(&7_u16.to_le_bytes());
|
|
||||||
raw[2..4].copy_from_slice(&3_u16.to_le_bytes());
|
|
||||||
let slots = TerrainSlotTable {
|
|
||||||
header_raw: SLOT_HEADER_ZERO.to_vec(),
|
|
||||||
slots_raw: vec![raw],
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
slots.render_dispatch(0),
|
|
||||||
Some(TerrainSlotRenderDispatch {
|
|
||||||
pair_table_index: 7,
|
|
||||||
pair_count: 3,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(slots.render_dispatch(1), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn slot_material_pairs_use_type11_pair_table_and_preserve_flags() {
|
|
||||||
let nres =
|
|
||||||
decode_nres(&minimal_land_msh(&face([0, 1, 2], [None, None, None]))).expect("nres");
|
|
||||||
let mut document = decode_land_msh(&nres).expect("land mesh");
|
|
||||||
document.slots = TerrainSlotTable {
|
|
||||||
header_raw: SLOT_HEADER_ZERO.to_vec(),
|
|
||||||
slots_raw: vec![[0_u8; SLOT_STRIDE]],
|
|
||||||
};
|
|
||||||
document.slots.slots_raw[0][..2].copy_from_slice(&1_u16.to_le_bytes());
|
|
||||||
document.slots.slots_raw[0][2..4].copy_from_slice(&2_u16.to_le_bytes());
|
|
||||||
document.accelerator = vec![[0, 0, 0, 0], [2, 1, 0, 0], [3, 2, 0x10, 0]];
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
document.slot_material_pairs(0),
|
|
||||||
Some(vec![
|
|
||||||
TerrainMaterialPair {
|
|
||||||
material_lookup: 0x0102,
|
|
||||||
flags: 0,
|
|
||||||
},
|
|
||||||
TerrainMaterialPair {
|
|
||||||
material_lookup: 0x0203,
|
|
||||||
flags: 0x0010,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
document.slot_material_pairs(0).expect("material pairs")[0].shade_lookup_key(),
|
|
||||||
0x0102
|
|
||||||
);
|
|
||||||
assert_eq!(document.slot_material_pairs(1), None);
|
|
||||||
|
|
||||||
document.slots.slots_raw[0][2..4].copy_from_slice(&3_u16.to_le_bytes());
|
|
||||||
assert_eq!(document.slot_material_pairs(0), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn terrain_material_tag_decodes_two_sidecar_selectors() {
|
|
||||||
let mut raw_face = face([0, 1, 2], [None, None, None]);
|
|
||||||
raw_face[4..6].copy_from_slice(&0x0102_u16.to_le_bytes());
|
|
||||||
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
|
|
||||||
let document = decode_land_msh(&nres).expect("land mesh");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
document.faces[0].material_layers(),
|
|
||||||
TerrainMaterialLayers {
|
|
||||||
land1_selector: Some(1),
|
|
||||||
land2_selector: 2,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
document.faces[0].material_layers().land1_selection(),
|
|
||||||
Some(TerrainMaterialSelection {
|
|
||||||
table_index: 0,
|
|
||||||
material_index: 1,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
document.faces[0]
|
|
||||||
.material_layers()
|
|
||||||
.land2_selection()
|
|
||||||
.phase_key(),
|
|
||||||
0x0001_0002
|
|
||||||
);
|
|
||||||
|
|
||||||
raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes());
|
|
||||||
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
|
|
||||||
let document = decode_land_msh(&nres).expect("land mesh");
|
|
||||||
assert_eq!(
|
|
||||||
document.faces[0].material_layers(),
|
|
||||||
TerrainMaterialLayers {
|
|
||||||
land1_selector: None,
|
|
||||||
land2_selector: 3,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(document.faces[0].material_layers().land1_selection(), None);
|
|
||||||
assert_eq!(
|
|
||||||
document.faces[0].material_layers().land2_selection(),
|
|
||||||
TerrainMaterialSelection {
|
|
||||||
table_index: 1,
|
|
||||||
material_index: 3,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decodes_minimal_land_map() {
|
fn decodes_minimal_land_map() {
|
||||||
let nres = decode_nres(&minimal_land_map([(-1, -1), (-1, -1)], 0)).expect("nres");
|
let nres = decode_nres(&minimal_land_map([(-1, -1), (-1, -1)], 0)).expect("nres");
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
|
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -18,17 +18,11 @@
|
|||||||
clippy::unwrap_used
|
clippy::unwrap_used
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
//! Validated terrain runtime queries using XY ground coordinates and Z height.
|
//! Validated terrain runtime queries.
|
||||||
|
|
||||||
use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
|
use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
/// Terrain material-selector contract preserved from the decoded map mesh.
|
|
||||||
///
|
|
||||||
/// Applications access this semantic terrain API through this terrain crate
|
|
||||||
/// rather than taking a direct dependency on the binary-format parser.
|
|
||||||
pub use fparkan_terrain_format::{TerrainMaterialLayers, TerrainMaterialSelection};
|
|
||||||
|
|
||||||
/// Terrain world.
|
/// Terrain world.
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct TerrainWorld {
|
pub struct TerrainWorld {
|
||||||
@@ -36,14 +30,12 @@ pub struct TerrainWorld {
|
|||||||
grid: RuntimeGrid,
|
grid: RuntimeGrid,
|
||||||
adjacency: Vec<Vec<ArealId>>,
|
adjacency: Vec<Vec<ArealId>>,
|
||||||
surfaces: Vec<RuntimeTriangle>,
|
surfaces: Vec<RuntimeTriangle>,
|
||||||
surface_bvh: SurfaceBvh,
|
|
||||||
source_mesh: Option<LandMeshDocument>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Surface hit.
|
/// Surface hit.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct SurfaceHit {
|
pub struct SurfaceHit {
|
||||||
/// Source Z height.
|
/// Height.
|
||||||
pub height: f32,
|
pub height: f32,
|
||||||
/// Hit position.
|
/// Hit position.
|
||||||
pub position: [f32; 3],
|
pub position: [f32; 3],
|
||||||
@@ -142,7 +134,7 @@ impl std::error::Error for TerrainError {}
|
|||||||
|
|
||||||
/// Surface query.
|
/// Surface query.
|
||||||
pub trait SurfaceQuery {
|
pub trait SurfaceQuery {
|
||||||
/// Source Z height at an XY ground position.
|
/// Height at position.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
@@ -164,7 +156,7 @@ pub trait SurfaceQuery {
|
|||||||
|
|
||||||
/// Navigation query.
|
/// Navigation query.
|
||||||
pub trait NavigationQuery {
|
pub trait NavigationQuery {
|
||||||
/// Locates an areal from the XY components of a world position.
|
/// Locate areal.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
@@ -202,7 +194,7 @@ impl TerrainWorld {
|
|||||||
polygon: areal
|
polygon: areal
|
||||||
.vertices
|
.vertices
|
||||||
.iter()
|
.iter()
|
||||||
.map(|vertex| [vertex[0], vertex[1]])
|
.map(|vertex| [vertex[0], vertex[2]])
|
||||||
.collect(),
|
.collect(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -240,8 +232,6 @@ impl TerrainWorld {
|
|||||||
grid,
|
grid,
|
||||||
adjacency,
|
adjacency,
|
||||||
surfaces: Vec::new(),
|
surfaces: Vec::new(),
|
||||||
surface_bvh: SurfaceBvh::default(),
|
|
||||||
source_mesh: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,11 +244,8 @@ impl TerrainWorld {
|
|||||||
pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> {
|
pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
surfaces: build_surfaces(mesh)?,
|
surfaces: build_surfaces(mesh)?,
|
||||||
surface_bvh: SurfaceBvh::default(),
|
|
||||||
source_mesh: Some(mesh.clone()),
|
|
||||||
..Self::default()
|
..Self::default()
|
||||||
}
|
})
|
||||||
.with_surface_bvh())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds terrain runtime data from decoded `Land.msh` and `Land.map`.
|
/// Builds terrain runtime data from decoded `Land.msh` and `Land.map`.
|
||||||
@@ -273,8 +260,6 @@ impl TerrainWorld {
|
|||||||
) -> Result<Self, TerrainError> {
|
) -> Result<Self, TerrainError> {
|
||||||
let mut world = Self::from_land_map(map)?;
|
let mut world = Self::from_land_map(map)?;
|
||||||
world.surfaces = build_surfaces(mesh)?;
|
world.surfaces = build_surfaces(mesh)?;
|
||||||
world.surface_bvh = SurfaceBvh::from_surfaces(&world.surfaces);
|
|
||||||
world.source_mesh = Some(mesh.clone());
|
|
||||||
Ok(world)
|
Ok(world)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,35 +275,12 @@ impl TerrainWorld {
|
|||||||
self.surfaces.len()
|
self.surfaces.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the validated source mesh retained for renderer integration.
|
|
||||||
///
|
|
||||||
/// Runtime collision queries use their own compact triangle representation;
|
|
||||||
/// keeping this document preserves the original geometry and UV streams for
|
|
||||||
/// rendering without asking the application layer to decode an archive again.
|
|
||||||
#[must_use]
|
|
||||||
pub fn source_mesh(&self) -> Option<&LandMeshDocument> {
|
|
||||||
self.source_mesh.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns source terrain positions without exposing parser ownership to apps.
|
|
||||||
#[must_use]
|
|
||||||
pub fn source_positions(&self) -> Option<&[[f32; 3]]> {
|
|
||||||
self.source_mesh
|
|
||||||
.as_ref()
|
|
||||||
.map(|mesh| mesh.positions.as_slice())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_surface_bvh(mut self) -> Self {
|
|
||||||
self.surface_bvh = SurfaceBvh::from_surfaces(&self.surfaces);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn locate_by_candidates(
|
fn locate_by_candidates(
|
||||||
&self,
|
&self,
|
||||||
position: [f32; 3],
|
position: [f32; 3],
|
||||||
candidates: &[ArealId],
|
candidates: &[ArealId],
|
||||||
) -> Result<Option<ArealId>, TerrainError> {
|
) -> Result<Option<ArealId>, TerrainError> {
|
||||||
let point = [position[0], position[1]];
|
let point = [position[0], position[2]];
|
||||||
for candidate in candidates {
|
for candidate in candidates {
|
||||||
let Some(areal) = usize::try_from(candidate.0)
|
let Some(areal) = usize::try_from(candidate.0)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -409,8 +371,7 @@ impl SurfaceQuery for TerrainWorld {
|
|||||||
return Err(TerrainError::Unsupported);
|
return Err(TerrainError::Unsupported);
|
||||||
}
|
}
|
||||||
let mut best = None;
|
let mut best = None;
|
||||||
for index in self.surface_bvh.xy_candidates(position) {
|
for triangle in &self.surfaces {
|
||||||
let triangle = &self.surfaces[index];
|
|
||||||
if let Some(height) = triangle.height_at(position) {
|
if let Some(height) = triangle.height_at(position) {
|
||||||
best = Some(best.map_or(height, |current: f32| current.max(height)));
|
best = Some(best.map_or(height, |current: f32| current.max(height)));
|
||||||
}
|
}
|
||||||
@@ -427,11 +388,8 @@ impl SurfaceQuery for TerrainWorld {
|
|||||||
if self.surfaces.is_empty() {
|
if self.surfaces.is_empty() {
|
||||||
return Err(TerrainError::Unsupported);
|
return Err(TerrainError::Unsupported);
|
||||||
}
|
}
|
||||||
let mut candidates = self.surface_bvh.ray_candidates(origin, direction);
|
|
||||||
candidates.sort_unstable();
|
|
||||||
let mut best: Option<SurfaceHit> = None;
|
let mut best: Option<SurfaceHit> = None;
|
||||||
for index in candidates {
|
for triangle in &self.surfaces {
|
||||||
let triangle = &self.surfaces[index];
|
|
||||||
if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
|
if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -447,7 +405,7 @@ impl SurfaceQuery for TerrainWorld {
|
|||||||
origin[2] + direction[2] * distance,
|
origin[2] + direction[2] * distance,
|
||||||
];
|
];
|
||||||
best = Some(SurfaceHit {
|
best = Some(SurfaceHit {
|
||||||
height: position[2],
|
height: position[1],
|
||||||
position,
|
position,
|
||||||
distance,
|
distance,
|
||||||
face: triangle.face,
|
face: triangle.face,
|
||||||
@@ -486,216 +444,20 @@ struct RuntimeTriangle {
|
|||||||
vertices: [[f32; 3]; 3],
|
vertices: [[f32; 3]; 3],
|
||||||
}
|
}
|
||||||
|
|
||||||
const SURFACE_BVH_LEAF_SIZE: usize = 8;
|
|
||||||
const SURFACE_BOUNDS_EPSILON: f32 = 1.0e-4;
|
|
||||||
|
|
||||||
/// Deterministic CPU index over validated terrain triangles.
|
|
||||||
///
|
|
||||||
/// The index changes only candidate selection: triangle math and observable
|
|
||||||
/// tie-breaking remain in [`SurfaceQuery`] above.
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
struct SurfaceBvh {
|
|
||||||
nodes: Vec<SurfaceBvhNode>,
|
|
||||||
triangle_indices: Vec<usize>,
|
|
||||||
root: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct SurfaceBvhNode {
|
|
||||||
min: [f32; 3],
|
|
||||||
max: [f32; 3],
|
|
||||||
kind: SurfaceBvhNodeKind,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
enum SurfaceBvhNodeKind {
|
|
||||||
Leaf { first: usize, count: usize },
|
|
||||||
Branch { left: usize, right: usize },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SurfaceBvh {
|
|
||||||
fn from_surfaces(surfaces: &[RuntimeTriangle]) -> Self {
|
|
||||||
if surfaces.is_empty() {
|
|
||||||
return Self::default();
|
|
||||||
}
|
|
||||||
let mut index = Self {
|
|
||||||
nodes: Vec::with_capacity(surfaces.len().saturating_mul(2)),
|
|
||||||
triangle_indices: Vec::with_capacity(surfaces.len()),
|
|
||||||
root: None,
|
|
||||||
};
|
|
||||||
let indices: Vec<usize> = (0..surfaces.len()).collect();
|
|
||||||
index.root = Some(index.build(surfaces, indices));
|
|
||||||
index
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build(&mut self, surfaces: &[RuntimeTriangle], mut indices: Vec<usize>) -> usize {
|
|
||||||
let (min, max) = surface_bounds(surfaces, &indices);
|
|
||||||
if indices.len() <= SURFACE_BVH_LEAF_SIZE {
|
|
||||||
indices.sort_unstable();
|
|
||||||
let first = self.triangle_indices.len();
|
|
||||||
let count = indices.len();
|
|
||||||
self.triangle_indices.extend(indices);
|
|
||||||
let node = self.nodes.len();
|
|
||||||
self.nodes.push(SurfaceBvhNode {
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
kind: SurfaceBvhNodeKind::Leaf { first, count },
|
|
||||||
});
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
let axis = longest_axis(min, max);
|
|
||||||
indices.sort_by(|left, right| {
|
|
||||||
surface_centroid(&surfaces[*left])[axis]
|
|
||||||
.total_cmp(&surface_centroid(&surfaces[*right])[axis])
|
|
||||||
.then_with(|| left.cmp(right))
|
|
||||||
});
|
|
||||||
let right_indices = indices.split_off(indices.len() / 2);
|
|
||||||
let left = self.build(surfaces, indices);
|
|
||||||
let right = self.build(surfaces, right_indices);
|
|
||||||
let node = self.nodes.len();
|
|
||||||
self.nodes.push(SurfaceBvhNode {
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
kind: SurfaceBvhNodeKind::Branch { left, right },
|
|
||||||
});
|
|
||||||
node
|
|
||||||
}
|
|
||||||
|
|
||||||
fn xy_candidates(&self, point: [f32; 2]) -> Vec<usize> {
|
|
||||||
let mut candidates = Vec::new();
|
|
||||||
let Some(root) = self.root else {
|
|
||||||
return candidates;
|
|
||||||
};
|
|
||||||
let mut pending = vec![root];
|
|
||||||
while let Some(node_index) = pending.pop() {
|
|
||||||
let node = &self.nodes[node_index];
|
|
||||||
if !point_within_surface_bounds(point, node.min, node.max) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match node.kind {
|
|
||||||
SurfaceBvhNodeKind::Leaf { first, count } => {
|
|
||||||
candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
|
|
||||||
}
|
|
||||||
SurfaceBvhNodeKind::Branch { left, right } => {
|
|
||||||
pending.push(right);
|
|
||||||
pending.push(left);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
candidates
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ray_candidates(&self, origin: [f32; 3], direction: [f32; 3]) -> Vec<usize> {
|
|
||||||
let mut candidates = Vec::new();
|
|
||||||
let Some(root) = self.root else {
|
|
||||||
return candidates;
|
|
||||||
};
|
|
||||||
let mut pending = vec![root];
|
|
||||||
while let Some(node_index) = pending.pop() {
|
|
||||||
let node = &self.nodes[node_index];
|
|
||||||
if !ray_intersects_surface_bounds(origin, direction, node.min, node.max) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match node.kind {
|
|
||||||
SurfaceBvhNodeKind::Leaf { first, count } => {
|
|
||||||
candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
|
|
||||||
}
|
|
||||||
SurfaceBvhNodeKind::Branch { left, right } => {
|
|
||||||
pending.push(right);
|
|
||||||
pending.push(left);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
candidates
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn surface_bounds(surfaces: &[RuntimeTriangle], indices: &[usize]) -> ([f32; 3], [f32; 3]) {
|
|
||||||
let mut min = [f32::INFINITY; 3];
|
|
||||||
let mut max = [f32::NEG_INFINITY; 3];
|
|
||||||
for index in indices {
|
|
||||||
for vertex in surfaces[*index].vertices {
|
|
||||||
for axis in 0..3 {
|
|
||||||
min[axis] = min[axis].min(vertex[axis]);
|
|
||||||
max[axis] = max[axis].max(vertex[axis]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(min, max)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn surface_centroid(triangle: &RuntimeTriangle) -> [f32; 3] {
|
|
||||||
let mut centroid = [0.0; 3];
|
|
||||||
for vertex in triangle.vertices {
|
|
||||||
for axis in 0..3 {
|
|
||||||
centroid[axis] += vertex[axis] / 3.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
centroid
|
|
||||||
}
|
|
||||||
|
|
||||||
fn longest_axis(min: [f32; 3], max: [f32; 3]) -> usize {
|
|
||||||
let extent = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
|
||||||
if extent[1] > extent[0] && extent[1] >= extent[2] {
|
|
||||||
1
|
|
||||||
} else if extent[2] > extent[0] && extent[2] > extent[1] {
|
|
||||||
2
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn point_within_surface_bounds(point: [f32; 2], min: [f32; 3], max: [f32; 3]) -> bool {
|
|
||||||
point[0] >= min[0] - SURFACE_BOUNDS_EPSILON
|
|
||||||
&& point[0] <= max[0] + SURFACE_BOUNDS_EPSILON
|
|
||||||
&& point[1] >= min[1] - SURFACE_BOUNDS_EPSILON
|
|
||||||
&& point[1] <= max[1] + SURFACE_BOUNDS_EPSILON
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ray_intersects_surface_bounds(
|
|
||||||
origin: [f32; 3],
|
|
||||||
direction: [f32; 3],
|
|
||||||
min: [f32; 3],
|
|
||||||
max: [f32; 3],
|
|
||||||
) -> bool {
|
|
||||||
let mut near = 0.0_f32;
|
|
||||||
let mut far = f32::INFINITY;
|
|
||||||
for axis in 0..3 {
|
|
||||||
if direction[axis].abs() <= f32::EPSILON {
|
|
||||||
if origin[axis] < min[axis] - SURFACE_BOUNDS_EPSILON
|
|
||||||
|| origin[axis] > max[axis] + SURFACE_BOUNDS_EPSILON
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let inverse = 1.0 / direction[axis];
|
|
||||||
let first = (min[axis] - origin[axis]) * inverse;
|
|
||||||
let second = (max[axis] - origin[axis]) * inverse;
|
|
||||||
near = near.max(first.min(second));
|
|
||||||
far = far.min(first.max(second));
|
|
||||||
if far < near {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
far >= 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RuntimeTriangle {
|
impl RuntimeTriangle {
|
||||||
fn height_at(&self, position: [f32; 2]) -> Option<f32> {
|
fn height_at(&self, position: [f32; 2]) -> Option<f32> {
|
||||||
let a = [self.vertices[0][0], self.vertices[0][1]];
|
let a = [self.vertices[0][0], self.vertices[0][2]];
|
||||||
let b = [self.vertices[1][0], self.vertices[1][1]];
|
let b = [self.vertices[1][0], self.vertices[1][2]];
|
||||||
let c = [self.vertices[2][0], self.vertices[2][1]];
|
let c = [self.vertices[2][0], self.vertices[2][2]];
|
||||||
let weights = barycentric_2d(position, a, b, c)?;
|
let weights = barycentric_2d(position, a, b, c)?;
|
||||||
if weights
|
if weights
|
||||||
.iter()
|
.iter()
|
||||||
.all(|weight| *weight >= -1.0e-4 && *weight <= 1.0001)
|
.all(|weight| *weight >= -1.0e-4 && *weight <= 1.0001)
|
||||||
{
|
{
|
||||||
Some(
|
Some(
|
||||||
weights[0] * self.vertices[0][2]
|
weights[0] * self.vertices[0][1]
|
||||||
+ weights[1] * self.vertices[1][2]
|
+ weights[1] * self.vertices[1][1]
|
||||||
+ weights[2] * self.vertices[2][2],
|
+ weights[2] * self.vertices[2][1],
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -786,9 +548,9 @@ impl RuntimeGrid {
|
|||||||
for areal in &map.areals {
|
for areal in &map.areals {
|
||||||
for vertex in &areal.vertices {
|
for vertex in &areal.vertices {
|
||||||
bounds_min[0] = bounds_min[0].min(vertex[0]);
|
bounds_min[0] = bounds_min[0].min(vertex[0]);
|
||||||
bounds_min[1] = bounds_min[1].min(vertex[1]);
|
bounds_min[1] = bounds_min[1].min(vertex[2]);
|
||||||
bounds_max[0] = bounds_max[0].max(vertex[0]);
|
bounds_max[0] = bounds_max[0].max(vertex[0]);
|
||||||
bounds_max[1] = bounds_max[1].max(vertex[1]);
|
bounds_max[1] = bounds_max[1].max(vertex[2]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !bounds_min[0].is_finite()
|
if !bounds_min[0].is_finite()
|
||||||
@@ -838,7 +600,7 @@ impl RuntimeGrid {
|
|||||||
if self.cells_x == 0 || self.cells_y == 0 || self.cells.is_empty() {
|
if self.cells_x == 0 || self.cells_y == 0 || self.cells.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let point = [position[0], position[1]];
|
let point = [position[0], position[2]];
|
||||||
if point[0] < self.min[0]
|
if point[0] < self.min[0]
|
||||||
|| point[0] > self.max[0]
|
|| point[0] > self.max[0]
|
||||||
|| point[1] < self.min[1]
|
|| point[1] < self.min[1]
|
||||||
@@ -986,18 +748,18 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(world.areal_count(), 2);
|
assert_eq!(world.areal_count(), 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
world.locate_areal([0.25, 0.25, 41.0]).expect("locate"),
|
world.locate_areal([0.25, 0.0, 0.25]).expect("locate"),
|
||||||
Some(ArealId(0))
|
Some(ArealId(0))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
world.locate_areal([1.75, 0.25, -12.0]).expect("locate"),
|
world.locate_areal([1.75, 0.0, 0.25]).expect("locate"),
|
||||||
Some(ArealId(1))
|
Some(ArealId(1))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
world
|
world
|
||||||
.route(RouteRequest {
|
.route(RouteRequest {
|
||||||
start: [0.25, 0.25, 0.0],
|
start: [0.25, 0.0, 0.25],
|
||||||
goal: [1.75, 0.25, 0.0],
|
goal: [1.75, 0.0, 0.25],
|
||||||
})
|
})
|
||||||
.expect("route"),
|
.expect("route"),
|
||||||
Some(ArealRoute {
|
Some(ArealRoute {
|
||||||
@@ -1013,8 +775,8 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
world
|
world
|
||||||
.route(RouteRequest {
|
.route(RouteRequest {
|
||||||
start: [10.0, 10.0, 0.0],
|
start: [10.0, 0.0, 10.0],
|
||||||
goal: [1.75, 0.25, 0.0],
|
goal: [1.75, 0.0, 0.25],
|
||||||
})
|
})
|
||||||
.expect("route"),
|
.expect("route"),
|
||||||
None
|
None
|
||||||
@@ -1031,8 +793,8 @@ mod tests {
|
|||||||
|
|
||||||
let hit = world
|
let hit = world
|
||||||
.raycast(
|
.raycast(
|
||||||
[0.25, 0.25, 2.0],
|
[0.25, 2.0, 0.25],
|
||||||
[0.0, 0.0, -1.0],
|
[0.0, -1.0, 0.0],
|
||||||
FullSurfaceMask(0x0000_0001),
|
FullSurfaceMask(0x0000_0001),
|
||||||
)
|
)
|
||||||
.expect("raycast")
|
.expect("raycast")
|
||||||
@@ -1044,8 +806,8 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
world
|
world
|
||||||
.raycast(
|
.raycast(
|
||||||
[0.25, 0.25, 2.0],
|
[0.25, 2.0, 0.25],
|
||||||
[0.0, 0.0, -1.0],
|
[0.0, -1.0, 0.0],
|
||||||
FullSurfaceMask(0x8000_0000)
|
FullSurfaceMask(0x8000_0000)
|
||||||
)
|
)
|
||||||
.expect("raycast"),
|
.expect("raycast"),
|
||||||
@@ -1053,30 +815,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn surface_bvh_reduces_candidates_without_changing_surface_math() {
|
|
||||||
let surfaces: Vec<_> = (0_u16..16)
|
|
||||||
.map(|face| {
|
|
||||||
let x = f32::from(face) * 10.0;
|
|
||||||
RuntimeTriangle {
|
|
||||||
face: usize::from(face),
|
|
||||||
mask: FullSurfaceMask(1),
|
|
||||||
vertices: [[x, 0.0, x], [x + 1.0, 0.0, x], [x, 1.0, x]],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let index = SurfaceBvh::from_surfaces(&surfaces);
|
|
||||||
|
|
||||||
let xy = index.xy_candidates([0.25, 0.25]);
|
|
||||||
assert!(xy.contains(&0));
|
|
||||||
assert!(xy.len() < surfaces.len());
|
|
||||||
assert_eq!(surfaces[xy[0]].height_at([0.25, 0.25]), Some(0.0));
|
|
||||||
|
|
||||||
let ray = index.ray_candidates([0.25, 0.25, 5.0], [0.0, 0.0, -1.0]);
|
|
||||||
assert!(ray.contains(&0));
|
|
||||||
assert!(ray.len() < surfaces.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "requires licensed corpus"]
|
#[ignore = "requires licensed corpus"]
|
||||||
fn licensed_corpus_land_maps_build_navigation_worlds() {
|
fn licensed_corpus_land_maps_build_navigation_worlds() {
|
||||||
@@ -1141,7 +879,6 @@ mod tests {
|
|||||||
let root = corpus_root(corpus);
|
let root = corpus_root(corpus);
|
||||||
let mut files = 0usize;
|
let mut files = 0usize;
|
||||||
let mut faces = 0usize;
|
let mut faces = 0usize;
|
||||||
let mut indexed_queries = 0usize;
|
|
||||||
for path in files_under(&root) {
|
for path in files_under(&root) {
|
||||||
if !path
|
if !path
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -1160,33 +897,12 @@ mod tests {
|
|||||||
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
|
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
|
||||||
let world = TerrainWorld::from_land_msh(&mesh)
|
let world = TerrainWorld::from_land_msh(&mesh)
|
||||||
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
|
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
|
||||||
if world.surface_count() > SURFACE_BVH_LEAF_SIZE {
|
|
||||||
let triangle = &world.surfaces[0];
|
|
||||||
let point = [
|
|
||||||
(triangle.vertices[0][0]
|
|
||||||
+ triangle.vertices[1][0]
|
|
||||||
+ triangle.vertices[2][0])
|
|
||||||
/ 3.0,
|
|
||||||
(triangle.vertices[0][1]
|
|
||||||
+ triangle.vertices[1][1]
|
|
||||||
+ triangle.vertices[2][1])
|
|
||||||
/ 3.0,
|
|
||||||
];
|
|
||||||
let candidates = world.surface_bvh.xy_candidates(point);
|
|
||||||
assert!(candidates.contains(&0), "{corpus} {path:?} face zero");
|
|
||||||
assert!(
|
|
||||||
candidates.len() < world.surface_count(),
|
|
||||||
"{corpus} {path:?} surface index did not reduce candidates"
|
|
||||||
);
|
|
||||||
indexed_queries += 1;
|
|
||||||
}
|
|
||||||
files += 1;
|
files += 1;
|
||||||
faces += world.surface_count();
|
faces += world.surface_count();
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(files, expected_files, "{corpus} Land.msh count");
|
assert_eq!(files, expected_files, "{corpus} Land.msh count");
|
||||||
assert_eq!(faces, expected_faces, "{corpus} surface face count");
|
assert_eq!(faces, expected_faces, "{corpus} surface face count");
|
||||||
assert_eq!(indexed_queries, files, "{corpus} indexed Land.msh count");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1204,9 +920,9 @@ mod tests {
|
|||||||
},
|
},
|
||||||
positions: vec![
|
positions: vec![
|
||||||
[0.0, 0.0, 0.0],
|
[0.0, 0.0, 0.0],
|
||||||
[1.0, 0.0, 1.0],
|
[1.0, 1.0, 0.0],
|
||||||
[0.0, 1.0, 1.0],
|
[0.0, 1.0, 1.0],
|
||||||
[1.0, 1.0, 2.0],
|
[1.0, 2.0, 1.0],
|
||||||
],
|
],
|
||||||
normals: Vec::new(),
|
normals: Vec::new(),
|
||||||
uv0: Vec::new(),
|
uv0: Vec::new(),
|
||||||
@@ -1249,10 +965,10 @@ mod tests {
|
|||||||
areals: vec![
|
areals: vec![
|
||||||
Areal {
|
Areal {
|
||||||
prefix_raw: [0; 56],
|
prefix_raw: [0; 56],
|
||||||
anchor: [0.5, 0.5, 0.0],
|
anchor: [0.5, 0.0, 0.5],
|
||||||
reserved_12: 0.0,
|
reserved_12: 0.0,
|
||||||
area_metric: 1.0,
|
area_metric: 1.0,
|
||||||
normal: [0.0, 0.0, 1.0],
|
normal: [0.0, 1.0, 0.0],
|
||||||
logic_flag: 0,
|
logic_flag: 0,
|
||||||
reserved_36: 0,
|
reserved_36: 0,
|
||||||
class_id: 0,
|
class_id: 0,
|
||||||
@@ -1260,8 +976,8 @@ mod tests {
|
|||||||
vertices: vec![
|
vertices: vec![
|
||||||
[0.0, 0.0, 0.0],
|
[0.0, 0.0, 0.0],
|
||||||
[1.0, 0.0, 0.0],
|
[1.0, 0.0, 0.0],
|
||||||
[1.0, 1.0, 0.0],
|
[1.0, 0.0, 1.0],
|
||||||
[0.0, 1.0, 0.0],
|
[0.0, 0.0, 1.0],
|
||||||
],
|
],
|
||||||
links: vec![
|
links: vec![
|
||||||
EdgeLink {
|
EdgeLink {
|
||||||
@@ -1293,10 +1009,10 @@ mod tests {
|
|||||||
},
|
},
|
||||||
Areal {
|
Areal {
|
||||||
prefix_raw: [0; 56],
|
prefix_raw: [0; 56],
|
||||||
anchor: [1.5, 0.5, 0.0],
|
anchor: [1.5, 0.0, 0.5],
|
||||||
reserved_12: 0.0,
|
reserved_12: 0.0,
|
||||||
area_metric: 1.0,
|
area_metric: 1.0,
|
||||||
normal: [0.0, 0.0, 1.0],
|
normal: [0.0, 1.0, 0.0],
|
||||||
logic_flag: 0,
|
logic_flag: 0,
|
||||||
reserved_36: 0,
|
reserved_36: 0,
|
||||||
class_id: 0,
|
class_id: 0,
|
||||||
@@ -1304,8 +1020,8 @@ mod tests {
|
|||||||
vertices: vec![
|
vertices: vec![
|
||||||
[1.0, 0.0, 0.0],
|
[1.0, 0.0, 0.0],
|
||||||
[2.0, 0.0, 0.0],
|
[2.0, 0.0, 0.0],
|
||||||
[2.0, 1.0, 0.0],
|
[2.0, 0.0, 1.0],
|
||||||
[1.0, 1.0, 0.0],
|
[1.0, 0.0, 1.0],
|
||||||
],
|
],
|
||||||
links: vec![
|
links: vec![
|
||||||
EdgeLink {
|
EdgeLink {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
|
fparkan-render = { path = "../fparkan-render" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ repository.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
+85
-141
@@ -25,13 +25,12 @@ use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::unix::ffi::OsStrExt;
|
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::fs::MetadataExt;
|
use std::os::unix::fs::MetadataExt;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use std::os::windows::fs::MetadataExt;
|
use std::os::windows::fs::MetadataExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
/// VFS metadata.
|
/// VFS metadata.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -106,6 +105,7 @@ pub trait Vfs: Send + Sync {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct DirectoryVfs {
|
pub struct DirectoryVfs {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
fingerprint_cache: Arc<Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DirectoryVfs {
|
impl DirectoryVfs {
|
||||||
@@ -114,23 +114,32 @@ impl DirectoryVfs {
|
|||||||
pub fn new(root: impl AsRef<Path>) -> Self {
|
pub fn new(root: impl AsRef<Path>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
root: root.as_ref().to_path_buf(),
|
root: root.as_ref().to_path_buf(),
|
||||||
|
fingerprint_cache: Arc::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
||||||
join_under(&self.root, path).map_err(|_| VfsError::Path)?;
|
join_under(&self.root, path).map_err(|_| VfsError::Path)?;
|
||||||
resolve_casefolded(&self.root, path)
|
resolve_casefolded(&self.root, path.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn metadata_from_host_file(path: &Path) -> Result<VfsMetadata, VfsError> {
|
fn metadata_from_host_file(&self, path: &Path) -> Result<VfsMetadata, VfsError> {
|
||||||
let metadata = fs::symlink_metadata(path).map_err(VfsError::Io)?;
|
let metadata = fs::symlink_metadata(path).map_err(VfsError::Io)?;
|
||||||
metadata_from_host_file(path, &metadata)
|
metadata_from_host_file_with_cache(path, &metadata, &self.fingerprint_cache)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
struct CachedHostFingerprint {
|
||||||
|
len: u64,
|
||||||
|
modified: Option<SystemTime>,
|
||||||
|
identity: Option<u64>,
|
||||||
|
fingerprint: Sha256Digest,
|
||||||
|
}
|
||||||
|
|
||||||
impl Vfs for DirectoryVfs {
|
impl Vfs for DirectoryVfs {
|
||||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||||
Self::metadata_from_host_file(&self.host_path(path)?)
|
self.metadata_from_host_file(&self.host_path(path)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||||
@@ -162,60 +171,21 @@ impl Vfs for DirectoryVfs {
|
|||||||
let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?;
|
let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?;
|
||||||
entries.push(VfsEntry {
|
entries.push(VfsEntry {
|
||||||
path: prefix.clone(),
|
path: prefix.clone(),
|
||||||
metadata: metadata_from_host_file(&base, &metadata)?,
|
metadata: metadata_from_host_file_with_cache(
|
||||||
|
&base,
|
||||||
|
&metadata,
|
||||||
|
&self.fingerprint_cache,
|
||||||
|
)?,
|
||||||
});
|
});
|
||||||
return Ok(entries);
|
return Ok(entries);
|
||||||
}
|
}
|
||||||
list_recursive(&self.root, &base, &mut entries)?;
|
list_recursive(&self.root, &base, &self.fingerprint_cache, &mut entries)?;
|
||||||
entries.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
|
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_casefolded(root: &Path, normalized: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError> {
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
return resolve_casefolded_unix(root, normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
resolve_casefolded_text(root, normalized.display_lossy())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn resolve_casefolded_unix(root: &Path, normalized: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
|
||||||
let mut current = root.to_path_buf();
|
|
||||||
for segment in normalized.as_bytes().split(|byte| *byte == b'/') {
|
|
||||||
current = resolve_casefolded_segment(¤t, segment, normalized)?;
|
|
||||||
}
|
|
||||||
Ok(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn resolve_casefolded_segment(
|
|
||||||
dir: &Path,
|
|
||||||
segment: &[u8],
|
|
||||||
normalized: &NormalizedPath,
|
|
||||||
) -> Result<PathBuf, VfsError> {
|
|
||||||
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
|
|
||||||
let mut matches = Vec::new();
|
|
||||||
for entry in read_dir {
|
|
||||||
let entry = entry.map_err(VfsError::Io)?;
|
|
||||||
let name = entry.file_name();
|
|
||||||
if name.as_bytes().eq_ignore_ascii_case(segment) {
|
|
||||||
if entry.file_type().map_err(VfsError::Io)?.is_symlink() {
|
|
||||||
return Err(VfsError::Path);
|
|
||||||
}
|
|
||||||
matches.push(entry.path());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
select_casefolded_match(normalized.display_lossy(), dir, segment, matches)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn resolve_casefolded_text(root: &Path, normalized: &str) -> Result<PathBuf, VfsError> {
|
|
||||||
let mut current = root.to_path_buf();
|
let mut current = root.to_path_buf();
|
||||||
for segment in normalized.split('/') {
|
for segment in normalized.split('/') {
|
||||||
let read_dir = fs::read_dir(¤t).map_err(VfsError::Io)?;
|
let read_dir = fs::read_dir(¤t).map_err(VfsError::Io)?;
|
||||||
@@ -241,11 +211,10 @@ fn resolve_casefolded_text(root: &Path, normalized: &str) -> Result<PathBuf, Vfs
|
|||||||
fn select_casefolded_match(
|
fn select_casefolded_match(
|
||||||
normalized: &str,
|
normalized: &str,
|
||||||
current: &Path,
|
current: &Path,
|
||||||
segment: impl AsRef<[u8]>,
|
segment: &str,
|
||||||
mut matches: Vec<PathBuf>,
|
mut matches: Vec<PathBuf>,
|
||||||
) -> Result<PathBuf, VfsError> {
|
) -> Result<PathBuf, VfsError> {
|
||||||
matches.sort();
|
matches.sort();
|
||||||
let segment = String::from_utf8_lossy(segment.as_ref());
|
|
||||||
match matches.len() {
|
match matches.len() {
|
||||||
0 => Err(VfsError::NotFound(normalized.to_string())),
|
0 => Err(VfsError::NotFound(normalized.to_string())),
|
||||||
1 => Ok(matches.remove(0)),
|
1 => Ok(matches.remove(0)),
|
||||||
@@ -257,7 +226,12 @@ fn select_casefolded_match(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<(), VfsError> {
|
fn list_recursive(
|
||||||
|
root: &Path,
|
||||||
|
dir: &Path,
|
||||||
|
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
|
||||||
|
out: &mut Vec<VfsEntry>,
|
||||||
|
) -> Result<(), VfsError> {
|
||||||
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
|
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
|
||||||
let mut children = Vec::new();
|
let mut children = Vec::new();
|
||||||
for entry in read_dir {
|
for entry in read_dir {
|
||||||
@@ -271,35 +245,68 @@ fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<()
|
|||||||
return Err(VfsError::Path);
|
return Err(VfsError::Path);
|
||||||
}
|
}
|
||||||
if metadata.is_dir() {
|
if metadata.is_dir() {
|
||||||
list_recursive(root, &child, out)?;
|
list_recursive(root, &child, fingerprint_cache, out)?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !metadata.is_file() {
|
if !metadata.is_file() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let rel = child.strip_prefix(root).map_err(|_| VfsError::Path)?;
|
let rel = child.strip_prefix(root).map_err(|_| VfsError::Path)?;
|
||||||
#[cfg(unix)]
|
let rel_text = rel.to_str().ok_or(VfsError::Path)?;
|
||||||
let rel_bytes = rel.as_os_str().as_bytes();
|
let path = fparkan_path::normalize_relative(
|
||||||
#[cfg(not(unix))]
|
rel_text.as_bytes(),
|
||||||
let rel_bytes = rel.to_str().ok_or(VfsError::Path)?.as_bytes();
|
fparkan_path::PathPolicy::HostCompatible,
|
||||||
let path =
|
)
|
||||||
fparkan_path::normalize_relative(rel_bytes, fparkan_path::PathPolicy::HostCompatible)
|
|
||||||
.map_err(|_| VfsError::Path)?;
|
.map_err(|_| VfsError::Path)?;
|
||||||
out.push(VfsEntry {
|
out.push(VfsEntry {
|
||||||
path,
|
path,
|
||||||
metadata: metadata_from_host_file(&child, &metadata)?,
|
metadata: metadata_from_host_file_with_cache(&child, &metadata, fingerprint_cache)?,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn metadata_from_host_file(path: &Path, metadata: &fs::Metadata) -> Result<VfsMetadata, VfsError> {
|
fn metadata_from_host_file_with_cache(
|
||||||
|
path: &Path,
|
||||||
|
metadata: &fs::Metadata,
|
||||||
|
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
|
||||||
|
) -> Result<VfsMetadata, VfsError> {
|
||||||
if !metadata.is_file() {
|
if !metadata.is_file() {
|
||||||
return Err(VfsError::Path);
|
return Err(VfsError::Path);
|
||||||
}
|
}
|
||||||
let len = metadata.len();
|
let len = metadata.len();
|
||||||
|
let modified = metadata.modified().ok();
|
||||||
|
if let Some(cached) = fingerprint_cache
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| VfsError::Path)?
|
||||||
|
.get(path)
|
||||||
|
.cloned()
|
||||||
|
.filter(|cached| {
|
||||||
|
cached.len == len
|
||||||
|
&& cached.modified == modified
|
||||||
|
&& cached.identity == file_identity(metadata)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Ok(VfsMetadata {
|
||||||
|
len,
|
||||||
|
fingerprint: cached.fingerprint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let bytes = fs::read(path).map_err(VfsError::Io)?;
|
let bytes = fs::read(path).map_err(VfsError::Io)?;
|
||||||
let fingerprint = sha256(&bytes);
|
let fingerprint = sha256(&bytes);
|
||||||
|
fingerprint_cache
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| VfsError::Path)?
|
||||||
|
.insert(
|
||||||
|
path.to_path_buf(),
|
||||||
|
CachedHostFingerprint {
|
||||||
|
len,
|
||||||
|
modified,
|
||||||
|
identity: file_identity(metadata),
|
||||||
|
fingerprint,
|
||||||
|
},
|
||||||
|
);
|
||||||
Ok(VfsMetadata { len, fingerprint })
|
Ok(VfsMetadata { len, fingerprint })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,11 +344,11 @@ impl MemoryVfs {
|
|||||||
let matches = self
|
let matches = self
|
||||||
.lookup
|
.lookup
|
||||||
.get(&key)
|
.get(&key)
|
||||||
.ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))?;
|
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||||
match matches.as_slice() {
|
match matches.as_slice() {
|
||||||
[single] => Ok(single.as_slice()),
|
[single] => Ok(single.as_slice()),
|
||||||
[] => Err(VfsError::NotFound(path.display_lossy().to_string())),
|
[] => Err(VfsError::NotFound(path.as_str().to_string())),
|
||||||
_ => Err(VfsError::Ambiguous(path.display_lossy().to_string())),
|
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,14 +362,10 @@ fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
#[allow(clippy::unnecessary_wraps)]
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
||||||
// Stable Rust does not expose the Windows file index. Keep a best-effort
|
|
||||||
// replacement detector from metadata that is available through the
|
|
||||||
// supported standard-library API; `read` also compares size and
|
|
||||||
// last-write time before returning bytes.
|
|
||||||
Some(
|
Some(
|
||||||
metadata.creation_time().rotate_left(17)
|
(metadata.volume_serial_number() as u64).rotate_left(40)
|
||||||
^ metadata.last_write_time().rotate_left(31)
|
^ ((metadata.file_index_high() as u64) << 32)
|
||||||
^ u64::from(metadata.file_attributes()).rotate_left(47),
|
^ metadata.file_index_low() as u64,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +380,7 @@ impl Vfs for MemoryVfs {
|
|||||||
let bytes = self
|
let bytes = self
|
||||||
.files
|
.files
|
||||||
.get(resolved)
|
.get(resolved)
|
||||||
.ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))?;
|
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||||
Ok(VfsMetadata {
|
Ok(VfsMetadata {
|
||||||
len: bytes.len() as u64,
|
len: bytes.len() as u64,
|
||||||
fingerprint: sha256(bytes),
|
fingerprint: sha256(bytes),
|
||||||
@@ -389,7 +392,7 @@ impl Vfs for MemoryVfs {
|
|||||||
self.files
|
self.files
|
||||||
.get(resolved)
|
.get(resolved)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))
|
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||||
@@ -473,7 +476,7 @@ impl Vfs for OverlayVfs {
|
|||||||
Err(err) => return Err(err),
|
Err(err) => return Err(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(VfsError::NotFound(path.display_lossy().to_string()))
|
Err(VfsError::NotFound(path.as_str().to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||||
@@ -484,7 +487,7 @@ impl Vfs for OverlayVfs {
|
|||||||
Err(err) => return Err(err),
|
Err(err) => return Err(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(VfsError::NotFound(path.display_lossy().to_string()))
|
Err(VfsError::NotFound(path.as_str().to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||||
@@ -493,7 +496,7 @@ impl Vfs for OverlayVfs {
|
|||||||
match layer.list(prefix) {
|
match layer.list(prefix) {
|
||||||
Ok(entries) => {
|
Ok(entries) => {
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let key = ascii_lookup_key(entry.path.as_bytes()).0;
|
let key = entry.path.as_str().to_ascii_uppercase();
|
||||||
by_key.entry(key).or_insert(entry);
|
by_key.entry(key).or_insert(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,7 +505,7 @@ impl Vfs for OverlayVfs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut entries: Vec<_> = by_key.into_values().collect();
|
let mut entries: Vec<_> = by_key.into_values().collect();
|
||||||
entries.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
|
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -511,10 +514,6 @@ impl Vfs for OverlayVfs {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use fparkan_path::{normalize_relative, PathPolicy};
|
use fparkan_path::{normalize_relative, PathPolicy};
|
||||||
#[cfg(unix)]
|
|
||||||
use std::ffi::OsString;
|
|
||||||
#[cfg(unix)]
|
|
||||||
use std::os::unix::ffi::OsStringExt;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn directory_vfs_resolves_ascii_casefolded_segments() {
|
fn directory_vfs_resolves_ascii_casefolded_segments() {
|
||||||
@@ -635,33 +634,6 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(outside).expect("cleanup outside");
|
std::fs::remove_dir_all(outside).expect("cleanup outside");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn directory_vfs_resolves_non_utf8_host_entries_by_raw_bytes() {
|
|
||||||
let root = unique_test_dir("non-utf8");
|
|
||||||
let data_dir = root.join("DATA");
|
|
||||||
std::fs::create_dir_all(&data_dir).expect("mkdir");
|
|
||||||
let file_name = OsString::from_vec(vec![0xFF, b'.', b'b', b'i', b'n']);
|
|
||||||
let raw_path = data_dir.join(&file_name);
|
|
||||||
if let Err(err) = std::fs::write(&raw_path, b"raw") {
|
|
||||||
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
|
|
||||||
std::fs::remove_dir_all(root).expect("cleanup");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let vfs = DirectoryVfs::new(&root);
|
|
||||||
let path = normalize_relative(b"data/\xFF.bin", PathPolicy::HostCompatible).expect("path");
|
|
||||||
|
|
||||||
assert_eq!(vfs.read(&path).expect("read raw path").as_ref(), b"raw");
|
|
||||||
let entries = vfs
|
|
||||||
.list(&normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix"))
|
|
||||||
.expect("list");
|
|
||||||
assert_eq!(entries.len(), 1);
|
|
||||||
assert_eq!(entries[0].path.identity_bytes(), b"DATA/\xFF.bin");
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(root).expect("cleanup");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn casefold_selector_reports_ambiguous_segments() {
|
fn casefold_selector_reports_ambiguous_segments() {
|
||||||
let err = select_casefolded_match(
|
let err = select_casefolded_match(
|
||||||
@@ -742,34 +714,6 @@ mod tests {
|
|||||||
assert_eq!(entries[0].metadata.len, 4);
|
assert_eq!(entries[0].metadata.len, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn overlay_vfs_keeps_lossy_equivalent_entries_distinct() {
|
|
||||||
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
|
|
||||||
let mut high = MemoryVfs::default();
|
|
||||||
let mut low = MemoryVfs::default();
|
|
||||||
high.insert(
|
|
||||||
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("high path"),
|
|
||||||
Arc::from(b"high".as_slice()),
|
|
||||||
);
|
|
||||||
low.insert(
|
|
||||||
normalize_relative(b"DATA/\xFE.bin", PathPolicy::HostCompatible).expect("low path"),
|
|
||||||
Arc::from(b"low".as_slice()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let overlay = OverlayVfs::from_layers(vec![Arc::new(high), Arc::new(low)]);
|
|
||||||
let entries = overlay.list(&prefix).expect("list");
|
|
||||||
|
|
||||||
assert_eq!(entries.len(), 2);
|
|
||||||
assert_eq!(
|
|
||||||
entries[0].path.display_lossy(),
|
|
||||||
entries[1].path.display_lossy()
|
|
||||||
);
|
|
||||||
assert_ne!(
|
|
||||||
entries[0].path.identity_bytes(),
|
|
||||||
entries[1].path.identity_bytes()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unique_test_dir(name: &str) -> PathBuf {
|
fn unique_test_dir(name: &str) -> PathBuf {
|
||||||
let mut path = std::env::temp_dir();
|
let mut path = std::env::temp_dir();
|
||||||
path.push(format!("fparkan-vfs-{name}-{}", std::process::id()));
|
path.push(format!("fparkan-vfs-{name}-{}", std::process::id()));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
use fparkan_binary::sha256;
|
use fparkan_binary::sha256;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v3\0";
|
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v2\0";
|
||||||
|
|
||||||
/// Object handle with generation.
|
/// Object handle with generation.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
@@ -50,20 +50,6 @@ pub struct Tick(pub u64);
|
|||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct StateHash(pub [u8; 32]);
|
pub struct StateHash(pub [u8; 32]);
|
||||||
|
|
||||||
/// Exact source transform carried by one world object.
|
|
||||||
///
|
|
||||||
/// Values are IEEE-754 bit patterns so mission transforms retain their input
|
|
||||||
/// identity in deterministic state before a controller interprets them.
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
||||||
pub struct TransformState {
|
|
||||||
/// Position words in source axis order.
|
|
||||||
pub position: [u32; 3],
|
|
||||||
/// Orientation words in source axis order.
|
|
||||||
pub orientation: [u32; 3],
|
|
||||||
/// Non-uniform scale words in source axis order.
|
|
||||||
pub scale: [u32; 3],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// World phase.
|
/// World phase.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum WorldPhase {
|
pub enum WorldPhase {
|
||||||
@@ -124,8 +110,6 @@ pub struct WorldSnapshot {
|
|||||||
pub tick: Tick,
|
pub tick: Tick,
|
||||||
/// Live object handles.
|
/// Live object handles.
|
||||||
pub objects: Vec<ObjectHandle>,
|
pub objects: Vec<ObjectHandle>,
|
||||||
/// Exact transforms for live registered objects in object-handle order.
|
|
||||||
pub transforms: Vec<(ObjectHandle, TransformState)>,
|
|
||||||
/// Commands processed during this step.
|
/// Commands processed during this step.
|
||||||
pub events: Vec<WorldEvent>,
|
pub events: Vec<WorldEvent>,
|
||||||
/// State hash.
|
/// State hash.
|
||||||
@@ -183,7 +167,6 @@ struct Slot {
|
|||||||
owner_id: Option<OwnerId>,
|
owner_id: Option<OwnerId>,
|
||||||
mirror_id: Option<OriginalObjectId>,
|
mirror_id: Option<OriginalObjectId>,
|
||||||
registration_sequence: Option<u64>,
|
registration_sequence: Option<u64>,
|
||||||
transform: TransformState,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// World.
|
/// World.
|
||||||
@@ -265,7 +248,6 @@ pub fn construct_object(world: &mut World, draft: ObjectDraft) -> Result<ObjectH
|
|||||||
owner_id: None,
|
owner_id: None,
|
||||||
mirror_id: None,
|
mirror_id: None,
|
||||||
registration_sequence: None,
|
registration_sequence: None,
|
||||||
transform: TransformState::default(),
|
|
||||||
});
|
});
|
||||||
Ok(handle)
|
Ok(handle)
|
||||||
}
|
}
|
||||||
@@ -353,43 +335,6 @@ pub fn identity_metadata(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the exact source transform for a live object.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
|
|
||||||
pub fn set_transform(
|
|
||||||
world: &mut World,
|
|
||||||
handle: ObjectHandle,
|
|
||||||
transform: TransformState,
|
|
||||||
) -> Result<(), WorldError> {
|
|
||||||
checked_slot_mut(world, handle)?.transform = transform;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the exact source transform for a live object.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
|
|
||||||
pub fn transform_state(world: &World, handle: ObjectHandle) -> Result<TransformState, WorldError> {
|
|
||||||
Ok(checked_slot(world, handle)?.transform)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the live registered handle carrying an original mission id.
|
|
||||||
#[must_use]
|
|
||||||
pub fn handle_by_original_id(world: &World, original_id: OriginalObjectId) -> Option<ObjectHandle> {
|
|
||||||
world.slots.iter().enumerate().find_map(|(index, slot)| {
|
|
||||||
let index = u32::try_from(index).ok()?;
|
|
||||||
(slot.live && slot.registered && slot.original_id == Some(original_id)).then_some(
|
|
||||||
ObjectHandle {
|
|
||||||
generation: slot.generation,
|
|
||||||
slot: index,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Requests deletion.
|
/// Requests deletion.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -478,7 +423,6 @@ where
|
|||||||
let snapshot = WorldSnapshot {
|
let snapshot = WorldSnapshot {
|
||||||
tick: world.tick,
|
tick: world.tick,
|
||||||
objects: live_registered(world),
|
objects: live_registered(world),
|
||||||
transforms: live_registered_with_transforms(world),
|
|
||||||
events,
|
events,
|
||||||
hash: canonical_state_hash(world),
|
hash: canonical_state_hash(world),
|
||||||
};
|
};
|
||||||
@@ -515,7 +459,6 @@ fn canonical_state_bytes(world: &World) -> Vec<u8> {
|
|||||||
push_optional_u32(&mut out, slot.mirror_id.map(|id| id.0));
|
push_optional_u32(&mut out, slot.mirror_id.map(|id| id.0));
|
||||||
push_optional_u16(&mut out, slot.owner_id.map(|id| id.0));
|
push_optional_u16(&mut out, slot.owner_id.map(|id| id.0));
|
||||||
push_optional_u64(&mut out, slot.registration_sequence);
|
push_optional_u64(&mut out, slot.registration_sequence);
|
||||||
push_transform(&mut out, slot.transform);
|
|
||||||
}
|
}
|
||||||
push_len(&mut out, world.queue.len());
|
push_len(&mut out, world.queue.len());
|
||||||
for command in &world.queue {
|
for command in &world.queue {
|
||||||
@@ -696,17 +639,6 @@ fn push_handle(out: &mut Vec<u8>, handle: ObjectHandle) {
|
|||||||
push_u32(out, handle.slot);
|
push_u32(out, handle.slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_transform(out: &mut Vec<u8>, transform: TransformState) {
|
|
||||||
for word in transform
|
|
||||||
.position
|
|
||||||
.into_iter()
|
|
||||||
.chain(transform.orientation)
|
|
||||||
.chain(transform.scale)
|
|
||||||
{
|
|
||||||
push_u32(out, word);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> {
|
fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> {
|
||||||
let slot = world
|
let slot = world
|
||||||
.slots
|
.slots
|
||||||
@@ -757,24 +689,6 @@ fn live_registered(world: &World) -> Vec<ObjectHandle> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn live_registered_with_transforms(world: &World) -> Vec<(ObjectHandle, TransformState)> {
|
|
||||||
world
|
|
||||||
.slots
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter_map(|(idx, slot)| {
|
|
||||||
let slot_index = u32::try_from(idx).ok()?;
|
|
||||||
(slot.live && slot.registered).then_some((
|
|
||||||
ObjectHandle {
|
|
||||||
generation: slot.generation,
|
|
||||||
slot: slot_index,
|
|
||||||
},
|
|
||||||
slot.transform,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -790,25 +704,6 @@ mod tests {
|
|||||||
assert_eq!(after.objects, vec![handle]);
|
assert_eq!(after.objects, vec![handle]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn transform_is_exactly_snapshotted_and_changes_state_hash() {
|
|
||||||
let mut world = new(WorldConfig);
|
|
||||||
let handle =
|
|
||||||
construct_object(&mut world, ObjectDraft { original_id: None }).expect("object");
|
|
||||||
register_object(&mut world, handle).expect("register");
|
|
||||||
let before = step(&mut world, &InputSnapshot).expect("before");
|
|
||||||
let transform = TransformState {
|
|
||||||
position: [1.0_f32.to_bits(), (-2.0_f32).to_bits(), 3.0_f32.to_bits()],
|
|
||||||
orientation: [0.0_f32.to_bits(), 0.5_f32.to_bits(), (-0.25_f32).to_bits()],
|
|
||||||
scale: [1.0_f32.to_bits(), 2.0_f32.to_bits(), 0.5_f32.to_bits()],
|
|
||||||
};
|
|
||||||
set_transform(&mut world, handle, transform).expect("set transform");
|
|
||||||
let after = step(&mut world, &InputSnapshot).expect("after");
|
|
||||||
assert_eq!(transform_state(&world, handle), Ok(transform));
|
|
||||||
assert_eq!(after.transforms, vec![(handle, transform)]);
|
|
||||||
assert_ne!(before.hash, after.hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registration_sequence_stale_and_duplicate_original_contracts() {
|
fn registration_sequence_stale_and_duplicate_original_contracts() {
|
||||||
let mut world = new(WorldConfig);
|
let mut world = new(WorldConfig);
|
||||||
@@ -828,16 +723,10 @@ mod tests {
|
|||||||
.expect("second");
|
.expect("second");
|
||||||
register_object(&mut world, first).expect("register first");
|
register_object(&mut world, first).expect("register first");
|
||||||
register_object(&mut world, second).expect("register second");
|
register_object(&mut world, second).expect("register second");
|
||||||
assert_eq!(
|
|
||||||
handle_by_original_id(&world, OriginalObjectId(7)),
|
|
||||||
Some(first)
|
|
||||||
);
|
|
||||||
assert_eq!(handle_by_original_id(&world, OriginalObjectId(9)), None);
|
|
||||||
assert_eq!(registration_sequence(&world, first), Ok(Some(0)));
|
assert_eq!(registration_sequence(&world, first), Ok(Some(0)));
|
||||||
assert_eq!(registration_sequence(&world, second), Ok(Some(1)));
|
assert_eq!(registration_sequence(&world, second), Ok(Some(1)));
|
||||||
|
|
||||||
request_delete(&mut world, first).expect("delete");
|
request_delete(&mut world, first).expect("delete");
|
||||||
assert_eq!(handle_by_original_id(&world, OriginalObjectId(7)), None);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
register_object(&mut world, first),
|
register_object(&mut world, first),
|
||||||
Err(WorldError::StaleHandle)
|
Err(WorldError::StaleHandle)
|
||||||
@@ -1164,7 +1053,7 @@ mod tests {
|
|||||||
handles.push(handle);
|
handles.push(handle);
|
||||||
}
|
}
|
||||||
for (index, handle) in handles.iter().copied().enumerate() {
|
for (index, handle) in handles.iter().copied().enumerate() {
|
||||||
if (seed as usize + index).is_multiple_of(3) {
|
if (seed as usize + index) % 3 == 0 {
|
||||||
request_delete(&mut world, handle).expect("delete");
|
request_delete(&mut world, handle).expect("delete");
|
||||||
} else {
|
} else {
|
||||||
enqueue(
|
enqueue(
|
||||||
|
|||||||
@@ -14,12 +14,17 @@ deny = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[licenses]
|
[licenses]
|
||||||
|
unlicensed = "deny"
|
||||||
|
copyleft = "allow"
|
||||||
allow = [
|
allow = [
|
||||||
"Apache-2.0",
|
"Apache-2.0",
|
||||||
|
"BSD-2-Clause",
|
||||||
"BSD-3-Clause",
|
"BSD-3-Clause",
|
||||||
|
"CC0-1.0",
|
||||||
"GPL-2.0-only",
|
"GPL-2.0-only",
|
||||||
"ISC",
|
"ISC",
|
||||||
"MIT",
|
"MIT",
|
||||||
|
"MPL-2.0",
|
||||||
"Unicode-3.0",
|
"Unicode-3.0",
|
||||||
"Zlib",
|
"Zlib",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -113,22 +113,36 @@ key, configuration, device profile, initial state, input/time script и верс
|
|||||||
|
|
||||||
## Local evidence requests
|
## Local evidence requests
|
||||||
|
|
||||||
Поддерживаемая desktop-платформа — только Windows/Vulkan. Локальный smoke
|
На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
|
||||||
`fparkan-vulkan-smoke` уже подтверждает настоящий Win32 surface/swapchain,
|
Для локально воспроизводимого Desktop backend подтверждено только command/state trace
|
||||||
300 кадров, controlled resize и отсутствие validation warnings/errors.
|
в существующем GL-воркфлоу:
|
||||||
Это закрывает фундамент Stage 0, но не доказывает визуальный паритет игры.
|
|
||||||
|
|
||||||
Следующие доказательства всё ещё нужны именно для Iron3D-compatible рендера:
|
- `fixtures/acceptance/macos-gl33-triangle-capture.json`;
|
||||||
|
|
||||||
- capability-gated capture из оригинального GOG процесса с camera/matrix,
|
`S3-GL-001` пока не закрыт: текущая evidence не отражает полноценный
|
||||||
draw/state и frame-boundary provenance;
|
`winit`+`fparkan-render-vulkan` path с real surface/present pipeline.
|
||||||
- фиксированные Windows Vulkan captures статической модели, lightmapped модели
|
Для закрытия требования требуется постоянный workspace-владельческий backend на
|
||||||
и terrain после совпадения backend-neutral command capture;
|
`winit`/`fparkan-platform-winit` + `fparkan-render-vulkan` с реальным
|
||||||
- затем controlled captures анимации, FX, прозрачности, теней и атмосферы.
|
surface/present pipeline, command/state parity и licensed frame capture.
|
||||||
|
|
||||||
Linux/macOS, GLES2, RG40XX и удалённые portability runners не являются
|
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
|
||||||
acceptance-гейтами этого проекта. Они не должны появляться в списке блокеров
|
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
|
||||||
или подменять Windows evidence.
|
command/state trace. Локальный Docker probe существующего Rust image не нашёл
|
||||||
|
`libGL`, `libEGL`, `libGLES` или `libOSMesa`, поэтому закрытие этого gate требует
|
||||||
|
отдельно предоставленного Docker image с Rust + Mesa/EGL/OSMesa либо разрешения
|
||||||
|
на установку соответствующего проверочного окружения.
|
||||||
|
|
||||||
|
Для текущей macOS-focused цели `S3-GL-002`, `L3-DEVICE-001` и `L5-RG40-001`
|
||||||
|
помечены как `omitted`: они остаются требованиями portable target scope, но не
|
||||||
|
блокируют локальный macOS acceptance-аудит. При возврате RG40XX/GLES2 в область
|
||||||
|
цели эти gates снова должны требовать внешнего evidence.
|
||||||
|
|
||||||
|
`L3-DEVICE-001` и `L5-RG40-001` не закрываются локально без RG40XX H или
|
||||||
|
эквивалентного удалённого runner-а. Требуемое доказательство: запуск выбранной
|
||||||
|
миссии при 640x480 на целевом профиле, сохранённые stdout/stderr, build
|
||||||
|
fingerprint, manifest игрового каталога, frame/tick budget, memory budget и
|
||||||
|
итоговый pass/fail report. Desktop/headless результаты не считаются заменой
|
||||||
|
on-device smoke.
|
||||||
|
|
||||||
## Closure criteria
|
## Closure criteria
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# Кампания, сохранения и восстановление сессии
|
|
||||||
|
|
||||||
## Известная файловая поверхность
|
|
||||||
|
|
||||||
Demo содержит `MISSIONS/dispatcher.ini` и `SAVE/saveslots.cfg`.
|
|
||||||
`dispatcher.ini` хранит campaign progression (в demo — `[COMPLETE]`), а
|
|
||||||
`saveslots.cfg` — ordered UI-метаданные slots, не полный snapshot мира.
|
|
||||||
В Части 2 slots 1 и 7 помечены занятыми без соответствующего payload; поэтому
|
|
||||||
проверяются независимо: наличие metadata record, физического файла и
|
|
||||||
format/version/integrity payload.
|
|
||||||
|
|
||||||
Campaign различает существование миссии, её доступность, старт, успешное или
|
|
||||||
неуспешное завершение и уже применённый результат. Обработка одного
|
|
||||||
mission-complete event идемпотентна. Пустой slot и повреждённый существующий
|
|
||||||
payload — разные состояния.
|
|
||||||
|
|
||||||
## Контракт standalone save
|
|
||||||
|
|
||||||
Полный snapshot сохраняет campaign/mission context, time/pause/step phase,
|
|
||||||
stable object IDs, ownership, transforms, lifecycle/properties/cross-links,
|
|
||||||
world changes, Behavior/Control/AI/research/economy state, script variables/IP/
|
|
||||||
timers, authoritative RNG, gameplay-relevant FX и queued messages. Camera/UI
|
|
||||||
можно сохранить как presentation context; GPU/audio handles и draw buffers
|
|
||||||
восстанавливаются, а не сериализуются.
|
|
||||||
|
|
||||||
Снимок разрешён только после calculation и deferred operations, вне queue
|
|
||||||
traversal, после применённых network messages и до чтения mutable state
|
|
||||||
renderer-ом. Native pointers, vtable/allocator addresses и resource mapping
|
|
||||||
pointers запрещены: ссылки идут через stable IDs и resource keys.
|
|
||||||
|
|
||||||
Новый формат — versioned chunks (`WORLD`, `OBJECTS`, `BEHAVIOR`, `PHYSICS`,
|
|
||||||
`AI`, `SCRIPT`, `RESEARCH`, `RNG`, `CAMERA_UI`, optional network) с magic,
|
|
||||||
format/profile/content fingerprint, size и checksum. Неизвестный optional
|
|
||||||
chunk пропускается; required chunk блокирует load. Это дизайн FParkan, не
|
|
||||||
утверждение о binary format оригинала.
|
|
||||||
|
|
||||||
Запись транзакционна: временный файл → повторное чтение/checksum → fs sync →
|
|
||||||
атомарная замена payload → обновление slot metadata. Загрузка создаёт mission
|
|
||||||
и objects без публикации, восстанавливает IDs/cross-links/controllers/RNG,
|
|
||||||
регистрирует их, валидирует и только затем разрешает следующий tick.
|
|
||||||
|
|
||||||
## Проверки и граница
|
|
||||||
|
|
||||||
`save -> load` обязан давать тот же canonical state hash, OriginalObjectId,
|
|
||||||
cross-links и продолжение RNG. Corrupt required chunk не публикует частичный
|
|
||||||
мир; crash при записи не уничтожает старый slot; completion event идемпотентен.
|
|
||||||
|
|
||||||
Для native format нужны controlled original saves, binary diffs и trace
|
|
||||||
serializer-а. До этого FParkan может иметь совместимую семантику собственных
|
|
||||||
saves, но не заявляет byte/network interoperability с оригинальными файлами.
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
# Сценарная VM, формулы и игровые свойства
|
|
||||||
|
|
||||||
## Подтверждённый surface
|
|
||||||
|
|
||||||
Миссионный сценарный слой задаёт стартовые события, completion/failure,
|
|
||||||
messages, teleports, задачи, research и campaign transitions. Точки входа и
|
|
||||||
файлы: `ai.dll: CreateSuperAI/GetSuperAI`, `MisLoad.dll: LoadResearch`,
|
|
||||||
`ArealMap.dll: CalcFullResearchCost`, `MISSIONS/SCRIPTS/*.scr`, `*.fml`,
|
|
||||||
`*.trf`, `varset.var`, `MISSIONS/dispatcher.ini`, `mission.cfg`, `messages.cfg`
|
|
||||||
и `briefing.cfg`.
|
|
||||||
|
|
||||||
`.scr` — binary package с version checks, symbol/event sections и offsets;
|
|
||||||
полная opcode grammar не доказана. Его внешний framing теперь читает
|
|
||||||
`fparkan-script`: первый little-endian `u32` является числом required opcode
|
|
||||||
handlers, второй — числом event records. Каждый event хранит `name_len`,
|
|
||||||
`name_len + 1` raw bytes с обязательным NUL, opaque event word и count вложенных
|
|
||||||
records. Вложенный record сохраняет семь `u32` header words (в disk order),
|
|
||||||
список `u32` references после шестого header word и trailing seventh word.
|
|
||||||
Никакой из этих words ещё не получает semantic name. `.fml` — текстовый
|
|
||||||
symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaults.
|
|
||||||
`fparkan-script::parse_varset` уже читает подтверждённые numeric
|
|
||||||
`VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются
|
|
||||||
opaque, поэтому legacy non-UTF-8 text не ломает загрузку); `STRING(...)` и
|
|
||||||
`FUNCTION(...)` пока сохранены за границей этого numeric contract;
|
|
||||||
GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration:
|
|
||||||
31 `float` и 200 `DWORD` (от `f0` до `fY`);
|
|
||||||
loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при
|
|
||||||
`not found` откатывается к этому shared file. Runtime повторяет данный порядок
|
|
||||||
транзакционно и публикует selected `MissionScriptVarSet` с путём/provenance, но
|
|
||||||
ещё не исполняет declarations как VM state;
|
|
||||||
`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
|
|
||||||
consumer-inferred.
|
|
||||||
|
|
||||||
## Безопасная модель исполнения
|
|
||||||
|
|
||||||
Новая VM разделяет immutable package (bytecode, symbols, events, constants),
|
|
||||||
per-mission variables/timers/frames, bindings logical-name/ObjectId/clan/
|
|
||||||
research key и typed commands к World3D/Behavior/UI/campaign. После varset
|
|
||||||
defaults и bindings она dispatches Init/start, на каждом tick обновляет timers,
|
|
||||||
ставит готовые events в стабильную очередь и исполняет bounded instruction
|
|
||||||
budget. Опасное удаление идёт через World3D queue и общий deferred lifecycle.
|
|
||||||
|
|
||||||
До восстановления opcode table package mode читает header/strings/symbols/
|
|
||||||
event offsets/raw bytecode losslessly. Статический анализ уже выделил отдельный
|
|
||||||
five-way evaluator condition records (`ai.dll` VA `0x10005180`): tags `1..5`,
|
|
||||||
type guards, object lookup и completion flag. Это не следует выдавать за
|
|
||||||
instruction dispatcher или jump table `.scr`: bytecode opcode table всё ещё
|
|
||||||
требует отдельного доказательства. Unknown opcode нельзя пропустить как один
|
|
||||||
byte: это ломает синхронизацию. Для каждого доказанного opcode фиксируются
|
|
||||||
number, size, operands, control flow, effects, errors и минимальный test.
|
|
||||||
|
|
||||||
GOG `ai.dll` доказывает этот framing двумя consumer-ами: loader по
|
|
||||||
`0x10001000` открывает `<bundle>.scr`, `varset.var`, `<bundle>.fml`, затем
|
|
||||||
собирает ровно 73 pointers handlers; `0x10011b20` читает описанную count-driven
|
|
||||||
структуру. Команда
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cargo run -p fparkan-cli -- script inspect `
|
|
||||||
'C:\GOG Games\Parkan - Iron Strategy\MISSIONS\SCRIPTS\c1m2p.scr' --format json
|
|
||||||
```
|
|
||||||
|
|
||||||
на исходном пакете возвращает `opcode_handler_count=73`, 9 events, 17 nested
|
|
||||||
records, 20 references и 0 trailing bytes. Это corpus evidence для reader-а,
|
|
||||||
но не разрешение на исполнение неизвестных 73 opcodes.
|
|
||||||
|
|
||||||
Теперь установлен selector: loader `0x10001000` создаёт 73 pointers в
|
|
||||||
фиксированном порядке, а `0x10011e70` копирует их без перестановки в runtime
|
|
||||||
array. Во всех 58 GOG `.scr` первый header word каждого nested record равен
|
|
||||||
`0..72` либо `0xffff_ffff`: соответственно 2095 handler selectors и 3992
|
|
||||||
sentinel records. Поэтому `ScriptInstruction::dispatch_selector()` возвращает
|
|
||||||
`Handler(0..72)`, `Sentinel` или сохраняемый `Unknown(u32)`. Первый handler
|
|
||||||
(`Handler(0)`, VA `0x10008034`) только устанавливает current context и flag
|
|
||||||
`+0x50 = 1`; это не даёт ему игрового имени и не заменяет runtime trace.
|
|
||||||
|
|
||||||
`Handler(1)` — второй table entry, VA `0x10007fd0`, — не создаёт игровую
|
|
||||||
команду. Он сохраняет active VM context, берёт один instruction-derived index
|
|
||||||
через current event/instruction offsets `+0x48/+0x4c`, а затем разрешает его
|
|
||||||
в varset object по `this + 0x18`. Resolver `0x10002d30` проверяет
|
|
||||||
`0 <= index < count` и возвращает record `base + index * 0x30`; invalid index
|
|
||||||
вызывает C++ exception, а не становится нулём. Полученный 48-byte record
|
|
||||||
передаётся в `0x10013190`, который возвращает x87 floating result: kinds `0`
|
|
||||||
и `4` идут через отдельный opaque conversion path, kind `1` — signed integer,
|
|
||||||
kind `2` выбирает одну из двух static scalar constants по нулевости payload,
|
|
||||||
kind `3` — float, kind `5` — unsigned integer; остальные и пустые cases дают
|
|
||||||
один fixed fallback scalar. Это доказанный numeric
|
|
||||||
bridge для VM, но пока не Rust handler: неизвестны точный disk operand slot,
|
|
||||||
ownership значения на FPU stack и следующий consumer, поэтому нельзя назвать
|
|
||||||
его арифметическим opcode или silently заменить portable `f32` execution.
|
|
||||||
Отдельный проход по всем 58 GOG `.scr` (6 087 instruction records) не нашёл
|
|
||||||
ни одного selector `1`: из них 2 095 записей выбирают один из handlers, а
|
|
||||||
3 992 являются sentinel. Значит, это установленная, но не corpus-reachable
|
|
||||||
ветка данного издания; её нельзя делать приоритетным execution path без
|
|
||||||
отдельного dynamic/evidence route.
|
|
||||||
|
|
||||||
`Handler(2)` (третья entry table, VA `0x10009610`) уже имеет статический
|
|
||||||
contract, но ещё не Rust execution: он выбирает active event/instruction через
|
|
||||||
runtime offsets `+0x48/+0x4c` и разрешает семь 32-bit slots через varset object
|
|
||||||
`+0x18`. Их доказанный dataflow: slot 0 даёт один `u32` и base string, slot 1
|
|
||||||
даёт numeric scalar, slots 2 и 3 — по `u32`, slots 4, 5 и 6 принимают только
|
|
||||||
kind `5`/`3` и иначе дают `0.0`. Затем он вызывает `0x100059f0` объекта по
|
|
||||||
`this + 0x7c` и очищает flag `+0x50`.
|
|
||||||
|
|
||||||
Этот callee больше не opaque. Он строит key из семи значений, ищет matching
|
|
||||||
record в своей collection по `this + 0x24` и при совпадении обновляет только
|
|
||||||
record fields `+0x0c` и `+0x14`, затем вызывает его refresh path `0x10005070`.
|
|
||||||
При отсутствии record он лениво ищет в event table имена `<base>_Start` и
|
|
||||||
`<base>_Continue`, сохраняет их IDs в indexed state и materializes новый
|
|
||||||
internal record. В этой ветке не видно прямого World3D/Behavior call, поэтому
|
|
||||||
это доказанная scheduler/event-record boundary, а не команда движения, атаки
|
|
||||||
или строительства. Semantic names семи slots и consumer нового record остаются
|
|
||||||
открытыми; до dynamic capture Rust возвращает явный
|
|
||||||
unsupported result, а не «примерный» game command.
|
|
||||||
|
|
||||||
У этой границы также нет скрытого immediate dispatch: после добавления новой
|
|
||||||
записи `0x100059f0` вызывает `0x1000f920`, а Ghidra 12.1.2 декомпилирует эту
|
|
||||||
функцию как пустой `return`. Следовательно, найденные `<base>_Start` и
|
|
||||||
`<base>_Continue` только кэшируются в scheduler state; их фактический consumer
|
|
||||||
находится в отдельном позднем update path. Воспроизводимый read-only extractor:
|
|
||||||
`tools/ghidra/ExportAiVmHandler2Dispatch.java`.
|
|
||||||
|
|
||||||
Corpus priority теперь измерен, а не предполагается: во всех 58 GOG `.scr`
|
|
||||||
имеются 6 087 instruction records, из них 3 992 sentinel; самый частый
|
|
||||||
non-sentinel selector — `Handler(30)`, 246 records. Его VA `0x1000c266`
|
|
||||||
читает первые два reference words активной instruction, разрешает каждый
|
|
||||||
через varset (`0x10002d30` и `0x10013570`) и вызывает внешний callback с
|
|
||||||
тремя `u32`: `(0, first, second)`. Callback не принадлежит `ai.dll`: его
|
|
||||||
кладёт десятый argument экспортного `CreateSuperAI`. Тот же callback встречен
|
|
||||||
у `Handler(57)` с первым word `2` и у отдельного lifecycle path с первым word
|
|
||||||
`1`; предметная семантика этих modes ещё не доказана. В частности, это пока
|
|
||||||
не основание назвать Handler(30) сообщением, приказом или UI opcode. Точный
|
|
||||||
text-to-varset resolver расположен за wrapper `0x10011ea0` в
|
|
||||||
`0x100174a0`. Воспроизводимые exports: `ExportAiVmHandler30.java`,
|
|
||||||
`FindAiVmHandler30Callback.java`, `ExportAiVarSetLoader.java`.
|
|
||||||
|
|
||||||
Следующий pass восстанавливает эту индексацию. `0x100174a0` добавляет каждый
|
|
||||||
recognized source declaration в encounter order как 48-byte record; GOG shared
|
|
||||||
`varset.var` не содержит `STRING(...)`, поэтому его 231 numeric `VAR` entries
|
|
||||||
образуют точно это index space. `0x10013570` возвращает `DWORD` record kind
|
|
||||||
raw `u32`; float kind проходит `__ftol`, чей x87 rounding profile ещё требует
|
|
||||||
capture. Полный GOG scan всех 246 Handler(30) instructions показывает 492
|
|
||||||
operand references: все 492 in-range и указывают на `DWORD`. Поэтому
|
|
||||||
`VarSet::resolve_handler30` уже materializes точный opaque callback command
|
|
||||||
`(mode=0, first, second)` для данного corpus path, но явно отклоняет float,
|
|
||||||
out-of-range и incomplete instructions вместо silent coercion. Extractors:
|
|
||||||
`ExportAiVarSetParser.java`, `ExportAiVarSetU32Resolver.java`.
|
|
||||||
|
|
||||||
Следующий static pass закрывает equality/update policy. Identity ровно равна
|
|
||||||
`(slot0 word, slot4 IEEE-754 bits, slot5 IEEE-754 bits)`, поэтому `-0.0` и
|
|
||||||
`+0.0` различаются. Новый 100-byte record получает slot1 в поле `+0x14`,
|
|
||||||
slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+0x0c`. При
|
|
||||||
совпавшем key refresh случается только когда slot1 сравнивается unequal
|
|
||||||
(включая NaN); он заменяет `+0x14` и `+0x0c`, затем прибавляет сохранённый
|
|
||||||
`+0x28` к `+0x24` с x86 wrapping arithmetic. `fparkan-script` отражает эту
|
|
||||||
изолированную часть как `Handler2RecordScheduler`; он не выполняет bytecode,
|
|
||||||
не назначает игровых имён и не делает event lookup за original VM.
|
|
||||||
|
|
||||||
На границе mission runtime выбранный TMA clan `first_resource` теперь
|
|
||||||
материализуется как отдельный `MissionScriptBundle`: loader нормализует
|
|
||||||
`<base>.scr`, декодирует его тем же bounded reader-ом и публикует immutable
|
|
||||||
package вместе с clan provenance. Headless report выводит число таких packages
|
|
||||||
и их named events. Это именно wiring входных данных, не VM execution: Init и
|
|
||||||
остальные events пока не dispatch-ятся, а ошибка чтения сохраняет
|
|
||||||
transactional rollback mission loader-а.
|
|
||||||
|
|
||||||
TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не
|
|
||||||
задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе
|
|
||||||
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
|
|
||||||
`ClanID`, ore, speed и free-time properties.
|
|
||||||
|
|
||||||
Research/economy работают в simulation: `LoadResearch` и
|
|
||||||
`CalcFullResearchCost` доказывают данные и вычислимую стоимость, но не полный
|
|
||||||
layout prerequisites/modifiers/unlocks. Formula evaluator требует strict
|
|
||||||
grammar/version, typed operands, deterministic numeric policy, bounded stack и
|
|
||||||
явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
|
|
||||||
|
|
||||||
### Handler(19): AutoDemo Init varset initialization
|
|
||||||
|
|
||||||
`Handler(19)` is the twentieth VM-table entry at GOG `ai.dll` VA `0x1000aa38`.
|
|
||||||
It is the only instruction in the `Init` event of the two `default.scr` bundles
|
|
||||||
referenced by `MISSIONS\\Autodemo.00\\data.tma`; together those bundles account for
|
|
||||||
the observed 18 named script events. Each instruction references varset records
|
|
||||||
`224`, `225`, and `226`: `ClanBaseX`, `ClanBaseY`, and `ClanID` respectively.
|
|
||||||
|
|
||||||
The original writes three raw DWORD values in order. It converts the VM fields
|
|
||||||
at `+0x80` and `+0x84` through the x87 `__ftol` helper and stores the resulting
|
|
||||||
words into references 0 and 1. It copies the raw word from `+0x7c` into reference
|
|
||||||
2, then clears VM field `+0x50`. The default targets are `DWORD` records; the
|
|
||||||
shared setter preserves the incoming word for that type. Therefore this is not
|
|
||||||
a license to replace the first two conversions with Rust float casts: their
|
|
||||||
rounding behavior remains an x87 compatibility boundary until it has captured
|
|
||||||
test vectors.
|
|
||||||
|
|
||||||
The missing source-field provenance is now constrained by the public creation
|
|
||||||
boundary. `CreateSuperAI` at `ai.dll` VA `0x1000f710` allocates `0x8b0` bytes
|
|
||||||
and calls constructor `0x10001000` with its first eight arguments. That
|
|
||||||
constructor calls `0x10006340(this + 0x7c, clan_id, base_x, base_y)`: these are
|
|
||||||
the fields later read by `Handler(19)`. `base_x` and `base_y` are unsigned and
|
|
||||||
the constructor rejects values greater than `10000`. The actual `__ftol` helper
|
|
||||||
at `0x1001df70` saves the x87 control word, sets its rounding-control bits to
|
|
||||||
truncate, executes `fistp qword`, then restores the control word.
|
|
||||||
|
|
||||||
Consequently the runtime resolves and retains this one proved vertical slice
|
|
||||||
during mission loading: each selected clan's TMA anchor is accepted only in the recovered
|
|
||||||
`0..=10000` base range, truncated through the recovered x87 rule, and paired
|
|
||||||
with its zero-based clan index. For every `Init` instruction whose selector is
|
|
||||||
`Handler(19)`, `VarSet::resolve_handler19` produces the three per-clan DWORD
|
|
||||||
writes. The runtime then materializes an independent declaration-ordered value
|
|
||||||
array for each selected clan and applies those writes, so later recovered
|
|
||||||
handlers can consume `ClanBaseX`, `ClanBaseY`, and `ClanID` as runtime cells
|
|
||||||
rather than loader defaults. Other Init selectors and all other events remain
|
|
||||||
decoded but unexecuted. AutoDemo validates the path end-to-end: its non-integral first
|
|
||||||
anchor (`500.2857`) yields captured `ClanBaseX=500`, and the live GOG process
|
|
||||||
contains two initialized SuperAI entries `(500, 752, 0)` and `(728, 449, 1)`;
|
|
||||||
the Rust loader reports `script_init_states=2` and `script_varset_states=2`.
|
|
||||||
|
|
||||||
`GetSuperAI` returns element `n` of the 64-pointer global table at preferred
|
|
||||||
`ai.dll + 0x55398` for `n <= 63`. The read-only
|
|
||||||
`tools/capture-ai-init.ps1` probe observed the running GOG AutoDemo values
|
|
||||||
`(500, 752, 0)` for entry 0 and `(728, 449, 1)` for entry 1 at fields
|
|
||||||
`(+0x80, +0x84, +0x7c)`. These values are integral samples, not a rounding
|
|
||||||
profile.
|
|
||||||
|
|
||||||
The Rust reader exposes `VarSet::resolve_handler19`. It accepts the already
|
|
||||||
converted first two words and the third raw word, produces three typed writes,
|
|
||||||
and rejects missing, out-of-range, or non-`DWORD` targets. The runtime only
|
|
||||||
binds it to the proven creation/anchor path above; it does not guess the
|
|
||||||
remaining script event semantics. The associated Ghidra scripts are
|
|
||||||
`ExportAiVmHandler19.java`, `ExportAiVmHandler19Setter.java`,
|
|
||||||
`ExportAiVmHandler19SetterCallee.java`, and `ExportAiGetSuperAi.java`.
|
|
||||||
The creation and conversion boundaries are reproducible with
|
|
||||||
`ExportAiCreateSuperAi.java`, `ExportAiSuperAiConstructor.java`, and
|
|
||||||
`ExportAiFtol.java`.
|
|
||||||
|
|
||||||
### Runtime Handler(30) operand binding
|
|
||||||
|
|
||||||
`resolve_handler30_with_values` preserves the recovered declaration-kind ABI
|
|
||||||
but reads operands from instantiated per-clan cells rather than textual
|
|
||||||
defaults. Runtime exposes `resolve_loaded_handler30` for the exact opaque
|
|
||||||
`(mode=0, first, second)` callback command. It intentionally returns that
|
|
||||||
command without automatically invoking a game-side consumer: only one
|
|
||||||
consumer branch has a safe Rust meaning so far.
|
|
||||||
|
|
||||||
The live GOG AutoDemo closes that consumer boundary: the read-only callback
|
|
||||||
pointer at `ai.dll + 0x555e4` is `0x100611d0`, or `iron3d.dll + 0x611d0` at
|
|
||||||
the observed load base. Its recovered `__cdecl` ABI is `(mode, command,
|
|
||||||
payload)`, matching `Handler(30)` as `(0, first, second)`. In `mode == 0`,
|
|
||||||
`command == 0` and `payload == 0` selects `VOICE_MISSION_FAIL`, records the
|
|
||||||
failed status, and clears an IGame byte; `payload == 1` selects
|
|
||||||
`VOICE_MISSION_COMPLETE`, records completion, and sets that byte. Commands
|
|
||||||
3, 4, and 5 have additional game-side paths; mode 2 is a separate IGame
|
|
||||||
call.
|
|
||||||
|
|
||||||
`command == 1` is now statically traced, but still has no justified domain
|
|
||||||
name. Its payload is a signed integer key for a binary-search tree: the
|
|
||||||
receiver's `+0x04` is the sentinel/root, each node has child links at `+0x08`
|
|
||||||
and `+0x0c`, a key at `+0x10`, and a dispatched payload at `+0x14`. A matching
|
|
||||||
node reaches `FUN_10095600(node + 0x14)`. That payload has one-shot flag
|
|
||||||
`+0x19` and a second flag `+0x18`. On its first dispatch, Iron3D sets `+0x19`,
|
|
||||||
obtains a resource-manager string through virtual slot `+0x1c`, and passes the
|
|
||||||
payload's `+0x0c` through `FUN_10061fb0`. On later dispatches it uses
|
|
||||||
resource-manager IDs `0x181a` and `0x184f`, an IGame selector `0x2ed`, and
|
|
||||||
emits an opaque request with category `3` or `4` selected by `+0x18`. This
|
|
||||||
proves the lookup and one-shot/repeat split, not the UI/message subject or the
|
|
||||||
semantics of either resource ID; Rust therefore retains command `1` as
|
|
||||||
`Unhandled`.
|
|
||||||
|
|
||||||
Reproduce the callback and the command-one consumers with
|
|
||||||
`capture-ai-init.ps1`, `ExportIron3dAiCallback.java`,
|
|
||||||
`ExportIron3dAiCallbackCommand1.java`, and
|
|
||||||
`ExportIron3dAiCallbackCommand1Dispatch.java`.
|
|
||||||
|
|
||||||
Runtime now applies only this recovered branch as
|
|
||||||
`apply_loaded_script_host_callback`: `(0, 0, 0)` transitions a loaded mission
|
|
||||||
to `Failed`, `(0, 0, 1)` transitions it to `Completed`, and a repeated target
|
|
||||||
state is a no-op just as the Iron3D guards require. The effect is deliberately
|
|
||||||
separate from audio/UI playback; commands 1, 3, 4, 5 and mode 2 return
|
|
||||||
`Unhandled` until their consumers are recovered.
|
|
||||||
|
|
||||||
### Handler(8): problem-record state write
|
|
||||||
|
|
||||||
`Handler(8)` is the ninth VM-table entry at GOG `ai.dll` VA `0x10009b0d`.
|
|
||||||
All 179 corpus records have exactly one in-range `DWORD` reference; the two
|
|
||||||
observed entries are `ST_SOLVING=1` (122 records) and `ST_SOLVED=2` (57).
|
|
||||||
The handler resolves loader-bound `dCurrentProblem` through varset index
|
|
||||||
`this+0x868`, uses that live DWORD as a bounds-checked index into a table at
|
|
||||||
`this+0xa0` with 100-byte records, then resolves the instruction's one DWORD
|
|
||||||
and writes it to the selected record at `+0x18`.
|
|
||||||
|
|
||||||
The write has two statically proven exceptional branches. State `2` calls a
|
|
||||||
reset helper that zeroes record words `0..=3` and `6` before invoking two
|
|
||||||
opaque callback slots; state `3` does the same except word `3` is preserved.
|
|
||||||
Both then write `+0x18`. Every other state simply writes the state word.
|
|
||||||
`VarSet::resolve_handler8` emits a `Handler8StateChange` with the caller-owned
|
|
||||||
live record index, resolved state, and explicit reset kind. It does not invent
|
|
||||||
the table owner, the pre-reset helper, or callback semantics. Reproduce the
|
|
||||||
evidence with `ExportAiVmHandler8.java`, `ExportAiVmHandler8Callees.java`, and
|
|
||||||
`ExportAiVmHandler8Transitions.java`.
|
|
||||||
|
|
||||||
### Handler(15): typed target-call boundary
|
|
||||||
|
|
||||||
`Handler(15)` is the sixteenth VM-table entry at GOG `ai.dll` VA `0x10008054`
|
|
||||||
and the second most frequent non-sentinel selector: 236 records in 28 of 58
|
|
||||||
GOG packages. It is not a one-word opcode. Across the complete corpus,
|
|
||||||
references `0..3` are `DWORD`, `4..7` are `float`, and `8` is the DWORD mode.
|
|
||||||
The original resolves the first four through `0x10013570`, the next four
|
|
||||||
through x87 scalar helper `0x10013190`, then reads the mode through
|
|
||||||
`0x10013570`.
|
|
||||||
|
|
||||||
The shipped `varset.var` names the only observed mode values: `NONE=0` uses
|
|
||||||
9 total references; `TARGET_BY_LOGIC_ID=0x0201`, `TARGET_BY_TYPE=0x0203`,
|
|
||||||
`TARGET_NOT_DEFINED=0x0204`, and `TARGET_BY_NAME=0x0205` use 10; and
|
|
||||||
`TARGET_BY_PLACE=0x0202` uses 11. The corpus contains 34/122/80 records in
|
|
||||||
these three arities. The trailing references at positions 9 and, only for
|
|
||||||
`TARGET_BY_PLACE`, 10 are all in-range DWORD declarations.
|
|
||||||
|
|
||||||
After resolving those inputs, the handler looks up an opaque target through
|
|
||||||
virtual slot `+0x1c` on `this+0x3d8` using the first word. A missing target
|
|
||||||
sets VM flag `+0x50=5`; otherwise the handler builds a temporary call record,
|
|
||||||
applies the mode-specific tail, and invokes the target's `+0x0c` virtual slot
|
|
||||||
with that record and the third word. Its zero/non-zero result becomes
|
|
||||||
`+0x50=0/1`. The target type, the virtual method's semantic action, and its
|
|
||||||
return value remain unproven. Accordingly `VarSet::resolve_handler15` only
|
|
||||||
materializes a type-checked `Handler15Invocation` and `Handler15TargetPayload`;
|
|
||||||
it never executes the opaque target call. Missing, out-of-range, wrong-type,
|
|
||||||
and unobserved-mode inputs are explicit errors. Reproduce the static evidence
|
|
||||||
with `tools/ghidra/ExportAiVmHandler15.java`.
|
|
||||||
|
|
||||||
## Готовность
|
|
||||||
|
|
||||||
Все demo packages должны проходить package/version checks, offsets оставаться
|
|
||||||
в bytecode, а confirmed disassembler — не терять синхронизацию. VM считается
|
|
||||||
готовой после deterministic Init/basic mission events, stable object bindings,
|
|
||||||
typed research/property tests и save/load script state. Для закрытия остаются
|
|
||||||
dispatcher/jump table, minimal differential packages и traces world/variable
|
|
||||||
effects; до них unknown opcode — явная unsupported branch, не no-op.
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# Shell, HUD, шрифты и локализация
|
|
||||||
|
|
||||||
## Доказанная граница
|
|
||||||
|
|
||||||
`iron_3d.exe` создаёт процесс и окно; `iron3d.dll` экспортирует `createShell`,
|
|
||||||
`deleteShell` и `getIShell`; `services.dll` предоставляет `getGUIServer` и
|
|
||||||
`getDisplay`. Поэтому меню, briefing, HUD и системные диалоги — отдельная
|
|
||||||
оболочка над World3D, а не игровые объекты, созданные ради отрисовки.
|
|
||||||
|
|
||||||
```text
|
|
||||||
Win32 messages
|
|
||||||
-> shell/GUI input и manual events
|
|
||||||
-> simulation World3D
|
|
||||||
-> world render
|
|
||||||
-> shell/HUD overlay и presentation state
|
|
||||||
```
|
|
||||||
|
|
||||||
Точное положение legacy DirectDraw flip относительно последнего UI draw пока
|
|
||||||
требует API capture. Разделение world и UI pass доказано, но не следует
|
|
||||||
выдавать его за восстановленный layout классов виджетов.
|
|
||||||
|
|
||||||
## Ресурсы и идентичность
|
|
||||||
|
|
||||||
В demo найдены `ui/shell_ctrls.cfg`, `ui/menu_resources.cfg`, `ui/cursor.cfg`,
|
|
||||||
`ui/game_resources.cfg`, `ui/hq.cfg`, `DATA/TextRes.cfg`, `gamefont.rlb`,
|
|
||||||
`sprites.lib` и `Palettes.lib`. Конфигурации задают controls/resources/cursor,
|
|
||||||
overlays и HQ; `TextRes.cfg` связывает символические ключи с текстом.
|
|
||||||
|
|
||||||
`gamefont.rlb` содержит две LZSS-записи (`0x040`), `sprites.lib` — 24 записи
|
|
||||||
raw Deflate (`0x100`). `sprites.lib::INTERF8.TEX` использует известный
|
|
||||||
`deflate_eof_plus_one` quirk. UI обязан пользоваться общим RsLi reader, а не
|
|
||||||
отдельной похожей распаковкой.
|
|
||||||
|
|
||||||
Ключ ресурса, legacy path, локализованный текст и исходные bytes — четыре
|
|
||||||
разные идентичности. Archive keys остаются ASCII-casefold; `TextRes.cfg`,
|
|
||||||
briefing/messages и script strings могут требовать ANSI/CP1251 decoding.
|
|
||||||
Unicode-представление хранит raw bytes для roundtrip и diagnostics.
|
|
||||||
|
|
||||||
`gamefont.rlb` и `sprites.lib` побайтно совпадают в Частях 1 и 2. Во второй
|
|
||||||
части добавлен `ui_factory.lib` (NRes с шестью Texm) и расширен
|
|
||||||
`ui/minimap.lib`; при этом пересобранные `iron3d.dll`/`services.dll` требуют
|
|
||||||
отдельной трассировки lifecycle и HUD state.
|
|
||||||
|
|
||||||
## Контракт новой реализации
|
|
||||||
|
|
||||||
UI scene хранит корневые widgets, focus, viewport/clip rectangles и modal
|
|
||||||
depth. Demo `640x480` — полезный baseline, но не доказанное универсальное
|
|
||||||
design resolution. Отдельно хранятся legacy-layout coordinates, реальный
|
|
||||||
viewport, scale/letterbox policy, mouse-to-layout transform, clipping и
|
|
||||||
z-order. Cursor, sprite и hit-test обязаны применять одно преобразование.
|
|
||||||
|
|
||||||
Font contract включает glyph image, advance, bearing/offset, line height и
|
|
||||||
fallback glyph. Binary glyph metrics `gamefont.rlb` пока не восстановлены:
|
|
||||||
payload читается lossless через RsLi, а семантика устанавливается по consumer
|
|
||||||
trace.
|
|
||||||
|
|
||||||
Нормализованное событие проходит modal widget, затем shell command map; лишь
|
|
||||||
разрешённая gameplay-команда превращается в World3D manual event. Held state и
|
|
||||||
axes попадают в calculation snapshot. Это не позволяет UI-click одновременно
|
|
||||||
исполнить команду мира. HUD только читает presentation view (selected object,
|
|
||||||
resources, mission text, timers, research/build state, camera mode); authority
|
|
||||||
остаётся у simulation.
|
|
||||||
|
|
||||||
## Проверки и открытые вопросы
|
|
||||||
|
|
||||||
- UI cfg читаются до EOF с сохранением неизвестных fields; symbolic key
|
|
||||||
разрешается в RsLi entry независимо от регистра.
|
|
||||||
- Visual rect и hit-test совпадают при разных viewport; modal scene блокирует
|
|
||||||
gameplay input; missing optional sprite/font имеет named fallback.
|
|
||||||
- UI-only и world-only command captures собираются раздельно.
|
|
||||||
|
|
||||||
Не закрыты grammar всех `ui/*.cfg`, hierarchy original widgets, glyph metrics,
|
|
||||||
HUD state machine и pixel-perfect layout. Нужны GUI-factory hooks, event traces
|
|
||||||
и captures меню, briefing, HQ и игрового HUD.
|
|
||||||
@@ -6,78 +6,10 @@ Baseline command:
|
|||||||
cargo xtask ci
|
cargo xtask ci
|
||||||
```
|
```
|
||||||
|
|
||||||
Result on 2026-07-18:
|
Result on 2026-06-23:
|
||||||
|
|
||||||
- canonical pipeline passes locally with Rust 1.97.1: formatting, policy,
|
- canonical pipeline now uses a fixed MSRV/toolchain, policy checks,
|
||||||
shader provenance, workspace tests, `clippy`, docs and `cargo deny` are
|
full-format workspace test command, `clippy`/`doc`/`cargo deny` gates and
|
||||||
executed through `cargo xtask ci`;
|
typed manifest parsing in `xtask`;
|
||||||
- `rust-toolchain.toml` pins Rust 1.97.1 and installs only the
|
- `rpath`/offline mode is still useful for synthetic local checks;
|
||||||
`x86_64-pc-windows-msvc` target; Linux and macOS target installation is not
|
- full online dependency resolution remains unavailable in the sandbox.
|
||||||
part of the default developer setup;
|
|
||||||
- internal path dependencies are version-pinned and the Windows-only winit
|
|
||||||
graph enables only `rwh_06`, excluding Unix window-system dependencies;
|
|
||||||
- `cargo deny` runs without advisory exceptions in the supported Windows graph.
|
|
||||||
|
|
||||||
Native Vulkan evidence:
|
|
||||||
|
|
||||||
- Windows x86_64 local smoke passed again on 2026-07-18 with Rust 1.97.1, the
|
|
||||||
system Vulkan loader 1.4.350, and an AMD Radeon Pro WX 3200 Series;
|
|
||||||
- the smoke created a Win32 surface and swapchain, presented 300 frames,
|
|
||||||
exercised a controlled resize (three observed resize events and two
|
|
||||||
swapchain recreations), and shut down with zero validation warnings and
|
|
||||||
errors;
|
|
||||||
- Windows is the sole supported runtime target for this project; Linux and
|
|
||||||
macOS smoke gates are explicitly out of scope.
|
|
||||||
|
|
||||||
Scope labels:
|
|
||||||
|
|
||||||
- Stage 0 codebase gates: locally evidenced.
|
|
||||||
- Stage 0 Windows native runtime: locally evidenced.
|
|
||||||
- Linux/macOS runtime and cross-platform hosted CI: out of scope.
|
|
||||||
|
|
||||||
## Current architecture contract
|
|
||||||
|
|
||||||
The standalone engine keeps binary formats, the resource graph, simulation,
|
|
||||||
animation math and backend-neutral render commands independent from the GPU
|
|
||||||
adapter. Windows presentation uses `winit`, `raw-window-handle`, `ash-window`
|
|
||||||
and `ash`; only the Vulkan/FFI adapters may contain narrowly documented
|
|
||||||
`unsafe` code. Raw Vulkan handles do not cross that boundary.
|
|
||||||
|
|
||||||
The baseline is Vulkan 1.1 with surface, Win32-surface and swapchain support,
|
|
||||||
binary semaphores/fences and a classic render-pass path. Device capability is
|
|
||||||
queried at runtime; dynamic rendering, descriptor indexing, synchronization2,
|
|
||||||
timeline semaphores and extended dynamic state are optional capability-gated
|
|
||||||
enhancements, not requirements. The canonical initial texture upload is RGBA8
|
|
||||||
UNORM. Headless builds remain independent of `winit`, Vulkan and a window
|
|
||||||
system.
|
|
||||||
|
|
||||||
## Current stage model
|
|
||||||
|
|
||||||
The canonical Vulkan-revision plan has six dependency-ordered stages numbered
|
|
||||||
0--5. Keeping its original numbering matters: Stage 4 is an evidence-gated
|
|
||||||
animation/FX runtime, while Stage 5 is the mission/world vertical slice that
|
|
||||||
depends on it. They must not be reported as one completed stage.
|
|
||||||
|
|
||||||
0. reproducible Windows/Vulkan foundation;
|
|
||||||
1. paths, VFS and lossless archives;
|
|
||||||
2. prototype graph and prepared CPU assets;
|
|
||||||
3. static Vulkan model/terrain viewer;
|
|
||||||
4. animation and FX runtime, with reference-only semantics until runtime
|
|
||||||
captures close the x87 and effect-lifecycle evidence gaps;
|
|
||||||
5. transactional map, mission and world vertical slice, rendered from the
|
|
||||||
same immutable snapshot through Vulkan.
|
|
||||||
|
|
||||||
This is a local, Windows-only adoption of the Notion page "План реализации
|
|
||||||
stage 0--5: Vulkan revision" (reviewed on 2026-07-18). Its former
|
|
||||||
Linux/macOS portability and hosted-CI goals are intentionally not imported:
|
|
||||||
they conflict with the current supported-platform boundary above. The
|
|
||||||
portable architectural rules that do apply -- backend-neutral commands,
|
|
||||||
runtime capability queries, narrow Vulkan/FFI `unsafe`, offline shader
|
|
||||||
validation, and command capture before pixel comparison -- are retained in
|
|
||||||
this audit and the rendering tome.
|
|
||||||
|
|
||||||
Contract tests and failure tests precede implementation. Synthetic checks never
|
|
||||||
read licensed roots; licensed corpus checks use absolute paths from the local
|
|
||||||
manifest. Backend-neutral command capture precedes pixel comparison, and GPU
|
|
||||||
addresses, allocator addresses and driver timing are excluded from deterministic
|
|
||||||
state hashes.
|
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
# Сверка локальной книги с FParkan в Notion
|
|
||||||
|
|
||||||
Проверка выполнена 18 июля 2026 года по странице `FParkan`, её восьми томам,
|
|
||||||
плану Vulkan revision и приложениям A--D. Цель — не копировать структуру
|
|
||||||
Notion, а убедиться, что каждый доказательный контракт доступен в `docs/` и
|
|
||||||
остается применимым к Windows/Vulkan scope.
|
|
||||||
|
|
||||||
| Notion | Локальное место |
|
|
||||||
| --- | --- |
|
|
||||||
| Статьи 1--3 | `tomes/01-guide.md` |
|
|
||||||
| Статьи 4--8 | `tomes/02-architecture.md` |
|
|
||||||
| Статьи 9--13 | `tomes/03-resources.md` и `reference/` |
|
|
||||||
| Статьи 14--18 | `tomes/04-world.md` и `reference/tma.md` |
|
|
||||||
| Статьи 19--27 | `tomes/05-render.md`, `reference/` и `rendering/` |
|
|
||||||
| Статьи 28--32 | `tomes/06-behavior.md` |
|
|
||||||
| Статьи 33--37 | `tomes/07-implementation.md` и `baseline/vulkan-revision-plan.md` |
|
|
||||||
| Статьи 38--42 | `tomes/08-evidence.md`, `appendices/glossary.md`, `evidence/` |
|
|
||||||
| Приложение A | этот audit, `baseline/current-project-audit.md` и тематические тома |
|
|
||||||
| Приложение B | `appendices/ui-shell.md` |
|
|
||||||
| Приложение C | `appendices/saves-campaign.md` |
|
|
||||||
| Приложение D | `appendices/script-vm.md` |
|
|
||||||
|
|
||||||
В ходе сверки добавлены отсутствовавшие локальные контракты UI/Shell,
|
|
||||||
сохранений/campaign и Script VM. Повторы не переносились: форматы, ABI и
|
|
||||||
corpus statistics уже находятся рядом с соответствующими readers/consumers.
|
|
||||||
|
|
||||||
Не перенесены только противоречащие утвержденному scope цели: Linux, macOS,
|
|
||||||
MoltenVK, GLES/RG40XX и hosted CI. Они не считаются пробелами. Текущие
|
|
||||||
доказательства Windows/Vulkan и все последующие уточнения ведутся только в
|
|
||||||
локальных файлах.
|
|
||||||
|
|
||||||
## Повторная содержательная сверка (18 июля 2026)
|
|
||||||
|
|
||||||
Повторная сверка проверяет не количество дочерних страниц в Notion, а
|
|
||||||
утверждения, которые они добавляют к реализации. Источником были корневая
|
|
||||||
страница `FParkan`, восемь оглавлений с 42 статьями, а также две специальные
|
|
||||||
страницы: `План реализации stage 0–5: Vulkan revision` (редакция 18 июля) и
|
|
||||||
`Ревью перехода на Vulkan: решение, доказательства и ограничения`.
|
|
||||||
|
|
||||||
| Контракт из Notion | Локальное подтверждение | Результат |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Статьи 1–3: терминология, уровень доказательств, методика | `tomes/01-guide.md` | Полностью покрыто. |
|
|
||||||
| Статьи 4–8: bootstrap, DLL, frame loop, World3D | `tomes/02-architecture.md` | Полностью покрыто. |
|
|
||||||
| Статьи 9–13: VFS, NRes, RsLi, registry, unit и auxiliary formats | `tomes/03-resources.md` и `reference/` | Полностью покрыто. |
|
|
||||||
| Статьи 14–18: TMA, mission loader, Land, ArealMap и world construction | `tomes/04-world.md`, `reference/tma.md`, `reference/msh.md` | Полностью покрыто. |
|
|
||||||
| Статьи 19–27: Ngi32, MSH, animation, MAT0/WEAR/Texm, terrain и кадр | `tomes/05-render.md`, `reference/`, `rendering/` | Покрыто; локально дополнено более свежими evidence по D3D7 camera, Node38 и Terrain/GetShade. |
|
|
||||||
| Статьи 28–32: AI, control, camera, audio, network | `tomes/06-behavior.md`, `appendices/script-vm.md` | Полностью покрыто. |
|
|
||||||
| Статьи 33–37 и Vulkan revision: ports, stages, deterministic gates, Vulkan profile | `tomes/07-implementation.md`, `baseline/vulkan-revision-plan.md` | Полностью покрыто в Windows-only редакции. |
|
|
||||||
| Статьи 38–42 и приложения A–D: ABI, corpus, knowledge boundaries, glossary, shell, saves, VM | `tomes/08-evidence.md`, `appendices/`, `evidence/`, этот audit | Полностью покрыто. |
|
|
||||||
|
|
||||||
Пропущенных применимых технических контрактов в этом срезе не найдено. В
|
|
||||||
частности, локальный Vulkan plan уже содержит независимость решений Vulkan и
|
|
||||||
`winit`, Vulkan 1.1 baseline, `ash`-изоляцию, SPIR-V manifest/hash, capability
|
|
||||||
gates, canonical RGBA8 upload и первичность backend-neutral command capture.
|
|
||||||
Не переносились только исторические cross-platform acceptance требования из
|
|
||||||
Notion: они прямо отменены текущим Windows-only scope, а не потеряны при
|
|
||||||
синхронизации.
|
|
||||||
|
|
||||||
Следовательно, новые факты следует добавлять непосредственно в тематический
|
|
||||||
локальный документ; повторный перенос дерева или дублирование страниц Notion
|
|
||||||
не требуется.
|
|
||||||
|
|
||||||
## Проверяемые источники и правило разрешения расхождений
|
|
||||||
|
|
||||||
Содержательная сверка опирается не только на оглавления. Были прочитаны
|
|
||||||
корневая страница `FParkan`, оглавления томов I--VIII и актуальные специальные
|
|
||||||
страницы [Vulkan revision](https://app.notion.com/p/387e79f2db3981778f94cdf34db5f93f),
|
|
||||||
[Vulkan review](https://app.notion.com/p/388e79f2db39810eb649edbe90bca529),
|
|
||||||
а также исторические статьи 33--34. Это позволяет отличить контракт от
|
|
||||||
исторического статуса работы.
|
|
||||||
|
|
||||||
- В локальной книге сохранены применимые контракты Vulkan revision: Windows
|
|
||||||
как единственная acceptance-платформа, Vulkan 1.1 baseline, изоляция
|
|
||||||
`ash`/raw handles в adapter-е, capability gates, offline SPIR-V и первичность
|
|
||||||
backend-neutral command capture.
|
|
||||||
- Не переносится прежний статус-аудит Notion (например, утверждения о
|
|
||||||
synthetic-only renderer или незакрытом Windows smoke): он описывал состояние
|
|
||||||
до последующих локальных captures и потому не является спецификацией.
|
|
||||||
- Не переносятся Linux, macOS/MoltenVK и portability-enumeration требования.
|
|
||||||
Это сознательно исключённая область, а не пробел документации.
|
|
||||||
- Если страница Notion и свежий локальный evidence расходятся, локальный
|
|
||||||
evidence с командой воспроизведения, артефактом и датой имеет приоритет;
|
|
||||||
спорный факт отмечается как граница знания, пока не будет перепроверен.
|
|
||||||
|
|
||||||
Таким образом, на момент сверки не обнаружено пропущенных применимых
|
|
||||||
технических контрактов: содержательные добавления из Notion уже разнесены по
|
|
||||||
тематическим локальным документам, а новые результаты разработки должны
|
|
||||||
добавляться только локально.
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
# План реализации: Vulkan revision (Windows)
|
|
||||||
|
|
||||||
Это локальная, действующая редакция плана `stage 0--5`. Она была получена
|
|
||||||
вдумчивой сверкой с одноимённой страницей книги FParkan в Notion 18 июля
|
|
||||||
2026 года. В Notion оставлены исторические формулировки и кроссплатформенные
|
|
||||||
цели; этот документ сохраняет все применимые технические требования и
|
|
||||||
приводит их к текущему контракту: самостоятельный движок для Windows,
|
|
||||||
оригинальные файлы игры и Vulkan.
|
|
||||||
|
|
||||||
## Неизменяемые решения
|
|
||||||
|
|
||||||
- Vulkan — единственный GPU API. Он заменяет прежнюю DirectDraw/Direct3D
|
|
||||||
реализацию на уровне наблюдаемой семантики кадра, а не через эмуляцию COM
|
|
||||||
объектов или буквальный перевод старых вызовов.
|
|
||||||
- `winit` — отдельный adapter окна, ввода и event loop. Vulkan не является
|
|
||||||
заменой SDL2: это независимые слои. В текущем проекте SDL2 не используется.
|
|
||||||
- `ash`, `ash-window` и `raw-window-handle` остаются внутри Windows
|
|
||||||
platform/Vulkan adapters. Backend-neutral crates не экспортируют raw Vulkan
|
|
||||||
handles и сохраняют `#![forbid(unsafe_code)]`; каждый `unsafe` в adapter-е
|
|
||||||
имеет локальный safety contract, правило владения и regression test.
|
|
||||||
- Baseline: Vulkan 1.1, surface + Win32 surface + swapchain, classic render
|
|
||||||
pass, binary semaphores и fences. Format/queue/present/image-count/sampler
|
|
||||||
capabilities запрашиваются у конкретного устройства. Dynamic rendering,
|
|
||||||
descriptor indexing, synchronization2, timeline semaphores и extended
|
|
||||||
dynamic state допускаются только через capability gate.
|
|
||||||
- Исходные MSH, WEAR, MAT0 и Texm остаются CPU-форматами. Стартовый upload
|
|
||||||
путь — канонический RGBA8 UNORM; packed/native GPU formats допустимы только
|
|
||||||
после доказательства эквивалентности. Shader variants собираются offline в
|
|
||||||
SPIR-V и проверяются validator-ом, manifest-ом и hash.
|
|
||||||
- Первичный эталон — backend-neutral command capture. Сравнение пикселей
|
|
||||||
начинается лишь после совпадения draw order, pipeline key, resource IDs,
|
|
||||||
descriptor bindings, ranges и transforms. GPU handles, allocator addresses
|
|
||||||
и driver timing не входят в deterministic state hash.
|
|
||||||
|
|
||||||
Windows — единственная runtime-платформа acceptance. Требования Notion к
|
|
||||||
Linux, macOS/MoltenVK, portability enumeration и hosted CI намеренно не
|
|
||||||
переносятся: они противоречат утверждённой области проекта, а не являются
|
|
||||||
пропуском документации. Headless сборка по-прежнему не зависит от окна,
|
|
||||||
Vulkan loader или `winit`.
|
|
||||||
|
|
||||||
## Stage 0 — воспроизводимая Windows/Vulkan основа
|
|
||||||
|
|
||||||
**Цель:** минимальный реальный Vulkan vertical slice без игровых assets и
|
|
||||||
локальные повторяемые gates.
|
|
||||||
|
|
||||||
- Зафиксировать stable Rust/MSRV, `Cargo.lock` и `--locked`; расширять
|
|
||||||
`cargo xtask ci` форматированием, tests, clippy, документацией, policy для
|
|
||||||
licenses/advisories/sources и проверкой разрешённого `unsafe` allowlist.
|
|
||||||
- Synthetic gate не читает лицензированные каталоги и не может молча пропускать
|
|
||||||
тест. Licensed corpus запускается отдельно по абсолютным путям local
|
|
||||||
manifest. Hosted CI/CD в этот scope не входит.
|
|
||||||
- Поддерживать typed parsing конфигурации xtask и `cargo_metadata`, а не
|
|
||||||
ручную интерпретацию TOML; исключать устаревшие adapter names и Python
|
|
||||||
runtime components из policy.
|
|
||||||
- Поддерживать `fparkan-platform-winit` (lifecycle, resize/DPI, input,
|
|
||||||
suspend/resume, raw handles) и `fparkan-render-vulkan` (instance,
|
|
||||||
validation, device scoring, queues, swapchain, resize/out-of-date/suboptimal
|
|
||||||
handling, deterministic capability report).
|
|
||||||
- Acceptance: Windows smoke создаёт настоящее окно/swapchain, показывает не
|
|
||||||
менее 300 кадров с resize и завершается без validation errors; negative
|
|
||||||
cases проверяют loader/device/present-queue/surface-format failures.
|
|
||||||
|
|
||||||
## Stage 1 — пути, VFS и архивы
|
|
||||||
|
|
||||||
**Цель:** безопасный lossless resource substrate без GPU coupling.
|
|
||||||
|
|
||||||
- Для каждого пути различать raw legacy bytes, normalized path, ASCII lookup
|
|
||||||
key и host path; strict и compatible policy не смешивать.
|
|
||||||
- Применить symlink-safe traversal и casefold-collision policy ко всем VFS.
|
|
||||||
- В каждый parser/decompressor внедрить общие `DecodeLimits` и
|
|
||||||
`AllocationBudget`; malformed offsets, counts и decompression bombs должны
|
|
||||||
завершаться bounded errors.
|
|
||||||
- Довести NRes и RsLi до lossless reader/editor/writer: сохранять unknown и
|
|
||||||
non-zero regions, stable directory order, все наблюдённые decode methods,
|
|
||||||
explicit compatibility profile и output limits.
|
|
||||||
- Resource repository обязан иметь generation handles, decoded-byte budget,
|
|
||||||
deterministic eviction, lock-free decompression section и структурированные
|
|
||||||
ошибки с archive/entry/path/offset/phase/cause chain.
|
|
||||||
- Acceptance: synthetic no-edit и edit roundtrip, stale handles, traversal,
|
|
||||||
symlink/casefold и byte-identical corpus reports; Part 1/Part 2 не дают
|
|
||||||
необъяснённых parser failures.
|
|
||||||
|
|
||||||
## Stage 2 — prototype graph и CPU assets
|
|
||||||
|
|
||||||
**Цель:** полный mission-reachable graph и typed prepared assets до GPU.
|
|
||||||
|
|
||||||
- Разрешить `objects.rlb`, unit DAT, inheritance, BASE/resource variants и
|
|
||||||
все компоненты unit, сохраняя hierarchy, provenance и multi-component
|
|
||||||
composition.
|
|
||||||
- Каждый edge хранит typed provenance: mission object, component, prototype,
|
|
||||||
model, wear, material, texture, lightmap или effect. Циклы, depth limit,
|
|
||||||
optional fallback и corrupt reachable dependency имеют разные outcomes.
|
|
||||||
- `fparkan-assets` — единственный слой CPU preparation; apps и runtime не
|
|
||||||
парсят assets ad hoc. Assets immutable, имеют stable IDs, а graph failures
|
|
||||||
содержат полную parent chain.
|
|
||||||
- Acceptance: graph order/IDs стабильны; все mission-reachable requests
|
|
||||||
обеих частей завершаются с failures 0 и передают runtime только prepared
|
|
||||||
assets.
|
|
||||||
|
|
||||||
## Stage 3 — статический Vulkan viewer
|
|
||||||
|
|
||||||
**Цель:** доказуемый статический MSH/terrain render из оригинальных assets.
|
|
||||||
|
|
||||||
- Закрыть validation streams/slots/batches/indices, Texm decode/mips/palettes/
|
|
||||||
Page rectangles и WEAR/MAT0 fallback с раздельными texture/lightmap identity.
|
|
||||||
- Backend-neutral `LegacyPipelineState` и canonical `PipelineKey` выбираются
|
|
||||||
до GPU. Vulkan adapter владеет staging/device buffers, image transitions,
|
|
||||||
samplers/descriptors, pipeline cache, depth, diffuse/lightmap bindings,
|
|
||||||
alpha/depth/cull/blend mapping и lifecycle per-frame resources.
|
|
||||||
- Viewer/debug modes включают model, texture, material, wireframe, normals,
|
|
||||||
bounds, LOD/group и terrain; upload cache ограничен GPU budget.
|
|
||||||
- Acceptance: CPU golden vectors, descriptor/pipeline-key/row-stride tests,
|
|
||||||
command captures до GPU и fixed-camera captures модели, lightmapped модели
|
|
||||||
и terrain; Windows validation smoke остаётся clean.
|
|
||||||
|
|
||||||
## Stage 4 — animation и FX runtime
|
|
||||||
|
|
||||||
**Цель:** заменить reference stubs доказанным deterministic runtime.
|
|
||||||
|
|
||||||
- Реализовать type 8/type 19 node sampling, fallback keys, hierarchy и
|
|
||||||
material timeline по подтверждённым modes/masks. Portable math не выдают за
|
|
||||||
x87-compatible: второй путь появляется только после captured vectors.
|
|
||||||
- FXID отделяет lifecycle/time/RNG gates от backend: неподтверждённые fields
|
|
||||||
сохраняются raw и не исполняются как догадки; emit формирует
|
|
||||||
backend-neutral primitive/audio commands.
|
|
||||||
- Pose/effect snapshots immutable per frame; Part 1/Part 2 profiles различают
|
|
||||||
только там, где это подтверждено differential captures.
|
|
||||||
- Acceptance: frame-by-frame poses имеют approved references, FXID corpus не
|
|
||||||
имеет parser errors, один seed даёт одинаковые commands. До этого semantic
|
|
||||||
статус строго `reference-only`, а не `runtime-compatible`.
|
|
||||||
|
|
||||||
## Stage 5 — карта, миссия и мир
|
|
||||||
|
|
||||||
**Цель:** транзакционно загрузить миссию, выполнить headless steps и показать
|
|
||||||
тот же immutable world snapshot через Vulkan.
|
|
||||||
|
|
||||||
- Закрыть Land.msh/TerrainFace28, Land.map, grid/graph validation и runtime
|
|
||||||
spatial acceleration для surface/raycast/visibility queries.
|
|
||||||
- Loader выполняет `Context -> Map -> TMA -> Graph -> Assets -> Construct ->
|
|
||||||
Register`, откатывая любую ошибку. Он сохраняет raw transforms, properties,
|
|
||||||
original IDs и provenance всех mission components.
|
|
||||||
- World queue, generation handles, deferred deletion, deterministic clock и
|
|
||||||
snapshot contract проверяются replay/hash tests; terrain/navigation и render
|
|
||||||
читают один опубликованный snapshot, не mutable world.
|
|
||||||
- Acceptance: headless mission replay стабилен, transaction rollback не
|
|
||||||
оставляет частичного мира, а Windows Vulkan frame использует ту же snapshot
|
|
||||||
и имеет связанный command/pixel artifact.
|
|
||||||
|
|
||||||
## Сверка с Notion
|
|
||||||
|
|
||||||
Восемь томов локальной книги покрывают 42 основные статьи Notion по тем же
|
|
||||||
разделам I--VIII; приложения сведены в `appendices/` и том VIII. Специальное
|
|
||||||
Vulkan-ревью дополнительно внесло в локальные материалы следующие точные
|
|
||||||
факты: исходный Ngi32 dynamically resolves DirectDraw/Direct3D, современная
|
|
||||||
граница замены находится выше Vulkan, а совпадающий SHA-256 Ngi32 в Частях 1 и
|
|
||||||
2 позволяет использовать один backend contract. Детали доказательства и
|
|
||||||
текущие native captures находятся в `tomes/05-render.md`,
|
|
||||||
`evidence/original_engine_hashes.md` и `rendering/renderer_truth_table.md`.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# Original Engine Hashes
|
|
||||||
|
|
||||||
Страница фиксирует минимальный статический baseline, на который должны
|
|
||||||
ссылаться capture fixtures и Stage 4 evidence.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Источник: локальная статическая сверка Part 1 (`IS`) и Part 2 (`IS2`).
|
|
||||||
- Метод: SHA-256, export/import tables, `objdump -p`, `strings`.
|
|
||||||
- Эта страница не заменяет динамические traces: она задаёт only-if-match
|
|
||||||
binary baseline для дальнейших runtime captures.
|
|
||||||
|
|
||||||
## Stable binaries across Part 1 / Part 2
|
|
||||||
|
|
||||||
| Binary | SHA-256 | Size | Notes |
|
|
||||||
| --- | --- | ---: | --- |
|
|
||||||
| `Ngi32.dll` | `bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5` | 253952 | Shared low-level render/resource/audio boundary |
|
|
||||||
| `World3D.dll` | `17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e` | 208896 | Shared gameplay/world/render lifecycle baseline |
|
|
||||||
| `Terrain.dll` | `6d3e68f0e15b297f6c184af3113baf1f31e19c3326c18f0150dec659242ed667` | 708608 | Shared terrain/shade/world baseline |
|
|
||||||
| `iron_3d.exe` / `iron_3d_p2.exe` | `f476af85c034a4b4f34f49d0806e4dff397b5da0ee26d382a7674231144979f7` | 36864 | Shared launcher binary |
|
|
||||||
|
|
||||||
## GOG research baseline
|
|
||||||
|
|
||||||
The canonical disassembly source is the windowed GOG installation, not the
|
|
||||||
Part 1/Part 2 test installations. Its `Terrain.dll` is a distinct revision:
|
|
||||||
|
|
||||||
| Binary | SHA-256 | Size | Notes |
|
|
||||||
| --- | --- | ---: | --- |
|
|
||||||
| `Terrain.dll` | `af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead` | 499712 | GOG camera receiver evidence; do not reuse Part 1/2 RVAs without a matching hash |
|
|
||||||
|
|
||||||
## Divergent binaries across Part 1 / Part 2
|
|
||||||
|
|
||||||
Эти модули нельзя автоматически считать behavior-compatible между частями:
|
|
||||||
|
|
||||||
- `AniMesh.dll`
|
|
||||||
- `Effect.dll`
|
|
||||||
- `iron3d.dll`
|
|
||||||
- `services.dll`
|
|
||||||
- `Control.dll`
|
|
||||||
- `ArealMap.dll`
|
|
||||||
|
|
||||||
## Practical use
|
|
||||||
|
|
||||||
1. Frame-order traces для `World3D.dll`, `Terrain.dll` и `Ngi32.dll` можно
|
|
||||||
привязывать к shared profile, пока hash совпадает.
|
|
||||||
2. Animation and FX captures обязаны храниться раздельно для Part 1 и Part 2,
|
|
||||||
потому что `AniMesh.dll` и `Effect.dll` отличаются.
|
|
||||||
3. Любой runtime fixture должен записывать минимум:
|
|
||||||
- `game_part`
|
|
||||||
- `module_name`
|
|
||||||
- `module_sha256`
|
|
||||||
- `mission`
|
|
||||||
- `frame_or_tick`
|
|
||||||
- `schema_version`
|
|
||||||
|
|
||||||
## Export / import focus for Stage 4
|
|
||||||
|
|
||||||
- `World3D.dll`: `stdCalculateGame`, `stdRenderGame`, `sendEndOfRender`
|
|
||||||
- `Terrain.dll`: `GetShade`, `GetWorld`, `stdSetCurrentCamera2`
|
|
||||||
- `AniMesh.dll`: `LoadAgent`, `LoadAniMesh`
|
|
||||||
- `Effect.dll`: `CreateFxManager`, `InitializeSettings`
|
|
||||||
- `Ngi32.dll`: `niGet3DRender`, `n3dPrimitive`, `n3dEndScene`, `rsLoadTexture`, `rsLoadMultiTexture`
|
|
||||||
|
|
||||||
Если будущий capture fixture не указывает, к какому hash он относится, такой
|
|
||||||
fixture нельзя считать acceptance evidence.
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# Stage 4 Capture Schema
|
|
||||||
|
|
||||||
Stage 4 нельзя закрывать набором ad-hoc логов. Нужна схема, по которой
|
|
||||||
animation, FX и rendered frame captures сравниваются между Part 1, Part 2 и
|
|
||||||
современной реализацией.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- сделать captures пригодными для автоматического diff;
|
|
||||||
- не хранить host-specific пути, временные каталоги и нестабильные handles;
|
|
||||||
- связывать frame traces, command captures и pixel artifacts общим identity.
|
|
||||||
|
|
||||||
## Common envelope
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schema_version": "fparkan-stage4-capture-v1",
|
|
||||||
"capture_kind": "frame-trace | animation-pose | fx-lifecycle | render-frame",
|
|
||||||
"game_part": "part1 | part2",
|
|
||||||
"mission": "MISSIONS/.../data.tma",
|
|
||||||
"frame_id": 123,
|
|
||||||
"tick": 123,
|
|
||||||
"module_hashes": {
|
|
||||||
"World3D.dll": "sha256...",
|
|
||||||
"Terrain.dll": "sha256...",
|
|
||||||
"AniMesh.dll": "sha256...",
|
|
||||||
"Effect.dll": "sha256..."
|
|
||||||
},
|
|
||||||
"tool_version": "codex/manual/fixture version",
|
|
||||||
"notes": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Capture kinds
|
|
||||||
|
|
||||||
### `frame-trace`
|
|
||||||
|
|
||||||
Используется для порядка фаз и внешних вызовов.
|
|
||||||
|
|
||||||
Required fields:
|
|
||||||
|
|
||||||
- `events`: ordered list of `{ phase, symbol, sequence, object_id?, fx_id?, camera_id? }`
|
|
||||||
- `queue_counters`: deferred operations, visible objects, emitted FX, UI callbacks
|
|
||||||
- `rng_state`: optional, if recoverable
|
|
||||||
|
|
||||||
### `animation-pose`
|
|
||||||
|
|
||||||
Используется для x87 / portable sampler parity.
|
|
||||||
|
|
||||||
Required fields:
|
|
||||||
|
|
||||||
- `clip_id`
|
|
||||||
- `node_index`
|
|
||||||
- `sample_time`
|
|
||||||
- `numeric_profile`
|
|
||||||
- `translation`
|
|
||||||
- `rotation_quat`
|
|
||||||
- `scale`
|
|
||||||
- `matrix_hash`
|
|
||||||
|
|
||||||
### `fx-lifecycle`
|
|
||||||
|
|
||||||
Используется для create/update/emit/stop parity.
|
|
||||||
|
|
||||||
Required fields:
|
|
||||||
|
|
||||||
- `fx_id`
|
|
||||||
- `instance_id`
|
|
||||||
- `time`
|
|
||||||
- `opcode_events`
|
|
||||||
- `rng_calls`
|
|
||||||
- `resource_refs`
|
|
||||||
- `emissions`
|
|
||||||
|
|
||||||
### `render-frame`
|
|
||||||
|
|
||||||
Связывает backend-neutral snapshot с live Vulkan output.
|
|
||||||
|
|
||||||
Required fields:
|
|
||||||
|
|
||||||
- `camera`
|
|
||||||
- `visible_object_ids`
|
|
||||||
- `draws`
|
|
||||||
- `pipeline_keys`
|
|
||||||
- `resource_ids`
|
|
||||||
- `validation`
|
|
||||||
- `pixel_artifact`
|
|
||||||
|
|
||||||
## Stability rules
|
|
||||||
|
|
||||||
1. Не записывать абсолютные host paths.
|
|
||||||
2. Не записывать raw pointer addresses как identity fields.
|
|
||||||
3. `frame_id` должен совпадать между trace, command capture и pixel artifact.
|
|
||||||
4. GPU-specific transient handles допустимы только внутри diagnostics fields и
|
|
||||||
не участвуют в canonical equality.
|
|
||||||
5. Любой capture without `module_hashes` считается informational, а не
|
|
||||||
acceptance-grade.
|
|
||||||
|
|
||||||
## Acceptance mapping
|
|
||||||
|
|
||||||
- `S4-TRACE-*` rows читают `frame-trace`
|
|
||||||
- `S4-ANIM-*` rows читают `animation-pose`
|
|
||||||
- `S4-FX-*` rows читают `fx-lifecycle`
|
|
||||||
- `S4-VK-*` и `S4-PIXEL-*` rows читают `render-frame`
|
|
||||||
|
|
||||||
Эта схема intentionally минимальна. Новые поля можно добавлять, но нельзя
|
|
||||||
ломать перечисленные identity and parity anchors без смены `schema_version`.
|
|
||||||
@@ -9,27 +9,6 @@ Iron3D из *Parkan: Iron Strategy*. Она ведёт от запуска ор
|
|||||||
используется как быстрый доступ к форматам, проверочным правилам и границам
|
используется как быстрый доступ к форматам, проверочным правилам и границам
|
||||||
доказанного знания.
|
доказанного знания.
|
||||||
|
|
||||||
## Источник истины и синхронизация
|
|
||||||
|
|
||||||
Каталог `docs/` — единственный рабочий источник истины для FParkan. 18 июля
|
|
||||||
2026 года его содержательно сопоставили с книгой FParkan в Notion; результат,
|
|
||||||
маршрутизация статей и сознательно исключённые цели зафиксированы в
|
|
||||||
[сверке](baseline/notion-reconciliation.md). Актуальные локальные материалы
|
|
||||||
содержат и последующие результаты разработки. Notion больше не
|
|
||||||
является местом внесения изменений: новые факты, исправления и решения
|
|
||||||
фиксируются только здесь, в томе, справочнике или evidence-документе, которому
|
|
||||||
они принадлежат.
|
|
||||||
|
|
||||||
При расхождении приоритет имеют более новое локальное доказательство и текущий
|
|
||||||
scope проекта: самостоятельный runtime ориентирован исключительно на Windows и
|
|
||||||
Vulkan. Исторические упоминания других ОС в старых внешних заметках не
|
|
||||||
расширяют поддерживаемую платформу.
|
|
||||||
|
|
||||||
Действующий dependency-ordered план `stage 0--5` находится в
|
|
||||||
[Vulkan revision для Windows](baseline/vulkan-revision-plan.md). Это
|
|
||||||
редакторская локальная версия: она включает недостающие контрактные требования
|
|
||||||
из Notion, но не переносит снятые с проекта Linux/macOS и hosted-CI цели.
|
|
||||||
|
|
||||||
## Как читать
|
## Как читать
|
||||||
|
|
||||||
Если вы впервые разбираете игровой движок, начните с тома I и II. Там вводится
|
Если вы впервые разбираете игровой движок, начните с тома I и II. Там вводится
|
||||||
|
|||||||
@@ -43,25 +43,6 @@ struct Node38 {
|
|||||||
`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает
|
`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает
|
||||||
отсутствие геометрии для комбинации LOD/group.
|
отсутствие геометрии для комбинации LOD/group.
|
||||||
|
|
||||||
Validated `ModelAsset` также сохраняет decoded type 8 keys и type 19 map как
|
|
||||||
`ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`,
|
|
||||||
то есть доказанный static input. `parent_or_link == 0xFFFF` означает root;
|
|
||||||
иначе это parent index, обязательно меньший индекса child. Этот контракт
|
|
||||||
подтверждён на licensed animation gates обеих частей и защищён fallback-ом:
|
|
||||||
модель с нарушенным порядком не получает придуманную hierarchy.
|
|
||||||
|
|
||||||
В legacy-camera static preview стандартный узел уже получает свой fallback pose
|
|
||||||
до внешнего TMA/Iron3D transform. Parent pose поворачивает child translation,
|
|
||||||
затем translation суммируется, а rotations умножаются; после полученной global
|
|
||||||
pose применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия
|
|
||||||
намеренно дублируется на draw-range узла, потому что один source vertex может
|
|
||||||
быть нарисован разными node poses. Это static fallback hierarchy, а не полная
|
|
||||||
animation parity: dynamic type-19 frame-map sampling остаётся отдельной задачей.
|
|
||||||
|
|
||||||
Для воспроизводимого исследования `fparkan-cli model inspect --root <game>
|
|
||||||
--archive <archive> --resource <model.msh>` выводит Node38 metadata, включая
|
|
||||||
parent index, fallback key и наличие LOD0/group0 geometry.
|
|
||||||
|
|
||||||
## Slot and batch
|
## Slot and batch
|
||||||
|
|
||||||
Type 2 содержит header `0x8C`, затем `Slot68`:
|
Type 2 содержит header `0x8C`, затем `Slot68`:
|
||||||
|
|||||||
@@ -40,33 +40,6 @@ end-of-render.
|
|||||||
Подготовленный item должен ссылаться на immutable данные кадра. Изменение phase
|
Подготовленный item должен ссылаться на immutable данные кадра. Изменение phase
|
||||||
или texture cache посреди прохода не должно менять уже собранную очередь.
|
или texture cache посреди прохода не должно менять уже собранную очередь.
|
||||||
|
|
||||||
## GOG camera dispatch evidence
|
|
||||||
|
|
||||||
For the GOG `World3D.dll` baseline with SHA-256
|
|
||||||
`17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`,
|
|
||||||
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.
|
|
||||||
|
|
||||||
Receiver-side GOG Terrain evidence refines this contract. `stdSetCurrentCamera2`
|
|
||||||
(RVA `0x4FD40` in Terrain SHA-256
|
|
||||||
`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`) queries
|
|
||||||
selector `18` on the supplied object and invokes slot `+12` on that result. It
|
|
||||||
does not store the supplied pointer in `stdGetCurrentCamera2`; that getter
|
|
||||||
returns a Terrain global populated by selector `8` during Terrain initialization.
|
|
||||||
|
|
||||||
`Terrain.dll!LoadCamera` adds a separate factory-side contract. Its GOG entry
|
|
||||||
at RVA `0x4EBE0` is `__stdcall` with four machine-word arguments, allocates
|
|
||||||
`0x1A4` bytes, calls the constructor at RVA `0x4EC60`, and returns the interface
|
|
||||||
at object offset `+0x134`. The constructor passes arguments 3 and 4 into base
|
|
||||||
initialization at `this + 4`, writes argument 3 to `this + 0x138`, and calls a
|
|
||||||
helper at RVA `0x4CEF0` with arguments 1, 2 and 4 plus `this`. Argument 1 is a
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Parity risks
|
## Parity risks
|
||||||
|
|
||||||
- x87 precision and rounding;
|
- x87 precision and rounding;
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
# Renderer Truth Table
|
|
||||||
|
|
||||||
Эта страница нужна для одной вещи: не позволять путям smoke, planning и capture
|
|
||||||
выглядеть как «почти готовый renderer». Каждый путь доказывает разный класс
|
|
||||||
свойств, и acceptance не должен смешивать их.
|
|
||||||
|
|
||||||
## Краткая матрица
|
|
||||||
|
|
||||||
| Path | Native window / swapchain | Draws pixels | Uses original assets | Acceptance class | Что доказывает | Чего не доказывает |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| `fparkan-vulkan-smoke` / `VulkanSmokeRenderer` | Yes | Yes | Static MSH plus sampled TEXM | `covered-gpu` for Stage 0 smoke and explicit MSH/TEXM/descriptor bridge IDs | Loader, instance, surface, swapchain, submit/present, validation-clean triangle path; original MSH indexed draw; TEXM RGBA8 staging upload; per-batch WEAR/MAT0 diffuse descriptors and fragment sampling | MAT0 phase animation, lightmaps, legacy blend/depth/cull states, terrain, camera/node transforms, gameplay rendering |
|
|
||||||
| `VulkanPlanningBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Deterministic command validation, canonical capture, frame submission planning | Любой live GPU draw, pixel parity, validation-clean asset frame |
|
|
||||||
| `RecordingBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Stable command capture for backend-neutral tests | Native window, Vulkan, GPU resource lifetime, pixels |
|
|
||||||
| `NullBackend` | No | No | Optional CPU-side IDs only | Usually `covered` for validation-only rows | Command stream framing and bounds validation | Capture stability, GPU execution, pixels |
|
|
||||||
| `VulkanAssetRenderer` | Yes | Yes | Yes | `covered-gpu` | Static original asset rendering: MSH/Texm/WEAR/MAT0/terrain through Vulkan | Animation/FX parity unless explicitly wired |
|
|
||||||
| `fparkan-game --backend static-vulkan` | Yes, GOG `Autodemo.00` | Yes, full static AutoDemo preview | Eight mission objects, 66 MSH components, original diffuse TEXM and Land2 terrain base layer | `covered-gpu` for the bounded static-scene bridge | Diagnostic XY framing with recovered translation/scale/Z Euler placement, fallback node hierarchy, optional captured legacy camera, 32-bit GPU indices, diffuse descriptors, terrain base draws and synchronized teardown telemetry | Material phases, Land1 blend/microtexture/lightmaps, animated keys, FX, camera control and gameplay parity |
|
|
||||||
| Future rendered `fparkan-game` mode | Yes | Yes | Yes | `covered-gpu` plus original-evidence IDs | Mission-driven render snapshot execution and pixel capture | Original-runtime parity for animation/FX/x87 without dedicated captures |
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
1. IDs со смыслом `VK`, `GPU`, `DRAW`, `PIXEL`, `VALIDATION` или `RENDERED`
|
|
||||||
на Stage 3+ не могут закрываться через `NullBackend`, `RecordingBackend`
|
|
||||||
или `VulkanPlanningBackend`.
|
|
||||||
2. `covered-planning` означает command planning/capture evidence. Этот статус
|
|
||||||
никогда не считается доказательством draw пикселей.
|
|
||||||
3. `covered-stub` зарезервирован для явно помеченных `STUB` acceptance rows и
|
|
||||||
не считается compatibility closure для FX lifecycle.
|
|
||||||
4. `covered-gpu` требует live native handles, реальный draw path и связанный
|
|
||||||
renderer artifact: report, capture или approved pixel.
|
|
||||||
|
|
||||||
## Current repository status
|
|
||||||
|
|
||||||
- Реальный Vulkan в репозитории имеет smoke triangle path и узкий static asset bridge. `VulkanStaticDrawRange` сохраняет исходный `Batch20.material_index`; когда smoke запускается с `--wear-root`, `--wear-archive`, `--wear-name` без override, он дедуплицирует selectors, проходит каждый через `WEAR → MAT0 → Textures.lib`, создаёт по одному image/descriptor set и бинит set непосредственно перед соответствующим indexed draw. Direct TEXM и `--material-index` — намеренно однотекстурные compatibility modes. Fresh GOG `fortif.rlb::FR_L_MTP.msh` подтверждает 237 batch draws, но его selectors все `0`, поэтому live report содержит один binding `MTP_01.0`; unit contract подтверждает точное сопоставление двух разных selectors с разными descriptor sets. Это не доказывает material phase animation, lightmaps, alpha/depth/cull state, terrain, camera/node transforms или pixel approval.
|
|
||||||
- Lightmap остаётся отдельным, не реализованным contract: оригинальный `World3D.dll` экспортирует самостоятельный `SetLightMapLib` наряду с `SetTexturesLib` и `SetMaterialLib`; WEAR содержит независимый блок `LIGHTMAPS`. Текущая документация не подтверждает связь этих slots с `Batch20.material_index` или их UV/channel semantics, поэтому viewer не подменяет lightmap diffuse texture и не добавляет недоказанное binding.
|
|
||||||
- `apps/fparkan-game` по умолчанию выдаёт `render-planning` JSON report поверх
|
|
||||||
synthetic window descriptor и `VulkanPlanningBackend`. Opt-in `--backend static-vulkan`
|
|
||||||
создаёт native `winit` window. Для GOG `MISSIONS/Autodemo.00/data.tma` он уже
|
|
||||||
рендерит весь статический набор из восьми mission objects и 66 MSH components с
|
|
||||||
diagnostic XY camera (or an optional captured legacy camera), доказанным `Rz * Ry * Rx` placement transform,
|
|
||||||
fallback node hierarchy и Land2 base terrain. Последний validation-clean
|
|
||||||
текущий трёхкадровый запуск после применения placement transform в XY path
|
|
||||||
имел 71 material descriptor, `clip_visible_vertices=75543` и readback hash
|
|
||||||
`5444013368935681345`. Это `covered-gpu` для ограниченного
|
|
||||||
static-scene bridge, но не pixel parity и не доказательство material phase,
|
|
||||||
Land1 blend, microtexture, lightmap, dynamic animation, FX, live camera
|
|
||||||
selection или gameplay rendering.
|
|
||||||
- `apps/fparkan-viewer` сейчас inspection-only CLI и не открывает live Vulkan
|
|
||||||
asset viewer.
|
|
||||||
- Следующий реальный milestone для rendered acceptance: `VulkanAssetRenderer`
|
|
||||||
с upload/draw/capture path для хотя бы одной оригинальной модели и одного
|
|
||||||
terrain slice.
|
|
||||||
@@ -128,12 +128,6 @@ directory_offset = total_size - directory_size
|
|||||||
Reader проверяет, что `directory_offset >= 16`, умножение не переполнено, а
|
Reader проверяет, что `directory_offset >= 16`, умножение не переполнено, а
|
||||||
каталог заканчивается точно на `total_size`.
|
каталог заканчивается точно на `total_size`.
|
||||||
|
|
||||||
В repository NRes payload возвращается как slice с `Arc`-владельцем полного
|
|
||||||
неизменяемого архива: валидация формата не создаёт промежуточную копию каждого
|
|
||||||
payload. Такой view учитывается в том же bounded decoded-payload cache по
|
|
||||||
длине диапазона и сохраняет deterministic eviction. Это применимо только к
|
|
||||||
raw NRes; сжатый RsLi по-прежнему материализуется после декодирования.
|
|
||||||
|
|
||||||
### Запись каталога NRes
|
### Запись каталога NRes
|
||||||
|
|
||||||
```c
|
```c
|
||||||
@@ -468,37 +462,6 @@ string длиной 32 байта. Требование обязательног
|
|||||||
Части 2. Все соответствуют формуле размера, `kind == 1` и
|
Части 2. Все соответствуют формуле размера, `kind == 1` и
|
||||||
`archive_name == "objects.rlb"`.
|
`archive_name == "objects.rlb"`.
|
||||||
|
|
||||||
При построении mission prototype graph FParkan сохраняет для каждого root
|
|
||||||
упорядоченный список этих raw records. Список передаётся в
|
|
||||||
`MissionObjectDraft` без преобразования `kind`, `parent_or_link`, description
|
|
||||||
или tails в предполагаемые роли. Так runtime уже располагает исходными данными
|
|
||||||
составного unit для будущих Control/physics/AniMesh consumers, но не выдаёт
|
|
||||||
структурное сходство за доказанную семантику.
|
|
||||||
|
|
||||||
Каждый graph edge от `UnitDatRoot` к effective component и далее к его MSH
|
|
||||||
dependency также несёт `unit_component_index`. Это индекс исходной записи в
|
|
||||||
порядке файла, а не тип компонента; по нему diagnostics и будущие consumers
|
|
||||||
могут однозначно вернуться к raw 112-byte record.
|
|
||||||
|
|
||||||
Visual dependency expansion наследует этот индекс на edges к WEAR, MAT0,
|
|
||||||
diffuse texture и lightmap. Поэтому ошибка или asset в любой из этих фаз
|
|
||||||
сохраняет путь до конкретной записи unit DAT, а не только до миссионного object.
|
|
||||||
|
|
||||||
Для воспроизводимой проверки используется `fparkan-cli prototype inspect`.
|
|
||||||
Схема JSON `fparkan-prototype-inspect-v2` содержит lossless hex каждого
|
|
||||||
32-byte поля unit record и materialized graph edges с `unit_component_index`.
|
|
||||||
Например, для GOG AutoDemo:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cargo run -p fparkan-cli -- prototype inspect `
|
|
||||||
--root 'C:\GOG Games\Parkan - Iron Strategy' `
|
|
||||||
--key 'UNITS\UNITS\AutoDEMO\w_m_wlk2.dat' --format json
|
|
||||||
```
|
|
||||||
|
|
||||||
Вывод фиксирует 18 component records, их MSH/WEAR/MAT0/Texm dependencies и
|
|
||||||
точные parent edges. JSON предназначен для анализа и regression evidence, а
|
|
||||||
не для интерпретации `kind`, links или opaque tails как готовой game logic.
|
|
||||||
|
|
||||||
## Вспомогательные форматы
|
## Вспомогательные форматы
|
||||||
|
|
||||||
MSH, материал и текстура отвечают за видимую форму. Полноценный прототип
|
MSH, материал и текстура отвечают за видимую форму. Полноценный прототип
|
||||||
|
|||||||
@@ -135,14 +135,6 @@ repeat relation_count:
|
|||||||
TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как
|
TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как
|
||||||
raw-поля, пока их потребительская семантика не закрыта.
|
raw-поля, пока их потребительская семантика не закрыта.
|
||||||
|
|
||||||
Runtime уже использует первую строку как доказанный путь выбора пакета: к ней
|
|
||||||
добавляется `.scr`, если расширение отсутствует, и полученный compiled package
|
|
||||||
декодируется losslessly до регистрации мира. Для каждого клана сохраняются
|
|
||||||
индекс, исходный base path, нормализованный `.scr` path и пакет событий. Это
|
|
||||||
не запускает opcode и не приписывает ему gameplay-эффект, но делает сценарный
|
|
||||||
input частью транзакции загрузки: missing/invalid package отменяет mission
|
|
||||||
load до публикации частичного world state.
|
|
||||||
|
|
||||||
Mode `0` имеет отдельный count-driven layout:
|
Mode `0` имеет отдельный count-driven layout:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -563,27 +555,6 @@ candidate areas, затем выполняется точная геометри
|
|||||||
Если область не найдена, caller получает явный miss и решает, допустим ли
|
Если область не найдена, caller получает явный miss и решает, допустим ли
|
||||||
fallback к ближайшей области.
|
fallback к ближайшей области.
|
||||||
|
|
||||||
### Индекс поверхности
|
|
||||||
|
|
||||||
`TerrainWorld` хранит отдельный deterministic BVH по валидированным
|
|
||||||
`TerrainFace28` triangles. Он не заменяет `Land.map` grid: BVH отвечает за
|
|
||||||
низкоуровневые surface queries (`height_at` и `raycast`), тогда как grid и
|
|
||||||
areal graph остаются навигационным контрактом.
|
|
||||||
|
|
||||||
При построении индекс делит triangles по centroid вдоль самой протяжённой оси;
|
|
||||||
в leaf остаётся не более восьми face indices. XY query проходит только листья,
|
|
||||||
чьи AABB покрывают точку, а raycast — только AABB, пересечённые лучом. После
|
|
||||||
выбора кандидатов raycast сортирует их по исходному face index, поэтому при
|
|
||||||
равной дистанции сохраняется прежний deterministic tie-break; `height_at`
|
|
||||||
по-прежнему выбирает максимальную высоту среди действительно покрывающих
|
|
||||||
точку triangles. Индекс меняет стоимость запроса, но не геометрическую
|
|
||||||
семантику.
|
|
||||||
|
|
||||||
Synthetic test проверяет сокращение candidate set и прежние height/raycast
|
|
||||||
results. Licensed run проходит все 33 `Land.msh` Части 1 и 32 файла Части 2:
|
|
||||||
для каждого mesh с числом faces выше leaf limit query у центра первого face
|
|
||||||
включает этот face и использует меньше кандидатов, чем полный mesh.
|
|
||||||
|
|
||||||
### Маршрут
|
### Маршрут
|
||||||
|
|
||||||
После определения начальной и целевой областей маршрут строится по графу
|
После определения начальной и целевой областей маршрут строится по графу
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -133,33 +133,6 @@ Wizard получает желаемое направление и corridor, а
|
|||||||
описывает типы, defaults, ranges и строки через макросоподобные формы
|
описывает типы, defaults, ranges и строки через макросоподобные формы
|
||||||
`VAR(...)` и `STRING(...)`.
|
`VAR(...)` и `STRING(...)`.
|
||||||
|
|
||||||
Compiled package больше не opaque blob: `fparkan-script` losslessly читает
|
|
||||||
проверенный внешний framing. Сначала идут `opcode_handler_count` и
|
|
||||||
`event_count` (`u32 LE`), затем именованные события с NUL-terminated raw
|
|
||||||
именем и nested records. Reader сохраняет все seven raw header words и
|
|
||||||
references каждого record, жёстко ограничивает counts/allocations и отдельно
|
|
||||||
сохраняет trailing bytes. На `c1m2p.scr` GOG reader получает `73` handlers,
|
|
||||||
`9` events, `17` records и `20` references без trailing bytes. Это структура
|
|
||||||
файла, но не таблица семантик: названия opcode/words появятся только после
|
|
||||||
handler contracts и runtime traces.
|
|
||||||
|
|
||||||
Связь первого header word с dispatch теперь доказана статически: `ai.dll`
|
|
||||||
создаёт 73 handler pointers в известном порядке и копирует table без
|
|
||||||
перестановки. По всем 58 GOG packages первый word — индекс `0..72` либо
|
|
||||||
`0xffff_ffff` sentinel; `fparkan-script` отражает это как typed
|
|
||||||
`ScriptDispatchSelector`, сохраняя неожиданные значения `Unknown`. Первый
|
|
||||||
handler лишь инициирует execution context (`+0x50 = 1`); его gameplay meaning
|
|
||||||
пока не назван.
|
|
||||||
|
|
||||||
Первый часто встречающийся table entry с side effect — `Handler(2)` (176
|
|
||||||
records в corpus). Он берёт active instruction, разрешает семь slots через
|
|
||||||
varset, приводит три значения к float по observed kinds `5`/`3`, затем
|
|
||||||
materializes или refresh-ит внутренний event record. Его base string связывает
|
|
||||||
`<base>_Start` и `<base>_Continue` с event table; прямого World3D/Behavior
|
|
||||||
call на этой ветке не найдено. Slot names и consumer record ещё не установлены
|
|
||||||
динамически, поэтому Rust не исполняет handler как гипотетическую команду
|
|
||||||
движения/атаки/строительства.
|
|
||||||
|
|
||||||
Безопасная runtime-модель:
|
Безопасная runtime-модель:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -181,36 +154,6 @@ IDs и отправляет команды через игровые interfaces,
|
|||||||
открытым направлением. До появления decompiler-а `.scr` binary body сохраняется
|
открытым направлением. До появления decompiler-а `.scr` binary body сохраняется
|
||||||
lossless, а доказанные symbol/event tables документируются отдельно.
|
lossless, а доказанные symbol/event tables документируются отдельно.
|
||||||
|
|
||||||
### Подтверждённый evaluator выражений
|
|
||||||
|
|
||||||
Ghidra 12.1.2 decompile GOG `ai.dll` фиксирует отдельный evaluator по VA
|
|
||||||
`0x10005180` (не dispatcher инструкций `.scr`). Он получает индекс записи,
|
|
||||||
ищет её в контейнере `this + 0x34`, переключается по `u32 tag` в offset `+0`
|
|
||||||
и при успешном результате пишет completion byte в `+0x0c`. Из кода доказаны
|
|
||||||
ровно пять ветвей `tag = 1..5`; `tag = 1..3` требуют `u32` subtype в
|
|
||||||
`+0x04 == 0`, а `tag = 4..5` — `+0x04 == 1`. Поле payload находится по
|
|
||||||
`+0x08`.
|
|
||||||
|
|
||||||
Ветки 1--3 делают lookup через object/interface, достижимый от
|
|
||||||
`this + 0x60 + 0x35c`, и используют его virtual slot `+0x1c`. Ветка 2
|
|
||||||
дополнительно читает virtual slots `+0x10`/`+0x18` возвращённого объекта и
|
|
||||||
разрешает result tags `1`, `0x12`, `0x13`. Ветка 3 сравнивает virtual slot
|
|
||||||
`+0x44` с текущим object value. Ветки 4--5 используют `payload` как index в
|
|
||||||
контейнере `this + 0x4c`, обходят его child indices и делают тот же lookup;
|
|
||||||
их разные success-guards пока не именуются семантически. Это доказывает
|
|
||||||
typed condition/evaluation layer, но **не** формат `.scr`, размеры инструкций
|
|
||||||
или связь чисел tag с языковыми операторами.
|
|
||||||
|
|
||||||
Выгрузка воспроизводится без изменения PE:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
& 'C:\Tools\ghidra_12.1.2_PUBLIC\support\analyzeHeadless.bat' `
|
|
||||||
C:\temp\fparkan-ghidra ai -import 'C:\GOG Games\Parkan - Iron Strategy\ai.dll' `
|
|
||||||
-processor x86:LE:32:default `
|
|
||||||
-scriptPath C:\Develop\fparkan\tools\ghidra `
|
|
||||||
-postScript ExportAiExpressionDispatcher.java -deleteProject
|
|
||||||
```
|
|
||||||
|
|
||||||
### TRF и preload-данные
|
### TRF и preload-данные
|
||||||
|
|
||||||
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
|
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
|
||||||
@@ -263,82 +206,6 @@ CreateCollObject
|
|||||||
тригонометрию и `g_FastProc`. Это подтверждает его положение между gameplay
|
тригонометрию и `g_FastProc`. Это подтверждает его положение между gameplay
|
||||||
object и геометрией мира.
|
object и геометрией мира.
|
||||||
|
|
||||||
Статическая сверка GOG `Control.dll` уточняет границу, но пока не раскрывает
|
|
||||||
per-tick solver. PE32 image base — `0x10000000`; exports имеют следующие RVA:
|
|
||||||
|
|
||||||
```text
|
|
||||||
InitializeSettings 0x32260
|
|
||||||
LoadControlSystem 0x32280
|
|
||||||
LoadPhysicalModel 0x32580
|
|
||||||
CreateCollManager 0x325d0
|
|
||||||
CreateCollObject 0x32600
|
|
||||||
```
|
|
||||||
|
|
||||||
`LoadControlSystem` возвращается `ret 0x20`, следовательно ABI снимает со
|
|
||||||
стека восемь 32-bit аргументов. Оно выбирает allocation размером `0x668` при
|
|
||||||
mode `9` и `0x670` для другого mode, создаёт внутренний reader через virtual
|
|
||||||
dispatch с selector `10`, переносит несколько caller strings в локальные
|
|
||||||
buffers и передаёт собранную settings-структуру дальше через virtual slot
|
|
||||||
`+0x08` с key `0x80000020`. Это доказывает loader/configuration boundary и
|
|
||||||
два layout variants, но не даёт права назвать поля скоростью или acceleration.
|
|
||||||
`LoadPhysicalModel` аналогично создаёт `0xa0`-byte reader и возвращается
|
|
||||||
`ret 0x0c`; `CreateCollManager` и `CreateCollObject` возвращают interface
|
|
||||||
pointer с поправкой `+4` после внутренней инициализации.
|
|
||||||
|
|
||||||
Headless Ghidra 12.1.2 decompile GOG binary подтверждает ABI формой экспортов
|
|
||||||
`LoadControlSystem(char*, char*, char*, char*, char*, u32, void*, i32)`,
|
|
||||||
`LoadPhysicalModel(u32, u32, u32)`, `CreateCollManager(u32)` и
|
|
||||||
`CreateCollObject(u32, u32)`. Первые пять параметров Control loader — строки,
|
|
||||||
а mode передаётся последним; decompiler не восстанавливает предметные имена
|
|
||||||
остальных слов. `InitializeSettings` получает `CreateGameSettings()` из
|
|
||||||
World3D и делает virtual call slot `+0x24` с literal `0x15` и строкой по RVA
|
|
||||||
`0x42478`. Reproducible extractor находится в
|
|
||||||
`tools/ghidra/ExportControlFunctions.java`; он декомпилирует только эти exports
|
|
||||||
в локальном Ghidra project и не изменяет оригинальную DLL.
|
|
||||||
|
|
||||||
Именно update methods этих private objects, а не пять exports, остаются
|
|
||||||
следующим объектом динамической трассировки. Поэтому reference movement в
|
|
||||||
новом runtime намеренно не использует неподтверждённые параметры Control.
|
|
||||||
|
|
||||||
### Связь с AniMesh
|
|
||||||
|
|
||||||
PE import table GOG `AniMesh.dll` показывает, что это прямой consumer
|
|
||||||
`CreateCollManager`, `CreateCollObject` и `LoadControlSystem`; других DLL,
|
|
||||||
которые статически импортируют эти три named exports, не найдено. Внутренний
|
|
||||||
AniMesh path по VA `0x100032e7` вызывает Control thunks в строгой наблюдаемой
|
|
||||||
последовательности:
|
|
||||||
|
|
||||||
```text
|
|
||||||
LoadControlSystem (thunk 0x1001934e)
|
|
||||||
-> CreateCollObject (thunk 0x10019348)
|
|
||||||
-> CreateCollManager (thunk 0x10019342)
|
|
||||||
```
|
|
||||||
|
|
||||||
После factory calls caller immediately берёт returned interface vtable и
|
|
||||||
делает дальнейшие virtual calls; это связывает Control с загрузкой
|
|
||||||
AniMesh/unit components, а не с одной глобальной настройкой процесса. В
|
|
||||||
частности, `CreateCollObject` получает два stack arguments, а
|
|
||||||
`CreateCollManager` — один. Предметные значения этих arguments, ownership
|
|
||||||
private objects и per-tick update slots пока не восстановлены; порядок
|
|
||||||
создания не доказывает скорость, collision algorithm или AI semantics.
|
|
||||||
|
|
||||||
Headless Ghidra call-site decompile уточняет configuration provenance:
|
|
||||||
AniMesh передаёт в `LoadControlSystem` шесть 32-byte strings из одного
|
|
||||||
configuration block по offsets `+0x80`, `+0xa0`, `+0xc0`, `+0xe0`, `+0x100` и
|
|
||||||
`+0x120`, затем resource context и mode из owner `+0x6d8`. Returned Control
|
|
||||||
interface сразу получает пять virtual calls, связывающих его с owner slots
|
|
||||||
`+0x158`, `+0x160`, `+0x164`, `+0x168` и `+0x18c`; collision object затем
|
|
||||||
связывается с `+0x170`. Это достаточное основание хранить будущий Control
|
|
||||||
component как ordered raw-string/resource provenance, но не для присвоения
|
|
||||||
этим строкам смысловых имён до трассировки private update methods. Extractor:
|
|
||||||
`tools/ghidra/ExportAniMeshControlCaller.java`.
|
|
||||||
|
|
||||||
Runtime сохраняет ordered raw Unit DAT records рядом с каждым mission object
|
|
||||||
draft. Это создаёт проверяемую границу передачи данных от loader-а к будущему
|
|
||||||
Control consumer-у: никакой компонент пока не получает имя `Control` только
|
|
||||||
по `kind`, `parent_or_link` или description; semantic binding появится лишь
|
|
||||||
после trace private update/load methods.
|
|
||||||
|
|
||||||
### Control system и physical model
|
### Control system и physical model
|
||||||
|
|
||||||
`LoadControlSystem` загружает настройки controller-а: ограничения скорости,
|
`LoadControlSystem` загружает настройки controller-а: ограничения скорости,
|
||||||
@@ -423,46 +290,6 @@ Control получает world-interface Terrain и использует пов
|
|||||||
последовательности, а интеграция использует одну политику `dt` и округления.
|
последовательности, а интеграция использует одну политику `dt` и округления.
|
||||||
Иначе одинаковая миссия постепенно расходится даже без сети.
|
Иначе одинаковая миссия постепенно расходится даже без сети.
|
||||||
|
|
||||||
#### Reference controller в текущем runtime
|
|
||||||
|
|
||||||
`fparkan-runtime::advance_reference_movement` — намеренно маленький
|
|
||||||
детерминированный мост между сохранённым mission transform и `TerrainWorld`.
|
|
||||||
Он получает `OriginalObjectId`, явную XY-цель и положительный максимум шага,
|
|
||||||
находит только live/registered object по исходному ID, двигает XY не более чем
|
|
||||||
на этот шаг и записывает высоту из `TerrainWorld::height_at`. Orientation и
|
|
||||||
scale остаются исходными IEEE-754 words; функция не изменяет clock, очередь
|
|
||||||
World3D или animation state. Возвращаемое значение означает достижение именно
|
|
||||||
заданной XY-цели.
|
|
||||||
|
|
||||||
Это **не** восстановленный Control, Behavior или navigation controller:
|
|
||||||
функция не строит маршрут, не использует `dt`, скорость, terrain normal,
|
|
||||||
коллизии, acceleration и оригинальные AI decisions. Она существует как
|
|
||||||
проверяемый reference path, который фиксирует границу будущих controller-ов и
|
|
||||||
не позволяет renderer-у или gameplay обходить terrain query. Non-finite input,
|
|
||||||
неположительный шаг, отсутствующая миссия/объект и XY вне поверхности дают
|
|
||||||
явную ошибку без частичного изменения transform.
|
|
||||||
|
|
||||||
Licensed test на GOG `Autodemo.00` запускает этот путь для live mission object,
|
|
||||||
находит существующую поверхность и проверяет точные XY/Z words после snap. Это
|
|
||||||
доказывает связывание current runtime data, но не доказывает семантику
|
|
||||||
оригинального движения.
|
|
||||||
|
|
||||||
Путь доступен и через самостоятельный composition root:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
fparkan-headless --root "C:\GOG Games\Parkan - Iron Strategy" `
|
|
||||||
--mission MISSIONS/Autodemo.00/data.tma `
|
|
||||||
--move-object 0 419.10318 717.433 0.25 --ticks 1
|
|
||||||
```
|
|
||||||
|
|
||||||
`--move-object` принимает original object ID, target X/Y и maximum step;
|
|
||||||
требует `--root` и `--mission`, допускается один раз за запуск и отвергает
|
|
||||||
non-finite/неположительный шаг ещё при разборе аргументов. Приложение печатает
|
|
||||||
`reached`, затем normal headless tick/hash. Проверка на GOG 18 июля 2026 года
|
|
||||||
загрузила 8 objects, 343 areals и 3 174 terrain surfaces без graph failures;
|
|
||||||
команда для объекта `0` вернула `reached=false`, что подтверждает именно
|
|
||||||
ограниченный шаг, а не телепортацию к цели.
|
|
||||||
|
|
||||||
### Различия Control в Части 2
|
### Различия Control в Части 2
|
||||||
|
|
||||||
`Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals
|
`Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals
|
||||||
|
|||||||
@@ -219,13 +219,6 @@ graph.
|
|||||||
текстурированную статическую модель из исходного архива. Красивый viewer всё ещё
|
текстурированную статическую модель из исходного архива. Красивый viewer всё ещё
|
||||||
означает только asset compatibility, а не готовую игру.
|
означает только asset compatibility, а не готовую игру.
|
||||||
|
|
||||||
Текущее состояние репозитория нужно формулировать строже. `apps/fparkan-viewer`
|
|
||||||
сейчас является inspection CLI и synthetic command producer, а не live Vulkan
|
|
||||||
asset viewer. Реальный Vulkan в репозитории сегодня доказан только через
|
|
||||||
Stage 0 smoke triangle path; Stage 3 GPU vertical slice для оригинального
|
|
||||||
`MSH` + `Texm` + `WEAR/MAT0` + terrain остаётся блокером. Для различения
|
|
||||||
smoke, planning и live GPU путей используйте [таблицу правды renderer paths](../rendering/renderer_truth_table.md).
|
|
||||||
|
|
||||||
### Этап 4. Анимация и эффекты
|
### Этап 4. Анимация и эффекты
|
||||||
|
|
||||||
- реализовать MSH type 8/type 19 sampling и hierarchy;
|
- реализовать MSH type 8/type 19 sampling и hierarchy;
|
||||||
@@ -239,13 +232,6 @@ smoke, planning и live GPU путей используйте [таблицу п
|
|||||||
923/1 065 FXID создаются без parser errors; перезапуск одинакового effect seed
|
923/1 065 FXID создаются без parser errors; перезапуск одинакового effect seed
|
||||||
даёт идентичный список emitted primitives.
|
даёт идентичный список emitted primitives.
|
||||||
|
|
||||||
Текущее состояние репозитория опять же уже, чем целевой этап. В коде есть
|
|
||||||
portable reference sampler и детерминированный FX reference stub, но нет
|
|
||||||
runtime-captured parity для lifecycle/opcode semantics и нет Stage 4 rendered
|
|
||||||
acceptance поверх live Vulkan asset renderer. Поэтому rendered Stage 4 следует
|
|
||||||
считать заблокированным входным gate Stage 3, а parallel Stage 4 work вести
|
|
||||||
через captures, schemas и backend-neutral snapshots.
|
|
||||||
|
|
||||||
### Этап 5. Карта и мир
|
### Этап 5. Карта и мир
|
||||||
|
|
||||||
- реализовать `Land.msh` и corrected `TerrainFace28` layout;
|
- реализовать `Land.msh` и corrected `TerrainFace28` layout;
|
||||||
@@ -374,29 +360,6 @@ Demo mission total: 201 objects -> 501 prototypes -> 501 object MSH/WEAR.
|
|||||||
|
|
||||||
### Deterministic simulation replay
|
### Deterministic simulation replay
|
||||||
|
|
||||||
#### Mission transform state in the world contract
|
|
||||||
|
|
||||||
`fparkan-world` now carries a `TransformState` for every live object: the
|
|
||||||
three TMA position words, three orientation words and three scale words are
|
|
||||||
preserved as exact IEEE-754 bit patterns. This stores source identity before a
|
|
||||||
movement or physics controller interprets axes, units or Euler order.
|
|
||||||
`WorldSnapshot` publishes transforms in stable object-handle order and the
|
|
||||||
canonical SHA-256 state hash includes every transform word.
|
|
||||||
|
|
||||||
Mission loading assigns this state after construction and before registration.
|
|
||||||
A headless licensed GOG AutoDemo run on 2026-07-18 loaded eight objects, 343
|
|
||||||
areals and 3,174 terrain surfaces with zero graph failures, then completed two
|
|
||||||
deterministic ticks. This is the state foundation for a future route/movement
|
|
||||||
controller; it does not claim recovered velocity, collision or original
|
|
||||||
behavior-controller semantics.
|
|
||||||
|
|
||||||
The ordinary planning renderer now consumes this snapshot state before falling
|
|
||||||
back to a mission draft. Its current transform bridge applies the preserved
|
|
||||||
position and non-uniform scale only; raw orientation remains uninterpreted in
|
|
||||||
this backend-neutral path. A GOG AutoDemo planning run on 2026-07-18 completed
|
|
||||||
two ticks with eight objects, 66 draws and state hash
|
|
||||||
`a54855a4f47ffa380911228f295dd49a9a7b88d6ff271a23db48ba318b1fbbb4`.
|
|
||||||
|
|
||||||
Записывается начальная миссия, seed, input events, network messages и значения
|
Записывается начальная миссия, seed, input events, network messages и значения
|
||||||
внешних часов. На контрольных ticks сохраняется canonical state hash:
|
внешних часов. На контрольных ticks сохраняется canonical state hash:
|
||||||
|
|
||||||
|
|||||||
@@ -198,9 +198,9 @@ legacy ABI: n3d*, vrt*, bsp* compatibility entries
|
|||||||
Адреса указаны как RVA конкретной исследованной сборки:
|
Адреса указаны как RVA конкретной исследованной сборки:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
World3D stdCalculateGame 0x139A0
|
World3D stdCalculateGame 0x13910
|
||||||
World3D stdRenderGame 0x13BD0
|
World3D stdRenderGame 0x13B60
|
||||||
World3D sendEndOfRender 0x13D90
|
World3D sendEndOfRender 0x13D20
|
||||||
World3D UpdateManualEvents 0x10E10
|
World3D UpdateManualEvents 0x10E10
|
||||||
World3D ClearManualEvents 0x11180
|
World3D ClearManualEvents 0x11180
|
||||||
World3D DeleteGameObject 0x087B0
|
World3D DeleteGameObject 0x087B0
|
||||||
@@ -215,13 +215,6 @@ RVA используются только для сопоставления и
|
|||||||
implementation не должна встраивать их как постоянные игровые идентификаторы.
|
implementation не должна встраивать их как постоянные игровые идентификаторы.
|
||||||
Таблица внутренних RVA хранится по SHA-256 конкретного модуля.
|
Таблица внутренних RVA хранится по SHA-256 конкретного модуля.
|
||||||
|
|
||||||
Операционные evidence-артефакты, которые должны оставаться синхронизированными
|
|
||||||
с кодом и acceptance, вынесены в отдельные страницы:
|
|
||||||
|
|
||||||
- [Hashes и import/export summary оригинального движка](../evidence/original_engine_hashes.md)
|
|
||||||
- [Stage 4 capture schema](../evidence/stage4_capture_schema.md)
|
|
||||||
- [Renderer truth table](../rendering/renderer_truth_table.md)
|
|
||||||
|
|
||||||
Подтверждённые hashes неизменённых DLL:
|
Подтверждённые hashes неизменённых DLL:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -229,85 +222,6 @@ World3D.dll 17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e
|
|||||||
Ngi32.dll bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5
|
Ngi32.dll bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5
|
||||||
```
|
```
|
||||||
|
|
||||||
Повторный headless IDA/Hex-Rays review GOG `World3D.dll` с этим hash уточнил
|
|
||||||
RVA export-ов: `stdCalculateGame=0x139A0`, `stdRenderGame=0x13BD0`,
|
|
||||||
`sendEndOfRender=0x13D90`, `stdSetCurrentCamera=0x13E60` и
|
|
||||||
`stdGetCurrentCamera=0x13E80`. Предыдущая таблица была сдвинута и не должна
|
|
||||||
использоваться для hooks или differential capture.
|
|
||||||
|
|
||||||
`stdRenderGame(camera)` сначала вызывает экспорт Terrain
|
|
||||||
`stdSetCurrentCamera2(camera)`, затем сохраняет текущий camera pointer в
|
|
||||||
глобальном состоянии World3D. После этого виден запрос camera interface через
|
|
||||||
selector `6`, запрос связанного service через selector `264`, renderer/world
|
|
||||||
boundary slots и traversal render queues. В конце pointer очищается; dispatch
|
|
||||||
end-of-render callbacks вынесен также в отдельный `sendEndOfRender`.
|
|
||||||
Это доказывает порядок передачи camera и границы frame lifecycle, но не layout
|
|
||||||
camera object, не матрицы projection/view и не значения viewport selectors.
|
|
||||||
|
|
||||||
Отдельная проверка GOG `Terrain.dll` (`AF87D1B2E728A0BE73C52BE3B44CC196AB46DA7799F25A15D40F8C9B0B425EAD`,
|
|
||||||
499 712 bytes) уточняет receiver side. `stdSetCurrentCamera2` находится по
|
|
||||||
RVA `0x4FD40`: при инициализированном Terrain он требует у переданного объекта
|
|
||||||
interface selector `18` и вызывает slot `+12` результата. Он **не** записывает
|
|
||||||
переданный pointer в `stdGetCurrentCamera2`. Последний возвращает Terrain global,
|
|
||||||
который внутренний initialization path устанавливает результатом selector `8` на
|
|
||||||
Terrain object. Следовательно, selector `18`, slot `+12` и global selector `8`
|
|
||||||
должны оставаться именованными evidence boundary до dynamic capture; считать
|
|
||||||
`stdGetCurrentCamera2` getter-ом переданной camera было бы ошибкой.
|
|
||||||
|
|
||||||
Повторный headless-IDA review той же GOG базы уточняет рабочий static contract
|
|
||||||
`CBufferingCamera`. Метод Terrain RVA `0x4D740` копирует ровно 64 байта
|
|
||||||
(16 dword) в component offset `+0x10`. Frame-preparation метод RVA `0x4D9C0`
|
|
||||||
получает viewport rectangle через virtual slot `+0x3C`, выводит width, height,
|
|
||||||
centre и aspect, а projection читает через camera interface. Для projection type
|
|
||||||
`0` он использует float по component offset `+0x234` в `tan(angle / 2)`; для
|
|
||||||
type `2` получает five-float block через slot `+0x70`; иной non-zero type
|
|
||||||
завершается `Not supported projection type`. Это доказывает зависимость
|
|
||||||
projection от live camera/viewport, но не row/column convention, единицы угла,
|
|
||||||
near/far mapping, handedness или initial camera selection. Важно, что строки
|
|
||||||
`ICamera::SetTransformMatrix` (RVA `0x4F830`) и
|
|
||||||
`ICamera::GetTransformMatrix` (RVA `0x4F850`) ведут только в obsolete-call
|
|
||||||
stubs и не дают usable ABI.
|
|
||||||
|
|
||||||
Live elevated read-only probe впервые подтвердил relocation-aware runtime связь:
|
|
||||||
`Terrain.dll` был загружен по `0x02510000`, global `base + 0x7355C` содержал
|
|
||||||
non-null `0x0B37DF08`, а первый dword этого объекта был `0x025765B4` — ровно
|
|
||||||
relocated `off_100665B4` из `LoadCamera` construction path. Это доказывает
|
|
||||||
live camera object с outer vtable, но одновременно исправляет прежнее слишком
|
|
||||||
сильное сопоставление offsets: raw read `global + 0x10` не дал finite 4x4 matrix,
|
|
||||||
а `+0x234` был zero в данном sample. Receiver static procedures `0x4D740`/
|
|
||||||
`0x4D9C0` и exported global ещё не доказаны как один layout без interface
|
|
||||||
adjustment; их offsets остаются unassigned до recovery selector relationship.
|
|
||||||
|
|
||||||
Локальная IDA-база уточняет адреса этой связи: `stdGetCurrentCamera2` — это
|
|
||||||
шестибайтный getter по RVA `0x4FD80`, который возвращает `dword` по RVA
|
|
||||||
`0x7355C`. Единственный найденный **direct static** initializer этого global — функция Terrain
|
|
||||||
по RVA `0x4D4D0`: она запрашивает selector `8` у своего `this` и сохраняет
|
|
||||||
полученный interface pointer. Это доказывает адрес хранения и путь заполнения,
|
|
||||||
но не разрешает трактовать pointer как конкретный layout камеры либо читать его
|
|
||||||
как runtime evidence без доступа к процессу на том же уровне привилегий.
|
|
||||||
|
|
||||||
Elevated live sampling теперь доказывает, что direct static xref не исчерпывает
|
|
||||||
runtime writers: за 25 секунд autoplay global переключился между тремя heap
|
|
||||||
pointer, все с relocated outer vtables `0x025765B4`/`0x02576558`. У двух объектов
|
|
||||||
paired blocks `+0x2C/+0x3C/+0x4C` и `+0x6C/+0x7C/+0x8C` синхронно несли
|
|
||||||
world-like translation, например `(491.562, 761.551, 7.361)`; третий давал
|
|
||||||
normalized-looking `(0.098, 0.018, 0.856)` и не совпадал с paired block.
|
|
||||||
Наблюдение согласуется с автоматическими camera switches, но не маркирует mode;
|
|
||||||
оно запрещает называть `0x4D4D0` единственным runtime writer и требует recovery
|
|
||||||
indirect/unanalyzed write path.
|
|
||||||
|
|
||||||
Outer vtable `off_100665B4` теперь даёт exact transform adjustment для
|
|
||||||
world-like sample. Slot `+0x54` вызывает у subobject `outer + 4` slot `+0x20`
|
|
||||||
с selector `0`, затем копирует returned `+0x0C/+0x1C/+0x2C`. В live объекте
|
|
||||||
selector field `outer + 0x10` был `0xFFFFFFFF`; реализация selector `0` при
|
|
||||||
этом возвращает `outer + 0x20`. Значит observed triple
|
|
||||||
`outer + 0x2C/+0x3C/+0x4C` — доказанная translation часть active affine
|
|
||||||
transform, а не корреляция. Selector `2` возвращает paired block `outer + 0x60`;
|
|
||||||
outer slot `+0x70` применяет `atan2` к его axis values, что доказывает
|
|
||||||
orientation-angle path. Названия полей, angle order и handedness пока не
|
|
||||||
установлены, но raw affine transform и translation можно сохранять как
|
|
||||||
backend-neutral camera pose без догадок.
|
|
||||||
|
|
||||||
### Vtable и interface negotiation
|
### Vtable и interface negotiation
|
||||||
|
|
||||||
Вызовы вида `object->vfunc(offset)` доказывают порядок slots, даже когда имя
|
Вызовы вида `object->vfunc(offset)` доказывают порядок slots, даже когда имя
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
# Acceptance coverage manifest.
|
# Acceptance coverage manifest.
|
||||||
# Format: <acceptance-id>\t<covered|covered-planning|covered-stub|covered-gpu|partial|blocked|omitted>\t<evidence>
|
# Format: <acceptance-id>\t<covered|partial|blocked|omitted>\t<evidence>
|
||||||
# Scope note: Stage 0 native runtime acceptance is Windows-only.
|
|
||||||
# Scope note: Linux/macOS and hosted CI are outside the project scope.
|
|
||||||
L0-COPYRIGHT-001 covered cargo test -p fparkan-corpus --offline report_json_contains_metrics_and_hashes_not_paths_or_payloads
|
L0-COPYRIGHT-001 covered cargo test -p fparkan-corpus --offline report_json_contains_metrics_and_hashes_not_paths_or_payloads
|
||||||
L0-P1-001 covered cargo test -p fparkan-corpus --offline licensed_part1_manifest_profile_and_counts_match_baseline
|
L0-P1-001 covered cargo test -p fparkan-corpus --offline licensed_part1_manifest_profile_and_counts_match_baseline
|
||||||
L0-P1-002 covered cargo test -p fparkan-corpus --offline licensed_part1_has_no_casefold_relative_path_collisions
|
L0-P1-002 covered cargo test -p fparkan-corpus --offline licensed_part1_has_no_casefold_relative_path_collisions
|
||||||
@@ -17,7 +15,7 @@ S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rus
|
|||||||
S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift
|
S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift
|
||||||
S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job
|
S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job
|
||||||
S0-ARCH-010 covered cargo xtask acceptance audit emits measured commit_sha, git_dirty, runner_identity, rust_toolchain, and msrv metadata into the JSON artifact
|
S0-ARCH-010 covered cargo xtask acceptance audit emits measured commit_sha, git_dirty, runner_identity, rust_toolchain, and msrv metadata into the JSON artifact
|
||||||
S0-ARCH-011 covered cargo xtask native-smoke audit requires a passed Windows 300-frame report with measured resize/recreate, validation=0, clean git provenance, exact commit SHA shape, and a Windows target triple
|
S0-ARCH-011 covered .github/workflows/ci.yml runs cargo run -p fparkan-vulkan-smoke --locked -- --out target/fparkan/native-smoke/macos.json and cargo xtask native-smoke audit enforces a passed macOS 300-frame report with measured resize/recreate, validation=0, clean git provenance, exact commit SHA shape, and a platform-consistent target triple
|
||||||
S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context
|
S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context
|
||||||
S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable
|
S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable
|
||||||
S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent
|
S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent
|
||||||
@@ -32,7 +30,7 @@ S0-PLAT-001 covered cargo test -p fparkan-platform-winit --offline window_port_r
|
|||||||
S0-PLAT-002 covered cargo clippy -p fparkan-platform -p fparkan-platform-winit --all-targets --all-features --locked -- -D warnings
|
S0-PLAT-002 covered cargo clippy -p fparkan-platform -p fparkan-platform-winit --all-targets --all-features --locked -- -D warnings
|
||||||
S0-PLAT-003 covered cargo test -p fparkan-platform-winit --offline smoke_window_plan_requires_native_handles_and_nonzero_extent smoke_window_plan_rejects_zero_extent
|
S0-PLAT-003 covered cargo test -p fparkan-platform-winit --offline smoke_window_plan_requires_native_handles_and_nonzero_extent smoke_window_plan_rejects_zero_extent
|
||||||
S0-PLAT-004 covered cargo test -p fparkan-platform-winit --offline smoke_window_app_requires_created_native_window smoke_window_app_rejects_synthetic_window_without_native_handles
|
S0-PLAT-004 covered cargo test -p fparkan-platform-winit --offline smoke_window_app_requires_created_native_window smoke_window_app_rejects_synthetic_window_without_native_handles
|
||||||
S0-VK-001 covered-gpu Windows fparkan-vulkan-smoke native audit: real instance, Win32 surface, logical device, swapchain and indexed triangle presentation
|
S0-VK-001 covered cargo test -p fparkan-render-vulkan --offline planning_backend_tracks_render_request_and_simulated_present
|
||||||
S0-VK-002 covered cargo test -p fparkan-render-vulkan --offline device_scoring_is_deterministic_and_prefers_discrete_unified_queue
|
S0-VK-002 covered cargo test -p fparkan-render-vulkan --offline device_scoring_is_deterministic_and_prefers_discrete_unified_queue
|
||||||
S0-VK-003 covered cargo test -p fparkan-render-vulkan --offline portability_subset_is_reported_and_enabled_when_exposed
|
S0-VK-003 covered cargo test -p fparkan-render-vulkan --offline portability_subset_is_reported_and_enabled_when_exposed
|
||||||
S0-VK-004 covered cargo test -p fparkan-render-vulkan --offline rejects_missing_graphics_present_swapchain_and_format
|
S0-VK-004 covered cargo test -p fparkan-render-vulkan --offline rejects_missing_graphics_present_swapchain_and_format
|
||||||
@@ -52,8 +50,8 @@ S0-VK-017 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_j
|
|||||||
S0-VK-018 covered cargo test -p fparkan-render-vulkan --offline triangle_shader_manifest_hashes_are_stable checked_in_shader_manifest_matches_generated_report
|
S0-VK-018 covered cargo test -p fparkan-render-vulkan --offline triangle_shader_manifest_hashes_are_stable checked_in_shader_manifest_matches_generated_report
|
||||||
S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_report_json_is_stable
|
S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_report_json_is_stable
|
||||||
S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers
|
S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers
|
||||||
S0-VK-021 covered-gpu Windows fparkan-vulkan-smoke native audit: real submit/present loop, 300 frames and validation-clean teardown
|
S0-VK-021 covered cargo test -p fparkan-render-vulkan --offline frame_submission_plan_json_is_stable
|
||||||
S0-VK-022 covered-gpu Windows fparkan-vulkan-smoke native audit: resize drives swapchain recreation before clean shutdown
|
S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline planning_backend_tracks_render_request_and_simulated_present
|
||||||
S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_and_bootstrap_status
|
S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_and_bootstrap_status
|
||||||
S0-VK-024 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_loader_probe formats_vulkan_api_version
|
S0-VK-024 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_loader_probe formats_vulkan_api_version
|
||||||
S0-VK-025 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_instance_probe parses_instance_probe_as_loader_probe
|
S0-VK-025 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_instance_probe parses_instance_probe_as_loader_probe
|
||||||
@@ -66,7 +64,6 @@ S0-VK-031 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_wi
|
|||||||
S0-VK-032 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_swapchain
|
S0-VK-032 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_swapchain
|
||||||
S0-VK-033 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_logical_device
|
S0-VK-033 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_logical_device
|
||||||
S0-VK-034 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_required_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
|
S0-VK-034 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_required_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
|
||||||
S0-VK-035 covered cargo test -p fparkan-render-vulkan --offline final_validation_snapshot_is_captured_after_surface_device_swapchain_drop
|
|
||||||
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
|
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
|
||||||
S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
|
S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
|
||||||
L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
|
L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
|
||||||
@@ -105,8 +102,6 @@ S1-NRES-022 covered cargo test -p fparkan-nres --offline canonical_compact_round
|
|||||||
S1-NRES-023 covered cargo test -p fparkan-nres --offline editor_payload_update_rewrites_offsets_and_size
|
S1-NRES-023 covered cargo test -p fparkan-nres --offline editor_payload_update_rewrites_offsets_and_size
|
||||||
S1-NRES-024 covered cargo test -p fparkan-nres --offline editor_rename_rebuilds_search_mapping
|
S1-NRES-024 covered cargo test -p fparkan-nres --offline editor_rename_rebuilds_search_mapping
|
||||||
S1-NRES-025 covered cargo test -p fparkan-nres --offline editor_rejects_invalid_authoring_names
|
S1-NRES-025 covered cargo test -p fparkan-nres --offline editor_rejects_invalid_authoring_names
|
||||||
S1-NRES-026 covered cargo test -p fparkan-nres --offline strict_rejects_unsorted_lookup_table
|
|
||||||
S1-LIMIT-001 covered cargo test -p fparkan-nres --offline rejects_directory_size_before_allocation decode_rejects_entry_count_above_limit decode_rejects_input_bytes_above_limit decode_rejects_preserved_bytes_above_limit
|
|
||||||
S1-NRES-PROP-001 covered cargo test -p fparkan-nres --offline generated_archives_preserve_lossless_and_canonical_semantics
|
S1-NRES-PROP-001 covered cargo test -p fparkan-nres --offline generated_archives_preserve_lossless_and_canonical_semantics
|
||||||
S1-NRES-PROP-002 covered cargo test -p fparkan-nres --offline generated_editor_updates_roundtrip
|
S1-NRES-PROP-002 covered cargo test -p fparkan-nres --offline generated_editor_updates_roundtrip
|
||||||
S1-NRES-FUZZ-001 covered cargo test -p fparkan-nres --offline arbitrary_small_inputs_do_not_panic_or_overallocate
|
S1-NRES-FUZZ-001 covered cargo test -p fparkan-nres --offline arbitrary_small_inputs_do_not_panic_or_overallocate
|
||||||
@@ -119,8 +114,6 @@ S1-PATH-006 covered cargo test -p fparkan-path --offline rejects_absolute_drive_
|
|||||||
S1-PATH-007 covered cargo test -p fparkan-path --offline join_under_keeps_normalized_path_below_root
|
S1-PATH-007 covered cargo test -p fparkan-path --offline join_under_keeps_normalized_path_below_root
|
||||||
S1-PATH-008 covered cargo test -p fparkan-path --offline original_separators_and_raw_bytes_are_preserved
|
S1-PATH-008 covered cargo test -p fparkan-path --offline original_separators_and_raw_bytes_are_preserved
|
||||||
S1-PATH-009 covered cargo test -p fparkan-path --offline accepts_non_utf8_legacy_bytes
|
S1-PATH-009 covered cargo test -p fparkan-path --offline accepts_non_utf8_legacy_bytes
|
||||||
S1-PATH-010 covered cargo test -p fparkan-vfs --offline directory_vfs_resolves_non_utf8_host_entries_by_raw_bytes
|
|
||||||
S1-PATH-011 covered cargo test -p fparkan-path -p fparkan-vfs -p fparkan-resource --offline lossy_display_does_not_affect_identity_or_ordering overlay_vfs_keeps_lossy_equivalent_entries_distinct lossy_equivalent_archive_paths_remain_distinct
|
|
||||||
S1-VFS-005 covered cargo test -p fparkan-vfs --offline memory_vfs_list_prefix_is_boundary_safe
|
S1-VFS-005 covered cargo test -p fparkan-vfs --offline memory_vfs_list_prefix_is_boundary_safe
|
||||||
S1-RSLI-001 covered cargo test -p fparkan-rsli --offline parses_minimal_empty_library
|
S1-RSLI-001 covered cargo test -p fparkan-rsli --offline parses_minimal_empty_library
|
||||||
S1-RSLI-002 covered cargo test -p fparkan-rsli --offline rejects_invalid_header_fields
|
S1-RSLI-002 covered cargo test -p fparkan-rsli --offline rejects_invalid_header_fields
|
||||||
@@ -145,25 +138,16 @@ S1-RSLI-020 covered cargo test -p fparkan-rsli --offline rejects_registered_quir
|
|||||||
S1-RSLI-021 covered cargo test -p fparkan-rsli --offline named_deflate_eof_plus_one_quirk_accepts_only_approved_entry
|
S1-RSLI-021 covered cargo test -p fparkan-rsli --offline named_deflate_eof_plus_one_quirk_accepts_only_approved_entry
|
||||||
S1-RSLI-022 covered cargo test -p fparkan-rsli --offline unknown_header_bytes_are_lossless
|
S1-RSLI-022 covered cargo test -p fparkan-rsli --offline unknown_header_bytes_are_lossless
|
||||||
S1-RSLI-023 covered cargo test -p fparkan-rsli --offline no_op_lossless_roundtrip_preserves_bytes
|
S1-RSLI-023 covered cargo test -p fparkan-rsli --offline no_op_lossless_roundtrip_preserves_bytes
|
||||||
S1-RSLI-024 covered cargo test -p fparkan-rsli --offline strict_rejects_unsorted_presorted_mapping
|
|
||||||
S1-RSLI-025 covered cargo test -p fparkan-rsli --offline editor_can_mutate_names_and_payloads set_method_rejects_unknown_authoring_method
|
|
||||||
S1-LIMIT-002 covered cargo test -p fparkan-rsli --offline decode_rejects_entry_count_above_limit load_rejects_unpacked_size_above_limit_before_allocation
|
|
||||||
S1-RSLI-PROP-001 covered cargo test -p fparkan-rsli --offline generated_supported_methods_decode_expected_bytes
|
S1-RSLI-PROP-001 covered cargo test -p fparkan-rsli --offline generated_supported_methods_decode_expected_bytes
|
||||||
S1-RSLI-FUZZ-001 covered cargo test -p fparkan-rsli --offline arbitrary_small_inputs_do_not_panic
|
S1-RSLI-FUZZ-001 covered cargo test -p fparkan-rsli --offline arbitrary_small_inputs_do_not_panic
|
||||||
S1-RES-001 covered cargo test -p fparkan-resource --offline cached_repository_reads_synthetic_nres
|
S1-RES-001 covered cargo test -p fparkan-resource --offline cached_repository_reads_synthetic_nres
|
||||||
S1-RES-002 covered cargo test -p fparkan-resource --offline entry_handles_are_archive_qualified
|
S1-RES-002 covered cargo test -p fparkan-resource --offline entry_handles_are_archive_qualified
|
||||||
S1-RES-003 covered cargo test -p fparkan-resource --offline archive_cache_and_decoded_payload_cache_evict_independently
|
S1-RES-003 covered cargo test -p fparkan-resource --offline archive_cache_and_decoded_payload_cache_evict_independently
|
||||||
S1-RES-004 covered cargo test -p fparkan-resource --offline entry_read_error_carries_archive_path_and_entry_name
|
S1-RES-004 covered cargo test -p fparkan-resource --offline entry_read_error_carries_archive_path_and_entry_name
|
||||||
S1-RES-005 covered cargo test -p fparkan-resource --offline archive_cache_evicts_by_byte_budget
|
|
||||||
S1-RES-006 covered cargo test -p fparkan-resource --offline archive_cache_eviction_makes_old_handles_stale
|
|
||||||
S1-RES-007 covered cargo test -p fparkan-resource --offline lossy_equivalent_archive_paths_remain_distinct
|
|
||||||
S1-DIAG-001 covered cargo test -p fparkan-inspection --offline archive_diagnostic_preserves_source_path_phase_and_span model_archive_diagnostic_preserves_archive_entry_context
|
|
||||||
S1-VFS-001 covered cargo test -p fparkan-vfs --offline memory_vfs_uses_exact_lookup
|
S1-VFS-001 covered cargo test -p fparkan-vfs --offline memory_vfs_uses_exact_lookup
|
||||||
S1-VFS-002 covered cargo test -p fparkan-vfs --offline overlay_vfs_uses_first_matching_layer
|
S1-VFS-002 covered cargo test -p fparkan-vfs --offline overlay_vfs_uses_first_matching_layer
|
||||||
S1-VFS-003 covered cargo test -p fparkan-vfs --offline directory_vfs_resolves_ascii_casefolded_segments
|
S1-VFS-003 covered cargo test -p fparkan-vfs --offline directory_vfs_resolves_ascii_casefolded_segments
|
||||||
S1-VFS-004 covered cargo test -p fparkan-vfs --offline casefold_selector_reports_ambiguous_segments
|
S1-VFS-004 covered cargo test -p fparkan-vfs --offline casefold_selector_reports_ambiguous_segments
|
||||||
S1-LICENSED-001 covered local testdata corpora via FPARKAN_CORPUS_PART1_ROOT and FPARKAN_CORPUS_PART2_ROOT; cargo test -p fparkan-nres -p fparkan-resource -p fparkan-rsli --offline -- --ignored
|
|
||||||
S1-LICENSED-002 covered local testdata corpora via FPARKAN_CORPUS_PART1_ROOT and FPARKAN_CORPUS_PART2_ROOT; cargo test -p fparkan-nres -p fparkan-resource -p fparkan-rsli --offline -- --ignored
|
|
||||||
L2-P1-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts
|
L2-P1-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts
|
||||||
L2-P2-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts
|
L2-P2-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts
|
||||||
L2-P1-REG-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_registry_payloads_are_record_aligned
|
L2-P1-REG-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_registry_payloads_are_record_aligned
|
||||||
@@ -207,20 +191,6 @@ S2-GRAPH-003 covered cargo test -p fparkan-assets --offline repository_plan_dedu
|
|||||||
S2-GRAPH-004 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures
|
S2-GRAPH-004 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures
|
||||||
S2-GRAPH-005 covered cargo test -p fparkan-cli --offline prototype_graph_json_has_canonical_field_order
|
S2-GRAPH-005 covered cargo test -p fparkan-cli --offline prototype_graph_json_has_canonical_field_order
|
||||||
S2-GRAPH-006 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures
|
S2-GRAPH-006 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures
|
||||||
S2-GRAPH-EDGE-001 covered cargo test -p fparkan-assets --offline graph_materializes_visual_dependency_nodes_and_edges
|
|
||||||
S2-GRAPH-PROV-001 covered cargo test -p fparkan-assets --offline graph_visual_dependency_edges_preserve_root_and_parent_provenance
|
|
||||||
S2-GRAPH-IDEMP-001 covered cargo test -p fparkan-assets --offline graph_visual_dependency_expansion_is_idempotent
|
|
||||||
S2-UNIT-EMPTY-001 covered cargo test -p fparkan-prototype --offline empty_unit_dat_resolves_as_zero_component_root
|
|
||||||
S2-ASSET-MODEL-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
|
|
||||||
S2-ASSET-WEAR-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
|
|
||||||
S2-ASSET-MATERIAL-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
|
|
||||||
S2-ASSET-TEXM-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
|
|
||||||
S2-ASSET-ID-001 covered cargo test -p fparkan-assets --offline forced_model_id_collision_is_rejected forced_wear_id_collision_is_rejected forced_material_id_collision_is_rejected forced_texture_id_collision_is_rejected
|
|
||||||
S2-ASSET-LIMIT-001 covered cargo test -p fparkan-assets --offline profiled_asset_preparation_reports_unique_asset_counts asset_preparation_limits_reject_texture_pixel_budget
|
|
||||||
S2-RUNTIME-DRAFT-001 covered local testdata/IS and testdata/IS2; cargo test -p fparkan-runtime --offline -- --ignored selected_is_and_is2_missions_preserve_runtime_object_drafts
|
|
||||||
S2-CLI-JSON-001 covered cargo test -p fparkan-cli --offline prototype_graph_json_has_canonical_field_order mission_graph_json_has_canonical_field_order
|
|
||||||
S2-LICENSED-MISSION-P1-001 covered local testdata/IS; cargo test -p fparkan-runtime --offline -- --ignored selected_is_and_is2_missions_preserve_runtime_object_drafts
|
|
||||||
S2-LICENSED-MISSION-P2-001 covered local testdata/IS2; cargo test -p fparkan-runtime --offline -- --ignored selected_is_and_is2_missions_preserve_runtime_object_drafts
|
|
||||||
S2-PROP-001 covered cargo test -p fparkan-prototype --offline generated_acyclic_prototype_graph_resolves_deterministically
|
S2-PROP-001 covered cargo test -p fparkan-prototype --offline generated_acyclic_prototype_graph_resolves_deterministically
|
||||||
S2-FUZZ-001 covered cargo test -p fparkan-prototype --offline arbitrary_unit_and_registry_bytes_are_bounded_and_panic_free
|
S2-FUZZ-001 covered cargo test -p fparkan-prototype --offline arbitrary_unit_and_registry_bytes_are_bounded_and_panic_free
|
||||||
L3-P1-MSH-001 covered cargo test -p fparkan-msh --offline licensed_corpus_msh_assets_validate
|
L3-P1-MSH-001 covered cargo test -p fparkan-msh --offline licensed_corpus_msh_assets_validate
|
||||||
@@ -233,8 +203,8 @@ L3-P1-WEAR-001 covered cargo test -p fparkan-material --offline licensed_corpus_
|
|||||||
L3-P2-WEAR-001 covered cargo test -p fparkan-material --offline licensed_corpus_mat0_and_wear_parse
|
L3-P2-WEAR-001 covered cargo test -p fparkan-material --offline licensed_corpus_mat0_and_wear_parse
|
||||||
L3-P1-ASSET-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
|
L3-P1-ASSET-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
|
||||||
L3-P2-ASSET-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
|
L3-P2-ASSET-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
|
||||||
S2-PLANNING-CAPTURE-P1-001 covered-planning cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
|
L3-P1-CAPTURE-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
|
||||||
S2-PLANNING-CAPTURE-P2-001 covered-planning cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
|
L3-P2-CAPTURE-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
|
||||||
S3-WEAR-001 covered cargo test -p fparkan-material --offline wear_preserves_legacy_id_but_selects_by_index
|
S3-WEAR-001 covered cargo test -p fparkan-material --offline wear_preserves_legacy_id_but_selects_by_index
|
||||||
S3-WEAR-002 covered cargo test -p fparkan-material --offline wear_requires_declared_rows
|
S3-WEAR-002 covered cargo test -p fparkan-material --offline wear_requires_declared_rows
|
||||||
S3-WEAR-003 covered cargo test -p fparkan-material --offline wear_preserves_legacy_id_but_selects_by_index
|
S3-WEAR-003 covered cargo test -p fparkan-material --offline wear_preserves_legacy_id_but_selects_by_index
|
||||||
@@ -285,25 +255,17 @@ S3-MAT-RESOLVE-002 covered cargo test -p fparkan-material --offline resolve_mate
|
|||||||
S3-MAT-RESOLVE-003 covered cargo test -p fparkan-material --offline resolve_material_uses_first_entry_only_after_missing_default
|
S3-MAT-RESOLVE-003 covered cargo test -p fparkan-material --offline resolve_material_uses_first_entry_only_after_missing_default
|
||||||
S3-MAT-RESOLVE-004 covered cargo test -p fparkan-material --offline resolve_material_empty_texture_means_untextured
|
S3-MAT-RESOLVE-004 covered cargo test -p fparkan-material --offline resolve_material_empty_texture_means_untextured
|
||||||
S3-MAT-RESOLVE-005 covered cargo test -p fparkan-material --offline resolve_material_without_lightmap_keeps_lightmap_absent
|
S3-MAT-RESOLVE-005 covered cargo test -p fparkan-material --offline resolve_material_without_lightmap_keeps_lightmap_absent
|
||||||
S2-PLANNING-RENDER-001 covered cargo test -p fparkan-render --offline one_snapshot_draw_produces_one_draw_command
|
S3-RENDER-001 covered cargo test -p fparkan-render --offline one_snapshot_draw_produces_one_draw_command
|
||||||
S2-PLANNING-RENDER-002 covered cargo test -p fparkan-render --offline material_index_maps_through_resolved_material_slots
|
S3-RENDER-002 covered cargo test -p fparkan-render --offline material_index_maps_through_resolved_material_slots
|
||||||
S2-PLANNING-RENDER-003 covered cargo test -p fparkan-render --offline node_transform_is_retained
|
S3-RENDER-003 covered cargo test -p fparkan-render --offline node_transform_is_retained
|
||||||
S2-PLANNING-RENDER-004 covered cargo test -p fparkan-render --offline command_order_uses_phase_then_stable_key
|
S3-RENDER-004 covered cargo test -p fparkan-render --offline command_order_uses_phase_then_stable_key
|
||||||
S2-PLANNING-RENDER-005 covered cargo test -p fparkan-render --offline command_capture_independent_of_snapshot_construction_order
|
S3-RENDER-005 covered cargo test -p fparkan-render --offline command_capture_independent_of_snapshot_construction_order
|
||||||
S2-PLANNING-RENDER-006 covered cargo test -p fparkan-render --offline invalid_range_returns_contextual_error
|
S3-RENDER-006 covered cargo test -p fparkan-render --offline invalid_range_returns_contextual_error
|
||||||
S2-PLANNING-RENDER-007 covered cargo test -p fparkan-render --offline capture_is_stable
|
S3-RENDER-007 covered cargo test -p fparkan-render --offline capture_is_stable
|
||||||
S2-PLANNING-RENDER-008 covered-planning cargo test -p fparkan-render --offline recording_backend_stores_captures
|
S3-RENDER-008 covered cargo test -p fparkan-render --offline recording_backend_stores_captures
|
||||||
S2-PLANNING-RENDER-009 covered cargo xtask policy
|
S3-RENDER-009 covered cargo xtask policy
|
||||||
S3-VK-MESH-UPLOAD-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-msh.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH; original MSH reports 128 vertices and 252 indices
|
S3-GL-001 omitted permanent macOS Desktop GL 3.3 adapter is not implemented; historical CGL probe is retained as external evidence only
|
||||||
S3-VK-TEXM-UPLOAD-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-texm-upload.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_ROOT> --texture-archive Textures.lib --texture-name DEFAULT.0; original TEXM 16x16 decoded RGBA8, staged into device-local image and transitioned to SHADER_READ_ONLY_OPTIMAL
|
S3-GL-002 omitted outside the current macOS-focused goal scope; GLES2 remains documented for portable/non-macOS targets
|
||||||
S3-VK-DESCRIPTOR-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-texm-sampled.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_ROOT> --texture-archive Textures.lib --texture-name DEFAULT.0; original TEXM is bound as set=0,binding=0 combined image sampler and sampled in fragment shader; validation_warning_count=0 and validation_error_count=0
|
|
||||||
S3-VK-PIPELINE-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-alpha-test-smoke.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_ROOT> --texture-archive Textures.lib --texture-name DEFAULT.0; PipelineKey selects a deduplicated live Vulkan pipeline per static draw range; capability-selected depth attachment supports TestWrite/TestReadOnly; fragment alpha test receives per-range dynamic cutoff through push constants; baseline key is live-proven, while Batch20/MAT0 state/reference mapping remains an evidence gap
|
|
||||||
S3-VK-DRAW-MODEL-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-fr-l-mtp-per-batch-materials.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive fortif.rlb --model-name FR_L_MTP.msh --wear-root <GOG_ROOT> --wear-archive fortif.rlb --wear-name FR_L_MTP.WEA; live Win32 Vulkan issues 237 source-preserving indexed batch draws and binds the deduplicated WEAR→MAT0 material descriptor selected by each Batch20.material_index (this corpus model has selector 0 only)
|
|
||||||
S3-VK-DRAW-TERRAIN-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-land-11-smoke.json --frames 300 --resize-frame 120 --timeout-seconds 90 --terrain-path <GOG_ROOT>/DATA/MAPS/11/Land.msh --texture-root <GOG_ROOT> --texture-archive Textures.lib --texture-name DEFAULT.0; validated Land.msh TerrainFace28 order becomes one live indexed Vulkan terrain draw (6458 vertices, 24672 indices); terrain material/slot/camera semantics remain an evidence gap
|
|
||||||
S3-VK-PIXEL-CAPTURE-001 blocked awaits Stage 3 fixed-camera pixel capture approval flow
|
|
||||||
S3-VK-VALIDATION-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-msh.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH; validation_warning_count=0 and validation_error_count=0
|
|
||||||
S3-GL-001 omitted legacy OpenGL adapters are outside the Windows-only Vulkan renderer scope
|
|
||||||
S3-GL-002 omitted GLES2 and portable-device targets are outside the Windows-only Vulkan renderer scope
|
|
||||||
S3-GL-003 blocked legacy fparkan-render-gl adapter removed while Vulkan renderer path is being brought in as the stage-3 backend
|
S3-GL-003 blocked legacy fparkan-render-gl adapter removed while Vulkan renderer path is being brought in as the stage-3 backend
|
||||||
S3-VIEWER-001 covered cargo test -p fparkan-viewer --offline model_fixture_uses_viewer_service_and_render_commands
|
S3-VIEWER-001 covered cargo test -p fparkan-viewer --offline model_fixture_uses_viewer_service_and_render_commands
|
||||||
S4-ANIM-001 covered cargo test -p fparkan-animation --offline anim_key24_decodes_signed_quaternion
|
S4-ANIM-001 covered cargo test -p fparkan-animation --offline anim_key24_decodes_signed_quaternion
|
||||||
@@ -314,7 +276,7 @@ S4-ANIM-005 covered cargo test -p fparkan-animation --offline exact_key_time_ret
|
|||||||
S4-ANIM-006 covered cargo test -p fparkan-animation --offline pose_track_blends_translation_and_rotation
|
S4-ANIM-006 covered cargo test -p fparkan-animation --offline pose_track_blends_translation_and_rotation
|
||||||
S4-ANIM-007 covered cargo test -p fparkan-animation --offline quaternion_shortest_path_sign_flip_is_stable
|
S4-ANIM-007 covered cargo test -p fparkan-animation --offline quaternion_shortest_path_sign_flip_is_stable
|
||||||
S4-ANIM-008 covered cargo test -p fparkan-animation --offline zero_or_degenerate_key_interval_is_rejected
|
S4-ANIM-008 covered cargo test -p fparkan-animation --offline zero_or_degenerate_key_interval_is_rejected
|
||||||
S4-ANIM-009 omitted current sampler is a portable reference path; runtime-captured x87 parity vectors are not implemented
|
S4-ANIM-009 omitted current sampler is a portable reference path; runtime-captured x87 parity vectors are not implemented in the macOS-focused scope
|
||||||
S4-ANIM-010 omitted current sampler accepts the profile marker but does not implement an independent x87 compatibility path
|
S4-ANIM-010 omitted current sampler accepts the profile marker but does not implement an independent x87 compatibility path
|
||||||
S4-ANIM-011 covered cargo test -p fparkan-animation --offline blend_optional_pose_uses_valid_side
|
S4-ANIM-011 covered cargo test -p fparkan-animation --offline blend_optional_pose_uses_valid_side
|
||||||
S4-ANIM-012 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles
|
S4-ANIM-012 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles
|
||||||
@@ -441,7 +403,7 @@ L5-P1-MISSION-002 covered cargo test -p fparkan-runtime --offline licensed_corpo
|
|||||||
L5-P2-MISSION-002 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
|
L5-P2-MISSION-002 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
|
||||||
L5-P1-HEADLESS-001 covered cargo test -p fparkan-runtime --offline selected_is_and_is2_missions_execute_10000_deterministic_ticks
|
L5-P1-HEADLESS-001 covered cargo test -p fparkan-runtime --offline selected_is_and_is2_missions_execute_10000_deterministic_ticks
|
||||||
L5-P2-HEADLESS-001 covered cargo test -p fparkan-runtime --offline selected_is_and_is2_missions_execute_10000_deterministic_ticks
|
L5-P2-HEADLESS-001 covered cargo test -p fparkan-runtime --offline selected_is_and_is2_missions_execute_10000_deterministic_ticks
|
||||||
L5-P1-RENDER-001 covered-planning cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
|
L5-P1-RENDER-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
|
||||||
L5-P2-RENDER-001 covered-planning cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
|
L5-P2-RENDER-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
|
||||||
L3-DEVICE-001 omitted RG40XX and portable-device profiles are outside the Windows-only runtime scope
|
L3-DEVICE-001 omitted outside the current macOS-focused goal scope; RG40XX-capable device/profile evidence remains documented for the portable target scope
|
||||||
L5-RG40-001 omitted RG40XX 640x480 on-device mission smoke/performance/memory evidence is outside the Windows-only runtime scope
|
L5-RG40-001 omitted outside the current macOS-focused goal scope; RG40XX 640x480 on-device mission smoke/performance/memory evidence remains documented for the portable target scope
|
||||||
|
|||||||
|
@@ -64,7 +64,6 @@
|
|||||||
`S0-VK-032`
|
`S0-VK-032`
|
||||||
`S0-VK-033`
|
`S0-VK-033`
|
||||||
`S0-VK-034`
|
`S0-VK-034`
|
||||||
`S0-VK-035`
|
|
||||||
`S0-LIMIT-001`
|
`S0-LIMIT-001`
|
||||||
`S0-LIMIT-002`
|
`S0-LIMIT-002`
|
||||||
`L1-P1-NRES-001`
|
`L1-P1-NRES-001`
|
||||||
@@ -192,20 +191,6 @@
|
|||||||
`S2-GRAPH-004`
|
`S2-GRAPH-004`
|
||||||
`S2-GRAPH-005`
|
`S2-GRAPH-005`
|
||||||
`S2-GRAPH-006`
|
`S2-GRAPH-006`
|
||||||
`S2-GRAPH-EDGE-001`
|
|
||||||
`S2-GRAPH-PROV-001`
|
|
||||||
`S2-GRAPH-IDEMP-001`
|
|
||||||
`S2-UNIT-EMPTY-001`
|
|
||||||
`S2-ASSET-MODEL-001`
|
|
||||||
`S2-ASSET-WEAR-001`
|
|
||||||
`S2-ASSET-MATERIAL-001`
|
|
||||||
`S2-ASSET-TEXM-001`
|
|
||||||
`S2-ASSET-ID-001`
|
|
||||||
`S2-ASSET-LIMIT-001`
|
|
||||||
`S2-RUNTIME-DRAFT-001`
|
|
||||||
`S2-CLI-JSON-001`
|
|
||||||
`S2-LICENSED-MISSION-P1-001`
|
|
||||||
`S2-LICENSED-MISSION-P2-001`
|
|
||||||
`S2-PROP-001`
|
`S2-PROP-001`
|
||||||
`S2-FUZZ-001`
|
`S2-FUZZ-001`
|
||||||
`L3-P1-MSH-001`
|
`L3-P1-MSH-001`
|
||||||
@@ -218,8 +203,8 @@
|
|||||||
`L3-P2-WEAR-001`
|
`L3-P2-WEAR-001`
|
||||||
`L3-P1-ASSET-001`
|
`L3-P1-ASSET-001`
|
||||||
`L3-P2-ASSET-001`
|
`L3-P2-ASSET-001`
|
||||||
`S2-PLANNING-CAPTURE-P1-001`
|
`L3-P1-CAPTURE-001`
|
||||||
`S2-PLANNING-CAPTURE-P2-001`
|
`L3-P2-CAPTURE-001`
|
||||||
`S3-WEAR-001`
|
`S3-WEAR-001`
|
||||||
`S3-WEAR-002`
|
`S3-WEAR-002`
|
||||||
`S3-WEAR-003`
|
`S3-WEAR-003`
|
||||||
@@ -270,23 +255,15 @@
|
|||||||
`S3-MAT-RESOLVE-003`
|
`S3-MAT-RESOLVE-003`
|
||||||
`S3-MAT-RESOLVE-004`
|
`S3-MAT-RESOLVE-004`
|
||||||
`S3-MAT-RESOLVE-005`
|
`S3-MAT-RESOLVE-005`
|
||||||
`S2-PLANNING-RENDER-001`
|
`S3-RENDER-001`
|
||||||
`S2-PLANNING-RENDER-002`
|
`S3-RENDER-002`
|
||||||
`S2-PLANNING-RENDER-003`
|
`S3-RENDER-003`
|
||||||
`S2-PLANNING-RENDER-004`
|
`S3-RENDER-004`
|
||||||
`S2-PLANNING-RENDER-005`
|
`S3-RENDER-005`
|
||||||
`S2-PLANNING-RENDER-006`
|
`S3-RENDER-006`
|
||||||
`S2-PLANNING-RENDER-007`
|
`S3-RENDER-007`
|
||||||
`S2-PLANNING-RENDER-008`
|
`S3-RENDER-008`
|
||||||
`S2-PLANNING-RENDER-009`
|
`S3-RENDER-009`
|
||||||
`S3-VK-MESH-UPLOAD-001`
|
|
||||||
`S3-VK-TEXM-UPLOAD-001`
|
|
||||||
`S3-VK-DESCRIPTOR-001`
|
|
||||||
`S3-VK-PIPELINE-001`
|
|
||||||
`S3-VK-DRAW-MODEL-001`
|
|
||||||
`S3-VK-DRAW-TERRAIN-001`
|
|
||||||
`S3-VK-PIXEL-CAPTURE-001`
|
|
||||||
`S3-VK-VALIDATION-001`
|
|
||||||
`S3-GL-001`
|
`S3-GL-001`
|
||||||
`S3-GL-002`
|
`S3-GL-002`
|
||||||
`S3-GL-003`
|
`S3-GL-003`
|
||||||
|
|||||||
@@ -64,6 +64,5 @@
|
|||||||
`S0-VK-032`
|
`S0-VK-032`
|
||||||
`S0-VK-033`
|
`S0-VK-033`
|
||||||
`S0-VK-034`
|
`S0-VK-034`
|
||||||
`S0-VK-035`
|
|
||||||
`S0-LIMIT-001`
|
`S0-LIMIT-001`
|
||||||
`S0-LIMIT-002`
|
`S0-LIMIT-002`
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ schema = 1
|
|||||||
"4" = [
|
"4" = [
|
||||||
"fparkan-animation",
|
"fparkan-animation",
|
||||||
"fparkan-fx",
|
"fparkan-fx",
|
||||||
"fparkan-script",
|
|
||||||
]
|
]
|
||||||
"5" = [
|
"5" = [
|
||||||
"fparkan-game",
|
"fparkan-game",
|
||||||
|
|||||||
@@ -73,9 +73,6 @@ nav:
|
|||||||
- WEAR и MAT0: reference/materials.md
|
- WEAR и MAT0: reference/materials.md
|
||||||
- Texm: reference/texm.md
|
- Texm: reference/texm.md
|
||||||
- Render frame: reference/render-frame.md
|
- Render frame: reference/render-frame.md
|
||||||
- Renderer Truth Table: rendering/renderer_truth_table.md
|
|
||||||
- Original Engine Hashes: evidence/original_engine_hashes.md
|
|
||||||
- Stage 4 Capture Schema: evidence/stage4_capture_schema.md
|
|
||||||
- Приложения:
|
- Приложения:
|
||||||
- Глоссарий: appendices/glossary.md
|
- Глоссарий: appendices/glossary.md
|
||||||
- Границы знания: appendices/knowledge-boundaries.md
|
- Границы знания: appendices/knowledge-boundaries.md
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,9 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.97.1"
|
channel = "1.87.0"
|
||||||
components = ["clippy", "rustfmt", "rust-docs"]
|
components = ["clippy", "rustfmt", "rust-docs"]
|
||||||
targets = [
|
targets = [
|
||||||
|
"x86_64-unknown-linux-gnu",
|
||||||
"x86_64-pc-windows-msvc",
|
"x86_64-pc-windows-msvc",
|
||||||
|
"aarch64-apple-darwin",
|
||||||
|
"x86_64-apple-darwin",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
[CmdletBinding()]
|
|
||||||
param([Parameter(Mandatory = $true)][int]$ProcessId)
|
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
# Read-only observer for ai.dll's `GetSuperAI` singleton array. It never sends
|
|
||||||
# input, writes memory, suspends, injects, or calls into the original process.
|
|
||||||
Add-Type -TypeDefinition @'
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
public static class FparkanAiInitCapture {
|
|
||||||
public const uint TH32CS_SNAPMODULE = 0x00000008;
|
|
||||||
public const uint TH32CS_SNAPMODULE32 = 0x00000010;
|
|
||||||
public const uint PROCESS_QUERY_INFORMATION = 0x00000400;
|
|
||||||
public const uint PROCESS_VM_READ = 0x00000010;
|
|
||||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
||||||
public struct MODULEENTRY32 {
|
|
||||||
public uint dwSize, th32ModuleID, th32ProcessID, GlblcntUsage, ProccntUsage;
|
|
||||||
public IntPtr modBaseAddr;
|
|
||||||
public uint modBaseSize;
|
|
||||||
public IntPtr hModule;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szModule;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExePath;
|
|
||||||
}
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
public static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId);
|
|
||||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
||||||
public static extern bool Module32First(IntPtr snapshot, ref MODULEENTRY32 entry);
|
|
||||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
||||||
public static extern bool Module32Next(IntPtr snapshot, ref MODULEENTRY32 entry);
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
public static extern IntPtr OpenProcess(uint access, bool inheritHandle, uint processId);
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
public static extern bool ReadProcessMemory(IntPtr process, IntPtr address,
|
|
||||||
[Out] byte[] buffer, IntPtr size, out IntPtr bytesRead);
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
public static extern bool CloseHandle(IntPtr handle);
|
|
||||||
}
|
|
||||||
'@
|
|
||||||
|
|
||||||
function Read-Bytes([IntPtr]$Process, [Int64]$Address, [int]$Length) {
|
|
||||||
$bytes = [byte[]]::new($Length); $read = [IntPtr]::Zero
|
|
||||||
if (-not [FparkanAiInitCapture]::ReadProcessMemory($Process, [IntPtr]$Address,
|
|
||||||
$bytes, [IntPtr]$Length, [ref]$read) -or $read.ToInt64() -ne $Length) {
|
|
||||||
throw "ReadProcessMemory failed at 0x$('{0:X8}' -f $Address)"
|
|
||||||
}
|
|
||||||
$bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshot = [FparkanAiInitCapture]::CreateToolhelp32Snapshot(
|
|
||||||
[FparkanAiInitCapture]::TH32CS_SNAPMODULE -bor [FparkanAiInitCapture]::TH32CS_SNAPMODULE32,
|
|
||||||
[uint32]$ProcessId)
|
|
||||||
$aiBase = $null
|
|
||||||
$modules = @()
|
|
||||||
try {
|
|
||||||
$entry = [FparkanAiInitCapture+MODULEENTRY32]::new()
|
|
||||||
$entry.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanAiInitCapture+MODULEENTRY32])
|
|
||||||
if ([FparkanAiInitCapture]::Module32First($snapshot, [ref]$entry)) {
|
|
||||||
do {
|
|
||||||
$modules += [ordered]@{
|
|
||||||
name = $entry.szModule
|
|
||||||
base = $entry.modBaseAddr.ToInt64()
|
|
||||||
size = [int64]$entry.modBaseSize
|
|
||||||
}
|
|
||||||
if ($entry.szModule -ieq 'ai.dll') { $aiBase = $entry.modBaseAddr.ToInt64() }
|
|
||||||
$entry = [FparkanAiInitCapture+MODULEENTRY32]::new()
|
|
||||||
$entry.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanAiInitCapture+MODULEENTRY32])
|
|
||||||
} while ([FparkanAiInitCapture]::Module32Next($snapshot, [ref]$entry))
|
|
||||||
}
|
|
||||||
} finally { [void][FparkanAiInitCapture]::CloseHandle($snapshot) }
|
|
||||||
if ($null -eq $aiBase) { throw "ai.dll is not loaded by process $ProcessId" }
|
|
||||||
|
|
||||||
$process = [FparkanAiInitCapture]::OpenProcess(
|
|
||||||
[FparkanAiInitCapture]::PROCESS_QUERY_INFORMATION -bor [FparkanAiInitCapture]::PROCESS_VM_READ,
|
|
||||||
$false, [uint32]$ProcessId)
|
|
||||||
if ($process -eq [IntPtr]::Zero) { throw "OpenProcess read-only failed" }
|
|
||||||
try {
|
|
||||||
# CreateSuperAI stores its tenth host-callback argument at DAT_100555e4.
|
|
||||||
$callbackBytes = Read-Bytes $process ($aiBase + 0x555e4) 4
|
|
||||||
$callback = [BitConverter]::ToUInt32($callbackBytes, 0)
|
|
||||||
$callbackModule = $modules | Where-Object {
|
|
||||||
$callback -ge $_.base -and [int64]$callback -lt ($_.base + $_.size)
|
|
||||||
} | Select-Object -First 1
|
|
||||||
# GetSuperAI(i) returns (&DAT_10055398)[i], with ai.dll preferred base 0x10000000.
|
|
||||||
$entries = Read-Bytes $process ($aiBase + 0x55398) (64 * 4)
|
|
||||||
$samples = for ($index = 0; $index -lt 64; $index++) {
|
|
||||||
$pointer = [BitConverter]::ToUInt32($entries, $index * 4)
|
|
||||||
if ($pointer -le 0x10000) { continue }
|
|
||||||
try {
|
|
||||||
$fields = Read-Bytes $process ([int64]$pointer) 0x88
|
|
||||||
[ordered]@{
|
|
||||||
index = $index
|
|
||||||
super_ai = ('0x{0:X8}' -f $pointer)
|
|
||||||
word_7c = [BitConverter]::ToUInt32($fields, 0x7c)
|
|
||||||
float_80 = [BitConverter]::ToSingle($fields, 0x80)
|
|
||||||
float_84 = [BitConverter]::ToSingle($fields, 0x84)
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
[ordered]@{
|
|
||||||
schema = 'fparkan-ai-init-v1'
|
|
||||||
process_id = $ProcessId
|
|
||||||
ai_module_base = ('0x{0:X8}' -f $aiBase)
|
|
||||||
handler30_callback = ('0x{0:X8}' -f $callback)
|
|
||||||
handler30_callback_module = if ($null -eq $callbackModule) { $null } else { $callbackModule.name }
|
|
||||||
handler30_callback_rva = if ($null -eq $callbackModule) { $null } else { ('0x{0:X}' -f ([int64]$callback - $callbackModule.base)) }
|
|
||||||
entries = @($samples)
|
|
||||||
} |
|
|
||||||
ConvertTo-Json -Depth 4 -Compress
|
|
||||||
} finally { [void][FparkanAiInitCapture]::CloseHandle($process) }
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[int]$ProcessId,
|
|
||||||
[ValidateRange(1, 16384)]
|
|
||||||
[int]$ViewportWidth = 1024,
|
|
||||||
[ValidateRange(1, 16384)]
|
|
||||||
[int]$ViewportHeight = 768,
|
|
||||||
[ValidateRange(0.001, 1000.0)]
|
|
||||||
[double]$NearPlane = 0.5,
|
|
||||||
[ValidateRange(0.01, 100000.0)]
|
|
||||||
[double]$FarPlane = 700.0,
|
|
||||||
[ValidateRange(0.01, 3.13)]
|
|
||||||
[double]$FieldOfViewRadians = 1.3,
|
|
||||||
[ValidateRange(1, 600)]
|
|
||||||
[int]$CaptureAttempts = 60,
|
|
||||||
[ValidateRange(0, 1000000.0)]
|
|
||||||
[double]$MinimumWorldTranslation = 100.0,
|
|
||||||
[ValidateRange(0, 1000)]
|
|
||||||
[int]$RetryIntervalMilliseconds = 100
|
|
||||||
)
|
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
# This observer deliberately opens only PROCESS_QUERY_INFORMATION | PROCESS_VM_READ.
|
|
||||||
# It neither sends input nor writes, suspends, injects into, or calls the original game.
|
|
||||||
Add-Type -TypeDefinition @'
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
public static class FparkanOriginalCamera {
|
|
||||||
public const uint TH32CS_SNAPMODULE = 0x00000008;
|
|
||||||
public const uint TH32CS_SNAPMODULE32 = 0x00000010;
|
|
||||||
public const uint PROCESS_QUERY_INFORMATION = 0x00000400;
|
|
||||||
public const uint PROCESS_VM_READ = 0x00000010;
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
||||||
public struct MODULEENTRY32 {
|
|
||||||
public uint dwSize;
|
|
||||||
public uint th32ModuleID;
|
|
||||||
public uint th32ProcessID;
|
|
||||||
public uint GlblcntUsage;
|
|
||||||
public uint ProccntUsage;
|
|
||||||
public IntPtr modBaseAddr;
|
|
||||||
public uint modBaseSize;
|
|
||||||
public IntPtr hModule;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szModule;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
public static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId);
|
|
||||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
||||||
public static extern bool Module32First(IntPtr snapshot, ref MODULEENTRY32 entry);
|
|
||||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
||||||
public static extern bool Module32Next(IntPtr snapshot, ref MODULEENTRY32 entry);
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
public static extern IntPtr OpenProcess(uint access, bool inheritHandle, uint processId);
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
public static extern bool ReadProcessMemory(
|
|
||||||
IntPtr process,
|
|
||||||
IntPtr address,
|
|
||||||
[Out] byte[] buffer,
|
|
||||||
IntPtr size,
|
|
||||||
out IntPtr bytesRead);
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
public static extern bool CloseHandle(IntPtr handle);
|
|
||||||
}
|
|
||||||
'@
|
|
||||||
|
|
||||||
function Get-LastWin32ErrorText {
|
|
||||||
$code = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
|
|
||||||
"$code ($([ComponentModel.Win32Exception]::new($code).Message))"
|
|
||||||
}
|
|
||||||
|
|
||||||
function Read-OriginalBytes {
|
|
||||||
param(
|
|
||||||
[IntPtr]$Process,
|
|
||||||
[Int64]$Address,
|
|
||||||
[int]$Length
|
|
||||||
)
|
|
||||||
$buffer = [byte[]]::new($Length)
|
|
||||||
$read = [IntPtr]::Zero
|
|
||||||
if (-not [FparkanOriginalCamera]::ReadProcessMemory(
|
|
||||||
$Process,
|
|
||||||
[IntPtr]$Address,
|
|
||||||
$buffer,
|
|
||||||
[IntPtr]$Length,
|
|
||||||
[ref]$read)) {
|
|
||||||
throw "ReadProcessMemory at 0x$('{0:X8}' -f $Address) failed: $(Get-LastWin32ErrorText)"
|
|
||||||
}
|
|
||||||
if ($read.ToInt64() -ne $Length) {
|
|
||||||
throw "ReadProcessMemory at 0x$('{0:X8}' -f $Address) returned $($read.ToInt64()) of $Length bytes"
|
|
||||||
}
|
|
||||||
$buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshot = [FparkanOriginalCamera]::CreateToolhelp32Snapshot(
|
|
||||||
[FparkanOriginalCamera]::TH32CS_SNAPMODULE -bor [FparkanOriginalCamera]::TH32CS_SNAPMODULE32,
|
|
||||||
[uint32]$ProcessId
|
|
||||||
)
|
|
||||||
if ($snapshot -eq [IntPtr]::Zero -or $snapshot.ToInt64() -eq -1) {
|
|
||||||
throw "CreateToolhelp32Snapshot failed: $(Get-LastWin32ErrorText)"
|
|
||||||
}
|
|
||||||
|
|
||||||
$terrainBase = $null
|
|
||||||
try {
|
|
||||||
$module = [FparkanOriginalCamera+MODULEENTRY32]::new()
|
|
||||||
$module.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanOriginalCamera+MODULEENTRY32])
|
|
||||||
if (-not [FparkanOriginalCamera]::Module32First($snapshot, [ref]$module)) {
|
|
||||||
throw "Module32First failed: $(Get-LastWin32ErrorText)"
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
if ($module.szModule -ieq 'Terrain.dll') {
|
|
||||||
$terrainBase = $module.modBaseAddr.ToInt64()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
$module = [FparkanOriginalCamera+MODULEENTRY32]::new()
|
|
||||||
$module.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanOriginalCamera+MODULEENTRY32])
|
|
||||||
} while ([FparkanOriginalCamera]::Module32Next($snapshot, [ref]$module))
|
|
||||||
} finally {
|
|
||||||
[void][FparkanOriginalCamera]::CloseHandle($snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($null -eq $terrainBase) {
|
|
||||||
throw "Terrain.dll is not loaded by process $ProcessId"
|
|
||||||
}
|
|
||||||
|
|
||||||
$process = [FparkanOriginalCamera]::OpenProcess(
|
|
||||||
[FparkanOriginalCamera]::PROCESS_QUERY_INFORMATION -bor [FparkanOriginalCamera]::PROCESS_VM_READ,
|
|
||||||
$false,
|
|
||||||
[uint32]$ProcessId
|
|
||||||
)
|
|
||||||
if ($process -eq [IntPtr]::Zero) {
|
|
||||||
throw "OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ) failed: $(Get-LastWin32ErrorText)"
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
# Terrain.dll image RVA 0x7355c -> active 0x1a4-byte outer camera object.
|
|
||||||
# Its selector-0 affine block begins at outer + 0x20. Words 3/7/11 are
|
|
||||||
# the proven world-space translation components. AutoDemo also briefly
|
|
||||||
# selects normalized/reflection-like camera objects; a finite matrix alone
|
|
||||||
# is not sufficient evidence that it can project the mission world.
|
|
||||||
$expectedOuterVtable = [uint32]($terrainBase + 0x665b4)
|
|
||||||
$matrixBytes = $null
|
|
||||||
$translation = $null
|
|
||||||
$cameraOuter = $null
|
|
||||||
$lastObservation = 'camera pointer unavailable'
|
|
||||||
for ($attempt = 1; $attempt -le $CaptureAttempts; $attempt++) {
|
|
||||||
$candidateOuter = [BitConverter]::ToUInt32(
|
|
||||||
(Read-OriginalBytes $process ($terrainBase + 0x7355c) 4),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
if ($candidateOuter -lt 0x10000) {
|
|
||||||
$lastObservation = "camera pointer 0x$('{0:X8}' -f $candidateOuter)"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
$outerVtable = [BitConverter]::ToUInt32(
|
|
||||||
(Read-OriginalBytes $process ([int64]$candidateOuter) 4),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
if ($outerVtable -ne $expectedOuterVtable) {
|
|
||||||
$lastObservation = "outer vtable 0x$('{0:X8}' -f $outerVtable)"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
$matrixBytes = Read-OriginalBytes $process ([int64]$candidateOuter + 0x20) 64
|
|
||||||
$candidateTranslation = @(
|
|
||||||
[BitConverter]::ToSingle($matrixBytes, 12),
|
|
||||||
[BitConverter]::ToSingle($matrixBytes, 28),
|
|
||||||
[BitConverter]::ToSingle($matrixBytes, 44)
|
|
||||||
)
|
|
||||||
$nonFinite = @($candidateTranslation | Where-Object {
|
|
||||||
[Single]::IsNaN($_) -or [Single]::IsInfinity($_)
|
|
||||||
})
|
|
||||||
$length = [Math]::Sqrt(
|
|
||||||
[double]$candidateTranslation[0] * $candidateTranslation[0] +
|
|
||||||
[double]$candidateTranslation[1] * $candidateTranslation[1] +
|
|
||||||
[double]$candidateTranslation[2] * $candidateTranslation[2]
|
|
||||||
)
|
|
||||||
if ($nonFinite.Count -eq 0 -and $length -ge $MinimumWorldTranslation) {
|
|
||||||
$cameraOuter = $candidateOuter
|
|
||||||
$translation = $candidateTranslation
|
|
||||||
break
|
|
||||||
}
|
|
||||||
$lastObservation = "translation length $length at 0x$('{0:X8}' -f $candidateOuter)"
|
|
||||||
if ($attempt -lt $CaptureAttempts -and $RetryIntervalMilliseconds -gt 0) {
|
|
||||||
Start-Sleep -Milliseconds $RetryIntervalMilliseconds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($null -eq $translation) {
|
|
||||||
throw "No world-space selector-0 transform after $CaptureAttempts samples (minimum translation length $MinimumWorldTranslation; last observation: $lastObservation)"
|
|
||||||
}
|
|
||||||
$words = for ($index = 0; $index -lt 16; $index++) {
|
|
||||||
[BitConverter]::ToUInt32($matrixBytes, $index * 4)
|
|
||||||
}
|
|
||||||
[ordered]@{
|
|
||||||
schema = 'fparkan-legacy-camera-v1'
|
|
||||||
process_id = $ProcessId
|
|
||||||
terrain_module_base = ('0x{0:X8}' -f $terrainBase)
|
|
||||||
terrain_camera_global_rva = '0x7355c'
|
|
||||||
terrain_camera_outer = ('0x{0:X8}' -f $cameraOuter)
|
|
||||||
selector0_words = @($words)
|
|
||||||
selector0_translation = @($translation)
|
|
||||||
# LegacyD3d7Projection carries a D3D7 RECT: left, top, right, bottom.
|
|
||||||
viewport = @(0, 0, $ViewportWidth, $ViewportHeight)
|
|
||||||
near_plane = $NearPlane
|
|
||||||
far_plane = $FarPlane
|
|
||||||
field_of_view_radians = $FieldOfViewRadians
|
|
||||||
} | ConvertTo-Json -Depth 4 -Compress
|
|
||||||
} finally {
|
|
||||||
[void][FparkanOriginalCamera]::CloseHandle($process)
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory = $true)]
|
|
||||||
[int]$ProcessId,
|
|
||||||
[ValidateRange(1, 60)]
|
|
||||||
[int]$CaptureAttempts = 6,
|
|
||||||
[ValidateRange(0, 1000)]
|
|
||||||
[int]$RetryIntervalMilliseconds = 100,
|
|
||||||
[ValidateRange(0x00010000, 0x7fff0000)]
|
|
||||||
[UInt32]$SearchStart = 0x01000000,
|
|
||||||
[ValidateRange(0x00010000, 0x7fff0000)]
|
|
||||||
[UInt32]$SearchEnd = 0x20000000
|
|
||||||
)
|
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
# This observer deliberately opens only PROCESS_QUERY_INFORMATION | PROCESS_VM_READ.
|
|
||||||
# It neither sends input nor writes, suspends, injects into, or calls the original game.
|
|
||||||
Add-Type -TypeDefinition @'
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
public static class FparkanTerrainShadeProbe {
|
|
||||||
public const uint TH32CS_SNAPMODULE = 0x00000008;
|
|
||||||
public const uint TH32CS_SNAPMODULE32 = 0x00000010;
|
|
||||||
public const uint PROCESS_QUERY_INFORMATION = 0x00000400;
|
|
||||||
public const uint PROCESS_VM_READ = 0x00000010;
|
|
||||||
public const uint MEM_COMMIT = 0x1000;
|
|
||||||
public const uint PAGE_NOACCESS = 0x01;
|
|
||||||
public const uint PAGE_GUARD = 0x100;
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
||||||
public struct MODULEENTRY32 {
|
|
||||||
public uint dwSize;
|
|
||||||
public uint th32ModuleID;
|
|
||||||
public uint th32ProcessID;
|
|
||||||
public uint GlblcntUsage;
|
|
||||||
public uint ProccntUsage;
|
|
||||||
public IntPtr modBaseAddr;
|
|
||||||
public uint modBaseSize;
|
|
||||||
public IntPtr hModule;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szModule;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
public struct MEMORY_BASIC_INFORMATION {
|
|
||||||
public IntPtr BaseAddress;
|
|
||||||
public IntPtr AllocationBase;
|
|
||||||
public uint AllocationProtect;
|
|
||||||
public IntPtr RegionSize;
|
|
||||||
public uint State;
|
|
||||||
public uint Protect;
|
|
||||||
public uint Type;
|
|
||||||
}
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
public static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId);
|
|
||||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
||||||
public static extern bool Module32First(IntPtr snapshot, ref MODULEENTRY32 entry);
|
|
||||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
||||||
public static extern bool Module32Next(IntPtr snapshot, ref MODULEENTRY32 entry);
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
public static extern IntPtr OpenProcess(uint access, bool inheritHandle, uint processId);
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
public static extern bool ReadProcessMemory(
|
|
||||||
IntPtr process, IntPtr address, [Out] byte[] buffer, IntPtr size, out IntPtr bytesRead);
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
public static extern IntPtr VirtualQueryEx(
|
|
||||||
IntPtr process, IntPtr address, out MEMORY_BASIC_INFORMATION buffer, IntPtr length);
|
|
||||||
[DllImport("kernel32.dll", SetLastError = true)]
|
|
||||||
public static extern bool CloseHandle(IntPtr handle);
|
|
||||||
|
|
||||||
public static int FindU32(byte[] bytes, uint expected, int start) {
|
|
||||||
for (int index = start; index <= bytes.Length - 4; index += 4) {
|
|
||||||
if (BitConverter.ToUInt32(bytes, index) == expected) return index;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ulong Fnv1a64(byte[] bytes, int offset, int length) {
|
|
||||||
ulong hash = 14695981039346656037UL;
|
|
||||||
unchecked {
|
|
||||||
for (int index = offset; index < offset + length; index++) {
|
|
||||||
hash ^= bytes[index];
|
|
||||||
hash *= 1099511628211UL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'@
|
|
||||||
|
|
||||||
function Get-LastWin32ErrorText {
|
|
||||||
$code = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
|
|
||||||
"$code ($([ComponentModel.Win32Exception]::new($code).Message))"
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-TerrainModuleBase {
|
|
||||||
param([int]$ProbeProcessId)
|
|
||||||
$snapshot = [FparkanTerrainShadeProbe]::CreateToolhelp32Snapshot(
|
|
||||||
[FparkanTerrainShadeProbe]::TH32CS_SNAPMODULE -bor [FparkanTerrainShadeProbe]::TH32CS_SNAPMODULE32,
|
|
||||||
[uint32]$ProbeProcessId
|
|
||||||
)
|
|
||||||
if ($snapshot -eq [IntPtr]::Zero -or $snapshot.ToInt64() -eq -1) {
|
|
||||||
throw "CreateToolhelp32Snapshot failed: $(Get-LastWin32ErrorText)"
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$module = [FparkanTerrainShadeProbe+MODULEENTRY32]::new()
|
|
||||||
$module.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanTerrainShadeProbe+MODULEENTRY32])
|
|
||||||
if (-not [FparkanTerrainShadeProbe]::Module32First($snapshot, [ref]$module)) {
|
|
||||||
throw "Module32First failed: $(Get-LastWin32ErrorText)"
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
if ($module.szModule -ieq 'Terrain.dll') {
|
|
||||||
return $module.modBaseAddr.ToInt64()
|
|
||||||
}
|
|
||||||
$module = [FparkanTerrainShadeProbe+MODULEENTRY32]::new()
|
|
||||||
$module.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanTerrainShadeProbe+MODULEENTRY32])
|
|
||||||
} while ([FparkanTerrainShadeProbe]::Module32Next($snapshot, [ref]$module))
|
|
||||||
} finally {
|
|
||||||
[void][FparkanTerrainShadeProbe]::CloseHandle($snapshot)
|
|
||||||
}
|
|
||||||
throw "Terrain.dll is not loaded by process $ProbeProcessId"
|
|
||||||
}
|
|
||||||
|
|
||||||
function Read-Bytes {
|
|
||||||
param([IntPtr]$Process, [Int64]$Address, [int]$Length)
|
|
||||||
$buffer = [byte[]]::new($Length)
|
|
||||||
$read = [IntPtr]::Zero
|
|
||||||
if (-not [FparkanTerrainShadeProbe]::ReadProcessMemory(
|
|
||||||
$Process, [IntPtr]$Address, $buffer, [IntPtr]$Length, [ref]$read)) {
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
if ($read.ToInt64() -ne $Length) {
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
return $buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
function Find-ShadeCache {
|
|
||||||
param(
|
|
||||||
[IntPtr]$Process,
|
|
||||||
[UInt32]$ExpectedVtable,
|
|
||||||
[UInt32]$Start,
|
|
||||||
[UInt32]$End,
|
|
||||||
[Int64]$ExcludedImageStart,
|
|
||||||
[Int64]$ExcludedImageEnd
|
|
||||||
)
|
|
||||||
$mbiSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanTerrainShadeProbe+MEMORY_BASIC_INFORMATION])
|
|
||||||
[Int64]$cursor = $Start
|
|
||||||
while ($cursor -lt $End) {
|
|
||||||
$mbi = [FparkanTerrainShadeProbe+MEMORY_BASIC_INFORMATION]::new()
|
|
||||||
$queried = [FparkanTerrainShadeProbe]::VirtualQueryEx(
|
|
||||||
$Process, [IntPtr]$cursor, [ref]$mbi, [IntPtr]$mbiSize
|
|
||||||
)
|
|
||||||
if ($queried -eq [IntPtr]::Zero) { break }
|
|
||||||
$base = $mbi.BaseAddress.ToInt64()
|
|
||||||
$size = $mbi.RegionSize.ToInt64()
|
|
||||||
if ($size -le 0) { break }
|
|
||||||
$next = $base + $size
|
|
||||||
if ($mbi.State -eq [FparkanTerrainShadeProbe]::MEM_COMMIT -and
|
|
||||||
($mbi.Protect -band [FparkanTerrainShadeProbe]::PAGE_NOACCESS) -eq 0 -and
|
|
||||||
($mbi.Protect -band [FparkanTerrainShadeProbe]::PAGE_GUARD) -eq 0) {
|
|
||||||
$regionStart = [Math]::Max($base, [Int64]$Start)
|
|
||||||
$regionEnd = [Math]::Min($next, [Int64]$End)
|
|
||||||
for ([Int64]$offset = $regionStart; $offset -lt $regionEnd; $offset += 65536) {
|
|
||||||
$length = [int][Math]::Min(65536, $regionEnd - $offset)
|
|
||||||
$bytes = Read-Bytes $Process $offset $length
|
|
||||||
if ($null -eq $bytes) { continue }
|
|
||||||
$searchIndex = 0
|
|
||||||
while ($searchIndex -le $bytes.Length - 4) {
|
|
||||||
$index = [FparkanTerrainShadeProbe]::FindU32($bytes, $ExpectedVtable, $searchIndex)
|
|
||||||
if ($index -lt 0) { break }
|
|
||||||
$candidate = $offset + $index
|
|
||||||
if ($candidate -lt $ExcludedImageStart -or $candidate -ge $ExcludedImageEnd) {
|
|
||||||
return $candidate
|
|
||||||
}
|
|
||||||
$searchIndex = $index + 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$cursor = [Math]::Max($next, $cursor + 4096)
|
|
||||||
}
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-ShadeCacheSummary {
|
|
||||||
param([IntPtr]$Process, [Int64]$Cache, [byte[]]$Header)
|
|
||||||
$entryTable = [BitConverter]::ToUInt32($Header, 316)
|
|
||||||
$entryCount = [BitConverter]::ToUInt32($Header, 320)
|
|
||||||
$summary = [ordered]@{
|
|
||||||
materialized_entries = $null
|
|
||||||
profile_banks = @()
|
|
||||||
}
|
|
||||||
# The count field is runtime data. Keep a hard observer bound so a corrupt
|
|
||||||
# or stale candidate cannot turn a passive sample into an excessive read.
|
|
||||||
if ($entryTable -lt 0x1000 -or $entryCount -gt 100000) { return $summary }
|
|
||||||
$entryBytes = Read-Bytes $Process $entryTable ([int]($entryCount * 8))
|
|
||||||
if ($null -eq $entryBytes) { return $summary }
|
|
||||||
$materialized = 0
|
|
||||||
$banks = [System.Collections.Generic.SortedSet[int]]::new()
|
|
||||||
for ($index = 0; $index -lt $entryCount; $index++) {
|
|
||||||
$offset = $index * 8
|
|
||||||
if ([BitConverter]::ToUInt32($entryBytes, $offset) -ge 0x1000) {
|
|
||||||
$materialized++
|
|
||||||
[void]$banks.Add([int]$entryBytes[$offset + 4])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$summary.materialized_entries = $materialized
|
|
||||||
if ($banks.Count -eq 0) { return $summary }
|
|
||||||
$maxBank = $banks.Max
|
|
||||||
if ($maxBank -ge 100) { return $summary }
|
|
||||||
$bankBytes = Read-Bytes $Process ($Cache + 332) (($maxBank + 1) * 212)
|
|
||||||
if ($null -eq $bankBytes) { return $summary }
|
|
||||||
$profiles = [System.Collections.Generic.List[object]]::new()
|
|
||||||
foreach ($bank in $banks) {
|
|
||||||
$profiles.Add([ordered]@{
|
|
||||||
bank = $bank
|
|
||||||
fnv1a64 = [FparkanTerrainShadeProbe]::Fnv1a64($bankBytes, $bank * 212, 212).ToString()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
$summary.profile_banks = @($profiles)
|
|
||||||
return $summary
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($SearchEnd -le $SearchStart) { throw 'SearchEnd must exceed SearchStart' }
|
|
||||||
$terrainBase = Get-TerrainModuleBase $ProcessId
|
|
||||||
$process = [FparkanTerrainShadeProbe]::OpenProcess(
|
|
||||||
[FparkanTerrainShadeProbe]::PROCESS_QUERY_INFORMATION -bor [FparkanTerrainShadeProbe]::PROCESS_VM_READ,
|
|
||||||
$false,
|
|
||||||
[uint32]$ProcessId
|
|
||||||
)
|
|
||||||
if ($process -eq [IntPtr]::Zero) { throw "OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ) failed: $(Get-LastWin32ErrorText)" }
|
|
||||||
|
|
||||||
try {
|
|
||||||
$expectedVtable = [uint32]($terrainBase + 0x643d0)
|
|
||||||
$last = 'cache vtable was not present in readable scan range'
|
|
||||||
for ($attempt = 1; $attempt -le $CaptureAttempts; $attempt++) {
|
|
||||||
# The vtable value itself naturally appears in the Terrain image as
|
|
||||||
# relocation data; only a heap-resident object is a cache candidate.
|
|
||||||
$cache = Find-ShadeCache $process $expectedVtable $SearchStart $SearchEnd $terrainBase ($terrainBase + 0x100000)
|
|
||||||
if ($null -ne $cache) {
|
|
||||||
$header = Read-Bytes $process $cache 324
|
|
||||||
if ($null -ne $header) {
|
|
||||||
$summary = Get-ShadeCacheSummary $process $cache $header
|
|
||||||
[ordered]@{
|
|
||||||
schema = 'fparkan-terrain-shade-cache-v1'
|
|
||||||
process_id = $ProcessId
|
|
||||||
terrain_module_base = ('0x{0:X8}' -f $terrainBase)
|
|
||||||
cache_vtable_rva = '0x643d0'
|
|
||||||
cache_object = ('0x{0:X8}' -f $cache)
|
|
||||||
result_view = ('0x{0:X8}' -f [BitConverter]::ToUInt32($header, 24))
|
|
||||||
entry_table = ('0x{0:X8}' -f [BitConverter]::ToUInt32($header, 316))
|
|
||||||
entry_count = [BitConverter]::ToUInt32($header, 320)
|
|
||||||
materialized_entries = $summary.materialized_entries
|
|
||||||
profile_banks = $summary.profile_banks
|
|
||||||
scan_attempt = $attempt
|
|
||||||
} | ConvertTo-Json -Compress
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
$last = "cache candidate 0x$('{0:X8}' -f $cache) became unreadable"
|
|
||||||
}
|
|
||||||
if ($attempt -lt $CaptureAttempts -and $RetryIntervalMilliseconds -gt 0) {
|
|
||||||
Start-Sleep -Milliseconds $RetryIntervalMilliseconds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw "No live GetShade cache after $CaptureAttempts scans ($last)"
|
|
||||||
} finally {
|
|
||||||
[void][FparkanTerrainShadeProbe]::CloseHandle($process)
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
// Emits the GOG CreateSuperAI export to recover the mission-to-SuperAI
|
|
||||||
// initialization boundary. Run headless; the original PE remains read only.
|
|
||||||
import ghidra.app.decompiler.DecompInterface;
|
|
||||||
import ghidra.app.script.GhidraScript;
|
|
||||||
import ghidra.program.model.address.Address;
|
|
||||||
import ghidra.program.model.listing.Function;
|
|
||||||
|
|
||||||
public class ExportAiCreateSuperAi extends GhidraScript {
|
|
||||||
private static final long ADDRESS = 0x1000f710L;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run() throws Exception {
|
|
||||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
|
||||||
.getAddress(ADDRESS);
|
|
||||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
|
||||||
println("===== AI CreateSuperAI =====");
|
|
||||||
if (function == null) { println("missing"); return; }
|
|
||||||
DecompInterface decompiler = new DecompInterface();
|
|
||||||
decompiler.openProgram(currentProgram);
|
|
||||||
println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC());
|
|
||||||
decompiler.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user