Author SHA1 Message Date
renovate[bot] 2333491cb2 chore(deps): update ghcr.io/renovatebot/renovate docker tag to v44
Test / Lint (push) Failing after 1m55s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
Test / Lint (pull_request) Failing after 1m57s
Test / Test (pull_request) Skipped
Test / Render parity (pull_request) Skipped
2026-07-30 00:15:32 +00:00
renovate[bot] f0851576e8 chore(deps): update rust crate serde_json to v1.0.151
Test / Lint (pull_request) Failing after 3m19s
Test / Test (pull_request) Skipped
Test / Render parity (pull_request) Skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 49s
Test / Lint (push) Failing after 12s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-21 00:08:37 +00:00
renovate[bot] fb1ec5857d chore(deps): update rust crate serde to v1.0.229
Test / Lint (pull_request) Failing after 2m0s
Test / Test (pull_request) Skipped
Test / Render parity (pull_request) Skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m2s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-19 00:02:17 +00:00
Valentin Popov 33d16dfca3 docs(script): trace callback command one
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 19m21s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 22:55:31 +04:00
Valentin Popov f49010c723 feat(runtime): apply mission script outcomes 2026-07-18 22:36:38 +04:00
Valentin Popov 70c7afe25c docs: clarify Notion reconciliation 2026-07-18 22:35:34 +04:00
Valentin Popov 9e2cc1a283 docs(script): trace host callback 2026-07-18 22:30:49 +04:00
Valentin Popov bbefe65a62 feat(script): bind callbacks to runtime varsets 2026-07-18 22:25:28 +04:00
Valentin Popov 0ee635eb77 docs(render): describe all-roots Vulkan preview 2026-07-18 22:21:07 +04:00
Valentin Popov 14096788fc feat(runtime): apply clan init varsets 2026-07-18 22:18:49 +04:00
Valentin Popov d91e63fa9a feat(runtime): resolve clan init writes 2026-07-18 22:15:55 +04:00
Valentin Popov f324017151 feat(script): resolve default init writes 2026-07-18 22:04:10 +04:00
Valentin Popov 5d40ba3968 feat(script): resolve handler eight states 2026-07-18 21:53:09 +04:00
Valentin Popov d1edf4ba65 feat(script): resolve handler fifteen inputs 2026-07-18 21:47:33 +04:00
Valentin Popov a0a5a18028 fix(render): accept PowerShell camera captures 2026-07-18 21:40:01 +04:00
Valentin Popov 24ba0bdd37 feat(script): resolve handler thirty dword callbacks 2026-07-18 21:33:14 +04:00
Valentin Popov ea09804778 docs(ai): trace frequent handler thirty callback 2026-07-18 21:27:39 +04:00
Valentin Popov 837b613513 feat(runtime): load script varset defaults 2026-07-18 21:23:28 +04:00
Valentin Popov a7882545b7 feat(script): parse varset numeric defaults 2026-07-18 21:17:57 +04:00
Valentin Popov 3619f22b42 docs(ai): rule out handler two immediate dispatch 2026-07-18 21:13:40 +04:00
Valentin Popov bd225b318e feat(script): model handler two scheduler 2026-07-18 21:11:10 +04:00
Valentin Popov de715c0dfd docs(ai): trace handler two event scheduler 2026-07-18 21:05:55 +04:00
Valentin Popov 6af81522a1 docs(ai): mark handler one corpus-unreachable 2026-07-18 21:03:36 +04:00
Valentin Popov 545d661b2b docs(ai): recover numeric handler contract 2026-07-18 21:01:46 +04:00
Valentin Popov 9dcf6d456e feat(runtime): load mission script bundles 2026-07-18 20:56:34 +04:00
Valentin Popov 3592b46b0f docs: record notion reconciliation audit 2026-07-18 20:53:04 +04:00
Valentin Popov 7b0900aa1a docs(script): trace handler two contract 2026-07-18 20:46:37 +04:00
Valentin Popov 50a74cc0a6 feat(script): classify VM dispatch selectors 2026-07-18 20:44:10 +04:00
Valentin Popov d31d1f8eab feat(script): decode compiled package framing 2026-07-18 20:40:17 +04:00
Valentin Popov a7b434aeb2 docs(ai): recover expression evaluator contract 2026-07-18 20:32:56 +04:00
Valentin Popov 6c444d63c5 feat(cli): expose unit component provenance 2026-07-18 20:29:53 +04:00
Valentin Popov 2d0ded231b feat(assets): preserve unit component asset provenance 2026-07-18 20:26:15 +04:00
Valentin Popov 6e5ee47d51 feat(prototype): trace unit component edges 2026-07-18 20:23:53 +04:00
Valentin Popov d7150a58d5 feat(prototype): retain unit component provenance 2026-07-18 20:15:26 +04:00
Valentin Popov b7a102caa7 docs: reconcile Notion knowledge gaps 2026-07-18 20:10:26 +04:00
Valentin Popov 3553107514 docs(control): recover AniMesh config provenance 2026-07-18 20:02:46 +04:00
Valentin Popov 5052edbee9 docs(control): trace AniMesh loading chain 2026-07-18 20:00:50 +04:00
Valentin Popov 63ac1bd55e tools(ghidra): export Control ABI decompilation 2026-07-18 19:58:15 +04:00
Valentin Popov 369e2ead3c docs(control): record loader ABI boundaries 2026-07-18 19:53:36 +04:00
Valentin Popov 5e940f92ba feat(terrain): index surface queries with bvh 2026-07-18 19:51:19 +04:00
Valentin Popov 0736c76d87 feat(headless): expose reference movement command 2026-07-18 19:46:09 +04:00
Valentin Popov e339256776 feat(runtime): add terrain-snapped reference movement 2026-07-18 19:42:52 +04:00
Valentin Popov b1ff50f9db docs(plan): reconcile Notion Vulkan revision 2026-07-18 19:38:01 +04:00
Valentin Popov a01c21f865 feat(render): consume world snapshot transforms 2026-07-18 19:30:27 +04:00
Valentin Popov 77d38e4c12 feat(world): retain mission transform state 2026-07-18 19:27:28 +04:00
Valentin Popov 9788749650 feat(render): select static MAT0 material phases 2026-07-18 19:22:58 +04:00
Valentin Popov a339a3a635 feat(tools): summarize terrain shade profiles 2026-07-18 19:17:38 +04:00
Valentin Popov 273bd9e477 feat(tools): capture live terrain shade cache 2026-07-18 19:15:18 +04:00
Valentin Popov 5c1ccae393 feat(render): preview explicit animation frames 2026-07-18 19:08:49 +04:00
Valentin Popov 3b6dd392a7 fix(render): export final Vulkan readback image 2026-07-18 19:00:43 +04:00
Valentin Popov e96444459b fix(tools): reject non-world camera captures 2026-07-18 18:55:00 +04:00
Valentin Popov fd9c04cade feat(tools): capture live legacy camera 2026-07-18 18:47:14 +04:00
73 changed files with 6771 additions and 150 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs: jobs:
renovate: renovate:
container: ghcr.io/renovatebot/renovate:43 container: ghcr.io/renovatebot/renovate:44
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
Generated
+39 -17
View File
@@ -359,7 +359,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -404,6 +404,7 @@ dependencies = [
"fparkan-prototype", "fparkan-prototype",
"fparkan-resource", "fparkan-resource",
"fparkan-runtime", "fparkan-runtime",
"fparkan-script",
"fparkan-vfs", "fparkan-vfs",
"serde", "serde",
"serde_json", "serde_json",
@@ -448,6 +449,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"fparkan-assets", "fparkan-assets",
"fparkan-inspection", "fparkan-inspection",
"fparkan-path",
"fparkan-platform", "fparkan-platform",
"fparkan-platform-winit", "fparkan-platform-winit",
"fparkan-render", "fparkan-render",
@@ -606,10 +608,19 @@ dependencies = [
"fparkan-prototype", "fparkan-prototype",
"fparkan-render", "fparkan-render",
"fparkan-resource", "fparkan-resource",
"fparkan-script",
"fparkan-terrain",
"fparkan-vfs", "fparkan-vfs",
"fparkan-world", "fparkan-world",
] ]
[[package]]
name = "fparkan-script"
version = "0.1.0"
dependencies = [
"fparkan-binary",
]
[[package]] [[package]]
name = "fparkan-terrain" name = "fparkan-terrain"
version = "0.1.0" version = "0.1.0"
@@ -766,7 +777,7 @@ dependencies = [
"quote", "quote",
"rustc_version", "rustc_version",
"simd_cesu8", "simd_cesu8",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -794,7 +805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [ dependencies = [
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -938,7 +949,7 @@ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -1186,7 +1197,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -1346,9 +1357,9 @@ dependencies = [
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive", "serde_derive",
@@ -1356,29 +1367,29 @@ dependencies = [
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.0",
] ]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.150" version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@@ -1450,6 +1461,17 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -1476,7 +1498,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -1487,7 +1509,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -1630,7 +1652,7 @@ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
+1
View File
@@ -18,6 +18,7 @@ members = [
"crates/fparkan-render", "crates/fparkan-render",
"crates/fparkan-resource", "crates/fparkan-resource",
"crates/fparkan-rsli", "crates/fparkan-rsli",
"crates/fparkan-script",
"crates/fparkan-runtime", "crates/fparkan-runtime",
"crates/fparkan-terrain", "crates/fparkan-terrain",
"crates/fparkan-terrain-format", "crates/fparkan-terrain-format",
+2 -2
View File
@@ -27,12 +27,12 @@ Open source проект с реализацией компонентов игр
- [apps/fparkan-cli](apps/fparkan-cli) — CLI для архивов, графов и acceptance-отчетов. - [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-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-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. - `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 ещё не закрыты. - `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). - Truth table и evidence-артефакты вынесены в [`docs/rendering/renderer_truth_table.md`](docs/rendering/renderer_truth_table.md) и [`docs/evidence/`](docs/evidence).
+1 -3
View File
@@ -9,6 +9,7 @@ build = "build.rs"
[dependencies] [dependencies]
ash = "0.38" ash = "0.38"
ash-window = "0.13" ash-window = "0.13"
fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
fparkan-binary = { path = "../../crates/fparkan-binary", 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-msh = { path = "../../crates/fparkan-msh", version = "0.1.0" }
fparkan-platform = { path = "../../crates/fparkan-platform", 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 = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
[dev-dependencies]
fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
[lints.rust] [lints.rust]
unsafe_code = "allow" unsafe_code = "allow"
missing_docs = "warn" missing_docs = "warn"
@@ -2,7 +2,8 @@
use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex}; use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex};
use fparkan_msh::{ 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_render::{LegacyIron3dEulerTransform, LegacyPipelineState};
use fparkan_terrain_format::LandMeshDocument; 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) 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. /// 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 /// 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, model: &ModelAsset,
transform: LegacyIron3dEulerTransform, transform: LegacyIron3dEulerTransform,
scale: [f32; 3], 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> { ) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
if !transform if !transform
.translation .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() { 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); 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); return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale);
}; };
+3 -1
View File
@@ -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_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, 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_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_world_space_with_transform,
project_msh_to_static_mesh_in_xy_frame, 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, VulkanStaticXyFrame,
}; };
pub use self::capabilities::{ pub use self::capabilities::{
@@ -235,6 +235,7 @@ impl VulkanSmokeRenderer {
frame_sync: Vec::new(), frame_sync: Vec::new(),
images_in_flight: Vec::new(), images_in_flight: Vec::new(),
current_frame: 0, current_frame: 0,
last_readback_image_index: None,
depth_request: create_info.render_request.depth, depth_request: create_info.render_request.depth,
pending_extent: None, pending_extent: None,
swapchain_recreate_count: 0, swapchain_recreate_count: 0,
@@ -519,6 +520,7 @@ impl VulkanSmokeRenderer {
})?; })?;
if !self.resources_ref()?.readback_buffers.is_empty() { if !self.resources_ref()?.readback_buffers.is_empty() {
self.report.readback_copy_count = self.report.readback_copy_count.saturating_add(1); self.report.readback_copy_count = self.report.readback_copy_count.saturating_add(1);
self.last_readback_image_index = Some(image_index_usize);
} }
let present_wait = [render_finished]; let present_wait = [render_finished];
@@ -621,6 +623,7 @@ impl VulkanSmokeRenderer {
let resources = resources.commit(); let resources = resources.commit();
self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()]; self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()];
self.frame_sync = frame_sync; self.frame_sync = frame_sync;
self.last_readback_image_index = None;
self.report.swapchain_extent = swapchain_extent; self.report.swapchain_extent = swapchain_extent;
self.report.swapchain_image_count = swapchain_image_count; self.report.swapchain_image_count = swapchain_image_count;
self.report.swapchain_image_format = self.swapchain_ref()?.report.plan.format.format; self.report.swapchain_image_format = self.swapchain_ref()?.report.plan.format.format;
@@ -941,13 +944,19 @@ impl VulkanSmokeRenderer {
if resources.readback_buffers.is_empty() { if resources.readback_buffers.is_empty() {
return Ok(None); return Ok(None);
} }
let mut artifact = let Some(image_index) = completed_readback_buffer_index(
Vec::with_capacity(byte_len.saturating_mul(resources.readback_buffers.len())); self.last_readback_image_index,
for buffer in &resources.readback_buffers { resources.readback_buffers.len(),
let bytes = readback_buffer_bytes(device, buffer, byte_len)?; )?
artifact.extend_from_slice(&bytes); else {
} return Ok(None);
Ok(Some(artifact)) };
let buffer = resources.readback_buffers.get(image_index).ok_or(
VulkanSmokeRendererError::InvariantViolation {
context: "last readback image index",
},
)?;
Ok(Some(readback_buffer_bytes(device, buffer, byte_len)?))
} }
fn teardown(&mut self) { fn teardown(&mut self) {
@@ -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] { fn matrix_bytes(matrix: [f32; 16]) -> [u8; 64] {
let mut bytes = [0; 64]; let mut bytes = [0; 64];
for (index, value) in matrix.into_iter().enumerate() { for (index, value) in matrix.into_iter().enumerate() {
@@ -991,8 +1013,8 @@ impl Drop for VulkanSmokeRenderer {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order, completed_readback_buffer_index, take_runtime_children_with_validation_snapshot,
RollbackOnDrop, take_runtime_owners_in_dependency_order, RollbackOnDrop,
}; };
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; 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] #[test]
fn runtime_owners_drop_remaining_children_after_partial_init_failures() { fn runtime_owners_drop_remaining_children_after_partial_init_failures() {
let cases = [ let cases = [
@@ -603,7 +603,7 @@ pub struct VulkanValidationReport {
pub vuids: Vec<String>, 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)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanReadbackArtifact { pub struct VulkanReadbackArtifact {
/// Vulkan swapchain format as a raw enum value. /// Vulkan swapchain format as a raw enum value.
@@ -612,7 +612,8 @@ pub struct VulkanReadbackArtifact {
pub width: u32, pub width: u32,
/// Height of each image in pixels. /// Height of each image in pixels.
pub height: u32, 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>, pub bytes: Vec<u8>,
} }
@@ -735,6 +736,10 @@ pub struct VulkanSmokeRenderer {
pub(super) frame_sync: Vec<VulkanFrameSync>, pub(super) frame_sync: Vec<VulkanFrameSync>,
pub(super) images_in_flight: Vec<vk::Fence>, pub(super) images_in_flight: Vec<vk::Fence>,
pub(super) current_frame: usize, pub(super) current_frame: usize,
/// Last swapchain image whose color attachment was copied after a
/// successful graphics submission. Reset whenever swapchain resources are
/// replaced so a teardown never reads a buffer from an old swapchain.
pub(super) last_readback_image_index: Option<usize>,
pub(super) depth_request: fparkan_platform::DepthStencilSupport, pub(super) depth_request: fparkan_platform::DepthStencilSupport,
pub(super) pending_extent: Option<(u32, u32)>, pub(super) pending_extent: Option<(u32, u32)>,
pub(super) swapchain_recreate_count: u32, pub(super) swapchain_recreate_count: u32,
+1
View File
@@ -13,6 +13,7 @@ fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" } fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
fparkan-resource = { path = "../../crates/fparkan-resource", 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-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" } fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
+248 -3
View File
@@ -37,16 +37,18 @@ use fparkan_runtime::{
}; };
use fparkan_vfs::{DirectoryVfs, Vfs}; use fparkan_vfs::{DirectoryVfs, Vfs};
use serde::Serialize; use serde::Serialize;
use std::fmt::Write as _;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
const ARCHIVE_INSPECT_SCHEMA: &str = "fparkan-archive-inspect-v1"; const 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_GRAPH_SCHEMA: &str = "fparkan-mission-graph-v1";
const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1"; const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1";
const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1"; const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1";
const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1"; const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1";
const WEAR_INSPECT_SCHEMA: &str = "fparkan-wear-inspect-v1"; const WEAR_INSPECT_SCHEMA: &str = "fparkan-wear-inspect-v1";
const SCRIPT_INSPECT_SCHEMA: &str = "fparkan-script-inspect-v2";
#[derive(Serialize)] #[derive(Serialize)]
struct ArchiveInspectOutput<'a> { struct ArchiveInspectOutput<'a> {
@@ -65,6 +67,8 @@ struct PrototypeInspectOutput {
roots: usize, roots: usize,
node_count: usize, node_count: usize,
edge_count: usize, edge_count: usize,
unit_component_records: Vec<UnitComponentInspectOutput>,
edges: Vec<PrototypeGraphEdgeInspectOutput>,
prototype_requests: usize, prototype_requests: usize,
resolved: usize, resolved: usize,
unit_references: usize, unit_references: usize,
@@ -196,6 +200,62 @@ struct GraphFailureOutput {
resource: Option<String>, resource: Option<String>,
} }
#[derive(Serialize)]
struct UnitComponentInspectOutput {
root_index: usize,
component_index: usize,
archive_raw_hex: String,
resource_raw_hex: String,
kind: u32,
parent_or_link: i32,
description_raw_hex: String,
tail0: u32,
tail1: u32,
}
#[derive(Serialize)]
struct PrototypeGraphEdgeInspectOutput {
id: u32,
from: u32,
to: u32,
kind: &'static str,
requiredness: &'static str,
root_index: Option<usize>,
parent_edge: Option<u32>,
unit_component_index: Option<usize>,
archive: Option<String>,
resource_raw_hex: Option<String>,
}
#[derive(Serialize)]
struct ScriptInspectOutput<'a> {
schema_version: &'static str,
path: &'a str,
opcode_handler_count: u32,
events: usize,
instructions: usize,
references: usize,
trailing_bytes: usize,
first_header_word_candidates: Vec<ScriptHeaderWordCount>,
}
#[derive(Serialize)]
struct ScriptHeaderWordCount {
value: u32,
instructions: usize,
}
#[derive(Serialize)]
struct VarSetInspectOutput<'a> {
schema_version: &'static str,
path: &'a str,
declarations: usize,
float_defaults: usize,
dword_defaults: usize,
first_name: Option<&'a str>,
last_name: Option<&'a str>,
}
fn main() { fn main() {
let args: Vec<String> = std::env::args().skip(1).collect(); let args: Vec<String> = std::env::args().skip(1).collect();
let result = run(&args); let result = run(&args);
@@ -260,10 +320,99 @@ fn run(args: &[String]) -> Result<(), String> {
let rest = strip_format_json(rest)?; let rest = strip_format_json(rest)?;
inspect_model(&rest) inspect_model(&rest)
} }
[domain, command, rest @ ..] if domain == "script" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_script(&rest)
}
[domain, command, rest @ ..] if domain == "varset" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_varset(&rest)
}
_ => Err(usage()), _ => Err(usage()),
} }
} }
fn inspect_script(args: &[String]) -> Result<(), String> {
let path = parse_file_path(args, "script inspect")?;
let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
let package =
fparkan_script::decode(&bytes).map_err(|err| format!("{}: {err}", path.display()))?;
let display_path = path.display().to_string();
println!("{}", script_inspect_json(&display_path, &package)?);
Ok(())
}
fn script_inspect_json(
path: &str,
package: &fparkan_script::ScriptPackage,
) -> Result<String, String> {
let instructions = package
.events
.iter()
.map(|event| event.instructions.len())
.sum();
let references = package
.events
.iter()
.flat_map(|event| &event.instructions)
.map(|instruction| instruction.references.len())
.sum();
let mut candidates = std::collections::BTreeMap::<u32, usize>::new();
for instruction in package.events.iter().flat_map(|event| &event.instructions) {
*candidates.entry(instruction.header_words[0]).or_insert(0) += 1;
}
serialize_json(&ScriptInspectOutput {
schema_version: SCRIPT_INSPECT_SCHEMA,
path,
opcode_handler_count: package.opcode_handler_count,
events: package.events.len(),
instructions,
references,
trailing_bytes: package.trailing_bytes.len(),
first_header_word_candidates: candidates
.into_iter()
.map(|(value, instructions)| ScriptHeaderWordCount {
value,
instructions,
})
.collect(),
})
}
fn inspect_varset(args: &[String]) -> Result<(), String> {
let path = parse_file_path(args, "varset inspect")?;
let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
let varset =
fparkan_script::parse_varset(&bytes).map_err(|err| format!("{}: {err}", path.display()))?;
let display_path = path.display().to_string();
println!("{}", varset_inspect_json(&display_path, &varset)?);
Ok(())
}
fn varset_inspect_json(path: &str, varset: &fparkan_script::VarSet) -> Result<String, String> {
let float_defaults = varset
.declarations
.iter()
.filter(|declaration| declaration.type_name == fparkan_script::VarSetType::Float)
.count();
let dword_defaults = varset.declarations.len() - float_defaults;
serialize_json(&VarSetInspectOutput {
schema_version: "fparkan-varset-inspect-v1",
path,
declarations: varset.declarations.len(),
float_defaults,
dword_defaults,
first_name: varset
.declarations
.first()
.map(|declaration| declaration.name.as_str()),
last_name: varset
.declarations
.last()
.map(|declaration| declaration.name.as_str()),
})
}
fn exit_code(result: &Result<(), String>) -> i32 { fn exit_code(result: &Result<(), String>) -> i32 {
if result.is_ok() { if result.is_ok() {
0 0
@@ -350,6 +499,58 @@ fn prototype_inspect_json(
roots: report.root_count, roots: report.root_count,
node_count: graph.nodes.len(), node_count: graph.nodes.len(),
edge_count: graph.edges.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(), prototype_requests: graph.prototype_requests.len(),
resolved: report.resolved_count, resolved: report.resolved_count,
unit_references: report.unit_reference_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 { fn prototype_graph_edge_label(edge: fparkan_prototype::PrototypeGraphEdge) -> &'static str {
match edge { match edge {
fparkan_prototype::PrototypeGraphEdge::MissionToUnitDat => "mission_to_unit_dat", fparkan_prototype::PrototypeGraphEdge::MissionToUnitDat => "mission_to_unit_dat",
@@ -664,7 +888,7 @@ fn prototype_graph_requiredness_label(
} }
fn usage() -> String { fn usage() -> String {
"usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | terrain inspect <Land.msh> [--format json] | wear inspect --root <path> --archive <archive> --resource <wear.wea> [--format json] | model inspect --root <path> --archive <archive> --resource <model.msh> [--format json] | 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)] #[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] #[test]
fn archive_json_has_schema_version() { fn archive_json_has_schema_version() {
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true)) let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true))
@@ -720,7 +965,7 @@ mod tests {
assert_eq!( assert_eq!(
json, 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\":[]}"
); );
} }
+1
View File
@@ -8,6 +8,7 @@ repository.workspace = true
[dependencies] [dependencies]
fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" } fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
fparkan-inspection = { path = "../../crates/fparkan-inspection", 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-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
fparkan-render = { path = "../../crates/fparkan-render", 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" } fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
+321 -38
View File
@@ -23,6 +23,7 @@
use fparkan_assets::{PreparedTextureUsage, PreparedVisual}; use fparkan_assets::{PreparedTextureUsage, PreparedVisual};
use fparkan_inspection::load_standalone_wear_material_texture_mip0_rgba8_from_root; use fparkan_inspection::load_standalone_wear_material_texture_mip0_rgba8_from_root;
use fparkan_path::ResourceName;
use fparkan_platform::WindowPort; use fparkan_platform::WindowPort;
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
use fparkan_render::{ 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_legacy_world_space,
project_land_msh_to_static_mesh_in_xy_frame, 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_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, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
VulkanStaticXyFrame, VulkanStaticXyFrame,
@@ -51,7 +54,7 @@ use fparkan_terrain::TerrainWorld;
use fparkan_vfs::DirectoryVfs; use fparkan_vfs::DirectoryVfs;
#[cfg(test)] #[cfg(test)]
use fparkan_world::OriginalObjectId; use fparkan_world::OriginalObjectId;
use fparkan_world::WorldSnapshot; use fparkan_world::{TransformState, WorldSnapshot};
use serde::Deserialize; use serde::Deserialize;
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::path::PathBuf; use std::path::PathBuf;
@@ -110,6 +113,8 @@ fn run(args: &[String]) -> Result<String, String> {
terrain, terrain,
roots, roots,
camera, camera,
args.static_animation_frame,
args.static_material_phase,
&args.root, &args.root,
&loaded.land_msh_path, &loaded.land_msh_path,
)?; )?;
@@ -232,7 +237,7 @@ fn load_requested_mission(
.map_err(|err| err.to_string()) .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 { struct StaticPreviewScene {
mesh: VulkanStaticMesh, mesh: VulkanStaticMesh,
materials: Vec<VulkanStaticMaterial>, materials: Vec<VulkanStaticMaterial>,
@@ -241,10 +246,12 @@ struct StaticPreviewScene {
clip_visible_vertices: usize, clip_visible_vertices: usize,
mesh_components: usize, mesh_components: usize,
terrain_components: usize, terrain_components: usize,
animation_frame: Option<u16>,
material_phase: Option<u16>,
} }
/// Projects the mission terrain plus every MSH component of the explicitly /// 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 /// This intentionally takes only the first MAT0 texture request for each MSH
/// batch selector. Terrain maps its proven low-byte packed tag channel through /// 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, /// retained on draw ranges but awaits a recovered blend equation; orientation,
/// later material phases, animation, lightmaps and gameplay visibility remain /// later material phases, animation, lightmaps and gameplay visibility remain
/// outside this bridge. /// outside this bridge.
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn static_preview_mesh_and_materials( fn static_preview_mesh_and_materials(
assets: &MissionAssets, assets: &MissionAssets,
terrain: &TerrainWorld, terrain: &TerrainWorld,
roots: &[MissionObjectDraft], roots: &[MissionObjectDraft],
legacy_camera: Option<VulkanStaticCamera>, legacy_camera: Option<VulkanStaticCamera>,
static_animation_frame: Option<u16>,
static_material_phase: Option<u16>,
root: &std::path::Path, root: &std::path::Path,
land_msh_path: &str, land_msh_path: &str,
) -> Result<StaticPreviewScene, String> { ) -> Result<StaticPreviewScene, String> {
@@ -301,30 +311,52 @@ fn static_preview_mesh_and_materials(
let model = assets.model_by_id(model_id).ok_or_else(|| { let model = assets.model_by_id(model_id).ok_or_else(|| {
format!("static preview visual {visual_id:?} references unknown model {model_id:?}") format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
})?; })?;
let transform = LegacyIron3dEulerTransform {
translation: root.position,
orientation_radians: root.orientation_raw,
};
let component = if legacy_camera.is_some() { let component = if legacy_camera.is_some() {
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( if let Some(frame) = static_animation_frame {
&model.validated, project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
LegacyIron3dEulerTransform { &model.validated,
translation: root.position, transform,
orientation_radians: root.orientation_raw, root.scale,
}, frame,
root.scale, )
) } else {
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
&model.validated,
transform,
root.scale,
)
}
} else { } else {
let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?; 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( if let Some(animation_frame) = static_animation_frame {
&model.validated, project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses(
frame, &model.validated,
LegacyIron3dEulerTransform { frame,
translation: root.position, transform,
orientation_radians: root.orientation_raw, root.scale,
}, animation_frame,
root.scale, )
) } 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}"))?; .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
let selector_remap = let selector_remap = static_preview_component_materials(
static_preview_component_materials(assets, visual, &component, &mut materials)?; assets,
visual,
&component,
static_material_phase,
&mut materials,
)?;
append_static_preview_component(&mut mesh, component, &selector_remap)?; append_static_preview_component(&mut mesh, component, &selector_remap)?;
mesh_components += 1; mesh_components += 1;
} }
@@ -345,6 +377,8 @@ fn static_preview_mesh_and_materials(
}, },
mesh_components, mesh_components,
terrain_components: 1, terrain_components: 1,
animation_frame: static_animation_frame,
material_phase: static_material_phase,
}) })
} }
@@ -515,6 +549,7 @@ fn static_preview_component_materials(
assets: &MissionAssets, assets: &MissionAssets,
visual: &PreparedVisual, visual: &PreparedVisual,
mesh: &VulkanStaticMesh, mesh: &VulkanStaticMesh,
static_material_phase: Option<u16>,
materials: &mut Vec<VulkanStaticMaterial>, materials: &mut Vec<VulkanStaticMaterial>,
) -> Result<Vec<(u16, u16)>, String> { ) -> Result<Vec<(u16, u16)>, String> {
let mut source_selectors = mesh 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(|| { let material = assets.material_by_id(*material_id).ok_or_else(|| {
format!("static preview prepared material {source_selector} is unavailable") 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
format!("static preview material {source_selector} has no MAT0 diffuse texture") .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 let texture = assets
.textures .textures
.iter() .iter()
.find(|texture| { .find(|texture| {
texture.usage == PreparedTextureUsage::Diffuse texture.usage == PreparedTextureUsage::Diffuse && texture.source.name == texture_name
&& texture.source.name == *texture_name
}) })
.ok_or_else(|| { .ok_or_else(|| {
format!( format!(
@@ -678,6 +719,8 @@ struct StaticVulkanApp {
clip_visible_vertices: usize, clip_visible_vertices: usize,
mesh_components: usize, mesh_components: usize,
terrain_components: usize, terrain_components: usize,
animation_frame: Option<u16>,
material_phase: Option<u16>,
preview_roots: usize, preview_roots: usize,
target_frames: u64, target_frames: u64,
mission: String, mission: String,
@@ -708,6 +751,8 @@ impl StaticVulkanApp {
clip_visible_vertices: preview.clip_visible_vertices, clip_visible_vertices: preview.clip_visible_vertices,
mesh_components: preview.mesh_components, mesh_components: preview.mesh_components,
terrain_components: preview.terrain_components, terrain_components: preview.terrain_components,
animation_frame: preview.animation_frame,
material_phase: preview.material_phase,
preview_roots, preview_roots,
target_frames, target_frames,
mission: mission.to_string(), mission: mission.to_string(),
@@ -776,12 +821,14 @@ impl StaticVulkanApp {
(None, _) => None, (None, _) => None,
}; };
self.output = Some(format!( 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.camera_mode),
json_string(&self.mission), json_string(&self.mission),
self.object_count, self.object_count,
self.frames_presented, self.frames_presented,
self.preview_roots, 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.mesh_components,
self.terrain_components, self.terrain_components,
self.clip_visible_vertices, self.clip_visible_vertices,
@@ -977,12 +1024,14 @@ fn render_snapshot_with_assets(
material_slots: vec![material], material_slots: vec![material],
material_index: 0, material_index: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(), pipeline_state: fparkan_render::LegacyPipelineState::default(),
transform: mission_drafts transform: snapshot_transform(snapshot, *handle).unwrap_or_else(|| {
.and_then(|drafts| drafts.get(index)) mission_drafts
.map_or_else( .and_then(|drafts| drafts.get(index))
|| identity_transform(index_to_f32(index)), .map_or_else(
mission_position_scale_transform, || identity_transform(index_to_f32(index)),
), mission_position_scale_transform,
)
}),
range: IndexRange { start: 0, count: 3 }, range: IndexRange { start: 0, count: 3 },
stable_order, 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] { fn mission_position_scale_transform(draft: &MissionObjectDraft) -> [f32; 16] {
[ [
draft.scale[0], draft.scale[0],
@@ -1035,6 +1118,8 @@ struct Args {
readback_out: Option<PathBuf>, readback_out: Option<PathBuf>,
preview_roots: NonZeroUsize, preview_roots: NonZeroUsize,
legacy_camera_capture: Option<PathBuf>, legacy_camera_capture: Option<PathBuf>,
static_animation_frame: Option<u16>,
static_material_phase: Option<u16>,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -1044,6 +1129,7 @@ enum RenderBackendMode {
} }
impl Args { impl Args {
#[allow(clippy::too_many_lines)]
fn parse(args: &[String]) -> Result<Self, String> { fn parse(args: &[String]) -> Result<Self, String> {
let mut root = None; let mut root = None;
let mut mission = None; let mut mission = None;
@@ -1057,6 +1143,8 @@ impl Args {
// remains available for bounded diagnostic work. // remains available for bounded diagnostic work.
let mut preview_roots = NonZeroUsize::MAX; let mut preview_roots = NonZeroUsize::MAX;
let mut legacy_camera_capture = None; let mut legacy_camera_capture = None;
let mut static_animation_frame = None;
let mut static_material_phase = None;
let mut iter = args.iter(); let mut iter = args.iter();
while let Some(arg) = iter.next() { while let Some(arg) = iter.next() {
match arg.as_str() { match arg.as_str() {
@@ -1119,6 +1207,26 @@ impl Args {
"--legacy-camera-capture requires a path".to_string() "--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()), _ => return Err(usage()),
} }
} }
@@ -1133,6 +1241,12 @@ impl Args {
if readback_out.is_some() && backend != RenderBackendMode::StaticVulkan { if readback_out.is_some() && backend != RenderBackendMode::StaticVulkan {
return Err("--readback-out requires --backend static-vulkan".to_string()); 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 { Ok(Self {
root, root,
mission, mission,
@@ -1142,6 +1256,8 @@ impl Args {
readback_out, readback_out,
preview_roots, preview_roots,
legacy_camera_capture, 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> { fn parse_legacy_camera_capture(bytes: &[u8]) -> Result<VulkanStaticCamera, String> {
let capture: LegacyCameraCapture = serde_json::from_slice(bytes) // Windows PowerShell 5.1 writes redirected text as UTF-16LE with a BOM.
.map_err(|err| format!("invalid legacy camera JSON: {err}"))?; // `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" { if capture.schema != "fparkan-legacy-camera-v1" {
return Err("unsupported legacy camera capture schema".to_string()); 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()) .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 { fn json_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2); let mut out = String::with_capacity(value.len() + 2);
out.push('"'); out.push('"');
@@ -1213,7 +1371,7 @@ fn json_hash(hash: &[u8; 32]) -> String {
} }
fn usage() -> 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)] #[cfg(test)]
@@ -1281,6 +1439,8 @@ mod tests {
readback_out: None, readback_out: None,
preview_roots: NonZeroUsize::MAX, preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None, legacy_camera_capture: None,
static_animation_frame: None,
static_material_phase: None,
}) })
); );
} }
@@ -1305,6 +1465,8 @@ mod tests {
readback_out: None, readback_out: None,
preview_roots: NonZeroUsize::MAX, preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None, 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"))); 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] #[test]
fn rejects_zero_static_preview_root_count() { fn rejects_zero_static_preview_root_count() {
let error = Args::parse(&strings(&[ let error = Args::parse(&strings(&[
@@ -1392,6 +1610,8 @@ mod tests {
readback_out: None, readback_out: None,
preview_roots: NonZeroUsize::MAX, preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None, legacy_camera_capture: None,
static_animation_frame: None,
static_material_phase: None,
}) })
); );
} }
@@ -1456,6 +1676,14 @@ mod tests {
assert!(camera.is_finite()); assert!(camera.is_finite());
assert_eq!(camera.clip_from_world[0], 0.65_f32.cos()); 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!( 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}"#), 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()) Err("unsupported legacy camera capture schema".to_string())
@@ -1560,6 +1788,7 @@ mod tests {
slot: 5, slot: 5,
}, },
], ],
transforms: Vec::new(),
events: Vec::new(), events: Vec::new(),
hash: StateHash([0; 32]), hash: StateHash([0; 32]),
}; };
@@ -1591,6 +1820,7 @@ mod tests {
generation: 1, generation: 1,
slot: 7, slot: 7,
}], }],
transforms: Vec::new(),
events: Vec::new(), events: Vec::new(),
hash: StateHash([0; 32]), hash: StateHash([0; 32]),
}; };
@@ -1630,6 +1860,7 @@ mod tests {
scale: [2.0, 3.0, 4.0], scale: [2.0, 3.0, 4.0],
visual_ids: Vec::new(), visual_ids: Vec::new(),
properties: Vec::new(), properties: Vec::new(),
unit_components: Vec::new(),
}; };
assert_eq!( assert_eq!(
mission_position_scale_transform(&draft), 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] #[test]
fn render_snapshot_preserves_mission_original_object_id() -> Result<(), String> { fn render_snapshot_preserves_mission_original_object_id() -> Result<(), String> {
let snapshot = WorldSnapshot { let snapshot = WorldSnapshot {
@@ -1645,6 +1926,7 @@ mod tests {
generation: 1, generation: 1,
slot: 0, slot: 0,
}], }],
transforms: Vec::new(),
events: Vec::new(), events: Vec::new(),
hash: StateHash([0; 32]), hash: StateHash([0; 32]),
}; };
@@ -1657,6 +1939,7 @@ mod tests {
scale: [1.0; 3], scale: [1.0; 3],
visual_ids: Vec::new(), visual_ids: Vec::new(),
properties: Vec::new(), properties: Vec::new(),
unit_components: Vec::new(),
}]; }];
let commands = render_snapshot_commands_with_assets(&snapshot, None, Some(&drafts))?; let commands = render_snapshot_commands_with_assets(&snapshot, None, Some(&drafts))?;
+139 -5
View File
@@ -22,10 +22,11 @@
//! `FParkan` headless runtime entrypoint. //! `FParkan` headless runtime entrypoint.
use fparkan_runtime::{ 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_vfs::DirectoryVfs;
use fparkan_world::InputSnapshot; use fparkan_world::{InputSnapshot, OriginalObjectId};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
@@ -55,7 +56,7 @@ fn run() -> Result<(), String> {
let loaded = load_mission(&mut engine, MissionRequest { key: mission }) let loaded = load_mission(&mut engine, MissionRequest { key: mission })
.map_err(|err| format!("{err}"))?; .map_err(|err| format!("{err}"))?;
println!( println!(
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} 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.object_count,
loaded.areal_count, loaded.areal_count,
loaded.surface_count, loaded.surface_count,
@@ -65,9 +66,27 @@ fn run() -> Result<(), String> {
loaded.graph_material_resolved_count, loaded.graph_material_resolved_count,
loaded.graph_texture_resolved_count, loaded.graph_texture_resolved_count,
loaded.graph_lightmap_resolved_count, loaded.graph_lightmap_resolved_count,
loaded.script_bundle_count,
loaded.script_event_count,
loaded.script_varset_declaration_count,
loaded.script_init_state_count,
loaded.script_varset_state_count,
loaded.graph_failure_count loaded.graph_failure_count
); );
} }
if let Some(movement) = args.reference_movement {
let reached = advance_reference_movement(
&mut engine,
OriginalObjectId(movement.original_id),
movement.target_xy,
movement.max_step,
)
.map_err(|err| format!("{err}"))?;
println!(
"reference_movement original_id={} target_xy=[{},{}] max_step={} reached={reached}",
movement.original_id, movement.target_xy[0], movement.target_xy[1], movement.max_step,
);
}
let mut last = None; let mut last = None;
for _ in 0..args.ticks { for _ in 0..args.ticks {
last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?); last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?);
@@ -81,10 +100,19 @@ fn run() -> Result<(), String> {
Ok(()) Ok(())
} }
#[derive(Debug)]
struct Args { struct Args {
root: Option<PathBuf>, root: Option<PathBuf>,
mission: Option<String>, mission: Option<String>,
ticks: u64, ticks: u64,
reference_movement: Option<ReferenceMovement>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct ReferenceMovement {
original_id: u32,
target_xy: [f32; 2],
max_step: f32,
} }
impl Args { impl Args {
@@ -93,6 +121,7 @@ impl Args {
root: None, root: None,
mission: None, mission: None,
ticks: 1, ticks: 1,
reference_movement: None,
}; };
let mut iter = args.iter(); let mut iter = args.iter();
while let Some(arg) = iter.next() { while let Some(arg) = iter.next() {
@@ -118,16 +147,121 @@ impl Args {
.parse() .parse()
.map_err(|_| "--ticks must be an integer".to_string())?; .map_err(|_| "--ticks must be an integer".to_string())?;
} }
"--move-object" => {
if parsed.reference_movement.is_some() {
return Err("--move-object may be specified once".to_string());
}
let original_id = iter
.next()
.ok_or_else(|| "--move-object requires an original object id".to_string())?
.parse()
.map_err(|_| {
"--move-object object id must be an unsigned integer".to_string()
})?;
let x = parse_finite_argument(
iter.next(),
"--move-object requires a finite X target",
)?;
let y = parse_finite_argument(
iter.next(),
"--move-object requires a finite Y target",
)?;
let max_step = parse_finite_argument(
iter.next(),
"--move-object requires a finite positive maximum step",
)?;
if max_step <= 0.0 {
return Err("--move-object maximum step must be positive".to_string());
}
parsed.reference_movement = Some(ReferenceMovement {
original_id,
target_xy: [x, y],
max_step,
});
}
_ => return Err(usage()), _ => return Err(usage()),
} }
} }
if parsed.mission.is_some() && parsed.root.is_none() { if parsed.mission.is_some() && parsed.root.is_none() {
return Err("--mission requires --root".to_string()); return Err("--mission requires --root".to_string());
} }
if parsed.reference_movement.is_some() && parsed.mission.is_none() {
return Err("--move-object requires --mission".to_string());
}
Ok(parsed) Ok(parsed)
} }
} }
fn usage() -> String { fn parse_finite_argument(value: Option<&String>, error: &str) -> Result<f32, String> {
"usage: fparkan-headless [--root <path> --mission <path>] [--ticks <n>]".to_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"
);
}
} }
+80 -1
View File
@@ -24,7 +24,7 @@ use fparkan_material::{
decode_wear, resolve_material, Mat0Document, MaterialError, ResolvedMaterial, WearTable, decode_wear, resolve_material, Mat0Document, MaterialError, ResolvedMaterial, WearTable,
MAT0_KIND, WEAR_KIND, 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}; pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError}; use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError};
use fparkan_nres::{decode as decode_nres, ReadProfile}; use fparkan_nres::{decode as decode_nres, ReadProfile};
@@ -63,6 +63,17 @@ pub struct MissionTerrainPaths {
pub land_map: NormalizedPath, 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. /// Terrain loading errors that include runtime world construction failures.
#[derive(Debug)] #[derive(Debug)]
pub enum TerrainPreparationError { pub enum TerrainPreparationError {
@@ -121,6 +132,29 @@ pub fn decode_mission_land_path(
decode_tma_land_path(bytes, profile) 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. /// Builds canonical mission terrain paths from the mission `Land` reference.
/// ///
/// # Errors /// # Errors
@@ -1251,6 +1285,8 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
continue; continue;
}; };
let root_index = root_index_for_prototype(graph, prototype_index); 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); wear_requests = wear_requests.saturating_add(1);
report_visual_dependency_progress( report_visual_dependency_progress(
@@ -1301,6 +1337,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
Some(provenance_for_resource( Some(provenance_for_resource(
root_index, root_index,
mesh_parent_edge, mesh_parent_edge,
unit_component_index,
&wear_key, &wear_key,
)), )),
&mut next_edge, &mut next_edge,
@@ -1375,6 +1412,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
Some(provenance_for_resource( Some(provenance_for_resource(
root_index, root_index,
Some(wear_edge_id), Some(wear_edge_id),
unit_component_index,
&material_key, &material_key,
)), )),
&mut next_edge, &mut next_edge,
@@ -1424,6 +1462,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
Some(provenance_for_resource( Some(provenance_for_resource(
root_index, root_index,
Some(material_edge_id), Some(material_edge_id),
unit_component_index,
&texture_key, &texture_key,
)), )),
&mut next_edge, &mut next_edge,
@@ -1500,6 +1539,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
Some(provenance_for_resource( Some(provenance_for_resource(
root_index, root_index,
Some(wear_edge_id), Some(wear_edge_id),
unit_component_index,
&lightmap_key, &lightmap_key,
)), )),
&mut next_edge, &mut next_edge,
@@ -1622,11 +1662,13 @@ fn prototype_mesh_node_id(
fn provenance_for_resource( fn provenance_for_resource(
root_index: usize, root_index: usize,
parent_edge: Option<fparkan_prototype::PrototypeGraphEdgeId>, parent_edge: Option<fparkan_prototype::PrototypeGraphEdgeId>,
unit_component_index: Option<usize>,
resource: &ResourceKey, resource: &ResourceKey,
) -> PrototypeGraphProvenance { ) -> PrototypeGraphProvenance {
PrototypeGraphProvenance { PrototypeGraphProvenance {
root_index, root_index,
parent_edge, parent_edge,
unit_component_index,
archive: Some(resource.archive.display_lossy().to_string()), archive: Some(resource.archive.display_lossy().to_string()),
resource: Some(resource.name.0.clone()), resource: Some(resource.name.0.clone()),
span: None, span: None,
@@ -2010,6 +2052,8 @@ fn push_visual_failure(
) { ) {
let root_index = root_index_for_prototype(graph, prototype_index); let root_index = root_index_for_prototype(graph, prototype_index);
let parent_edge = parent_edge_for_failure(graph, prototype_index, edge); 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); let dependency = mesh_dependency_resource(graph, prototype_index);
report.failures.push(PrototypeGraphFailure { report.failures.push(PrototypeGraphFailure {
root_index, root_index,
@@ -2020,6 +2064,7 @@ fn push_visual_failure(
provenance: Some(PrototypeGraphProvenance { provenance: Some(PrototypeGraphProvenance {
root_index, root_index,
parent_edge, parent_edge,
unit_component_index,
archive: dependency.map(|resource| resource.archive.as_str().to_string()), archive: dependency.map(|resource| resource.archive.as_str().to_string()),
resource: Some(resource_raw), resource: Some(resource_raw),
span: None, span: None,
@@ -2036,6 +2081,18 @@ fn root_index_for_prototype(graph: &PrototypeGraph, prototype_index: usize) -> u
0 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( fn parent_edge_for_failure(
graph: &PrototypeGraph, graph: &PrototypeGraph,
prototype_index: usize, prototype_index: usize,
@@ -2724,6 +2781,19 @@ mod tests {
dependencies: Vec::new(), dependencies: Vec::new(),
}; };
let mut graph = prototype_graph_for_mesh(&prototype); 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 { let mut report = PrototypeGraphReport {
root_count: 1, root_count: 1,
direct_reference_count: 1, direct_reference_count: 1,
@@ -2797,6 +2867,14 @@ mod tests {
.iter() .iter()
.filter_map(|edge| edge.provenance.as_ref()) .filter_map(|edge| edge.provenance.as_ref())
.all(|provenance| provenance.root_index == 0)); .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] #[test]
@@ -3528,6 +3606,7 @@ mod tests {
roots: vec![prototype.key.clone()], roots: vec![prototype.key.clone()],
prototype_requests: vec![prototype.key.clone()], prototype_requests: vec![prototype.key.clone()],
root_prototype_request_spans: std::iter::once(0..1).collect(), root_prototype_request_spans: std::iter::once(0..1).collect(),
root_unit_components: vec![Vec::new()],
visual_dependencies_expanded: false, visual_dependencies_expanded: false,
nodes: vec![root_node, prototype_node, mesh_node], nodes: vec![root_node, prototype_node, mesh_node],
edges: vec![ edges: vec![
+141 -1
View File
@@ -21,7 +21,10 @@
//! Stage-3 MSH asset contract. //! Stage-3 MSH asset contract.
use encoding_rs::WINDOWS_1251; use encoding_rs::WINDOWS_1251;
use fparkan_animation::{evaluate_hierarchy, AnimKey24, NodePoseBuffer, ParentIndex, Pose}; use fparkan_animation::{
evaluate_hierarchy, AnimKey24, AnimationTime, NodePoseBuffer, ParentIndex, Pose, TimedPoseKey,
TimedPoseTrack,
};
use fparkan_nres::{EntryMeta, NresDocument, NresError}; use fparkan_nres::{EntryMeta, NresDocument, NresError};
/// Node table stream. /// Node table stream.
@@ -478,6 +481,88 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
evaluate_hierarchy(&parents, &poses).ok() evaluate_hierarchy(&parents, &poses).ok()
} }
/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
/// model at one explicitly supplied logical frame.
///
/// A node without a usable type-19 map, or a requested frame outside the
/// declared map length, retains its exact fallback key. Mapped keys are
/// sampled against their immediate successor using the decoded key times.
/// This is an offline asset-sampling contract; it does not claim ownership of
/// the original runtime's animation clock or x87 numeric profile.
///
/// Returns `None` when the model lacks a complete standard-node animation
/// representation or the map cannot be safely resolved.
#[must_use]
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
if model.node_stride != 38 || model.node_count == 0 {
return None;
}
let animation = model.animation.as_ref()?;
let mut parents = Vec::with_capacity(model.node_count);
let mut poses = Vec::with_capacity(model.node_count);
for index in 0..model.node_count {
let node = NodeId(u32::try_from(index).ok()?);
let metadata = node38_metadata(model, node)?;
let parent = if metadata.parent_or_link_raw == u16::MAX {
ParentIndex(None)
} else {
let parent = usize::from(metadata.parent_or_link_raw);
(parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))?
};
let fallback_index = usize::from(metadata.fallback_key);
let _fallback = animation.keys.get(fallback_index)?;
let key_index =
if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count {
fallback_index
} else {
let mapped_index =
usize::from(*animation.frame_map.get(
usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?,
)?);
if mapped_index < fallback_index {
mapped_index
} else {
fallback_index
}
};
let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?;
parents.push(parent);
poses.push(pose);
}
evaluate_hierarchy(&parents, &poses).ok()
}
fn sample_node38_key_pair(
keys: &[AnimKey24],
key_index: usize,
fallback_index: usize,
frame: u16,
) -> Option<Pose> {
let key = *keys.get(key_index)?;
if key_index == fallback_index {
return Some(key.sampling_pose());
}
let next = *keys.get(key_index.checked_add(1)?)?;
if next.time.0 <= key.time.0 {
return Some(key.sampling_pose());
}
let track = TimedPoseTrack::new(
key.sampling_pose(),
vec![
TimedPoseKey {
time: key.time,
pose: key.sampling_pose(),
},
TimedPoseKey {
time: next.time,
pose: next.sampling_pose(),
},
],
)
.ok()?;
track.sample(AnimationTime(f32::from(frame))).ok()
}
/// Returns draw batches for a validated slot. /// Returns draw batches for a validated slot.
/// ///
/// # Errors /// # Errors
@@ -1273,6 +1358,61 @@ mod tests {
assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001); assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001);
} }
#[test]
fn standard_nodes_sample_mapped_key_before_fallback_key() {
let mut node = node38([u16::MAX; 15]);
node[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
node[4..6].copy_from_slice(&0_u16.to_le_bytes());
node[6..8].copy_from_slice(&2_u16.to_le_bytes());
let model = ModelAsset {
node_stride: 38,
node_count: 1,
nodes_raw: node,
slots: Vec::new(),
positions: Vec::new(),
normals: None,
uv0: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose {
translation: [4.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(2.0),
pose: Pose {
translation: [8.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(9.0),
pose: Pose::default(),
},
],
frame_map: vec![0, 0, 0],
frame_count: 3,
}),
};
let pose = node38_sampled_hierarchy(&model, 1)
.expect("mapped hierarchy")
.poses[0];
assert!((pose.translation[0] - 6.0).abs() < f32::EPSILON);
assert_eq!(
node38_sampled_hierarchy(&model, 9)
.expect("fallback hierarchy")
.poses[0],
Pose::default()
);
}
#[test] #[test]
fn type2_header_and_slot_tail_framing_are_exact() { fn type2_header_and_slot_tail_framing_are_exact() {
let too_small = decode_nested(&build_nres(&[ let too_small = decode_nested(&build_nres(&[
+90 -6
View File
@@ -128,6 +128,13 @@ pub struct PrototypeGraph {
pub prototype_requests: Vec<PrototypeKey>, pub prototype_requests: Vec<PrototypeKey>,
/// Mission object-local spans of effective prototype requests. /// Mission object-local spans of effective prototype requests.
pub root_prototype_request_spans: Vec<std::ops::Range<usize>>, pub root_prototype_request_spans: Vec<std::ops::Range<usize>>,
/// Ordered raw unit DAT component records for each mission root.
///
/// This vector is aligned with [`Self::roots`]. Direct roots and legacy
/// unit bindings have an empty record list. Records are preserved without
/// assigning runtime semantics to their `kind`, links, descriptions, or
/// opaque tails.
pub root_unit_components: Vec<Vec<UnitComponentRecord>>,
/// Whether visual dependency expansion has already been applied. /// Whether visual dependency expansion has already been applied.
pub visual_dependencies_expanded: bool, pub visual_dependencies_expanded: bool,
/// Materialized prototype dependency graph nodes. /// Materialized prototype dependency graph nodes.
@@ -162,6 +169,12 @@ pub struct PrototypeGraphProvenance {
pub root_index: usize, pub root_index: usize,
/// Immediate parent edge that discovered this edge. /// Immediate parent edge that discovered this edge.
pub parent_edge: Option<PrototypeGraphEdgeId>, pub parent_edge: Option<PrototypeGraphEdgeId>,
/// Ordered Unit DAT record selected for this path, when the root is a
/// decoded multi-component unit.
///
/// The index identifies source provenance only; it does not classify the
/// component as physics, Control, animation, or any other subsystem.
pub unit_component_index: Option<usize>,
/// Source archive when available. /// Source archive when available.
pub archive: Option<String>, pub archive: Option<String>,
/// Source resource key when available. /// Source resource key when available.
@@ -637,6 +650,7 @@ fn resolve_direct_prototype(
struct ResolvedPrototypeRequests { struct ResolvedPrototypeRequests {
expected_count: usize, expected_count: usize,
prototypes: Vec<EffectivePrototype>, prototypes: Vec<EffectivePrototype>,
unit_components: Vec<UnitComponentRecord>,
} }
fn resolve_prototype_requests( fn resolve_prototype_requests(
@@ -652,6 +666,7 @@ fn resolve_prototype_requests(
Ok(ResolvedPrototypeRequests { Ok(ResolvedPrototypeRequests {
expected_count: 1, expected_count: 1,
prototypes: prototype.into_iter().collect(), prototypes: prototype.into_iter().collect(),
unit_components: Vec::new(),
}) })
} }
@@ -686,6 +701,7 @@ fn resolve_unit_dat_prototype_requests(
return Ok(ResolvedPrototypeRequests { return Ok(ResolvedPrototypeRequests {
expected_count: unit.records.len(), expected_count: unit.records.len(),
prototypes, prototypes,
unit_components: unit.records,
}); });
} }
@@ -697,6 +713,7 @@ fn resolve_unit_dat_prototype_requests(
Ok(ResolvedPrototypeRequests { Ok(ResolvedPrototypeRequests {
expected_count: 1, expected_count: 1,
prototypes: prototype.into_iter().collect(), prototypes: prototype.into_iter().collect(),
unit_components: Vec::new(),
}) })
} }
@@ -741,8 +758,15 @@ pub fn build_prototype_graph(
graph.roots.push(key); graph.roots.push(key);
let start = graph.prototype_requests.len(); let start = graph.prototype_requests.len();
let expansion = resolve_prototype_requests(repository, vfs, root)?; let expansion = resolve_prototype_requests(repository, vfs, root)?;
let root_provenance = provenance_for_root(root_index, root); graph
for prototype in expansion.prototypes { .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( let prototype_node = PrototypeGraphNode::prototype(
prototype.key.clone(), prototype.key.clone(),
PrototypeGraphNodeId(next_node), PrototypeGraphNodeId(next_node),
@@ -781,6 +805,7 @@ pub fn build_prototype_graph(
provenance: Some(provenance_for_mesh( provenance: Some(provenance_for_mesh(
root_index, root_index,
root_to_prototype_edge_id, root_to_prototype_edge_id,
unit_component_index,
dependency, dependency,
)), )),
}); });
@@ -836,16 +861,23 @@ pub fn build_prototype_graph_report(
root_node, root_node,
)); ));
let start = graph.prototype_requests.len(); let start = graph.prototype_requests.len();
let root_provenance = provenance_for_root(root_index, root);
match resolve_prototype_requests(repository, vfs, root) { match resolve_prototype_requests(repository, vfs, root) {
Ok(expansion) => { Ok(expansion) => {
let expected = expansion.expected_count; let expected = expansion.expected_count;
graph
.root_unit_components
.push(expansion.unit_components.clone());
if edge == PrototypeGraphEdge::MissionToUnitDat { if edge == PrototypeGraphEdge::MissionToUnitDat {
report.unit_component_count += expected; report.unit_component_count += expected;
} }
let actual = expansion.prototypes.len(); let actual = expansion.prototypes.len();
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( let prototype_node = PrototypeGraphNode::prototype(
prototype.key.clone(), prototype.key.clone(),
PrototypeGraphNodeId(next_node), PrototypeGraphNodeId(next_node),
@@ -886,6 +918,7 @@ pub fn build_prototype_graph_report(
provenance: Some(provenance_for_mesh( provenance: Some(provenance_for_mesh(
root_index, root_index,
root_to_prototype_edge_id, root_to_prototype_edge_id,
unit_component_index,
dependency, dependency,
)), )),
}); });
@@ -907,6 +940,7 @@ pub fn build_prototype_graph_report(
provenance: Some(PrototypeGraphProvenance { provenance: Some(PrototypeGraphProvenance {
root_index, root_index,
parent_edge: None, parent_edge: None,
unit_component_index: None,
archive: None, archive: None,
resource: Some(root.0.clone()), resource: Some(root.0.clone()),
span: None, span: None,
@@ -923,12 +957,16 @@ pub fn build_prototype_graph_report(
provenance: Some(PrototypeGraphProvenance { provenance: Some(PrototypeGraphProvenance {
root_index, root_index,
parent_edge: None, parent_edge: None,
unit_component_index: None,
archive: None, archive: None,
resource: Some(root.0.clone()), resource: Some(root.0.clone()),
span: None, span: None,
}), }),
}), }),
} }
if graph.root_unit_components.len() <= root_index {
graph.root_unit_components.push(Vec::new());
}
let end = graph.prototype_requests.len(); let end = graph.prototype_requests.len();
graph.root_prototype_request_spans.push(start..end); graph.root_prototype_request_spans.push(start..end);
} }
@@ -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 { PrototypeGraphProvenance {
root_index, root_index,
parent_edge: None, parent_edge: None,
unit_component_index,
archive: None, archive: None,
resource: Some(root.0.clone()), resource: Some(root.0.clone()),
span: None, span: None,
@@ -977,11 +1020,13 @@ fn provenance_for_root(root_index: usize, root: &ResourceName) -> PrototypeGraph
fn provenance_for_mesh( fn provenance_for_mesh(
root_index: usize, root_index: usize,
parent_edge: PrototypeGraphEdgeId, parent_edge: PrototypeGraphEdgeId,
unit_component_index: Option<usize>,
dependency: &ResourceKey, dependency: &ResourceKey,
) -> PrototypeGraphProvenance { ) -> PrototypeGraphProvenance {
PrototypeGraphProvenance { PrototypeGraphProvenance {
root_index, root_index,
parent_edge: Some(parent_edge), parent_edge: Some(parent_edge),
unit_component_index,
archive: Some(dependency.archive.as_str().to_string()), archive: Some(dependency.archive.as_str().to_string()),
resource: Some(dependency.name.0.clone()), resource: Some(dependency.name.0.clone()),
span: None, span: None,
@@ -1770,6 +1815,45 @@ mod tests {
assert_eq!(graph.prototype_requests.len(), 2); assert_eq!(graph.prototype_requests.len(), 2);
assert_eq!(graph.prototype_requests[0].0 .0, b"component_a"); assert_eq!(graph.prototype_requests[0].0 .0, b"component_a");
assert_eq!(graph.prototype_requests[1].0 .0, b"component_b"); assert_eq!(graph.prototype_requests[1].0 .0, b"component_b");
assert_eq!(graph.root_unit_components.len(), 1);
assert_eq!(graph.root_unit_components[0].len(), 2);
assert_eq!(
cstr_bytes(&graph.root_unit_components[0][0].resource_raw),
b"component_a"
);
assert_eq!(
cstr_bytes(&graph.root_unit_components[0][1].resource_raw),
b"component_b"
);
let component_edges: Vec<_> = graph
.edges
.iter()
.filter(|edge| edge.kind == PrototypeGraphEdgeKind::UnitDatToComponent)
.collect();
assert_eq!(component_edges.len(), 2);
assert_eq!(
component_edges[0]
.provenance
.as_ref()
.and_then(|provenance| provenance.unit_component_index),
Some(0)
);
assert_eq!(
component_edges[1]
.provenance
.as_ref()
.and_then(|provenance| provenance.unit_component_index),
Some(1)
);
assert!(graph
.edges
.iter()
.filter(|edge| edge.kind == PrototypeGraphEdgeKind::PrototypeToMesh)
.all(|edge| edge
.provenance
.as_ref()
.and_then(|value| value.unit_component_index)
.is_some()));
assert_eq!(resolved.len(), 2); assert_eq!(resolved.len(), 2);
assert_eq!(report.unit_reference_count, 1); assert_eq!(report.unit_reference_count, 1);
assert_eq!(report.unit_component_count, 2); assert_eq!(report.unit_component_count, 2);
+2
View File
@@ -11,7 +11,9 @@ fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-platform = { path = "../fparkan-platform", version = "0.1.0" } fparkan-platform = { path = "../fparkan-platform", version = "0.1.0" }
fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" } fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
fparkan-render = { path = "../fparkan-render", 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-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-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
fparkan-world = { path = "../fparkan-world", version = "0.1.0" } fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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
+259 -3
View File
@@ -36,6 +36,7 @@ pub struct TerrainWorld {
grid: RuntimeGrid, grid: RuntimeGrid,
adjacency: Vec<Vec<ArealId>>, adjacency: Vec<Vec<ArealId>>,
surfaces: Vec<RuntimeTriangle>, surfaces: Vec<RuntimeTriangle>,
surface_bvh: SurfaceBvh,
source_mesh: Option<LandMeshDocument>, source_mesh: Option<LandMeshDocument>,
} }
@@ -239,6 +240,7 @@ impl TerrainWorld {
grid, grid,
adjacency, adjacency,
surfaces: Vec::new(), surfaces: Vec::new(),
surface_bvh: SurfaceBvh::default(),
source_mesh: None, source_mesh: None,
}) })
} }
@@ -252,9 +254,11 @@ impl TerrainWorld {
pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> { pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> {
Ok(Self { Ok(Self {
surfaces: build_surfaces(mesh)?, surfaces: build_surfaces(mesh)?,
surface_bvh: SurfaceBvh::default(),
source_mesh: Some(mesh.clone()), source_mesh: Some(mesh.clone()),
..Self::default() ..Self::default()
}) }
.with_surface_bvh())
} }
/// Builds terrain runtime data from decoded `Land.msh` and `Land.map`. /// Builds terrain runtime data from decoded `Land.msh` and `Land.map`.
@@ -269,6 +273,7 @@ impl TerrainWorld {
) -> Result<Self, TerrainError> { ) -> Result<Self, TerrainError> {
let mut world = Self::from_land_map(map)?; let mut world = Self::from_land_map(map)?;
world.surfaces = build_surfaces(mesh)?; world.surfaces = build_surfaces(mesh)?;
world.surface_bvh = SurfaceBvh::from_surfaces(&world.surfaces);
world.source_mesh = Some(mesh.clone()); world.source_mesh = Some(mesh.clone());
Ok(world) Ok(world)
} }
@@ -303,6 +308,11 @@ impl TerrainWorld {
.map(|mesh| mesh.positions.as_slice()) .map(|mesh| mesh.positions.as_slice())
} }
fn with_surface_bvh(mut self) -> Self {
self.surface_bvh = SurfaceBvh::from_surfaces(&self.surfaces);
self
}
fn locate_by_candidates( fn locate_by_candidates(
&self, &self,
position: [f32; 3], position: [f32; 3],
@@ -399,7 +409,8 @@ impl SurfaceQuery for TerrainWorld {
return Err(TerrainError::Unsupported); return Err(TerrainError::Unsupported);
} }
let mut best = None; 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) { if let Some(height) = triangle.height_at(position) {
best = Some(best.map_or(height, |current: f32| current.max(height))); best = Some(best.map_or(height, |current: f32| current.max(height)));
} }
@@ -416,8 +427,11 @@ impl SurfaceQuery for TerrainWorld {
if self.surfaces.is_empty() { if self.surfaces.is_empty() {
return Err(TerrainError::Unsupported); return Err(TerrainError::Unsupported);
} }
let mut candidates = self.surface_bvh.ray_candidates(origin, direction);
candidates.sort_unstable();
let mut best: Option<SurfaceHit> = None; let mut best: Option<SurfaceHit> = None;
for triangle in &self.surfaces { for index in candidates {
let triangle = &self.surfaces[index];
if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 { if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
continue; continue;
} }
@@ -472,6 +486,202 @@ struct RuntimeTriangle {
vertices: [[f32; 3]; 3], vertices: [[f32; 3]; 3],
} }
const SURFACE_BVH_LEAF_SIZE: usize = 8;
const SURFACE_BOUNDS_EPSILON: f32 = 1.0e-4;
/// Deterministic CPU index over validated terrain triangles.
///
/// The index changes only candidate selection: triangle math and observable
/// tie-breaking remain in [`SurfaceQuery`] above.
#[derive(Clone, Debug, Default)]
struct SurfaceBvh {
nodes: Vec<SurfaceBvhNode>,
triangle_indices: Vec<usize>,
root: Option<usize>,
}
#[derive(Clone, Debug)]
struct SurfaceBvhNode {
min: [f32; 3],
max: [f32; 3],
kind: SurfaceBvhNodeKind,
}
#[derive(Clone, Debug)]
enum SurfaceBvhNodeKind {
Leaf { first: usize, count: usize },
Branch { left: usize, right: usize },
}
impl SurfaceBvh {
fn from_surfaces(surfaces: &[RuntimeTriangle]) -> Self {
if surfaces.is_empty() {
return Self::default();
}
let mut index = Self {
nodes: Vec::with_capacity(surfaces.len().saturating_mul(2)),
triangle_indices: Vec::with_capacity(surfaces.len()),
root: None,
};
let indices: Vec<usize> = (0..surfaces.len()).collect();
index.root = Some(index.build(surfaces, indices));
index
}
fn build(&mut self, surfaces: &[RuntimeTriangle], mut indices: Vec<usize>) -> usize {
let (min, max) = surface_bounds(surfaces, &indices);
if indices.len() <= SURFACE_BVH_LEAF_SIZE {
indices.sort_unstable();
let first = self.triangle_indices.len();
let count = indices.len();
self.triangle_indices.extend(indices);
let node = self.nodes.len();
self.nodes.push(SurfaceBvhNode {
min,
max,
kind: SurfaceBvhNodeKind::Leaf { first, count },
});
return node;
}
let axis = longest_axis(min, max);
indices.sort_by(|left, right| {
surface_centroid(&surfaces[*left])[axis]
.total_cmp(&surface_centroid(&surfaces[*right])[axis])
.then_with(|| left.cmp(right))
});
let right_indices = indices.split_off(indices.len() / 2);
let left = self.build(surfaces, indices);
let right = self.build(surfaces, right_indices);
let node = self.nodes.len();
self.nodes.push(SurfaceBvhNode {
min,
max,
kind: SurfaceBvhNodeKind::Branch { left, right },
});
node
}
fn xy_candidates(&self, point: [f32; 2]) -> Vec<usize> {
let mut candidates = Vec::new();
let Some(root) = self.root else {
return candidates;
};
let mut pending = vec![root];
while let Some(node_index) = pending.pop() {
let node = &self.nodes[node_index];
if !point_within_surface_bounds(point, node.min, node.max) {
continue;
}
match node.kind {
SurfaceBvhNodeKind::Leaf { first, count } => {
candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
}
SurfaceBvhNodeKind::Branch { left, right } => {
pending.push(right);
pending.push(left);
}
}
}
candidates
}
fn ray_candidates(&self, origin: [f32; 3], direction: [f32; 3]) -> Vec<usize> {
let mut candidates = Vec::new();
let Some(root) = self.root else {
return candidates;
};
let mut pending = vec![root];
while let Some(node_index) = pending.pop() {
let node = &self.nodes[node_index];
if !ray_intersects_surface_bounds(origin, direction, node.min, node.max) {
continue;
}
match node.kind {
SurfaceBvhNodeKind::Leaf { first, count } => {
candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
}
SurfaceBvhNodeKind::Branch { left, right } => {
pending.push(right);
pending.push(left);
}
}
}
candidates
}
}
fn surface_bounds(surfaces: &[RuntimeTriangle], indices: &[usize]) -> ([f32; 3], [f32; 3]) {
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
for index in indices {
for vertex in surfaces[*index].vertices {
for axis in 0..3 {
min[axis] = min[axis].min(vertex[axis]);
max[axis] = max[axis].max(vertex[axis]);
}
}
}
(min, max)
}
fn surface_centroid(triangle: &RuntimeTriangle) -> [f32; 3] {
let mut centroid = [0.0; 3];
for vertex in triangle.vertices {
for axis in 0..3 {
centroid[axis] += vertex[axis] / 3.0;
}
}
centroid
}
fn longest_axis(min: [f32; 3], max: [f32; 3]) -> usize {
let extent = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
if extent[1] > extent[0] && extent[1] >= extent[2] {
1
} else if extent[2] > extent[0] && extent[2] > extent[1] {
2
} else {
0
}
}
fn point_within_surface_bounds(point: [f32; 2], min: [f32; 3], max: [f32; 3]) -> bool {
point[0] >= min[0] - SURFACE_BOUNDS_EPSILON
&& point[0] <= max[0] + SURFACE_BOUNDS_EPSILON
&& point[1] >= min[1] - SURFACE_BOUNDS_EPSILON
&& point[1] <= max[1] + SURFACE_BOUNDS_EPSILON
}
fn ray_intersects_surface_bounds(
origin: [f32; 3],
direction: [f32; 3],
min: [f32; 3],
max: [f32; 3],
) -> bool {
let mut near = 0.0_f32;
let mut far = f32::INFINITY;
for axis in 0..3 {
if direction[axis].abs() <= f32::EPSILON {
if origin[axis] < min[axis] - SURFACE_BOUNDS_EPSILON
|| origin[axis] > max[axis] + SURFACE_BOUNDS_EPSILON
{
return false;
}
continue;
}
let inverse = 1.0 / direction[axis];
let first = (min[axis] - origin[axis]) * inverse;
let second = (max[axis] - origin[axis]) * inverse;
near = near.max(first.min(second));
far = far.min(first.max(second));
if far < near {
return false;
}
}
far >= 0.0
}
impl RuntimeTriangle { impl RuntimeTriangle {
fn height_at(&self, position: [f32; 2]) -> Option<f32> { fn height_at(&self, position: [f32; 2]) -> Option<f32> {
let a = [self.vertices[0][0], self.vertices[0][1]]; let a = [self.vertices[0][0], self.vertices[0][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] #[test]
#[ignore = "requires licensed corpus"] #[ignore = "requires licensed corpus"]
fn licensed_corpus_land_maps_build_navigation_worlds() { fn licensed_corpus_land_maps_build_navigation_worlds() {
@@ -907,6 +1141,7 @@ mod tests {
let root = corpus_root(corpus); let root = corpus_root(corpus);
let mut files = 0usize; let mut files = 0usize;
let mut faces = 0usize; let mut faces = 0usize;
let mut indexed_queries = 0usize;
for path in files_under(&root) { for path in files_under(&root) {
if !path if !path
.file_name() .file_name()
@@ -925,12 +1160,33 @@ mod tests {
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
let world = TerrainWorld::from_land_msh(&mesh) let world = TerrainWorld::from_land_msh(&mesh)
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
if world.surface_count() > SURFACE_BVH_LEAF_SIZE {
let triangle = &world.surfaces[0];
let point = [
(triangle.vertices[0][0]
+ triangle.vertices[1][0]
+ triangle.vertices[2][0])
/ 3.0,
(triangle.vertices[0][1]
+ triangle.vertices[1][1]
+ triangle.vertices[2][1])
/ 3.0,
];
let candidates = world.surface_bvh.xy_candidates(point);
assert!(candidates.contains(&0), "{corpus} {path:?} face zero");
assert!(
candidates.len() < world.surface_count(),
"{corpus} {path:?} surface index did not reduce candidates"
);
indexed_queries += 1;
}
files += 1; files += 1;
faces += world.surface_count(); faces += world.surface_count();
} }
assert_eq!(files, expected_files, "{corpus} Land.msh count"); assert_eq!(files, expected_files, "{corpus} Land.msh count");
assert_eq!(faces, expected_faces, "{corpus} surface face count"); assert_eq!(faces, expected_faces, "{corpus} surface face count");
assert_eq!(indexed_queries, files, "{corpus} indexed Land.msh count");
} }
} }
+112 -1
View File
@@ -23,7 +23,7 @@
use fparkan_binary::sha256; use fparkan_binary::sha256;
use std::collections::VecDeque; use std::collections::VecDeque;
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v2\0"; const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v3\0";
/// Object handle with generation. /// Object handle with generation.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -50,6 +50,20 @@ pub struct Tick(pub u64);
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StateHash(pub [u8; 32]); pub struct StateHash(pub [u8; 32]);
/// Exact source transform carried by one world object.
///
/// Values are IEEE-754 bit patterns so mission transforms retain their input
/// identity in deterministic state before a controller interprets them.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TransformState {
/// Position words in source axis order.
pub position: [u32; 3],
/// Orientation words in source axis order.
pub orientation: [u32; 3],
/// Non-uniform scale words in source axis order.
pub scale: [u32; 3],
}
/// World phase. /// World phase.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorldPhase { pub enum WorldPhase {
@@ -110,6 +124,8 @@ pub struct WorldSnapshot {
pub tick: Tick, pub tick: Tick,
/// Live object handles. /// Live object handles.
pub objects: Vec<ObjectHandle>, pub objects: Vec<ObjectHandle>,
/// Exact transforms for live registered objects in object-handle order.
pub transforms: Vec<(ObjectHandle, TransformState)>,
/// Commands processed during this step. /// Commands processed during this step.
pub events: Vec<WorldEvent>, pub events: Vec<WorldEvent>,
/// State hash. /// State hash.
@@ -167,6 +183,7 @@ struct Slot {
owner_id: Option<OwnerId>, owner_id: Option<OwnerId>,
mirror_id: Option<OriginalObjectId>, mirror_id: Option<OriginalObjectId>,
registration_sequence: Option<u64>, registration_sequence: Option<u64>,
transform: TransformState,
} }
/// World. /// World.
@@ -248,6 +265,7 @@ pub fn construct_object(world: &mut World, draft: ObjectDraft) -> Result<ObjectH
owner_id: None, owner_id: None,
mirror_id: None, mirror_id: None,
registration_sequence: None, registration_sequence: None,
transform: TransformState::default(),
}); });
Ok(handle) Ok(handle)
} }
@@ -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. /// Requests deletion.
/// ///
/// # Errors /// # Errors
@@ -423,6 +478,7 @@ where
let snapshot = WorldSnapshot { let snapshot = WorldSnapshot {
tick: world.tick, tick: world.tick,
objects: live_registered(world), objects: live_registered(world),
transforms: live_registered_with_transforms(world),
events, events,
hash: canonical_state_hash(world), hash: canonical_state_hash(world),
}; };
@@ -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_u32(&mut out, slot.mirror_id.map(|id| id.0));
push_optional_u16(&mut out, slot.owner_id.map(|id| id.0)); push_optional_u16(&mut out, slot.owner_id.map(|id| id.0));
push_optional_u64(&mut out, slot.registration_sequence); push_optional_u64(&mut out, slot.registration_sequence);
push_transform(&mut out, slot.transform);
} }
push_len(&mut out, world.queue.len()); push_len(&mut out, world.queue.len());
for command in &world.queue { for command in &world.queue {
@@ -639,6 +696,17 @@ fn push_handle(out: &mut Vec<u8>, handle: ObjectHandle) {
push_u32(out, handle.slot); push_u32(out, handle.slot);
} }
fn push_transform(out: &mut Vec<u8>, transform: TransformState) {
for word in transform
.position
.into_iter()
.chain(transform.orientation)
.chain(transform.scale)
{
push_u32(out, word);
}
}
fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> { fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> {
let slot = world let slot = world
.slots .slots
@@ -689,6 +757,24 @@ fn live_registered(world: &World) -> Vec<ObjectHandle> {
.collect() .collect()
} }
fn live_registered_with_transforms(world: &World) -> Vec<(ObjectHandle, TransformState)> {
world
.slots
.iter()
.enumerate()
.filter_map(|(idx, slot)| {
let slot_index = u32::try_from(idx).ok()?;
(slot.live && slot.registered).then_some((
ObjectHandle {
generation: slot.generation,
slot: slot_index,
},
slot.transform,
))
})
.collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -704,6 +790,25 @@ mod tests {
assert_eq!(after.objects, vec![handle]); assert_eq!(after.objects, vec![handle]);
} }
#[test]
fn transform_is_exactly_snapshotted_and_changes_state_hash() {
let mut world = new(WorldConfig);
let handle =
construct_object(&mut world, ObjectDraft { original_id: None }).expect("object");
register_object(&mut world, handle).expect("register");
let before = step(&mut world, &InputSnapshot).expect("before");
let transform = TransformState {
position: [1.0_f32.to_bits(), (-2.0_f32).to_bits(), 3.0_f32.to_bits()],
orientation: [0.0_f32.to_bits(), 0.5_f32.to_bits(), (-0.25_f32).to_bits()],
scale: [1.0_f32.to_bits(), 2.0_f32.to_bits(), 0.5_f32.to_bits()],
};
set_transform(&mut world, handle, transform).expect("set transform");
let after = step(&mut world, &InputSnapshot).expect("after");
assert_eq!(transform_state(&world, handle), Ok(transform));
assert_eq!(after.transforms, vec![(handle, transform)]);
assert_ne!(before.hash, after.hash);
}
#[test] #[test]
fn registration_sequence_stale_and_duplicate_original_contracts() { fn registration_sequence_stale_and_duplicate_original_contracts() {
let mut world = new(WorldConfig); let mut world = new(WorldConfig);
@@ -723,10 +828,16 @@ mod tests {
.expect("second"); .expect("second");
register_object(&mut world, first).expect("register first"); register_object(&mut world, first).expect("register first");
register_object(&mut world, second).expect("register second"); register_object(&mut world, second).expect("register second");
assert_eq!(
handle_by_original_id(&world, OriginalObjectId(7)),
Some(first)
);
assert_eq!(handle_by_original_id(&world, OriginalObjectId(9)), None);
assert_eq!(registration_sequence(&world, first), Ok(Some(0))); assert_eq!(registration_sequence(&world, first), Ok(Some(0)));
assert_eq!(registration_sequence(&world, second), Ok(Some(1))); assert_eq!(registration_sequence(&world, second), Ok(Some(1)));
request_delete(&mut world, first).expect("delete"); request_delete(&mut world, first).expect("delete");
assert_eq!(handle_by_original_id(&world, OriginalObjectId(7)), None);
assert_eq!( assert_eq!(
register_object(&mut world, first), register_object(&mut world, first),
Err(WorldError::StaleHandle) Err(WorldError::StaleHandle)
+13 -27
View File
@@ -113,36 +113,22 @@ key, configuration, device profile, initial state, input/time script и верс
## Local evidence requests ## Local evidence requests
На текущем рабочем месте закрыты статические, corpus и headless runtime gates. Поддерживаемая desktop-платформа — только Windows/Vulkan. Локальный smoke
Для локально воспроизводимого Desktop backend подтверждено только command/state trace `fparkan-vulkan-smoke` уже подтверждает настоящий Win32 surface/swapchain,
в существующем GL-воркфлоу: 300 кадров, controlled resize и отсутствие validation warnings/errors.
Это закрывает фундамент Stage 0, но не доказывает визуальный паритет игры.
- `fixtures/acceptance/macos-gl33-triangle-capture.json`; Следующие доказательства всё ещё нужны именно для Iron3D-compatible рендера:
`S3-GL-001` пока не закрыт: текущая evidence не отражает полноценный - capability-gated capture из оригинального GOG процесса с camera/matrix,
`winit`+`fparkan-render-vulkan` path с real surface/present pipeline. draw/state и frame-boundary provenance;
Для закрытия требования требуется постоянный workspace-владельческий backend на - фиксированные Windows Vulkan captures статической модели, lightmapped модели
`winit`/`fparkan-platform-winit` + `fparkan-render-vulkan` с реальным и terrain после совпадения backend-neutral command capture;
surface/present pipeline, command/state parity и licensed frame capture. - затем controlled captures анимации, FX, прозрачности, теней и атмосферы.
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2 Linux/macOS, GLES2, RG40XX и удалённые portability runners не являются
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же acceptance-гейтами этого проекта. Они не должны появляться в списке блокеров
command/state trace. Локальный Docker probe существующего Rust image не нашёл или подменять Windows evidence.
`libGL`, `libEGL`, `libGLES` или `libOSMesa`, поэтому закрытие этого gate требует
отдельно предоставленного Docker image с Rust + Mesa/EGL/OSMesa либо разрешения
на установку соответствующего проверочного окружения.
Для текущей macOS-focused цели `S3-GL-002`, `L3-DEVICE-001` и `L5-RG40-001`
помечены как `omitted`: они остаются требованиями portable target scope, но не
блокируют локальный macOS acceptance-аудит. При возврате RG40XX/GLES2 в область
цели эти gates снова должны требовать внешнего evidence.
`L3-DEVICE-001` и `L5-RG40-001` не закрываются локально без RG40XX H или
эквивалентного удалённого runner-а. Требуемое доказательство: запуск выбранной
миссии при 640x480 на целевом профиле, сохранённые stdout/stderr, build
fingerprint, manifest игрового каталога, frame/tick budget, memory budget и
итоговый pass/fail report. Desktop/headless результаты не считаются заменой
on-device smoke.
## Closure criteria ## Closure criteria
+50
View File
@@ -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 с оригинальными файлами.
+335
View File
@@ -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.
+74
View File
@@ -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.
+88
View File
@@ -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 05: Vulkan revision` (редакция 18 июля) и
`Ревью перехода на Vulkan: решение, доказательства и ограничения`.
| Контракт из Notion | Локальное подтверждение | Результат |
| --- | --- | --- |
| Статьи 1–3: терминология, уровень доказательств, методика | `tomes/01-guide.md` | Полностью покрыто. |
| Статьи 48: bootstrap, DLL, frame loop, World3D | `tomes/02-architecture.md` | Полностью покрыто. |
| Статьи 913: VFS, NRes, RsLi, registry, unit и auxiliary formats | `tomes/03-resources.md` и `reference/` | Полностью покрыто. |
| Статьи 1418: TMA, mission loader, Land, ArealMap и world construction | `tomes/04-world.md`, `reference/tma.md`, `reference/msh.md` | Полностью покрыто. |
| Статьи 1927: Ngi32, MSH, animation, MAT0/WEAR/Texm, terrain и кадр | `tomes/05-render.md`, `reference/`, `rendering/` | Покрыто; локально дополнено более свежими evidence по D3D7 camera, Node38 и Terrain/GetShade. |
| Статьи 2832: AI, control, camera, audio, network | `tomes/06-behavior.md`, `appendices/script-vm.md` | Полностью покрыто. |
| Статьи 3337 и Vulkan revision: ports, stages, deterministic gates, Vulkan profile | `tomes/07-implementation.md`, `baseline/vulkan-revision-plan.md` | Полностью покрыто в Windows-only редакции. |
| Статьи 38–42 и приложения AD: 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 уже разнесены по
тематическим локальным документам, а новые результаты разработки должны
добавляться только локально.
+158
View File
@@ -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
View File
@@ -12,9 +12,10 @@ Iron3D из *Parkan: Iron Strategy*. Она ведёт от запуска ор
## Источник истины и синхронизация ## Источник истины и синхронизация
Каталог `docs/` — единственный рабочий источник истины для FParkan. 18 июля Каталог `docs/` — единственный рабочий источник истины для FParkan. 18 июля
2026 года его постранично сопоставили с книгой FParkan в Notion: восемь томов 2026 года его содержательно сопоставили с книгой FParkan в Notion; результат,
и приложения уже покрывают её 42 основные статьи, а актуальные локальные маршрутизация статей и сознательно исключённые цели зафиксированы в
материалы содержат и последующие результаты разработки. Notion больше не [сверке](baseline/notion-reconciliation.md). Актуальные локальные материалы
содержат и последующие результаты разработки. Notion больше не
является местом внесения изменений: новые факты, исправления и решения является местом внесения изменений: новые факты, исправления и решения
фиксируются только здесь, в томе, справочнике или evidence-документе, которому фиксируются только здесь, в томе, справочнике или evidence-документе, которому
они принадлежат. они принадлежат.
@@ -24,6 +25,11 @@ scope проекта: самостоятельный runtime ориентиро
Vulkan. Исторические упоминания других ОС в старых внешних заметках не Vulkan. Исторические упоминания других ОС в старых внешних заметках не
расширяют поддерживаемую платформу. расширяют поддерживаемую платформу.
Действующий dependency-ordered план `stage 0--5` находится в
[Vulkan revision для Windows](baseline/vulkan-revision-plan.md). Это
редакторская локальная версия: она включает недостающие контрактные требования
из Notion, но не переносит снятые с проекта Linux/macOS и hosted-CI цели.
## Как читать ## Как читать
Если вы впервые разбираете игровой движок, начните с тома I и II. Там вводится Если вы впервые разбираете игровой движок, начните с тома I и II. Там вводится
+31
View File
@@ -468,6 +468,37 @@ string длиной 32 байта. Требование обязательног
Части 2. Все соответствуют формуле размера, `kind == 1` и Части 2. Все соответствуют формуле размера, `kind == 1` и
`archive_name == "objects.rlb"`. `archive_name == "objects.rlb"`.
При построении mission prototype graph FParkan сохраняет для каждого root
упорядоченный список этих raw records. Список передаётся в
`MissionObjectDraft` без преобразования `kind`, `parent_or_link`, description
или tails в предполагаемые роли. Так runtime уже располагает исходными данными
составного unit для будущих Control/physics/AniMesh consumers, но не выдаёт
структурное сходство за доказанную семантику.
Каждый graph edge от `UnitDatRoot` к effective component и далее к его MSH
dependency также несёт `unit_component_index`. Это индекс исходной записи в
порядке файла, а не тип компонента; по нему diagnostics и будущие consumers
могут однозначно вернуться к raw 112-byte record.
Visual dependency expansion наследует этот индекс на edges к WEAR, MAT0,
diffuse texture и lightmap. Поэтому ошибка или asset в любой из этих фаз
сохраняет путь до конкретной записи unit DAT, а не только до миссионного object.
Для воспроизводимой проверки используется `fparkan-cli prototype inspect`.
Схема JSON `fparkan-prototype-inspect-v2` содержит lossless hex каждого
32-byte поля unit record и materialized graph edges с `unit_component_index`.
Например, для GOG AutoDemo:
```powershell
cargo run -p fparkan-cli -- prototype inspect `
--root 'C:\GOG Games\Parkan - Iron Strategy' `
--key 'UNITS\UNITS\AutoDEMO\w_m_wlk2.dat' --format json
```
Вывод фиксирует 18 component records, их MSH/WEAR/MAT0/Texm dependencies и
точные parent edges. JSON предназначен для анализа и regression evidence, а
не для интерпретации `kind`, links или opaque tails как готовой game logic.
## Вспомогательные форматы ## Вспомогательные форматы
MSH, материал и текстура отвечают за видимую форму. Полноценный прототип MSH, материал и текстура отвечают за видимую форму. Полноценный прототип
+29
View File
@@ -135,6 +135,14 @@ repeat relation_count:
TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как
raw-поля, пока их потребительская семантика не закрыта. raw-поля, пока их потребительская семантика не закрыта.
Runtime уже использует первую строку как доказанный путь выбора пакета: к ней
добавляется `.scr`, если расширение отсутствует, и полученный compiled package
декодируется losslessly до регистрации мира. Для каждого клана сохраняются
индекс, исходный base path, нормализованный `.scr` path и пакет событий. Это
не запускает opcode и не приписывает ему gameplay-эффект, но делает сценарный
input частью транзакции загрузки: missing/invalid package отменяет mission
load до публикации частичного world state.
Mode `0` имеет отдельный count-driven layout: Mode `0` имеет отдельный count-driven layout:
```text ```text
@@ -555,6 +563,27 @@ candidate areas, затем выполняется точная геометри
Если область не найдена, caller получает явный miss и решает, допустим ли Если область не найдена, caller получает явный miss и решает, допустим ли
fallback к ближайшей области. fallback к ближайшей области.
### Индекс поверхности
`TerrainWorld` хранит отдельный deterministic BVH по валидированным
`TerrainFace28` triangles. Он не заменяет `Land.map` grid: BVH отвечает за
низкоуровневые surface queries (`height_at` и `raycast`), тогда как grid и
areal graph остаются навигационным контрактом.
При построении индекс делит triangles по centroid вдоль самой протяжённой оси;
в leaf остаётся не более восьми face indices. XY query проходит только листья,
чьи AABB покрывают точку, а raycast — только AABB, пересечённые лучом. После
выбора кандидатов raycast сортирует их по исходному face index, поэтому при
равной дистанции сохраняется прежний deterministic tie-break; `height_at`
по-прежнему выбирает максимальную высоту среди действительно покрывающих
точку triangles. Индекс меняет стоимость запроса, но не геометрическую
семантику.
Synthetic test проверяет сокращение candidate set и прежние height/raycast
results. Licensed run проходит все 33 `Land.msh` Части 1 и 32 файла Части 2:
для каждого mesh с числом faces выше leaf limit query у центра первого face
включает этот face и использует меньше кандидатов, чем полный mesh.
### Маршрут ### Маршрут
После определения начальной и целевой областей маршрут строится по графу После определения начальной и целевой областей маршрут строится по графу
+169 -17
View File
@@ -78,6 +78,19 @@ drivers и video modes, проверяет поддержку 3D, перевод
функции DirectDraw/Direct3D семейства 5-7 и публикует refcounted renderer. функции DirectDraw/Direct3D семейства 5-7 и публикует refcounted renderer.
`niGet3DRender` возвращает уже созданный объект и увеличивает число владельцев. `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 ```text
enumerate adapters and video modes enumerate adapters and video modes
-> choose CURRENT_D3DCARD -> choose CURRENT_D3DCARD
@@ -877,6 +890,14 @@ bytes с зафиксированным reference capture.
### First synchronized Vulkan pixel-readback artifact ### 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 с Stage 3 static viewer теперь выполняет фактический readback для surface с
`TRANSFER_SRC`: на каждый swapchain image создаётся host-visible coherent `TRANSFER_SRC`: на каждый swapchain image создаётся host-visible coherent
`TRANSFER_DST` buffer. После render pass command buffer переводит image из `TRANSFER_DST` buffer. После render pass command buffer переводит image из
@@ -895,8 +916,11 @@ static viewer. Он ещё не захватывает original DirectDraw frame
fixed original camera и не сравнивает два изображения, поэтому pixel-parity fixed original camera и не сравнивает два изображения, поэтому pixel-parity
acceptance остаётся blocked. acceptance остаётся blocked.
Smoke также сохраняет raw artifact рядом с JSON: `<report-stem>.readback-vkformat-<raw>.raw`. The following paragraph records the superseded multi-image export only as
Это concatenated current-swapchain images in Vulkan order, каждый с dimensions 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 намеренно указан в имени файла, из JSON и четырьмя bytes per pixel; format намеренно указан в имени файла,
потому что bytes не перекодируются; JSON содержит actual raw enum. GOG selected `50` and produced a 4,147,200-byte file потому что bytes не перекодируются; JSON содержит actual raw enum. GOG selected `50` and produced a 4,147,200-byte file
for two 960x540 images. Артефакт остаётся локальным output и не попадает в Git. 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. `CBufferingCamera` FOV=`1.04` as a distinct upstream interface value.
The live GOG layout is now independently repeatable without invoking any game The live GOG layout is now independently repeatable without invoking any game
method: `Terrain.dll + 0x7355c` contains the current camera interface pointer, method: `Terrain.dll + 0x7355c` contains the current outer camera-object
whose vtable relocates to `Terrain.dll` RVA `0x665b4`. Its selector-0 block pointer, whose vtable relocates to `Terrain.dll` RVA `0x665b4`. Selector 0
starts with one 32-bit internal tag at interface `+28`; the actual sixteen returns the affine block beginning at outer `+0x20`; its sixteen row-major
row-major matrix words begin at `+32`. Captures for `RawCameraTransform` must words have translations at indices 3, 7 and 11. The public `LoadCamera`
therefore contain those sixteen payload words, not the tag. A passive AutoDemo interface at outer `+0x134` is a different view and must not be substituted
sample produced a finite affine camera matrix and passed through the offline for that block. Captures for `RawCameraTransform` therefore contain the sixteen
Vulkan adapter as source-world geometry, rather than relying on a guessed selector-0 words from outer `+0x20`. A passive AutoDemo sample produced a
identity view. 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 The Vulkan static path now accepts this recovered camera as
`VulkanStaticCamera`. It composes the row-major D3D7 result 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 not claim live camera tracking, material parity, or an original-frame pixel
comparison. 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 For visual regression work, `fparkan-game --backend static-vulkan` also accepts
`--readback-out <path>`. It writes the final synchronized Vulkan readback bytes `--readback-out <path>`. It writes the final synchronized Vulkan swapchain
only after the renderer has completed its normal teardown evidence; the JSON image only after the renderer has completed its normal teardown evidence; the
report records the raw Vulkan format, byte count, hash, and requested path. A JSON report records its raw Vulkan format, byte count, hash, and requested
three-frame diagnostic AutoDemo run wrote 7,372,800 bytes at format `50` path. At 1280x720, format `50` (`VK_FORMAT_B8G8R8A8_UNORM`) produces exactly
(`VK_FORMAT_B8G8R8A8_UNORM`) with the same frame hash reported by the renderer. 3,686,400 bytes. The artifact is a Vulkan-side comparison input, not an
The artifact is a Vulkan-side comparison input, not an asserted original-frame asserted original-frame image.
image.
The first bounded launch showed that repeated fingerprinting, rather than The first bounded launch showed that repeated fingerprinting, rather than
Vulkan initialization, was the load-path bottleneck: each new MAT0/TEXM request 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 cache availability as contextual and cannot dereference or prebuild the cache
solely from constructor state. 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 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 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 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 и `95.29412..288.23532` становятся `2.978..9.007`, что согласуется с TMA Z и
live camera height. Это доказанное coordinate conversion, но оно само по себе live camera height. Это доказанное coordinate conversion, но оно само по себе
не сделало вершины видимыми и не является заявлением о pixel parity. не сделало вершины видимыми и не является заявлением о 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`.
+173
View File
@@ -133,6 +133,33 @@ Wizard получает желаемое направление и corridor, а
описывает типы, defaults, ranges и строки через макросоподобные формы описывает типы, defaults, ranges и строки через макросоподобные формы
`VAR(...)` и `STRING(...)`. `VAR(...)` и `STRING(...)`.
Compiled package больше не opaque blob: `fparkan-script` losslessly читает
проверенный внешний framing. Сначала идут `opcode_handler_count` и
`event_count` (`u32 LE`), затем именованные события с NUL-terminated raw
именем и nested records. Reader сохраняет все seven raw header words и
references каждого record, жёстко ограничивает counts/allocations и отдельно
сохраняет trailing bytes. На `c1m2p.scr` GOG reader получает `73` handlers,
`9` events, `17` records и `20` references без trailing bytes. Это структура
файла, но не таблица семантик: названия opcode/words появятся только после
handler contracts и runtime traces.
Связь первого header word с dispatch теперь доказана статически: `ai.dll`
создаёт 73 handler pointers в известном порядке и копирует table без
перестановки. По всем 58 GOG packages первый word — индекс `0..72` либо
`0xffff_ffff` sentinel; `fparkan-script` отражает это как typed
`ScriptDispatchSelector`, сохраняя неожиданные значения `Unknown`. Первый
handler лишь инициирует execution context (`+0x50 = 1`); его gameplay meaning
пока не назван.
Первый часто встречающийся table entry с side effect — `Handler(2)` (176
records в corpus). Он берёт active instruction, разрешает семь slots через
varset, приводит три значения к float по observed kinds `5`/`3`, затем
materializes или refresh-ит внутренний event record. Его base string связывает
`<base>_Start` и `<base>_Continue` с event table; прямого World3D/Behavior
call на этой ветке не найдено. Slot names и consumer record ещё не установлены
динамически, поэтому Rust не исполняет handler как гипотетическую команду
движения/атаки/строительства.
Безопасная runtime-модель: Безопасная runtime-модель:
```text ```text
@@ -154,6 +181,36 @@ IDs и отправляет команды через игровые interfaces,
открытым направлением. До появления decompiler-а `.scr` binary body сохраняется открытым направлением. До появления decompiler-а `.scr` binary body сохраняется
lossless, а доказанные symbol/event tables документируются отдельно. lossless, а доказанные symbol/event tables документируются отдельно.
### Подтверждённый evaluator выражений
Ghidra 12.1.2 decompile GOG `ai.dll` фиксирует отдельный evaluator по VA
`0x10005180` (не dispatcher инструкций `.scr`). Он получает индекс записи,
ищет её в контейнере `this + 0x34`, переключается по `u32 tag` в offset `+0`
и при успешном результате пишет completion byte в `+0x0c`. Из кода доказаны
ровно пять ветвей `tag = 1..5`; `tag = 1..3` требуют `u32` subtype в
`+0x04 == 0`, а `tag = 4..5``+0x04 == 1`. Поле payload находится по
`+0x08`.
Ветки 1--3 делают lookup через object/interface, достижимый от
`this + 0x60 + 0x35c`, и используют его virtual slot `+0x1c`. Ветка 2
дополнительно читает virtual slots `+0x10`/`+0x18` возвращённого объекта и
разрешает result tags `1`, `0x12`, `0x13`. Ветка 3 сравнивает virtual slot
`+0x44` с текущим object value. Ветки 4--5 используют `payload` как index в
контейнере `this + 0x4c`, обходят его child indices и делают тот же lookup;
их разные success-guards пока не именуются семантически. Это доказывает
typed condition/evaluation layer, но **не** формат `.scr`, размеры инструкций
или связь чисел tag с языковыми операторами.
Выгрузка воспроизводится без изменения PE:
```powershell
& 'C:\Tools\ghidra_12.1.2_PUBLIC\support\analyzeHeadless.bat' `
C:\temp\fparkan-ghidra ai -import 'C:\GOG Games\Parkan - Iron Strategy\ai.dll' `
-processor x86:LE:32:default `
-scriptPath C:\Develop\fparkan\tools\ghidra `
-postScript ExportAiExpressionDispatcher.java -deleteProject
```
### TRF и preload-данные ### TRF и preload-данные
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
@@ -206,6 +263,82 @@ CreateCollObject
тригонометрию и `g_FastProc`. Это подтверждает его положение между gameplay тригонометрию и `g_FastProc`. Это подтверждает его положение между gameplay
object и геометрией мира. object и геометрией мира.
Статическая сверка GOG `Control.dll` уточняет границу, но пока не раскрывает
per-tick solver. PE32 image base — `0x10000000`; exports имеют следующие RVA:
```text
InitializeSettings 0x32260
LoadControlSystem 0x32280
LoadPhysicalModel 0x32580
CreateCollManager 0x325d0
CreateCollObject 0x32600
```
`LoadControlSystem` возвращается `ret 0x20`, следовательно ABI снимает со
стека восемь 32-bit аргументов. Оно выбирает allocation размером `0x668` при
mode `9` и `0x670` для другого mode, создаёт внутренний reader через virtual
dispatch с selector `10`, переносит несколько caller strings в локальные
buffers и передаёт собранную settings-структуру дальше через virtual slot
`+0x08` с key `0x80000020`. Это доказывает loader/configuration boundary и
два layout variants, но не даёт права назвать поля скоростью или acceleration.
`LoadPhysicalModel` аналогично создаёт `0xa0`-byte reader и возвращается
`ret 0x0c`; `CreateCollManager` и `CreateCollObject` возвращают interface
pointer с поправкой `+4` после внутренней инициализации.
Headless Ghidra 12.1.2 decompile GOG binary подтверждает ABI формой экспортов
`LoadControlSystem(char*, char*, char*, char*, char*, u32, void*, i32)`,
`LoadPhysicalModel(u32, u32, u32)`, `CreateCollManager(u32)` и
`CreateCollObject(u32, u32)`. Первые пять параметров Control loader — строки,
а mode передаётся последним; decompiler не восстанавливает предметные имена
остальных слов. `InitializeSettings` получает `CreateGameSettings()` из
World3D и делает virtual call slot `+0x24` с literal `0x15` и строкой по RVA
`0x42478`. Reproducible extractor находится в
`tools/ghidra/ExportControlFunctions.java`; он декомпилирует только эти exports
в локальном Ghidra project и не изменяет оригинальную DLL.
Именно update methods этих private objects, а не пять exports, остаются
следующим объектом динамической трассировки. Поэтому reference movement в
новом runtime намеренно не использует неподтверждённые параметры Control.
### Связь с AniMesh
PE import table GOG `AniMesh.dll` показывает, что это прямой consumer
`CreateCollManager`, `CreateCollObject` и `LoadControlSystem`; других DLL,
которые статически импортируют эти три named exports, не найдено. Внутренний
AniMesh path по VA `0x100032e7` вызывает Control thunks в строгой наблюдаемой
последовательности:
```text
LoadControlSystem (thunk 0x1001934e)
-> CreateCollObject (thunk 0x10019348)
-> CreateCollManager (thunk 0x10019342)
```
После factory calls caller immediately берёт returned interface vtable и
делает дальнейшие virtual calls; это связывает Control с загрузкой
AniMesh/unit components, а не с одной глобальной настройкой процесса. В
частности, `CreateCollObject` получает два stack arguments, а
`CreateCollManager` — один. Предметные значения этих arguments, ownership
private objects и per-tick update slots пока не восстановлены; порядок
создания не доказывает скорость, collision algorithm или AI semantics.
Headless Ghidra call-site decompile уточняет configuration provenance:
AniMesh передаёт в `LoadControlSystem` шесть 32-byte strings из одного
configuration block по offsets `+0x80`, `+0xa0`, `+0xc0`, `+0xe0`, `+0x100` и
`+0x120`, затем resource context и mode из owner `+0x6d8`. Returned Control
interface сразу получает пять virtual calls, связывающих его с owner slots
`+0x158`, `+0x160`, `+0x164`, `+0x168` и `+0x18c`; collision object затем
связывается с `+0x170`. Это достаточное основание хранить будущий Control
component как ordered raw-string/resource provenance, но не для присвоения
этим строкам смысловых имён до трассировки private update methods. Extractor:
`tools/ghidra/ExportAniMeshControlCaller.java`.
Runtime сохраняет ordered raw Unit DAT records рядом с каждым mission object
draft. Это создаёт проверяемую границу передачи данных от loader-а к будущему
Control consumer-у: никакой компонент пока не получает имя `Control` только
по `kind`, `parent_or_link` или description; semantic binding появится лишь
после trace private update/load methods.
### Control system и physical model ### Control system и physical model
`LoadControlSystem` загружает настройки controller-а: ограничения скорости, `LoadControlSystem` загружает настройки controller-а: ограничения скорости,
@@ -290,6 +423,46 @@ Control получает world-interface Terrain и использует пов
последовательности, а интеграция использует одну политику `dt` и округления. последовательности, а интеграция использует одну политику `dt` и округления.
Иначе одинаковая миссия постепенно расходится даже без сети. Иначе одинаковая миссия постепенно расходится даже без сети.
#### Reference controller в текущем runtime
`fparkan-runtime::advance_reference_movement` — намеренно маленький
детерминированный мост между сохранённым mission transform и `TerrainWorld`.
Он получает `OriginalObjectId`, явную XY-цель и положительный максимум шага,
находит только live/registered object по исходному ID, двигает XY не более чем
на этот шаг и записывает высоту из `TerrainWorld::height_at`. Orientation и
scale остаются исходными IEEE-754 words; функция не изменяет clock, очередь
World3D или animation state. Возвращаемое значение означает достижение именно
заданной XY-цели.
Это **не** восстановленный Control, Behavior или navigation controller:
функция не строит маршрут, не использует `dt`, скорость, terrain normal,
коллизии, acceleration и оригинальные AI decisions. Она существует как
проверяемый reference path, который фиксирует границу будущих controller-ов и
не позволяет renderer-у или gameplay обходить terrain query. Non-finite input,
неположительный шаг, отсутствующая миссия/объект и XY вне поверхности дают
явную ошибку без частичного изменения transform.
Licensed test на GOG `Autodemo.00` запускает этот путь для live mission object,
находит существующую поверхность и проверяет точные XY/Z words после snap. Это
доказывает связывание current runtime data, но не доказывает семантику
оригинального движения.
Путь доступен и через самостоятельный composition root:
```powershell
fparkan-headless --root "C:\GOG Games\Parkan - Iron Strategy" `
--mission MISSIONS/Autodemo.00/data.tma `
--move-object 0 419.10318 717.433 0.25 --ticks 1
```
`--move-object` принимает original object ID, target X/Y и maximum step;
требует `--root` и `--mission`, допускается один раз за запуск и отвергает
non-finite/неположительный шаг ещё при разборе аргументов. Приложение печатает
`reached`, затем normal headless tick/hash. Проверка на GOG 18 июля 2026 года
загрузила 8 objects, 343 areals и 3 174 terrain surfaces без graph failures;
команда для объекта `0` вернула `reached=false`, что подтверждает именно
ограниченный шаг, а не телепортацию к цели.
### Различия Control в Части 2 ### Различия Control в Части 2
`Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals `Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals
+23
View File
@@ -374,6 +374,29 @@ Demo mission total: 201 objects -> 501 prototypes -> 501 object MSH/WEAR.
### Deterministic simulation replay ### Deterministic simulation replay
#### Mission transform state in the world contract
`fparkan-world` now carries a `TransformState` for every live object: the
three TMA position words, three orientation words and three scale words are
preserved as exact IEEE-754 bit patterns. This stores source identity before a
movement or physics controller interprets axes, units or Euler order.
`WorldSnapshot` publishes transforms in stable object-handle order and the
canonical SHA-256 state hash includes every transform word.
Mission loading assigns this state after construction and before registration.
A headless licensed GOG AutoDemo run on 2026-07-18 loaded eight objects, 343
areals and 3,174 terrain surfaces with zero graph failures, then completed two
deterministic ticks. This is the state foundation for a future route/movement
controller; it does not claim recovered velocity, collision or original
behavior-controller semantics.
The ordinary planning renderer now consumes this snapshot state before falling
back to a mission draft. Its current transform bridge applies the preserved
position and non-uniform scale only; raw orientation remains uninterpreted in
this backend-neutral path. A GOG AutoDemo planning run on 2026-07-18 completed
two ticks with eight objects, 66 draws and state hash
`a54855a4f47ffa380911228f295dd49a9a7b88d6ff271a23db48ba318b1fbbb4`.
Записывается начальная миссия, seed, input events, network messages и значения Записывается начальная миссия, seed, input events, network messages и значения
внешних часов. На контрольных ticks сохраняется canonical state hash: внешних часов. На контрольных ticks сохраняется canonical state hash:
+1
View File
@@ -35,6 +35,7 @@ schema = 1
"4" = [ "4" = [
"fparkan-animation", "fparkan-animation",
"fparkan-fx", "fparkan-fx",
"fparkan-script",
] ]
"5" = [ "5" = [
"fparkan-game", "fparkan-game",
+111
View File
@@ -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) }
+213
View File
@@ -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)
}
+272
View File
@@ -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)
}
+23
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}
+35
View File
@@ -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();
}
}
+23
View File
@@ -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();
}
}