Compare commits
51
Commits
4967f58af4
...
devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0851576e8 | ||
|
|
fb1ec5857d | ||
|
|
33d16dfca3 | ||
|
|
f49010c723 | ||
|
|
70c7afe25c | ||
|
|
9e2cc1a283 | ||
|
|
bbefe65a62 | ||
|
|
0ee635eb77 | ||
|
|
14096788fc | ||
|
|
d91e63fa9a | ||
|
|
f324017151 | ||
|
|
5d40ba3968 | ||
|
|
d1edf4ba65 | ||
|
|
a0a5a18028 | ||
|
|
24ba0bdd37 | ||
|
|
ea09804778 | ||
|
|
837b613513 | ||
|
|
a7882545b7 | ||
|
|
3619f22b42 | ||
|
|
bd225b318e | ||
|
|
de715c0dfd | ||
|
|
6af81522a1 | ||
|
|
545d661b2b | ||
|
|
9dcf6d456e | ||
|
|
3592b46b0f | ||
|
|
7b0900aa1a | ||
|
|
50a74cc0a6 | ||
|
|
d31d1f8eab | ||
|
|
a7b434aeb2 | ||
|
|
6c444d63c5 | ||
|
|
2d0ded231b | ||
|
|
6e5ee47d51 | ||
|
|
d7150a58d5 | ||
|
|
b7a102caa7 | ||
|
|
3553107514 | ||
|
|
5052edbee9 | ||
|
|
63ac1bd55e | ||
|
|
369e2ead3c | ||
|
|
5e940f92ba | ||
|
|
0736c76d87 | ||
|
|
e339256776 | ||
|
|
b1ff50f9db | ||
|
|
a01c21f865 | ||
|
|
77d38e4c12 | ||
|
|
9788749650 | ||
|
|
a339a3a635 | ||
|
|
273bd9e477 | ||
|
|
5c1ccae393 | ||
|
|
3b6dd392a7 | ||
|
|
e96444459b | ||
|
|
fd9c04cade |
Generated
+39
-17
@@ -359,7 +359,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -404,6 +404,7 @@ dependencies = [
|
||||
"fparkan-prototype",
|
||||
"fparkan-resource",
|
||||
"fparkan-runtime",
|
||||
"fparkan-script",
|
||||
"fparkan-vfs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -448,6 +449,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"fparkan-assets",
|
||||
"fparkan-inspection",
|
||||
"fparkan-path",
|
||||
"fparkan-platform",
|
||||
"fparkan-platform-winit",
|
||||
"fparkan-render",
|
||||
@@ -606,10 +608,19 @@ dependencies = [
|
||||
"fparkan-prototype",
|
||||
"fparkan-render",
|
||||
"fparkan-resource",
|
||||
"fparkan-script",
|
||||
"fparkan-terrain",
|
||||
"fparkan-vfs",
|
||||
"fparkan-world",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fparkan-script"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fparkan-binary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fparkan-terrain"
|
||||
version = "0.1.0"
|
||||
@@ -766,7 +777,7 @@ dependencies = [
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -794,7 +805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -938,7 +949,7 @@ dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1186,7 +1197,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1346,9 +1357,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
@@ -1356,29 +1367,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -1450,6 +1461,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -1476,7 +1498,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1487,7 +1509,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1630,7 +1652,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ members = [
|
||||
"crates/fparkan-render",
|
||||
"crates/fparkan-resource",
|
||||
"crates/fparkan-rsli",
|
||||
"crates/fparkan-script",
|
||||
"crates/fparkan-runtime",
|
||||
"crates/fparkan-terrain",
|
||||
"crates/fparkan-terrain-format",
|
||||
|
||||
@@ -27,12 +27,12 @@ Open source проект с реализацией компонентов игр
|
||||
- [apps/fparkan-cli](apps/fparkan-cli) — CLI для архивов, графов и acceptance-отчетов.
|
||||
- [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` использует first-root preview loader и открывает native Vulkan окно для всех подготовленных MSH-компонентов выбранного 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: для первого root он объединяет все подготовленные MSH-компоненты и загружает первую MAT0 diffuse texture на каждый используемый selector с preview-local remap; полный corpus, material phases, transforms и pixel parity ещё не подтверждены.
|
||||
- `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).
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ build = "build.rs"
|
||||
[dependencies]
|
||||
ash = "0.38"
|
||||
ash-window = "0.13"
|
||||
fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
|
||||
fparkan-binary = { path = "../../crates/fparkan-binary", version = "0.1.0" }
|
||||
fparkan-msh = { path = "../../crates/fparkan-msh", version = "0.1.0" }
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
||||
@@ -17,9 +18,6 @@ fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format", version
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "allow"
|
||||
missing_docs = "warn"
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex};
|
||||
use fparkan_msh::{
|
||||
draw_batches, node38_fallback_hierarchy, selected_slot, Group, Lod, ModelAsset, NodeId,
|
||||
draw_batches, node38_fallback_hierarchy, node38_sampled_hierarchy, selected_slot, Group, Lod,
|
||||
ModelAsset, NodeId,
|
||||
};
|
||||
use fparkan_render::{LegacyIron3dEulerTransform, LegacyPipelineState};
|
||||
use fparkan_terrain_format::LandMeshDocument;
|
||||
@@ -206,6 +207,39 @@ pub fn project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses(
|
||||
Ok(mesh)
|
||||
}
|
||||
|
||||
/// Projects a standard `Node38` model at one explicit portable-reference
|
||||
/// animation frame into a diagnostic XY frame.
|
||||
///
|
||||
/// This is the diagnostic-camera counterpart of
|
||||
/// [`project_msh_to_static_mesh_in_world_space_with_node_sampled_poses`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanAssetMeshError`] when the source cannot be represented by
|
||||
/// the static bridge or a transformed coordinate is non-finite.
|
||||
pub fn project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses(
|
||||
model: &ModelAsset,
|
||||
frame: VulkanStaticXyFrame,
|
||||
transform: LegacyIron3dEulerTransform,
|
||||
scale: [f32; 3],
|
||||
animation_frame: u16,
|
||||
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
||||
let mut mesh = project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
|
||||
model,
|
||||
transform,
|
||||
scale,
|
||||
animation_frame,
|
||||
)?;
|
||||
for vertex in &mut mesh.vertices {
|
||||
if !vertex.position.iter().all(|value| value.is_finite()) {
|
||||
return Err(VulkanAssetMeshError::NonFinitePosition);
|
||||
}
|
||||
let projected = frame.project(vertex.position);
|
||||
vertex.position = [projected[0], projected[1], 0.0];
|
||||
}
|
||||
Ok(mesh)
|
||||
}
|
||||
|
||||
/// Projects MSH geometry into source world coordinates with known translation and scale.
|
||||
///
|
||||
/// Unlike [`project_msh_to_static_mesh_in_xy_frame`], this does not normalize
|
||||
@@ -307,6 +341,45 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
|
||||
model: &ModelAsset,
|
||||
transform: LegacyIron3dEulerTransform,
|
||||
scale: [f32; 3],
|
||||
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
||||
project_msh_to_static_mesh_in_world_space_with_node_poses(
|
||||
model,
|
||||
transform,
|
||||
scale,
|
||||
node38_fallback_hierarchy(model),
|
||||
)
|
||||
}
|
||||
|
||||
/// Projects a standard `Node38` model at one explicit portable-reference
|
||||
/// animation frame.
|
||||
///
|
||||
/// This uses decoded type-8 keys and the type-19 map but deliberately does
|
||||
/// not infer the original engine's animation clock or x87 profile. Models
|
||||
/// without a complete sampled hierarchy retain the established unposed bridge.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`VulkanAssetMeshError`] when source geometry or transforms cannot
|
||||
/// be represented by the current static Vulkan input contract.
|
||||
pub fn project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
|
||||
model: &ModelAsset,
|
||||
transform: LegacyIron3dEulerTransform,
|
||||
scale: [f32; 3],
|
||||
animation_frame: u16,
|
||||
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
||||
project_msh_to_static_mesh_in_world_space_with_node_poses(
|
||||
model,
|
||||
transform,
|
||||
scale,
|
||||
node38_sampled_hierarchy(model, animation_frame),
|
||||
)
|
||||
}
|
||||
|
||||
fn project_msh_to_static_mesh_in_world_space_with_node_poses(
|
||||
model: &ModelAsset,
|
||||
transform: LegacyIron3dEulerTransform,
|
||||
scale: [f32; 3],
|
||||
hierarchy: Option<fparkan_animation::NodePoseBuffer>,
|
||||
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
|
||||
if !transform
|
||||
.translation
|
||||
@@ -320,7 +393,7 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
|
||||
if model.node_stride != 38 || model.node_count == 0 || model.animation.is_none() {
|
||||
return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale);
|
||||
}
|
||||
let Some(hierarchy) = node38_fallback_hierarchy(model) else {
|
||||
let Some(hierarchy) = hierarchy else {
|
||||
return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale);
|
||||
};
|
||||
|
||||
|
||||
@@ -44,9 +44,11 @@ pub use self::asset_mesh::{
|
||||
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, VulkanAssetMeshError,
|
||||
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::{
|
||||
|
||||
@@ -235,6 +235,7 @@ impl VulkanSmokeRenderer {
|
||||
frame_sync: Vec::new(),
|
||||
images_in_flight: Vec::new(),
|
||||
current_frame: 0,
|
||||
last_readback_image_index: None,
|
||||
depth_request: create_info.render_request.depth,
|
||||
pending_extent: None,
|
||||
swapchain_recreate_count: 0,
|
||||
@@ -519,6 +520,7 @@ impl VulkanSmokeRenderer {
|
||||
})?;
|
||||
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];
|
||||
@@ -621,6 +623,7 @@ impl VulkanSmokeRenderer {
|
||||
let resources = resources.commit();
|
||||
self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()];
|
||||
self.frame_sync = frame_sync;
|
||||
self.last_readback_image_index = None;
|
||||
self.report.swapchain_extent = swapchain_extent;
|
||||
self.report.swapchain_image_count = swapchain_image_count;
|
||||
self.report.swapchain_image_format = self.swapchain_ref()?.report.plan.format.format;
|
||||
@@ -941,13 +944,19 @@ impl VulkanSmokeRenderer {
|
||||
if resources.readback_buffers.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut artifact =
|
||||
Vec::with_capacity(byte_len.saturating_mul(resources.readback_buffers.len()));
|
||||
for buffer in &resources.readback_buffers {
|
||||
let bytes = readback_buffer_bytes(device, buffer, byte_len)?;
|
||||
artifact.extend_from_slice(&bytes);
|
||||
}
|
||||
Ok(Some(artifact))
|
||||
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) {
|
||||
@@ -968,6 +977,19 @@ impl VulkanSmokeRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -991,8 +1013,8 @@ impl Drop for VulkanSmokeRenderer {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order,
|
||||
RollbackOnDrop,
|
||||
completed_readback_buffer_index, take_runtime_children_with_validation_snapshot,
|
||||
take_runtime_owners_in_dependency_order, RollbackOnDrop,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
@@ -1075,6 +1097,13 @@ 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]
|
||||
fn runtime_owners_drop_remaining_children_after_partial_init_failures() {
|
||||
let cases = [
|
||||
|
||||
@@ -603,7 +603,7 @@ pub struct VulkanValidationReport {
|
||||
pub vuids: Vec<String>,
|
||||
}
|
||||
|
||||
/// CPU-owned copy of the final completed swapchain readback buffers.
|
||||
/// 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.
|
||||
@@ -612,7 +612,8 @@ pub struct VulkanReadbackArtifact {
|
||||
pub width: u32,
|
||||
/// Height of each image in pixels.
|
||||
pub height: u32,
|
||||
/// Concatenated raw four-byte-per-pixel images in swapchain order.
|
||||
/// One raw four-byte-per-pixel image: the last successfully submitted
|
||||
/// swapchain image before synchronized teardown.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -735,6 +736,10 @@ pub struct VulkanSmokeRenderer {
|
||||
pub(super) frame_sync: Vec<VulkanFrameSync>,
|
||||
pub(super) images_in_flight: Vec<vk::Fence>,
|
||||
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) swapchain_recreate_count: u32,
|
||||
|
||||
@@ -13,6 +13,7 @@ fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.
|
||||
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
|
||||
fparkan-resource = { path = "../../crates/fparkan-resource", version = "0.1.0" }
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
|
||||
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"
|
||||
|
||||
@@ -37,16 +37,18 @@ use fparkan_runtime::{
|
||||
};
|
||||
use fparkan_vfs::{DirectoryVfs, Vfs};
|
||||
use serde::Serialize;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
const ARCHIVE_INSPECT_SCHEMA: &str = "fparkan-archive-inspect-v1";
|
||||
const PROTOTYPE_INSPECT_SCHEMA: &str = "fparkan-prototype-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> {
|
||||
@@ -65,6 +67,8 @@ struct PrototypeInspectOutput {
|
||||
roots: usize,
|
||||
node_count: usize,
|
||||
edge_count: usize,
|
||||
unit_component_records: Vec<UnitComponentInspectOutput>,
|
||||
edges: Vec<PrototypeGraphEdgeInspectOutput>,
|
||||
prototype_requests: usize,
|
||||
resolved: usize,
|
||||
unit_references: usize,
|
||||
@@ -196,6 +200,62 @@ struct GraphFailureOutput {
|
||||
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() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = run(&args);
|
||||
@@ -260,10 +320,99 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if result.is_ok() {
|
||||
0
|
||||
@@ -350,6 +499,58 @@ fn prototype_inspect_json(
|
||||
roots: report.root_count,
|
||||
node_count: graph.nodes.len(),
|
||||
edge_count: graph.edges.len(),
|
||||
unit_component_records: graph
|
||||
.root_unit_components
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(root_index, records)| {
|
||||
records
|
||||
.iter()
|
||||
.enumerate()
|
||||
.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,
|
||||
@@ -638,6 +839,29 @@ fn graph_failure_output(failure: &fparkan_prototype::PrototypeGraphFailure) -> G
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_bytes(raw: &[u8]) -> String {
|
||||
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",
|
||||
@@ -664,7 +888,7 @@ fn prototype_graph_requiredness_label(
|
||||
}
|
||||
|
||||
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] | 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] | 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()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -693,6 +917,27 @@ 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]
|
||||
fn archive_json_has_schema_version() {
|
||||
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true))
|
||||
@@ -720,7 +965,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
json,
|
||||
"{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":\"root\",\"roots\":1,\"node_count\":0,\"edge_count\":0,\"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-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\":[]}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ repository.workspace = true
|
||||
[dependencies]
|
||||
fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
|
||||
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
|
||||
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
||||
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
|
||||
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
|
||||
|
||||
+310
-27
@@ -23,6 +23,7 @@
|
||||
|
||||
use fparkan_assets::{PreparedTextureUsage, PreparedVisual};
|
||||
use fparkan_inspection::load_standalone_wear_material_texture_mip0_rgba8_from_root;
|
||||
use fparkan_path::ResourceName;
|
||||
use fparkan_platform::WindowPort;
|
||||
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
|
||||
use fparkan_render::{
|
||||
@@ -35,7 +36,9 @@ use fparkan_render_vulkan::{
|
||||
project_land_msh_to_static_mesh_in_legacy_world_space,
|
||||
project_land_msh_to_static_mesh_in_xy_frame,
|
||||
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
|
||||
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses, VulkanPlanningBackend,
|
||||
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
|
||||
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses,
|
||||
project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanPlanningBackend,
|
||||
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
|
||||
VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
|
||||
VulkanStaticXyFrame,
|
||||
@@ -51,7 +54,7 @@ use fparkan_terrain::TerrainWorld;
|
||||
use fparkan_vfs::DirectoryVfs;
|
||||
#[cfg(test)]
|
||||
use fparkan_world::OriginalObjectId;
|
||||
use fparkan_world::WorldSnapshot;
|
||||
use fparkan_world::{TransformState, WorldSnapshot};
|
||||
use serde::Deserialize;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::path::PathBuf;
|
||||
@@ -110,6 +113,8 @@ fn run(args: &[String]) -> Result<String, String> {
|
||||
terrain,
|
||||
roots,
|
||||
camera,
|
||||
args.static_animation_frame,
|
||||
args.static_material_phase,
|
||||
&args.root,
|
||||
&loaded.land_msh_path,
|
||||
)?;
|
||||
@@ -232,7 +237,7 @@ fn load_requested_mission(
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// Static geometry and descriptors belonging to the first preview root.
|
||||
/// Static geometry and descriptors belonging to the selected preview roots.
|
||||
struct StaticPreviewScene {
|
||||
mesh: VulkanStaticMesh,
|
||||
materials: Vec<VulkanStaticMaterial>,
|
||||
@@ -241,10 +246,12 @@ struct StaticPreviewScene {
|
||||
clip_visible_vertices: usize,
|
||||
mesh_components: usize,
|
||||
terrain_components: usize,
|
||||
animation_frame: Option<u16>,
|
||||
material_phase: Option<u16>,
|
||||
}
|
||||
|
||||
/// Projects the mission terrain plus every MSH component of the explicitly
|
||||
/// bounded static-preview root into one diagnostic XY frame.
|
||||
/// bounded static-preview roots into one diagnostic XY frame.
|
||||
///
|
||||
/// This intentionally takes only the first MAT0 texture request for each MSH
|
||||
/// batch selector. Terrain maps its proven low-byte packed tag channel through
|
||||
@@ -252,11 +259,14 @@ struct StaticPreviewScene {
|
||||
/// retained on draw ranges but awaits a recovered blend equation; orientation,
|
||||
/// later material phases, animation, lightmaps and gameplay visibility remain
|
||||
/// outside this bridge.
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
fn static_preview_mesh_and_materials(
|
||||
assets: &MissionAssets,
|
||||
terrain: &TerrainWorld,
|
||||
roots: &[MissionObjectDraft],
|
||||
legacy_camera: Option<VulkanStaticCamera>,
|
||||
static_animation_frame: Option<u16>,
|
||||
static_material_phase: Option<u16>,
|
||||
root: &std::path::Path,
|
||||
land_msh_path: &str,
|
||||
) -> Result<StaticPreviewScene, String> {
|
||||
@@ -301,30 +311,52 @@ fn static_preview_mesh_and_materials(
|
||||
let model = assets.model_by_id(model_id).ok_or_else(|| {
|
||||
format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
|
||||
})?;
|
||||
let component = if legacy_camera.is_some() {
|
||||
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
|
||||
&model.validated,
|
||||
LegacyIron3dEulerTransform {
|
||||
let transform = LegacyIron3dEulerTransform {
|
||||
translation: root.position,
|
||||
orientation_radians: root.orientation_raw,
|
||||
},
|
||||
};
|
||||
let component = if legacy_camera.is_some() {
|
||||
if let Some(frame) = static_animation_frame {
|
||||
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
|
||||
&model.validated,
|
||||
transform,
|
||||
root.scale,
|
||||
frame,
|
||||
)
|
||||
} else {
|
||||
let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?;
|
||||
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses(
|
||||
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
|
||||
&model.validated,
|
||||
frame,
|
||||
LegacyIron3dEulerTransform {
|
||||
translation: root.position,
|
||||
orientation_radians: root.orientation_raw,
|
||||
},
|
||||
transform,
|
||||
root.scale,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?;
|
||||
if let Some(animation_frame) = static_animation_frame {
|
||||
project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses(
|
||||
&model.validated,
|
||||
frame,
|
||||
transform,
|
||||
root.scale,
|
||||
animation_frame,
|
||||
)
|
||||
} else {
|
||||
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses(
|
||||
&model.validated,
|
||||
frame,
|
||||
transform,
|
||||
root.scale,
|
||||
)
|
||||
}
|
||||
}
|
||||
.map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
|
||||
let selector_remap =
|
||||
static_preview_component_materials(assets, visual, &component, &mut materials)?;
|
||||
let selector_remap = static_preview_component_materials(
|
||||
assets,
|
||||
visual,
|
||||
&component,
|
||||
static_material_phase,
|
||||
&mut materials,
|
||||
)?;
|
||||
append_static_preview_component(&mut mesh, component, &selector_remap)?;
|
||||
mesh_components += 1;
|
||||
}
|
||||
@@ -345,6 +377,8 @@ fn static_preview_mesh_and_materials(
|
||||
},
|
||||
mesh_components,
|
||||
terrain_components: 1,
|
||||
animation_frame: static_animation_frame,
|
||||
material_phase: static_material_phase,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -515,6 +549,7 @@ fn static_preview_component_materials(
|
||||
assets: &MissionAssets,
|
||||
visual: &PreparedVisual,
|
||||
mesh: &VulkanStaticMesh,
|
||||
static_material_phase: Option<u16>,
|
||||
materials: &mut Vec<VulkanStaticMaterial>,
|
||||
) -> Result<Vec<(u16, u16)>, String> {
|
||||
let mut source_selectors = mesh
|
||||
@@ -538,15 +573,21 @@ fn static_preview_component_materials(
|
||||
let material = assets.material_by_id(*material_id).ok_or_else(|| {
|
||||
format!("static preview prepared material {source_selector} is unavailable")
|
||||
})?;
|
||||
let texture_name = material.texture_requests.first().ok_or_else(|| {
|
||||
let texture_name = static_material_phase
|
||||
.and_then(|phase_index| {
|
||||
let phase = material.mat0.phases.get(usize::from(phase_index))?;
|
||||
let length = phase.texture_raw.iter().position(|byte| *byte == 0).unwrap_or(phase.texture_raw.len());
|
||||
(length != 0).then(|| ResourceName(phase.texture_raw[..length].to_vec()))
|
||||
})
|
||||
.or_else(|| material.texture_requests.first().cloned())
|
||||
.ok_or_else(|| {
|
||||
format!("static preview material {source_selector} has no MAT0 diffuse texture")
|
||||
})?;
|
||||
let texture = assets
|
||||
.textures
|
||||
.iter()
|
||||
.find(|texture| {
|
||||
texture.usage == PreparedTextureUsage::Diffuse
|
||||
&& texture.source.name == *texture_name
|
||||
texture.usage == PreparedTextureUsage::Diffuse && texture.source.name == texture_name
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
@@ -678,6 +719,8 @@ struct StaticVulkanApp {
|
||||
clip_visible_vertices: usize,
|
||||
mesh_components: usize,
|
||||
terrain_components: usize,
|
||||
animation_frame: Option<u16>,
|
||||
material_phase: Option<u16>,
|
||||
preview_roots: usize,
|
||||
target_frames: u64,
|
||||
mission: String,
|
||||
@@ -708,6 +751,8 @@ impl StaticVulkanApp {
|
||||
clip_visible_vertices: preview.clip_visible_vertices,
|
||||
mesh_components: preview.mesh_components,
|
||||
terrain_components: preview.terrain_components,
|
||||
animation_frame: preview.animation_frame,
|
||||
material_phase: preview.material_phase,
|
||||
preview_roots,
|
||||
target_frames,
|
||||
mission: mission.to_string(),
|
||||
@@ -776,12 +821,14 @@ impl StaticVulkanApp {
|
||||
(None, _) => None,
|
||||
};
|
||||
self.output = Some(format!(
|
||||
"{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"camera_mode\":{},\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"mesh_components\":{},\"terrain_components\":{},\"clip_visible_vertices\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_format\":{},\"readback_bytes\":{},\"readback_hash\":{},\"readback_path\":{}}}",
|
||||
"{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"camera_mode\":{},\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"animation_frame\":{},\"material_phase\":{},\"mesh_components\":{},\"terrain_components\":{},\"clip_visible_vertices\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_format\":{},\"readback_bytes\":{},\"readback_hash\":{},\"readback_path\":{}}}",
|
||||
json_string(self.camera_mode),
|
||||
json_string(&self.mission),
|
||||
self.object_count,
|
||||
self.frames_presented,
|
||||
self.preview_roots,
|
||||
self.animation_frame.map_or_else(|| "null".to_string(), |frame| frame.to_string()),
|
||||
self.material_phase.map_or_else(|| "null".to_string(), |phase| phase.to_string()),
|
||||
self.mesh_components,
|
||||
self.terrain_components,
|
||||
self.clip_visible_vertices,
|
||||
@@ -977,12 +1024,14 @@ fn render_snapshot_with_assets(
|
||||
material_slots: vec![material],
|
||||
material_index: 0,
|
||||
pipeline_state: fparkan_render::LegacyPipelineState::default(),
|
||||
transform: mission_drafts
|
||||
transform: snapshot_transform(snapshot, *handle).unwrap_or_else(|| {
|
||||
mission_drafts
|
||||
.and_then(|drafts| drafts.get(index))
|
||||
.map_or_else(
|
||||
|| identity_transform(index_to_f32(index)),
|
||||
mission_position_scale_transform,
|
||||
),
|
||||
)
|
||||
}),
|
||||
range: IndexRange { start: 0, count: 3 },
|
||||
stable_order,
|
||||
});
|
||||
@@ -994,6 +1043,40 @@ fn render_snapshot_with_assets(
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_transform(
|
||||
snapshot: &WorldSnapshot,
|
||||
handle: fparkan_world::ObjectHandle,
|
||||
) -> Option<[f32; 16]> {
|
||||
snapshot
|
||||
.transforms
|
||||
.iter()
|
||||
.find_map(|(candidate, transform)| (*candidate == handle).then_some(*transform))
|
||||
.map(transform_state_position_scale_transform)
|
||||
}
|
||||
|
||||
fn transform_state_position_scale_transform(transform: TransformState) -> [f32; 16] {
|
||||
let position = transform.position.map(f32::from_bits);
|
||||
let scale = transform.scale.map(f32::from_bits);
|
||||
[
|
||||
scale[0],
|
||||
0.0,
|
||||
0.0,
|
||||
position[0],
|
||||
0.0,
|
||||
scale[1],
|
||||
0.0,
|
||||
position[1],
|
||||
0.0,
|
||||
0.0,
|
||||
scale[2],
|
||||
position[2],
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
]
|
||||
}
|
||||
|
||||
fn mission_position_scale_transform(draft: &MissionObjectDraft) -> [f32; 16] {
|
||||
[
|
||||
draft.scale[0],
|
||||
@@ -1035,6 +1118,8 @@ struct Args {
|
||||
readback_out: Option<PathBuf>,
|
||||
preview_roots: NonZeroUsize,
|
||||
legacy_camera_capture: Option<PathBuf>,
|
||||
static_animation_frame: Option<u16>,
|
||||
static_material_phase: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -1044,6 +1129,7 @@ enum RenderBackendMode {
|
||||
}
|
||||
|
||||
impl Args {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn parse(args: &[String]) -> Result<Self, String> {
|
||||
let mut root = None;
|
||||
let mut mission = None;
|
||||
@@ -1057,6 +1143,8 @@ impl Args {
|
||||
// remains available for bounded diagnostic work.
|
||||
let mut preview_roots = NonZeroUsize::MAX;
|
||||
let mut legacy_camera_capture = None;
|
||||
let mut static_animation_frame = None;
|
||||
let mut static_material_phase = None;
|
||||
let mut iter = args.iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
@@ -1119,6 +1207,26 @@ impl Args {
|
||||
"--legacy-camera-capture requires a path".to_string()
|
||||
})?);
|
||||
}
|
||||
"--static-animation-frame" => {
|
||||
static_animation_frame = Some(
|
||||
iter.next()
|
||||
.ok_or_else(|| "--static-animation-frame requires a value".to_string())?
|
||||
.parse()
|
||||
.map_err(|_| {
|
||||
"--static-animation-frame must be a u16 integer".to_string()
|
||||
})?,
|
||||
);
|
||||
}
|
||||
"--static-material-phase" => {
|
||||
static_material_phase = Some(
|
||||
iter.next()
|
||||
.ok_or_else(|| "--static-material-phase requires a value".to_string())?
|
||||
.parse()
|
||||
.map_err(|_| {
|
||||
"--static-material-phase must be a u16 integer".to_string()
|
||||
})?,
|
||||
);
|
||||
}
|
||||
_ => return Err(usage()),
|
||||
}
|
||||
}
|
||||
@@ -1133,6 +1241,12 @@ impl Args {
|
||||
if readback_out.is_some() && backend != RenderBackendMode::StaticVulkan {
|
||||
return Err("--readback-out requires --backend static-vulkan".to_string());
|
||||
}
|
||||
if static_animation_frame.is_some() && backend != RenderBackendMode::StaticVulkan {
|
||||
return Err("--static-animation-frame requires --backend static-vulkan".to_string());
|
||||
}
|
||||
if static_material_phase.is_some() && backend != RenderBackendMode::StaticVulkan {
|
||||
return Err("--static-material-phase requires --backend static-vulkan".to_string());
|
||||
}
|
||||
Ok(Self {
|
||||
root,
|
||||
mission,
|
||||
@@ -1142,6 +1256,8 @@ impl Args {
|
||||
readback_out,
|
||||
preview_roots,
|
||||
legacy_camera_capture,
|
||||
static_animation_frame,
|
||||
static_material_phase,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1162,8 +1278,13 @@ fn load_legacy_camera_capture(path: &std::path::Path) -> Result<VulkanStaticCame
|
||||
}
|
||||
|
||||
fn parse_legacy_camera_capture(bytes: &[u8]) -> Result<VulkanStaticCamera, String> {
|
||||
let capture: LegacyCameraCapture = serde_json::from_slice(bytes)
|
||||
.map_err(|err| format!("invalid legacy camera JSON: {err}"))?;
|
||||
// Windows PowerShell 5.1 writes redirected text as UTF-16LE with a BOM.
|
||||
// `capture-original-camera.ps1` intentionally emits plain JSON, so accept
|
||||
// that normal hand-off format as well as UTF-8 without making the caller
|
||||
// re-encode a read-only capture file.
|
||||
let json = decode_legacy_camera_capture_json(bytes)?;
|
||||
let capture: LegacyCameraCapture =
|
||||
serde_json::from_str(&json).map_err(|err| format!("invalid legacy camera JSON: {err}"))?;
|
||||
if capture.schema != "fparkan-legacy-camera-v1" {
|
||||
return Err("unsupported legacy camera capture schema".to_string());
|
||||
}
|
||||
@@ -1181,6 +1302,43 @@ fn parse_legacy_camera_capture(bytes: &[u8]) -> Result<VulkanStaticCamera, Strin
|
||||
.ok_or_else(|| "legacy camera capture contains an invalid D3D7 camera".to_string())
|
||||
}
|
||||
|
||||
fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, String> {
|
||||
let decode_utf16 = |words: Vec<u16>| {
|
||||
String::from_utf16(&words)
|
||||
.map_err(|err| format!("invalid UTF-16 legacy camera JSON: {err}"))
|
||||
};
|
||||
match bytes {
|
||||
[0xff, 0xfe, rest @ ..] => {
|
||||
let chunks = rest.chunks_exact(2);
|
||||
if !chunks.remainder().is_empty() {
|
||||
return Err("invalid UTF-16LE legacy camera JSON length".to_string());
|
||||
}
|
||||
decode_utf16(
|
||||
chunks
|
||||
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
[0xfe, 0xff, rest @ ..] => {
|
||||
let chunks = rest.chunks_exact(2);
|
||||
if !chunks.remainder().is_empty() {
|
||||
return Err("invalid UTF-16BE legacy camera JSON length".to_string());
|
||||
}
|
||||
decode_utf16(
|
||||
chunks
|
||||
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
[0xef, 0xbb, 0xbf, rest @ ..] => std::str::from_utf8(rest)
|
||||
.map(str::to_owned)
|
||||
.map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")),
|
||||
_ => std::str::from_utf8(bytes)
|
||||
.map(str::to_owned)
|
||||
.map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_string(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len() + 2);
|
||||
out.push('"');
|
||||
@@ -1213,7 +1371,7 @@ fn json_hash(hash: &[u8; 32]) -> String {
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--readback-out <path>] [--load-progress <path>]".to_string()
|
||||
"usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>] [--load-progress <path>]".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1281,6 +1439,8 @@ mod tests {
|
||||
readback_out: None,
|
||||
preview_roots: NonZeroUsize::MAX,
|
||||
legacy_camera_capture: None,
|
||||
static_animation_frame: None,
|
||||
static_material_phase: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1305,6 +1465,8 @@ mod tests {
|
||||
readback_out: None,
|
||||
preview_roots: NonZeroUsize::MAX,
|
||||
legacy_camera_capture: None,
|
||||
static_animation_frame: None,
|
||||
static_material_phase: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1356,6 +1518,62 @@ mod tests {
|
||||
assert_eq!(parsed.readback_out, Some(PathBuf::from("target/frame.raw")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_animation_frame_requires_static_vulkan_and_is_retained() {
|
||||
let common = [
|
||||
"--root",
|
||||
"testdata/IS",
|
||||
"--mission",
|
||||
"MISSIONS/Autodemo.00/data.tma",
|
||||
"--static-animation-frame",
|
||||
"12",
|
||||
];
|
||||
assert_eq!(
|
||||
Args::parse(&strings(&common)),
|
||||
Err("--static-animation-frame requires --backend static-vulkan".to_string())
|
||||
);
|
||||
let parsed = Args::parse(&strings(&[
|
||||
"--root",
|
||||
"testdata/IS",
|
||||
"--mission",
|
||||
"MISSIONS/Autodemo.00/data.tma",
|
||||
"--backend",
|
||||
"static-vulkan",
|
||||
"--static-animation-frame",
|
||||
"12",
|
||||
]))
|
||||
.expect("valid static animation frame");
|
||||
assert_eq!(parsed.static_animation_frame, Some(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_material_phase_requires_static_vulkan_and_is_retained() {
|
||||
let common = [
|
||||
"--root",
|
||||
"testdata/IS",
|
||||
"--mission",
|
||||
"MISSIONS/Autodemo.00/data.tma",
|
||||
"--static-material-phase",
|
||||
"2",
|
||||
];
|
||||
assert_eq!(
|
||||
Args::parse(&strings(&common)),
|
||||
Err("--static-material-phase requires --backend static-vulkan".to_string())
|
||||
);
|
||||
let parsed = Args::parse(&strings(&[
|
||||
"--root",
|
||||
"testdata/IS",
|
||||
"--mission",
|
||||
"MISSIONS/Autodemo.00/data.tma",
|
||||
"--backend",
|
||||
"static-vulkan",
|
||||
"--static-material-phase",
|
||||
"2",
|
||||
]))
|
||||
.expect("valid static material phase");
|
||||
assert_eq!(parsed.static_material_phase, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_static_preview_root_count() {
|
||||
let error = Args::parse(&strings(&[
|
||||
@@ -1392,6 +1610,8 @@ mod tests {
|
||||
readback_out: None,
|
||||
preview_roots: NonZeroUsize::MAX,
|
||||
legacy_camera_capture: None,
|
||||
static_animation_frame: None,
|
||||
static_material_phase: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1456,6 +1676,14 @@ mod tests {
|
||||
|
||||
assert!(camera.is_finite());
|
||||
assert_eq!(camera.clip_from_world[0], 0.65_f32.cos());
|
||||
let mut utf16le = vec![0xff, 0xfe];
|
||||
utf16le.extend(json.encode_utf16().flat_map(u16::to_le_bytes));
|
||||
assert_eq!(
|
||||
parse_legacy_camera_capture(&utf16le)
|
||||
.expect("PowerShell UTF-16LE capture must be accepted")
|
||||
.clip_from_world,
|
||||
camera.clip_from_world
|
||||
);
|
||||
assert_eq!(
|
||||
parse_legacy_camera_capture(br#"{"schema":"unknown","selector0_words":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"viewport":[0,0,1,1],"near_plane":0.1,"far_plane":1.0,"field_of_view_radians":1.0}"#),
|
||||
Err("unsupported legacy camera capture schema".to_string())
|
||||
@@ -1560,6 +1788,7 @@ mod tests {
|
||||
slot: 5,
|
||||
},
|
||||
],
|
||||
transforms: Vec::new(),
|
||||
events: Vec::new(),
|
||||
hash: StateHash([0; 32]),
|
||||
};
|
||||
@@ -1591,6 +1820,7 @@ mod tests {
|
||||
generation: 1,
|
||||
slot: 7,
|
||||
}],
|
||||
transforms: Vec::new(),
|
||||
events: Vec::new(),
|
||||
hash: StateHash([0; 32]),
|
||||
};
|
||||
@@ -1630,6 +1860,7 @@ mod tests {
|
||||
scale: [2.0, 3.0, 4.0],
|
||||
visual_ids: Vec::new(),
|
||||
properties: Vec::new(),
|
||||
unit_components: Vec::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
mission_position_scale_transform(&draft),
|
||||
@@ -1637,6 +1868,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_snapshot_prefers_world_transform_over_mission_draft() -> Result<(), String> {
|
||||
let handle = ObjectHandle {
|
||||
generation: 1,
|
||||
slot: 0,
|
||||
};
|
||||
let snapshot = WorldSnapshot {
|
||||
tick: Tick(1),
|
||||
objects: vec![handle],
|
||||
transforms: vec![(
|
||||
handle,
|
||||
TransformState {
|
||||
position: [
|
||||
100.0_f32.to_bits(),
|
||||
200.0_f32.to_bits(),
|
||||
300.0_f32.to_bits(),
|
||||
],
|
||||
orientation: [0; 3],
|
||||
scale: [4.0_f32.to_bits(), 5.0_f32.to_bits(), 6.0_f32.to_bits()],
|
||||
},
|
||||
)],
|
||||
events: Vec::new(),
|
||||
hash: StateHash([0; 32]),
|
||||
};
|
||||
let drafts = vec![MissionObjectDraft {
|
||||
original_id: None,
|
||||
resource_name_raw: Vec::new(),
|
||||
identity_or_clan_raw: 0,
|
||||
position: [1.0; 3],
|
||||
orientation_raw: [0.0; 3],
|
||||
scale: [1.0; 3],
|
||||
visual_ids: Vec::new(),
|
||||
properties: Vec::new(),
|
||||
unit_components: Vec::new(),
|
||||
}];
|
||||
|
||||
let commands = render_snapshot_commands_with_assets(&snapshot, None, Some(&drafts))?;
|
||||
let RenderCommand::Draw(draw) = &commands.commands[1] else {
|
||||
return Err("expected draw".to_string());
|
||||
};
|
||||
assert_eq!(
|
||||
draw.transform,
|
||||
[
|
||||
4.0, 0.0, 0.0, 100.0, 0.0, 5.0, 0.0, 200.0, 0.0, 0.0, 6.0, 300.0, 0.0, 0.0, 0.0,
|
||||
1.0
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_snapshot_preserves_mission_original_object_id() -> Result<(), String> {
|
||||
let snapshot = WorldSnapshot {
|
||||
@@ -1645,6 +1926,7 @@ mod tests {
|
||||
generation: 1,
|
||||
slot: 0,
|
||||
}],
|
||||
transforms: Vec::new(),
|
||||
events: Vec::new(),
|
||||
hash: StateHash([0; 32]),
|
||||
};
|
||||
@@ -1657,6 +1939,7 @@ mod tests {
|
||||
scale: [1.0; 3],
|
||||
visual_ids: Vec::new(),
|
||||
properties: Vec::new(),
|
||||
unit_components: Vec::new(),
|
||||
}];
|
||||
|
||||
let commands = render_snapshot_commands_with_assets(&snapshot, None, Some(&drafts))?;
|
||||
|
||||
@@ -22,10 +22,11 @@
|
||||
//! `FParkan` headless runtime entrypoint.
|
||||
|
||||
use fparkan_runtime::{
|
||||
create, load_mission, step_headless, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
||||
advance_reference_movement, create, load_mission, step_headless, EngineConfig, EngineMode,
|
||||
EngineServices, MissionRequest,
|
||||
};
|
||||
use fparkan_vfs::DirectoryVfs;
|
||||
use fparkan_world::InputSnapshot;
|
||||
use fparkan_world::{InputSnapshot, OriginalObjectId};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -55,7 +56,7 @@ fn run() -> Result<(), String> {
|
||||
let loaded = load_mission(&mut engine, MissionRequest { key: mission })
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
println!(
|
||||
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} graph_failures={}",
|
||||
"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={}",
|
||||
loaded.object_count,
|
||||
loaded.areal_count,
|
||||
loaded.surface_count,
|
||||
@@ -65,9 +66,27 @@ fn run() -> Result<(), String> {
|
||||
loaded.graph_material_resolved_count,
|
||||
loaded.graph_texture_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
|
||||
);
|
||||
}
|
||||
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;
|
||||
for _ in 0..args.ticks {
|
||||
last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?);
|
||||
@@ -81,10 +100,19 @@ fn run() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
root: Option<PathBuf>,
|
||||
mission: Option<String>,
|
||||
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 {
|
||||
@@ -93,6 +121,7 @@ impl Args {
|
||||
root: None,
|
||||
mission: None,
|
||||
ticks: 1,
|
||||
reference_movement: None,
|
||||
};
|
||||
let mut iter = args.iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
@@ -118,16 +147,121 @@ impl Args {
|
||||
.parse()
|
||||
.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()),
|
||||
}
|
||||
}
|
||||
if parsed.mission.is_some() && parsed.root.is_none() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: fparkan-headless [--root <path> --mission <path>] [--ticks <n>]".to_string()
|
||||
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 {
|
||||
"usage: fparkan-headless [--root <path> --mission <path>] [--move-object <original-id> <x> <y> <max-step>] [--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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ use fparkan_material::{
|
||||
decode_wear, resolve_material, Mat0Document, MaterialError, ResolvedMaterial, WearTable,
|
||||
MAT0_KIND, WEAR_KIND,
|
||||
};
|
||||
use fparkan_mission_format::{decode_tma, decode_tma_land_path};
|
||||
use fparkan_mission_format::{decode_tma, decode_tma_land_path, ClanBody};
|
||||
pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
|
||||
use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError};
|
||||
use fparkan_nres::{decode as decode_nres, ReadProfile};
|
||||
@@ -63,6 +63,17 @@ pub struct MissionTerrainPaths {
|
||||
pub land_map: NormalizedPath,
|
||||
}
|
||||
|
||||
/// Raw compiled-script base selected by one TMA clan.
|
||||
///
|
||||
/// The path remains raw legacy bytes until the runtime applies its path policy.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MissionScriptBundleBase {
|
||||
/// Index in the mission clan array.
|
||||
pub clan_index: usize,
|
||||
/// First clan resource path, without a required `.scr` suffix.
|
||||
pub path_raw: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Terrain loading errors that include runtime world construction failures.
|
||||
#[derive(Debug)]
|
||||
pub enum TerrainPreparationError {
|
||||
@@ -121,6 +132,29 @@ pub fn decode_mission_land_path(
|
||||
decode_tma_land_path(bytes, profile)
|
||||
}
|
||||
|
||||
/// Returns non-empty script/formula bases selected by TMA clans.
|
||||
///
|
||||
/// The first resource has the same position in the standard and spatial clan
|
||||
/// layouts. Its consumer-specific meaning is intentionally left to runtime.
|
||||
#[must_use]
|
||||
pub fn mission_script_bundle_bases(mission: &MissionDocument) -> Vec<MissionScriptBundleBase> {
|
||||
mission
|
||||
.clans
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(clan_index, clan)| {
|
||||
let path_raw = match &clan.body {
|
||||
ClanBody::Standard { first_resource, .. } => first_resource.path.raw.clone(),
|
||||
ClanBody::Spatial { first_resource, .. } => first_resource.raw.clone(),
|
||||
};
|
||||
(!path_raw.is_empty()).then_some(MissionScriptBundleBase {
|
||||
clan_index,
|
||||
path_raw,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Builds canonical mission terrain paths from the mission `Land` reference.
|
||||
///
|
||||
/// # Errors
|
||||
@@ -1251,6 +1285,8 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
|
||||
continue;
|
||||
};
|
||||
let root_index = root_index_for_prototype(graph, prototype_index);
|
||||
let unit_component_index =
|
||||
mesh_parent_edge.and_then(|edge| unit_component_index_for_edge(graph, edge));
|
||||
|
||||
wear_requests = wear_requests.saturating_add(1);
|
||||
report_visual_dependency_progress(
|
||||
@@ -1301,6 +1337,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
|
||||
Some(provenance_for_resource(
|
||||
root_index,
|
||||
mesh_parent_edge,
|
||||
unit_component_index,
|
||||
&wear_key,
|
||||
)),
|
||||
&mut next_edge,
|
||||
@@ -1375,6 +1412,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
|
||||
Some(provenance_for_resource(
|
||||
root_index,
|
||||
Some(wear_edge_id),
|
||||
unit_component_index,
|
||||
&material_key,
|
||||
)),
|
||||
&mut next_edge,
|
||||
@@ -1424,6 +1462,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
|
||||
Some(provenance_for_resource(
|
||||
root_index,
|
||||
Some(material_edge_id),
|
||||
unit_component_index,
|
||||
&texture_key,
|
||||
)),
|
||||
&mut next_edge,
|
||||
@@ -1500,6 +1539,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
|
||||
Some(provenance_for_resource(
|
||||
root_index,
|
||||
Some(wear_edge_id),
|
||||
unit_component_index,
|
||||
&lightmap_key,
|
||||
)),
|
||||
&mut next_edge,
|
||||
@@ -1622,11 +1662,13 @@ fn prototype_mesh_node_id(
|
||||
fn provenance_for_resource(
|
||||
root_index: usize,
|
||||
parent_edge: Option<fparkan_prototype::PrototypeGraphEdgeId>,
|
||||
unit_component_index: Option<usize>,
|
||||
resource: &ResourceKey,
|
||||
) -> PrototypeGraphProvenance {
|
||||
PrototypeGraphProvenance {
|
||||
root_index,
|
||||
parent_edge,
|
||||
unit_component_index,
|
||||
archive: Some(resource.archive.display_lossy().to_string()),
|
||||
resource: Some(resource.name.0.clone()),
|
||||
span: None,
|
||||
@@ -2010,6 +2052,8 @@ fn push_visual_failure(
|
||||
) {
|
||||
let root_index = root_index_for_prototype(graph, prototype_index);
|
||||
let parent_edge = parent_edge_for_failure(graph, prototype_index, edge);
|
||||
let unit_component_index =
|
||||
parent_edge.and_then(|value| unit_component_index_for_edge(graph, value));
|
||||
let dependency = mesh_dependency_resource(graph, prototype_index);
|
||||
report.failures.push(PrototypeGraphFailure {
|
||||
root_index,
|
||||
@@ -2020,6 +2064,7 @@ fn push_visual_failure(
|
||||
provenance: Some(PrototypeGraphProvenance {
|
||||
root_index,
|
||||
parent_edge,
|
||||
unit_component_index,
|
||||
archive: dependency.map(|resource| resource.archive.as_str().to_string()),
|
||||
resource: Some(resource_raw),
|
||||
span: None,
|
||||
@@ -2036,6 +2081,18 @@ fn root_index_for_prototype(graph: &PrototypeGraph, prototype_index: usize) -> u
|
||||
0
|
||||
}
|
||||
|
||||
fn unit_component_index_for_edge(
|
||||
graph: &PrototypeGraph,
|
||||
edge_id: fparkan_prototype::PrototypeGraphEdgeId,
|
||||
) -> Option<usize> {
|
||||
graph
|
||||
.edges
|
||||
.iter()
|
||||
.find(|edge| edge.id == edge_id)
|
||||
.and_then(|edge| edge.provenance.as_ref())
|
||||
.and_then(|provenance| provenance.unit_component_index)
|
||||
}
|
||||
|
||||
fn parent_edge_for_failure(
|
||||
graph: &PrototypeGraph,
|
||||
prototype_index: usize,
|
||||
@@ -2724,6 +2781,19 @@ mod tests {
|
||||
dependencies: Vec::new(),
|
||||
};
|
||||
let mut graph = prototype_graph_for_mesh(&prototype);
|
||||
graph
|
||||
.edges
|
||||
.iter_mut()
|
||||
.find(|edge| edge.kind == fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh)
|
||||
.expect("mesh edge")
|
||||
.provenance = Some(PrototypeGraphProvenance {
|
||||
root_index: 0,
|
||||
parent_edge: Some(fparkan_prototype::PrototypeGraphEdgeId(0)),
|
||||
unit_component_index: Some(3),
|
||||
archive: Some("static.rlb".to_string()),
|
||||
resource: Some(b"tree.msh".to_vec()),
|
||||
span: None,
|
||||
});
|
||||
let mut report = PrototypeGraphReport {
|
||||
root_count: 1,
|
||||
direct_reference_count: 1,
|
||||
@@ -2797,6 +2867,14 @@ mod tests {
|
||||
.iter()
|
||||
.filter_map(|edge| edge.provenance.as_ref())
|
||||
.all(|provenance| provenance.root_index == 0));
|
||||
for edge in [wear_edge, material_edge, texture_edge, lightmap_edge] {
|
||||
assert_eq!(
|
||||
edge.provenance
|
||||
.as_ref()
|
||||
.and_then(|provenance| provenance.unit_component_index),
|
||||
Some(3)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3528,6 +3606,7 @@ mod tests {
|
||||
roots: vec![prototype.key.clone()],
|
||||
prototype_requests: vec![prototype.key.clone()],
|
||||
root_prototype_request_spans: std::iter::once(0..1).collect(),
|
||||
root_unit_components: vec![Vec::new()],
|
||||
visual_dependencies_expanded: false,
|
||||
nodes: vec![root_node, prototype_node, mesh_node],
|
||||
edges: vec![
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
//! Stage-3 MSH asset contract.
|
||||
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
use fparkan_animation::{evaluate_hierarchy, AnimKey24, NodePoseBuffer, ParentIndex, Pose};
|
||||
use fparkan_animation::{
|
||||
evaluate_hierarchy, AnimKey24, AnimationTime, NodePoseBuffer, ParentIndex, Pose, TimedPoseKey,
|
||||
TimedPoseTrack,
|
||||
};
|
||||
use fparkan_nres::{EntryMeta, NresDocument, NresError};
|
||||
|
||||
/// Node table stream.
|
||||
@@ -478,6 +481,88 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
|
||||
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.
|
||||
///
|
||||
/// # Errors
|
||||
@@ -1273,6 +1358,61 @@ mod tests {
|
||||
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]
|
||||
fn type2_header_and_slot_tail_framing_are_exact() {
|
||||
let too_small = decode_nested(&build_nres(&[
|
||||
|
||||
@@ -128,6 +128,13 @@ pub struct PrototypeGraph {
|
||||
pub prototype_requests: Vec<PrototypeKey>,
|
||||
/// Mission object-local spans of effective prototype requests.
|
||||
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.
|
||||
@@ -162,6 +169,12 @@ pub struct PrototypeGraphProvenance {
|
||||
pub root_index: usize,
|
||||
/// Immediate parent edge that discovered this edge.
|
||||
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.
|
||||
pub archive: Option<String>,
|
||||
/// Source resource key when available.
|
||||
@@ -637,6 +650,7 @@ fn resolve_direct_prototype(
|
||||
struct ResolvedPrototypeRequests {
|
||||
expected_count: usize,
|
||||
prototypes: Vec<EffectivePrototype>,
|
||||
unit_components: Vec<UnitComponentRecord>,
|
||||
}
|
||||
|
||||
fn resolve_prototype_requests(
|
||||
@@ -652,6 +666,7 @@ fn resolve_prototype_requests(
|
||||
Ok(ResolvedPrototypeRequests {
|
||||
expected_count: 1,
|
||||
prototypes: prototype.into_iter().collect(),
|
||||
unit_components: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -686,6 +701,7 @@ fn resolve_unit_dat_prototype_requests(
|
||||
return Ok(ResolvedPrototypeRequests {
|
||||
expected_count: unit.records.len(),
|
||||
prototypes,
|
||||
unit_components: unit.records,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -697,6 +713,7 @@ fn resolve_unit_dat_prototype_requests(
|
||||
Ok(ResolvedPrototypeRequests {
|
||||
expected_count: 1,
|
||||
prototypes: prototype.into_iter().collect(),
|
||||
unit_components: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -741,8 +758,15 @@ pub fn build_prototype_graph(
|
||||
graph.roots.push(key);
|
||||
let start = graph.prototype_requests.len();
|
||||
let expansion = resolve_prototype_requests(repository, vfs, root)?;
|
||||
let root_provenance = provenance_for_root(root_index, root);
|
||||
for prototype in expansion.prototypes {
|
||||
graph
|
||||
.root_unit_components
|
||||
.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(
|
||||
prototype.key.clone(),
|
||||
PrototypeGraphNodeId(next_node),
|
||||
@@ -781,6 +805,7 @@ pub fn build_prototype_graph(
|
||||
provenance: Some(provenance_for_mesh(
|
||||
root_index,
|
||||
root_to_prototype_edge_id,
|
||||
unit_component_index,
|
||||
dependency,
|
||||
)),
|
||||
});
|
||||
@@ -836,16 +861,23 @@ pub fn build_prototype_graph_report(
|
||||
root_node,
|
||||
));
|
||||
let start = graph.prototype_requests.len();
|
||||
let root_provenance = provenance_for_root(root_index, root);
|
||||
|
||||
match resolve_prototype_requests(repository, vfs, root) {
|
||||
Ok(expansion) => {
|
||||
let expected = expansion.expected_count;
|
||||
graph
|
||||
.root_unit_components
|
||||
.push(expansion.unit_components.clone());
|
||||
if edge == PrototypeGraphEdge::MissionToUnitDat {
|
||||
report.unit_component_count += expected;
|
||||
}
|
||||
let actual = expansion.prototypes.len();
|
||||
for prototype in expansion.prototypes {
|
||||
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(
|
||||
prototype.key.clone(),
|
||||
PrototypeGraphNodeId(next_node),
|
||||
@@ -886,6 +918,7 @@ pub fn build_prototype_graph_report(
|
||||
provenance: Some(provenance_for_mesh(
|
||||
root_index,
|
||||
root_to_prototype_edge_id,
|
||||
unit_component_index,
|
||||
dependency,
|
||||
)),
|
||||
});
|
||||
@@ -907,6 +940,7 @@ pub fn build_prototype_graph_report(
|
||||
provenance: Some(PrototypeGraphProvenance {
|
||||
root_index,
|
||||
parent_edge: None,
|
||||
unit_component_index: None,
|
||||
archive: None,
|
||||
resource: Some(root.0.clone()),
|
||||
span: None,
|
||||
@@ -923,12 +957,16 @@ pub fn build_prototype_graph_report(
|
||||
provenance: Some(PrototypeGraphProvenance {
|
||||
root_index,
|
||||
parent_edge: None,
|
||||
unit_component_index: None,
|
||||
archive: None,
|
||||
resource: Some(root.0.clone()),
|
||||
span: None,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
if graph.root_unit_components.len() <= root_index {
|
||||
graph.root_unit_components.push(Vec::new());
|
||||
}
|
||||
let end = graph.prototype_requests.len();
|
||||
graph.root_prototype_request_spans.push(start..end);
|
||||
}
|
||||
@@ -964,10 +1002,15 @@ fn graph_error_edge(edge: PrototypeGraphEdge, err: &PrototypeError) -> Prototype
|
||||
}
|
||||
}
|
||||
|
||||
fn provenance_for_root(root_index: usize, root: &ResourceName) -> PrototypeGraphProvenance {
|
||||
fn provenance_for_root(
|
||||
root_index: usize,
|
||||
root: &ResourceName,
|
||||
unit_component_index: Option<usize>,
|
||||
) -> PrototypeGraphProvenance {
|
||||
PrototypeGraphProvenance {
|
||||
root_index,
|
||||
parent_edge: None,
|
||||
unit_component_index,
|
||||
archive: None,
|
||||
resource: Some(root.0.clone()),
|
||||
span: None,
|
||||
@@ -977,11 +1020,13 @@ fn provenance_for_root(root_index: usize, root: &ResourceName) -> PrototypeGraph
|
||||
fn provenance_for_mesh(
|
||||
root_index: usize,
|
||||
parent_edge: PrototypeGraphEdgeId,
|
||||
unit_component_index: Option<usize>,
|
||||
dependency: &ResourceKey,
|
||||
) -> PrototypeGraphProvenance {
|
||||
PrototypeGraphProvenance {
|
||||
root_index,
|
||||
parent_edge: Some(parent_edge),
|
||||
unit_component_index,
|
||||
archive: Some(dependency.archive.as_str().to_string()),
|
||||
resource: Some(dependency.name.0.clone()),
|
||||
span: None,
|
||||
@@ -1770,6 +1815,45 @@ mod tests {
|
||||
assert_eq!(graph.prototype_requests.len(), 2);
|
||||
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.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!(report.unit_reference_count, 1);
|
||||
assert_eq!(report.unit_component_count, 2);
|
||||
|
||||
@@ -11,7 +11,9 @@ fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
||||
fparkan-platform = { path = "../fparkan-platform", version = "0.1.0" }
|
||||
fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
|
||||
fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
|
||||
fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
|
||||
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
|
||||
fparkan-script = { path = "../fparkan-script", version = "0.1.0" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
||||
fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[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
@@ -36,6 +36,7 @@ pub struct TerrainWorld {
|
||||
grid: RuntimeGrid,
|
||||
adjacency: Vec<Vec<ArealId>>,
|
||||
surfaces: Vec<RuntimeTriangle>,
|
||||
surface_bvh: SurfaceBvh,
|
||||
source_mesh: Option<LandMeshDocument>,
|
||||
}
|
||||
|
||||
@@ -239,6 +240,7 @@ impl TerrainWorld {
|
||||
grid,
|
||||
adjacency,
|
||||
surfaces: Vec::new(),
|
||||
surface_bvh: SurfaceBvh::default(),
|
||||
source_mesh: None,
|
||||
})
|
||||
}
|
||||
@@ -252,9 +254,11 @@ impl TerrainWorld {
|
||||
pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> {
|
||||
Ok(Self {
|
||||
surfaces: build_surfaces(mesh)?,
|
||||
surface_bvh: SurfaceBvh::default(),
|
||||
source_mesh: Some(mesh.clone()),
|
||||
..Self::default()
|
||||
})
|
||||
}
|
||||
.with_surface_bvh())
|
||||
}
|
||||
|
||||
/// Builds terrain runtime data from decoded `Land.msh` and `Land.map`.
|
||||
@@ -269,6 +273,7 @@ impl TerrainWorld {
|
||||
) -> Result<Self, TerrainError> {
|
||||
let mut world = Self::from_land_map(map)?;
|
||||
world.surfaces = build_surfaces(mesh)?;
|
||||
world.surface_bvh = SurfaceBvh::from_surfaces(&world.surfaces);
|
||||
world.source_mesh = Some(mesh.clone());
|
||||
Ok(world)
|
||||
}
|
||||
@@ -303,6 +308,11 @@ impl TerrainWorld {
|
||||
.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(
|
||||
&self,
|
||||
position: [f32; 3],
|
||||
@@ -399,7 +409,8 @@ impl SurfaceQuery for TerrainWorld {
|
||||
return Err(TerrainError::Unsupported);
|
||||
}
|
||||
let mut best = None;
|
||||
for triangle in &self.surfaces {
|
||||
for index in self.surface_bvh.xy_candidates(position) {
|
||||
let triangle = &self.surfaces[index];
|
||||
if let Some(height) = triangle.height_at(position) {
|
||||
best = Some(best.map_or(height, |current: f32| current.max(height)));
|
||||
}
|
||||
@@ -416,8 +427,11 @@ impl SurfaceQuery for TerrainWorld {
|
||||
if self.surfaces.is_empty() {
|
||||
return Err(TerrainError::Unsupported);
|
||||
}
|
||||
let mut candidates = self.surface_bvh.ray_candidates(origin, direction);
|
||||
candidates.sort_unstable();
|
||||
let mut best: Option<SurfaceHit> = None;
|
||||
for triangle in &self.surfaces {
|
||||
for index in candidates {
|
||||
let triangle = &self.surfaces[index];
|
||||
if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -472,6 +486,202 @@ struct RuntimeTriangle {
|
||||
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 {
|
||||
fn height_at(&self, position: [f32; 2]) -> Option<f32> {
|
||||
let a = [self.vertices[0][0], self.vertices[0][1]];
|
||||
@@ -843,6 +1053,30 @@ 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]
|
||||
#[ignore = "requires licensed corpus"]
|
||||
fn licensed_corpus_land_maps_build_navigation_worlds() {
|
||||
@@ -907,6 +1141,7 @@ mod tests {
|
||||
let root = corpus_root(corpus);
|
||||
let mut files = 0usize;
|
||||
let mut faces = 0usize;
|
||||
let mut indexed_queries = 0usize;
|
||||
for path in files_under(&root) {
|
||||
if !path
|
||||
.file_name()
|
||||
@@ -925,12 +1160,33 @@ mod tests {
|
||||
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
|
||||
let world = TerrainWorld::from_land_msh(&mesh)
|
||||
.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;
|
||||
faces += world.surface_count();
|
||||
}
|
||||
|
||||
assert_eq!(files, expected_files, "{corpus} Land.msh count");
|
||||
assert_eq!(faces, expected_faces, "{corpus} surface face count");
|
||||
assert_eq!(indexed_queries, files, "{corpus} indexed Land.msh count");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
use fparkan_binary::sha256;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v2\0";
|
||||
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v3\0";
|
||||
|
||||
/// Object handle with generation.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
@@ -50,6 +50,20 @@ pub struct Tick(pub u64);
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
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.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum WorldPhase {
|
||||
@@ -110,6 +124,8 @@ pub struct WorldSnapshot {
|
||||
pub tick: Tick,
|
||||
/// Live object handles.
|
||||
pub objects: Vec<ObjectHandle>,
|
||||
/// Exact transforms for live registered objects in object-handle order.
|
||||
pub transforms: Vec<(ObjectHandle, TransformState)>,
|
||||
/// Commands processed during this step.
|
||||
pub events: Vec<WorldEvent>,
|
||||
/// State hash.
|
||||
@@ -167,6 +183,7 @@ struct Slot {
|
||||
owner_id: Option<OwnerId>,
|
||||
mirror_id: Option<OriginalObjectId>,
|
||||
registration_sequence: Option<u64>,
|
||||
transform: TransformState,
|
||||
}
|
||||
|
||||
/// World.
|
||||
@@ -248,6 +265,7 @@ pub fn construct_object(world: &mut World, draft: ObjectDraft) -> Result<ObjectH
|
||||
owner_id: None,
|
||||
mirror_id: None,
|
||||
registration_sequence: None,
|
||||
transform: TransformState::default(),
|
||||
});
|
||||
Ok(handle)
|
||||
}
|
||||
@@ -335,6 +353,43 @@ 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.
|
||||
///
|
||||
/// # Errors
|
||||
@@ -423,6 +478,7 @@ where
|
||||
let snapshot = WorldSnapshot {
|
||||
tick: world.tick,
|
||||
objects: live_registered(world),
|
||||
transforms: live_registered_with_transforms(world),
|
||||
events,
|
||||
hash: canonical_state_hash(world),
|
||||
};
|
||||
@@ -459,6 +515,7 @@ fn canonical_state_bytes(world: &World) -> Vec<u8> {
|
||||
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_u64(&mut out, slot.registration_sequence);
|
||||
push_transform(&mut out, slot.transform);
|
||||
}
|
||||
push_len(&mut out, world.queue.len());
|
||||
for command in &world.queue {
|
||||
@@ -639,6 +696,17 @@ fn push_handle(out: &mut Vec<u8>, handle: ObjectHandle) {
|
||||
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> {
|
||||
let slot = world
|
||||
.slots
|
||||
@@ -689,6 +757,24 @@ fn live_registered(world: &World) -> Vec<ObjectHandle> {
|
||||
.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -704,6 +790,25 @@ mod tests {
|
||||
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]
|
||||
fn registration_sequence_stale_and_duplicate_original_contracts() {
|
||||
let mut world = new(WorldConfig);
|
||||
@@ -723,10 +828,16 @@ mod tests {
|
||||
.expect("second");
|
||||
register_object(&mut world, first).expect("register first");
|
||||
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, second), Ok(Some(1)));
|
||||
|
||||
request_delete(&mut world, first).expect("delete");
|
||||
assert_eq!(handle_by_original_id(&world, OriginalObjectId(7)), None);
|
||||
assert_eq!(
|
||||
register_object(&mut world, first),
|
||||
Err(WorldError::StaleHandle)
|
||||
|
||||
@@ -113,36 +113,22 @@ key, configuration, device profile, initial state, input/time script и верс
|
||||
|
||||
## Local evidence requests
|
||||
|
||||
На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
|
||||
Для локально воспроизводимого Desktop backend подтверждено только command/state trace
|
||||
в существующем GL-воркфлоу:
|
||||
Поддерживаемая desktop-платформа — только Windows/Vulkan. Локальный smoke
|
||||
`fparkan-vulkan-smoke` уже подтверждает настоящий Win32 surface/swapchain,
|
||||
300 кадров, controlled resize и отсутствие validation warnings/errors.
|
||||
Это закрывает фундамент Stage 0, но не доказывает визуальный паритет игры.
|
||||
|
||||
- `fixtures/acceptance/macos-gl33-triangle-capture.json`;
|
||||
Следующие доказательства всё ещё нужны именно для Iron3D-compatible рендера:
|
||||
|
||||
`S3-GL-001` пока не закрыт: текущая evidence не отражает полноценный
|
||||
`winit`+`fparkan-render-vulkan` path с real surface/present pipeline.
|
||||
Для закрытия требования требуется постоянный workspace-владельческий backend на
|
||||
`winit`/`fparkan-platform-winit` + `fparkan-render-vulkan` с реальным
|
||||
surface/present pipeline, command/state parity и licensed frame capture.
|
||||
- capability-gated capture из оригинального GOG процесса с camera/matrix,
|
||||
draw/state и frame-boundary provenance;
|
||||
- фиксированные Windows Vulkan captures статической модели, lightmapped модели
|
||||
и terrain после совпадения backend-neutral command capture;
|
||||
- затем controlled captures анимации, FX, прозрачности, теней и атмосферы.
|
||||
|
||||
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
|
||||
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
|
||||
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.
|
||||
Linux/macOS, GLES2, RG40XX и удалённые portability runners не являются
|
||||
acceptance-гейтами этого проекта. Они не должны появляться в списке блокеров
|
||||
или подменять Windows evidence.
|
||||
|
||||
## Closure criteria
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Кампания, сохранения и восстановление сессии
|
||||
|
||||
## Известная файловая поверхность
|
||||
|
||||
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 с оригинальными файлами.
|
||||
@@ -0,0 +1,335 @@
|
||||
# Сценарная 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.
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Сверка локальной книги с 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 уже разнесены по
|
||||
тематическим локальным документам, а новые результаты разработки должны
|
||||
добавляться только локально.
|
||||
@@ -0,0 +1,158 @@
|
||||
# План реализации: 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`.
|
||||
+9
-3
@@ -12,9 +12,10 @@ Iron3D из *Parkan: Iron Strategy*. Она ведёт от запуска ор
|
||||
## Источник истины и синхронизация
|
||||
|
||||
Каталог `docs/` — единственный рабочий источник истины для FParkan. 18 июля
|
||||
2026 года его постранично сопоставили с книгой FParkan в Notion: восемь томов
|
||||
и приложения уже покрывают её 42 основные статьи, а актуальные локальные
|
||||
материалы содержат и последующие результаты разработки. Notion больше не
|
||||
2026 года его содержательно сопоставили с книгой FParkan в Notion; результат,
|
||||
маршрутизация статей и сознательно исключённые цели зафиксированы в
|
||||
[сверке](baseline/notion-reconciliation.md). Актуальные локальные материалы
|
||||
содержат и последующие результаты разработки. Notion больше не
|
||||
является местом внесения изменений: новые факты, исправления и решения
|
||||
фиксируются только здесь, в томе, справочнике или evidence-документе, которому
|
||||
они принадлежат.
|
||||
@@ -24,6 +25,11 @@ scope проекта: самостоятельный runtime ориентиро
|
||||
Vulkan. Исторические упоминания других ОС в старых внешних заметках не
|
||||
расширяют поддерживаемую платформу.
|
||||
|
||||
Действующий dependency-ordered план `stage 0--5` находится в
|
||||
[Vulkan revision для Windows](baseline/vulkan-revision-plan.md). Это
|
||||
редакторская локальная версия: она включает недостающие контрактные требования
|
||||
из Notion, но не переносит снятые с проекта Linux/macOS и hosted-CI цели.
|
||||
|
||||
## Как читать
|
||||
|
||||
Если вы впервые разбираете игровой движок, начните с тома I и II. Там вводится
|
||||
|
||||
@@ -468,6 +468,37 @@ string длиной 32 байта. Требование обязательног
|
||||
Части 2. Все соответствуют формуле размера, `kind == 1` и
|
||||
`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, материал и текстура отвечают за видимую форму. Полноценный прототип
|
||||
|
||||
@@ -135,6 +135,14 @@ repeat relation_count:
|
||||
TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как
|
||||
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:
|
||||
|
||||
```text
|
||||
@@ -555,6 +563,27 @@ candidate areas, затем выполняется точная геометри
|
||||
Если область не найдена, caller получает явный miss и решает, допустим ли
|
||||
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.
|
||||
|
||||
### Маршрут
|
||||
|
||||
После определения начальной и целевой областей маршрут строится по графу
|
||||
|
||||
+169
-17
@@ -78,6 +78,19 @@ drivers и video modes, проверяет поддержку 3D, перевод
|
||||
функции DirectDraw/Direct3D семейства 5-7 и публикует refcounted renderer.
|
||||
`niGet3DRender` возвращает уже созданный объект и увеличивает число владельцев.
|
||||
|
||||
Статическая проверка подтверждает этот вывод без подмены его runtime-паритетом:
|
||||
`Ngi32.dll` импортирует `LoadLibraryA`, `GetProcAddress` и `FreeLibrary`, но не
|
||||
имеет статического импорта `DDRAW.dll`; в нём присутствуют строки `DDRAW`,
|
||||
`DirectDrawCreate`, `DirectDrawCreateEx`, `DirectDrawEnumerateA` и
|
||||
`DirectDrawEnumerateExA`, а также GUID семейств `IDirectDraw` 1/2/4/7,
|
||||
`IDirect3D` 1/2/3/7 и соответствующих `IDirect3DDevice`. Следовательно,
|
||||
современная реализация заменяет наблюдаемую fixed-function семантику над этой
|
||||
границей, а не должна эмулировать COM-объекты DirectX. Равный SHA-256 Ngi32 в
|
||||
Частях 1 и 2 зафиксирован в
|
||||
[evidence](../evidence/original_engine_hashes.md); это поддерживает единый
|
||||
Vulkan backend contract, но не отменяет раздельных corpus/capture baselines
|
||||
для изменённых assets и gameplay DLL.
|
||||
|
||||
```text
|
||||
enumerate adapters and video modes
|
||||
-> choose CURRENT_D3DCARD
|
||||
@@ -877,6 +890,14 @@ bytes с зафиксированным reference capture.
|
||||
|
||||
### First synchronized Vulkan pixel-readback artifact
|
||||
|
||||
The current artifact contract is one final swapchain image, not a
|
||||
concatenation of every swapchain allocation. After the final successful
|
||||
graphics submission, the renderer retains that image index; synchronized
|
||||
teardown reads only its matching buffer. The raw payload is therefore exactly
|
||||
`width * height * 4` bytes for the current format-50 path. Historical
|
||||
multi-image byte counts below describe the superseded concatenation behavior
|
||||
and are not comparable to this final-image contract.
|
||||
|
||||
Stage 3 static viewer теперь выполняет фактический readback для surface с
|
||||
`TRANSFER_SRC`: на каждый swapchain image создаётся host-visible coherent
|
||||
`TRANSFER_DST` buffer. После render pass command buffer переводит image из
|
||||
@@ -895,8 +916,11 @@ static viewer. Он ещё не захватывает original DirectDraw frame
|
||||
fixed original camera и не сравнивает два изображения, поэтому pixel-parity
|
||||
acceptance остаётся blocked.
|
||||
|
||||
Smoke также сохраняет raw artifact рядом с JSON: `<report-stem>.readback-vkformat-<raw>.raw`.
|
||||
Это concatenated current-swapchain images in Vulkan order, каждый с dimensions
|
||||
The following paragraph records the superseded multi-image export only as
|
||||
historical bootstrap evidence; it is not the current artifact contract.
|
||||
|
||||
Smoke также сохранял raw artifact рядом с JSON: `<report-stem>.readback-vkformat-<raw>.raw`.
|
||||
Это были concatenated current-swapchain images in Vulkan order, каждый с dimensions
|
||||
из JSON и четырьмя bytes per pixel; format намеренно указан в имени файла,
|
||||
потому что bytes не перекодируются; JSON содержит actual raw enum. GOG selected `50` and produced a 4,147,200-byte file
|
||||
for two 960x540 images. Артефакт остаётся локальным output и не попадает в Git.
|
||||
@@ -1421,14 +1445,15 @@ proves ownership and the D3D7 boundary, while keeping the earlier
|
||||
`CBufferingCamera` FOV=`1.04` as a distinct upstream interface value.
|
||||
|
||||
The live GOG layout is now independently repeatable without invoking any game
|
||||
method: `Terrain.dll + 0x7355c` contains the current camera interface pointer,
|
||||
whose vtable relocates to `Terrain.dll` RVA `0x665b4`. Its selector-0 block
|
||||
starts with one 32-bit internal tag at interface `+28`; the actual sixteen
|
||||
row-major matrix words begin at `+32`. Captures for `RawCameraTransform` must
|
||||
therefore contain those sixteen payload words, not the tag. A passive AutoDemo
|
||||
sample produced a finite affine camera matrix and passed through the offline
|
||||
Vulkan adapter as source-world geometry, rather than relying on a guessed
|
||||
identity view.
|
||||
method: `Terrain.dll + 0x7355c` contains the current outer camera-object
|
||||
pointer, whose vtable relocates to `Terrain.dll` RVA `0x665b4`. Selector 0
|
||||
returns the affine block beginning at outer `+0x20`; its sixteen row-major
|
||||
words have translations at indices 3, 7 and 11. The public `LoadCamera`
|
||||
interface at outer `+0x134` is a different view and must not be substituted
|
||||
for that block. Captures for `RawCameraTransform` therefore contain the sixteen
|
||||
selector-0 words from outer `+0x20`. A passive AutoDemo sample produced a
|
||||
finite affine camera matrix and passed through the offline Vulkan adapter as
|
||||
source-world geometry, rather than relying on a guessed identity view.
|
||||
|
||||
The Vulkan static path now accepts this recovered camera as
|
||||
`VulkanStaticCamera`. It composes the row-major D3D7 result as
|
||||
@@ -1498,14 +1523,49 @@ errors. This validates the offline handoff for one captured instant; it does
|
||||
not claim live camera tracking, material parity, or an original-frame pixel
|
||||
comparison.
|
||||
|
||||
`tools/capture-original-camera.ps1` makes that handoff repeatable for a live
|
||||
GOG process. It finds the actual `Terrain.dll` base with Toolhelp module
|
||||
enumeration, opens the chosen PID with only
|
||||
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, verifies the expected outer
|
||||
vtable, and reads exactly 64 selector-0 bytes at outer `+0x20`. AutoDemo can
|
||||
briefly select a normalized/reflection-like camera object whose finite
|
||||
translation is near the origin. The tool therefore re-reads the global on
|
||||
each attempt and accepts a sample only when the proven translation length
|
||||
exceeds its explicit `-MinimumWorldTranslation` threshold (default `100`);
|
||||
the JSON retains the accepted outer pointer and translation for audit. It emits the
|
||||
`fparkan-legacy-camera-v1` JSON accepted by `fparkan-game`; it does not write
|
||||
to the process, send window messages, inject code, suspend threads, or invoke
|
||||
an original method. PowerShell execution policy may require the explicit
|
||||
one-shot bypass below; that changes neither the script nor the game.
|
||||
|
||||
```powershell
|
||||
$capture = powershell -NoProfile -ExecutionPolicy Bypass -File `
|
||||
.\tools\capture-original-camera.ps1 -ProcessId 5632
|
||||
$capture | Set-Content -NoNewline target\fparkan\live-camera.json
|
||||
cargo run -q -p fparkan-game -- --root 'C:\GOG Games\Parkan - Iron Strategy' `
|
||||
--mission 'MISSIONS\Autodemo.00\data.tma' --backend static-vulkan --frames 3 `
|
||||
--legacy-camera-capture target\fparkan\live-camera.json `
|
||||
--readback-out target\fparkan\autodemo-live-camera.raw
|
||||
```
|
||||
|
||||
On the unattended live AutoDemo the accepted world-space capture completed the
|
||||
same all-root static bridge with 10,152 clip-visible vertices, 71 descriptors,
|
||||
a 7,372,800-byte format-50 readback, and zero Vulkan validation
|
||||
warnings/errors. A passive desktop-surface capture of that original window also
|
||||
proved that the selected instant contains the textured terrain, atmospheric
|
||||
sky, HUD and weapon FX; `PrintWindow` itself returns a black Direct3D buffer,
|
||||
so it must not be treated as an original-frame oracle. The paired images expose
|
||||
the next real renderer gap: sharing the raw pose and Ngi32 projection inputs is
|
||||
not yet pixel parity, because the static bridge still lacks the full runtime
|
||||
camera-selection timing, terrain composition, culling, lighting, UI and FX.
|
||||
|
||||
For visual regression work, `fparkan-game --backend static-vulkan` also accepts
|
||||
`--readback-out <path>`. It writes the final synchronized Vulkan readback bytes
|
||||
only after the renderer has completed its normal teardown evidence; the JSON
|
||||
report records the raw Vulkan format, byte count, hash, and requested path. A
|
||||
three-frame diagnostic AutoDemo run wrote 7,372,800 bytes at format `50`
|
||||
(`VK_FORMAT_B8G8R8A8_UNORM`) with the same frame hash reported by the renderer.
|
||||
The artifact is a Vulkan-side comparison input, not an asserted original-frame
|
||||
image.
|
||||
`--readback-out <path>`. It writes the final synchronized Vulkan swapchain
|
||||
image only after the renderer has completed its normal teardown evidence; the
|
||||
JSON report records its raw Vulkan format, byte count, hash, and requested
|
||||
path. At 1280x720, format `50` (`VK_FORMAT_B8G8R8A8_UNORM`) produces exactly
|
||||
3,686,400 bytes. The artifact is a Vulkan-side comparison input, not an
|
||||
asserted original-frame image.
|
||||
|
||||
The first bounded launch showed that repeated fingerprinting, rather than
|
||||
Vulkan initialization, was the load-path bottleneck: each new MAT0/TEXM request
|
||||
@@ -1688,6 +1748,38 @@ recovered lookup routine. Therefore a future runtime implementation must treat
|
||||
cache availability as contextual and cannot dereference or prebuild the cache
|
||||
solely from constructor state.
|
||||
|
||||
`tools/capture-terrain-shade-cache.ps1` now makes the same evidence repeatable
|
||||
without debugger attachment. It discovers `Terrain.dll` with Toolhelp, opens
|
||||
only `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, searches readable
|
||||
32-bit-process regions for the already proven cache vtable, and excludes the
|
||||
Terrain image itself because relocations there also contain that pointer value.
|
||||
It reports only addresses and count metadata; it does not alter the process or
|
||||
save game resources. On the unattended GOG AutoDemo process 5632 on 2026-07-18
|
||||
it found the live cache at `0x035C64B4`, with entry table `0x17284590` and
|
||||
exclusive count `3462`. Two adjacent passive captures retained the cache
|
||||
address/table/count but returned result views `0x0274BD89` and `0x008A60DC`.
|
||||
That changing result view is direct evidence that it is transient lookup
|
||||
state, not a persistent cache identity or a WEAR row. The observation narrows
|
||||
the next terrain task to profiling the cache's generated entries and their
|
||||
consumer; it still does not establish a Land1 blend equation.
|
||||
|
||||
The probe now also reads the bounded eight-byte entry table and only hashes
|
||||
the fixed 212-byte profile banks it observes; raw process bytes never enter
|
||||
the repository. The same AutoDemo cache had 3,426 materialized record pointers
|
||||
among 3,462 entries and referenced banks `0..4`. Two consecutive captures
|
||||
kept that count, bank set, and all five FNV-1a-64 bank hashes stable:
|
||||
`9426539192111566946`, `2915543853261903601`, `13156371703608727216`,
|
||||
`11280405573172155968`, and `18234288329145104612`. Their result views changed
|
||||
from `0x008A565C` to `0x008A55A8`. Thus the generated cache/profile state is
|
||||
stable over these render samples while the shared result view is not. This
|
||||
still identifies neither the record-field semantics nor the final terrain
|
||||
blend/pass operation.
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File `
|
||||
tools\capture-terrain-shade-cache.ps1 -ProcessId <iron_3d-pid>
|
||||
```
|
||||
|
||||
The static Vulkan bridge now carries each contiguous face run as a separate
|
||||
draw range keyed by the original packed `material_tag`; it neither reorders
|
||||
triangles nor collapses the high byte. For the first usable visual layer, the
|
||||
@@ -1794,3 +1886,63 @@ path; он не является оценкой сходства с original fra
|
||||
`95.29412..288.23532` становятся `2.978..9.007`, что согласуется с TMA Z и
|
||||
live camera height. Это доказанное coordinate conversion, но оно само по себе
|
||||
не сделало вершины видимыми и не является заявлением о pixel parity.
|
||||
|
||||
### Explicit offline Node38 animation-frame preview
|
||||
|
||||
The static Vulkan path now accepts `--static-animation-frame <u16>`. For each
|
||||
standard `Node38` model it resolves the already-decoded type-19 map at that
|
||||
logical frame, selects its mapped type-8 key when it is valid before the
|
||||
node's fallback key, samples the selected key and its immediate successor with
|
||||
the portable reference sampler, then composes the resulting parent hierarchy.
|
||||
Nodes with no usable map, or a requested frame outside the declared map,
|
||||
retain their exact fallback pose. An invalid/incomplete hierarchy deliberately
|
||||
falls back to the existing unposed static geometry path instead of guessing a
|
||||
runtime state.
|
||||
|
||||
This option is a reproducible asset-viewer probe, not a gameplay clock: it
|
||||
does not identify the original animation controller, x87 rounding profile,
|
||||
clip selection, LOD/group controller, blending or FX timing. The JSON report
|
||||
contains `animation_frame` (`null` for the established fallback preview) so
|
||||
readbacks from the two modes cannot be mistaken for each other. On 2026-07-18,
|
||||
the licensed GOG `MISSIONS/Autodemo.00/data.tma` run with all eight roots,
|
||||
`--static-animation-frame 1`, two frames and format `50` completed with 66
|
||||
mesh components, 75,543 clip-visible vertices, a 1280×720 / 3,686,400-byte
|
||||
readback hash `4638497144561211935`, and validation warnings/errors `0/0`.
|
||||
|
||||
### Windows PowerShell camera-capture hand-off
|
||||
|
||||
`tools/capture-original-camera.ps1` is an observer: it opens the original
|
||||
process only with `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, samples the
|
||||
active Terrain camera and emits `fparkan-legacy-camera-v1` JSON. Windows
|
||||
PowerShell 5.1 commonly redirects or pipes that text as UTF-16LE with a BOM.
|
||||
The Vulkan viewer now accepts BOM-marked UTF-16LE and UTF-16BE in addition to
|
||||
UTF-8 (including an UTF-8 BOM), so a normal PowerShell capture can be used
|
||||
directly as `--legacy-camera-capture` without a lossy manual re-encoding step.
|
||||
Malformed byte lengths and invalid Unicode remain explicit load errors.
|
||||
|
||||
Fresh read-only evidence from the running licensed GOG AutoDemo sampled outer
|
||||
camera `0x0B368FD8` in `Terrain.dll` at base `0x02510000`; selector-0
|
||||
translation was `(548.020752, 559.672852, 3.00516248)`. Passing the captured
|
||||
UTF-16LE file to the native Vulkan viewer rendered all eight mission roots
|
||||
with `camera_mode="legacy-d3d7-capture"`, 66 mesh components, 6,787
|
||||
clip-visible vertices, 71 material descriptors, a 1280x720 / 3,686,400-byte
|
||||
format-50 readback hash `7904810245714997753`, and validation warnings/errors
|
||||
`0/0`. This proves the live-camera hand-off and projection path; it does not
|
||||
establish frame-by-frame camera-selection parity, terrain shading or pixel
|
||||
parity with the original renderer.
|
||||
|
||||
### Explicit MAT0 phase preview
|
||||
|
||||
`fparkan-game --backend static-vulkan --static-material-phase <u16>` now
|
||||
selects the exact decoded MAT0 texture for that phase of each MSH material.
|
||||
The override is deliberately per material: when the requested phase is absent
|
||||
or intentionally untextured, that material retains its existing first prepared
|
||||
diffuse request, rather than fabricating a texture or aborting the whole
|
||||
mission. Terrain remains on its separately proven Land2 base path. The JSON
|
||||
report records `material_phase` so the artifact's selector is explicit. This
|
||||
is an asset-viewer control, not a claim about the original material clock,
|
||||
phase mode, interpolation, random offset, lighting or Land1 composition.
|
||||
|
||||
On 2026-07-18 the licensed GOG AutoDemo run with all eight roots, phase `1`,
|
||||
two frames and format `50` completed validation-clean with 71 descriptors and
|
||||
a 1280×720 / 3,686,400-byte readback hash `3152437928708207395`.
|
||||
|
||||
@@ -133,6 +133,33 @@ Wizard получает желаемое направление и corridor, а
|
||||
описывает типы, defaults, ranges и строки через макросоподобные формы
|
||||
`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-модель:
|
||||
|
||||
```text
|
||||
@@ -154,6 +181,36 @@ IDs и отправляет команды через игровые interfaces,
|
||||
открытым направлением. До появления decompiler-а `.scr` binary body сохраняется
|
||||
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-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
|
||||
@@ -206,6 +263,82 @@ CreateCollObject
|
||||
тригонометрию и `g_FastProc`. Это подтверждает его положение между gameplay
|
||||
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
|
||||
|
||||
`LoadControlSystem` загружает настройки controller-а: ограничения скорости,
|
||||
@@ -290,6 +423,46 @@ Control получает world-interface Terrain и использует пов
|
||||
последовательности, а интеграция использует одну политику `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.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals
|
||||
|
||||
@@ -374,6 +374,29 @@ Demo mission total: 201 objects -> 501 prototypes -> 501 object MSH/WEAR.
|
||||
|
||||
### 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 и значения
|
||||
внешних часов. На контрольных ticks сохраняется canonical state hash:
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ schema = 1
|
||||
"4" = [
|
||||
"fparkan-animation",
|
||||
"fparkan-fx",
|
||||
"fparkan-script",
|
||||
]
|
||||
"5" = [
|
||||
"fparkan-game",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
[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) }
|
||||
@@ -0,0 +1,213 @@
|
||||
[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)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
[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)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Emits the decompiled AI expression evaluator containing the recovered
|
||||
// tag 1..5 dispatch. Run through Ghidra headless analysis only; it never
|
||||
// mutates the original PE image.
|
||||
import ghidra.app.decompiler.DecompInterface;
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Function;
|
||||
|
||||
public class ExportAiExpressionDispatcher extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10005180L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionContaining(address);
|
||||
println("===== AI expression dispatcher =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
println("entry=" + function.getEntryPoint());
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the x87-to-integer helper called by Handler(19). 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 ExportAiFtol extends GhidraScript {
|
||||
private static final long ADDRESS = 0x1001df70L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI x87 __ftol helper =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the GOG ai.dll GetSuperAI export to recover the live singleton
|
||||
// boundary for read-only handler-input capture.
|
||||
import ghidra.app.decompiler.DecompInterface;
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Function;
|
||||
|
||||
public class ExportAiGetSuperAi extends GhidraScript {
|
||||
private static final long ADDRESS = 0x1000f780L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI GetSuperAI =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the GOG AI script-bundle loader, discovered from references to
|
||||
// "MISSIONS\\SCRIPTS\\" and ".scr". Run headless; original input stays 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 ExportAiScriptLoader extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10001000L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI script loader =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Emits path literals used by the GOG AI script loader at 0x10001000.
|
||||
// Run headless; the original PE remains read only.
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
|
||||
public class ExportAiScriptLoaderStrings extends GhidraScript {
|
||||
private static final long[] ADDRESSES = {
|
||||
0x10038a88L, 0x10038a9cL, 0x10038aa4L, 0x10038aacL,
|
||||
0x10038ab4L, 0x10038abcL, 0x10038ad0L, 0x10038ad8L,
|
||||
0x10038ae0L
|
||||
};
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
for (long value : ADDRESSES) {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(value);
|
||||
byte[] bytes = new byte[256];
|
||||
int count = currentProgram.getMemory().getBytes(address, bytes);
|
||||
StringBuilder text = new StringBuilder();
|
||||
for (int index = 0; index < count && bytes[index] != 0; index++) {
|
||||
text.append((char) (bytes[index] & 0xff));
|
||||
}
|
||||
println(address + " = \"" + text + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the immediate .scr package reader called by the AI script loader.
|
||||
// Run through Ghidra headless analysis; the original PE is never modified.
|
||||
import ghidra.app.decompiler.DecompInterface;
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Function;
|
||||
|
||||
public class ExportAiScriptPackageReader extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10011B20L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI .scr package reader =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the SuperAI constructor called by CreateSuperAI. 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 ExportAiSuperAiConstructor extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10001000L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI SuperAI constructor =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the text `.var` loader called after the AI script loader selects a
|
||||
// bundle-local or shared varset. 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 ExportAiVarSetLoader extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10011ea0L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI varset loader =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the actual text-to-varset parser called by the AI varset loader.
|
||||
// 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 ExportAiVarSetParser extends GhidraScript {
|
||||
private static final long ADDRESS = 0x100174a0L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI varset parser =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the u32 resolver used by corpus-reachable Handler(30) for indexed
|
||||
// varset records. 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 ExportAiVarSetU32Resolver extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10013570L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI varset u32 resolver =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the first function in the AI DLL's verified 73-entry VM handler table.
|
||||
// Run through Ghidra headless analysis; 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 ExportAiVmHandler0 extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10008034L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI VM handler 0 =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits Handler(1), the second function in the AI DLL's verified 73-entry VM table.
|
||||
// Run through Ghidra headless analysis; 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 ExportAiVmHandler1 extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10007fd0L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI VM Handler(1) =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits Handler(15), a frequent non-sentinel selector in the GOG compiled
|
||||
// script corpus. 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 ExportAiVmHandler15 extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10008054L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI VM Handler(15) =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits Handler(19), the first instruction in both AutoDemo default-script
|
||||
// Init events. 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 ExportAiVmHandler19 extends GhidraScript {
|
||||
private static final long ADDRESS = 0x1000aa38L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI VM Handler(19) =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits Handler(19)'s common x87 varset setter. 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 ExportAiVmHandler19Setter extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10013770L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI VM Handler(19) setter =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the common varset storage helper reached by Handler(19)'s setter.
|
||||
// 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 ExportAiVmHandler19SetterCallee extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10012fe0L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI VM Handler(19) setter storage helper =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Emits the two direct callees recovered from AI VM Handler(1).
|
||||
// Run through Ghidra headless analysis; 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 ExportAiVmHandler1Callees extends GhidraScript {
|
||||
private static final long[] ADDRESSES = { 0x10002d30L, 0x10013190L };
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
for (long value : ADDRESSES) {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(value);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI VM Handler(1) callee " + address + " =====");
|
||||
if (function == null) { println("missing"); continue; }
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
}
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits Handler(2), the third function in the AI DLL's verified 73-entry VM table.
|
||||
// Run through Ghidra headless analysis; 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 ExportAiVmHandler2 extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10009610L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI VM Handler(2) =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the opaque direct callee reached by corpus-reachable AI VM Handler(2).
|
||||
// Run through Ghidra headless analysis; 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 ExportAiVmHandler2Callee extends GhidraScript {
|
||||
private static final long ADDRESS = 0x100059f0L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI VM Handler(2) direct callee =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Emits the direct post-insertion dispatcher reached by the corpus-reachable
|
||||
// AI VM Handler(2) scheduler boundary. Run headless; the original PE is 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 ExportAiVmHandler2Dispatch extends GhidraScript {
|
||||
private static final long[] ADDRESSES = {
|
||||
0x1000f920L, 0x10004be0L, 0x10004d00L, 0x10004db0L
|
||||
};
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
for (long value : ADDRESSES) {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(value);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI Handler(2) post-insertion helper " + address + " =====");
|
||||
if (function == null) { println("missing"); continue; }
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
}
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Emits record construction, equality, refresh and insertion helpers called by
|
||||
// the corpus-reachable AI VM Handler(2) scheduler boundary.
|
||||
// Run through Ghidra headless analysis; 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 ExportAiVmHandler2SchedulerHelpers extends GhidraScript {
|
||||
private static final long[] ADDRESSES = {
|
||||
0x10004e50L, 0x10004c50L, 0x10005070L, 0x100073e0L
|
||||
};
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
for (long value : ADDRESSES) {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(value);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI Handler(2) scheduler helper " + address + " =====");
|
||||
if (function == null) { println("missing"); continue; }
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
}
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits Handler(30), the most frequent non-sentinel selector in the GOG
|
||||
// compiled-script corpus. 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 ExportAiVmHandler30 extends GhidraScript {
|
||||
private static final long ADDRESS = 0x1000c266L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI VM Handler(30) =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits Handler(8), a frequent non-sentinel selector in the GOG compiled
|
||||
// script corpus. 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 ExportAiVmHandler8 extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10009b0dL;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI VM Handler(8) =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Emits the two non-trivial local callees reached by Handler(8). 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 ExportAiVmHandler8Callees extends GhidraScript {
|
||||
private static final long[] ADDRESSES = {0x10002e90L, 0x10005710L};
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
for (long value : ADDRESSES) {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(value);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI Handler(8) callee " + address + " =====");
|
||||
if (function == null) { println("missing"); continue; }
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
}
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Emits the state-transition helpers selected by Handler(8). 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 ExportAiVmHandler8Transitions extends GhidraScript {
|
||||
private static final long[] ADDRESSES = {0x10005010L, 0x10005040L};
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
for (long value : ADDRESSES) {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(value);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI Handler(8) transition " + address + " =====");
|
||||
if (function == null) { println("missing"); continue; }
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
}
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the routine that receives the AI VM's verified 73-entry handler table.
|
||||
// Run through Ghidra headless analysis; 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 ExportAiVmTableInstall extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10011E70L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== AI VM handler table install =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Emits the function containing the observed AniMesh Control-loader sequence.
|
||||
import ghidra.app.decompiler.DecompInterface;
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Function;
|
||||
|
||||
public class ExportAniMeshControlCaller extends GhidraScript {
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(0x100032e7L);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionContaining(address);
|
||||
println("===== AniMesh Control caller =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
println("entry=" + function.getEntryPoint());
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Emits decompiled C for the stable public Control.dll exports.
|
||||
// Run only through Ghidra headless analysis; it does not modify the input PE.
|
||||
import ghidra.app.decompiler.DecompInterface;
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Function;
|
||||
|
||||
public class ExportControlFunctions extends GhidraScript {
|
||||
private static final String[] NAMES = {
|
||||
"InitializeSettings", "LoadControlSystem", "LoadPhysicalModel",
|
||||
"CreateCollManager", "CreateCollObject"
|
||||
};
|
||||
private static final long[] ADDRESSES = {
|
||||
0x10032260L, 0x10032280L, 0x10032580L, 0x100325d0L, 0x10032600L
|
||||
};
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
for (int index = 0; index < NAMES.length; index++) {
|
||||
Address address = currentProgram.getAddressFactory()
|
||||
.getDefaultAddressSpace().getAddress(ADDRESSES[index]);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("\n===== " + NAMES[index] + " =====");
|
||||
if (function == null) {
|
||||
println("missing");
|
||||
continue;
|
||||
}
|
||||
println(decompiler.decompileFunction(function, 60, monitor)
|
||||
.getDecompiledFunction().getC());
|
||||
}
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the live CreateSuperAI host callback selected by the GOG AutoDemo.
|
||||
// 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 ExportIron3dAiCallback extends GhidraScript {
|
||||
private static final long ADDRESS = 0x100611d0L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== Iron3D CreateSuperAI callback =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits the command-one consumer reached from the recovered CreateSuperAI
|
||||
// host callback. 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 ExportIron3dAiCallbackCommand1 extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10095160L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== Iron3D CreateSuperAI callback command 1 =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Emits command one's selected-node dispatch callee. 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 ExportIron3dAiCallbackCommand1Dispatch extends GhidraScript {
|
||||
private static final long ADDRESS = 0x10095600L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||
println("===== Iron3D callback command 1 node dispatch =====");
|
||||
if (function == null) { println("missing"); return; }
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC());
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Locates callers that reference the stable AI script-loader literals.
|
||||
// Run through Ghidra headless analysis; the original PE is read only.
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Function;
|
||||
import ghidra.program.model.mem.Memory;
|
||||
import ghidra.program.model.symbol.Reference;
|
||||
import ghidra.program.model.symbol.ReferenceManager;
|
||||
|
||||
public class FindAiScriptLoaderReferences extends GhidraScript {
|
||||
private static final String[] NEEDLES = {".scr", "MISSIONS\\SCRIPTS\\"};
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Memory memory = currentProgram.getMemory();
|
||||
ReferenceManager references = currentProgram.getReferenceManager();
|
||||
for (String needle : NEEDLES) {
|
||||
byte[] bytes = (needle + "\0").getBytes("US-ASCII");
|
||||
Address address = memory.findBytes(memory.getMinAddress(), memory.getMaxAddress(), bytes, null, true, monitor);
|
||||
println("===== " + needle + " =====");
|
||||
if (address == null) { println("missing"); continue; }
|
||||
println("literal=" + address);
|
||||
for (Reference reference : references.getReferencesTo(address)) {
|
||||
Function caller = currentProgram.getFunctionManager().getFunctionContaining(reference.getFromAddress());
|
||||
println("reference=" + reference.getFromAddress() + " caller=" + (caller == null ? "missing" : caller.getEntryPoint()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Finds writers and readers of Handler(30)'s callback pointer, then decompiles
|
||||
// their containing functions. 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;
|
||||
import ghidra.program.model.symbol.Reference;
|
||||
|
||||
public class FindAiVmHandler30Callback extends GhidraScript {
|
||||
private static final long ADDRESS = 0x100555e4L;
|
||||
|
||||
@Override
|
||||
public void run() throws Exception {
|
||||
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(ADDRESS);
|
||||
DecompInterface decompiler = new DecompInterface();
|
||||
decompiler.openProgram(currentProgram);
|
||||
for (Reference reference : currentProgram.getReferenceManager().getReferencesTo(address)) {
|
||||
Function function = currentProgram.getFunctionManager()
|
||||
.getFunctionContaining(reference.getFromAddress());
|
||||
println("===== callback reference " + reference.getFromAddress() + " =====");
|
||||
if (function == null) { println("no containing function"); continue; }
|
||||
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||
}
|
||||
decompiler.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user