93 Commits
Author SHA1 Message Date
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
Valentin Popov 4967f58af4 docs(plan): align Vulkan stage boundaries 2026-07-18 18:39:52 +04:00
Valentin Popov 17df86f0f3 feat(game): export static Vulkan readback 2026-07-18 18:38:10 +04:00
Valentin Popov 99a6caf06a docs(render): validate live camera capture ABI 2026-07-18 18:30:45 +04:00
Valentin Popov d9ccf7f64d feat(game): preview all mission roots by default 2026-07-18 18:23:40 +04:00
Valentin Popov 4d024223bb feat(render): apply placement in xy preview 2026-07-18 18:19:55 +04:00
Valentin Popov cc7a1a0faa docs(terrain): trace generated shade profiles 2026-07-18 18:12:23 +04:00
Valentin Popov 6ab2f0bc22 docs(terrain): trace shade cache population 2026-07-18 18:09:09 +04:00
Valentin Popov ed62e3cac5 docs(terrain): recover shade cache allocation layout 2026-07-18 18:07:31 +04:00
Valentin Popov 9820b71ff7 docs: synchronize local documentation authority 2026-07-18 18:04:05 +04:00
Valentin Popov 3f2f56892f docs(terrain): decode transient shade cache lookup 2026-07-18 17:55:59 +04:00
Valentin Popov 7c9a6215eb docs(terrain): record shade cache lifecycle 2026-07-18 17:51:00 +04:00
Valentin Popov 62c8423d8b docs(terrain): delimit shade cache records 2026-07-18 17:43:54 +04:00
Valentin Popov e9c1a83c51 fix(terrain): preserve opaque shade lookup keys 2026-07-18 17:39:52 +04:00
Valentin Popov 575124830b feat(terrain): separate shade material selections 2026-07-18 17:31:13 +04:00
Valentin Popov 7a7578d0db feat(terrain): decode slot material pairs 2026-07-18 17:25:33 +04:00
Valentin Popov 5577a04a40 feat(terrain): expose slot render dispatch 2026-07-18 17:20:37 +04:00
Valentin Popov 8b211f9a1c docs(terrain): trace renderer material handoff 2026-07-18 17:11:05 +04:00
Valentin Popov d1caf230c6 docs(terrain): record material phase boundary 2026-07-18 17:07:34 +04:00
Valentin Popov a858910853 feat(terrain): encode material manager selectors 2026-07-18 17:01:17 +04:00
Valentin Popov 83caf4d6ab feat(terrain): recover material layer contract 2026-07-18 16:52:46 +04:00
Valentin Popov 4df4686509 feat(terrain): bind map base textures 2026-07-18 16:41:53 +04:00
Valentin Popov e0f0e4a18e feat(terrain): inspect packed material tags 2026-07-18 16:36:25 +04:00
Valentin Popov 1a8e1eaed1 feat(animation): recover Node38 fallback hierarchy 2026-07-18 16:25:20 +04:00
Valentin Popov 858dcd0d1d feat(render): apply static node fallback poses 2026-07-18 16:14:58 +04:00
Valentin Popov cfa6d27032 feat(msh): retain static node animation data 2026-07-18 16:08:18 +04:00
Valentin Popov 16449430d1 feat(render): select static model LOD slots 2026-07-18 16:02:31 +04:00
Valentin Popov a59f16d6b5 fix(render): match Ngi32 camera matrices 2026-07-18 15:58:24 +04:00
Valentin Popov fea984ddf5 feat(render): diagnose captured camera clipping 2026-07-18 15:50:52 +04:00
Valentin Popov 2a793f1854 feat(render): apply recovered TMA orientation 2026-07-18 15:35:00 +04:00
Valentin Popov 468ecc4e32 feat(render): recover Iron3D Euler transform 2026-07-18 15:28:20 +04:00
Valentin Popov 438aaebcbc feat(render): support full-scene 32-bit indices 2026-07-18 15:19:07 +04:00
Valentin Popov 733bfcf6fa perf(assets): reuse archives during mission loads 2026-07-18 15:15:29 +04:00
Valentin Popov 4007f6fcec feat(game): accept legacy camera captures 2026-07-18 15:07:31 +04:00
Valentin Popov 82f0f28ed9 feat(render): support per-frame camera updates 2026-07-18 14:58:51 +04:00
Valentin Popov d98e033f4f feat(render): retain source world geometry 2026-07-18 14:56:19 +04:00
Valentin Popov bfb048698e feat(render): bridge legacy camera to Vulkan 2026-07-18 14:53:11 +04:00
Valentin Popov 94de743581 feat(render): recover legacy d3d camera matrices 2026-07-18 14:42:36 +04:00
Valentin Popov f1a3315798 docs(evidence): trace camera projection dispatch 2026-07-18 14:32:21 +04:00
Valentin Popov c2b3f87e2c feat(render): retain raw camera projection state 2026-07-18 14:26:25 +04:00
Valentin Popov 02bfbcff84 docs(evidence): bound camera projection recovery 2026-07-18 14:21:48 +04:00
Valentin Popov 4a5a56c098 docs(evidence): capture live camera projection input 2026-07-18 14:17:31 +04:00
Valentin Popov ac1cd9640d feat(render): invert raw affine camera transforms 2026-07-18 14:13:24 +04:00
91 changed files with 10143 additions and 266 deletions
Generated
+43 -17
View File
@@ -359,7 +359,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -404,6 +404,7 @@ dependencies = [
"fparkan-prototype",
"fparkan-resource",
"fparkan-runtime",
"fparkan-script",
"fparkan-vfs",
"serde",
"serde_json",
@@ -447,6 +448,8 @@ name = "fparkan-game"
version = "0.1.0"
dependencies = [
"fparkan-assets",
"fparkan-inspection",
"fparkan-path",
"fparkan-platform",
"fparkan-platform-winit",
"fparkan-render",
@@ -455,6 +458,8 @@ dependencies = [
"fparkan-terrain",
"fparkan-vfs",
"fparkan-world",
"serde",
"serde_json",
"winit",
]
@@ -564,6 +569,7 @@ version = "0.1.0"
dependencies = [
"ash",
"ash-window",
"fparkan-animation",
"fparkan-binary",
"fparkan-msh",
"fparkan-platform",
@@ -602,10 +608,19 @@ dependencies = [
"fparkan-prototype",
"fparkan-render",
"fparkan-resource",
"fparkan-script",
"fparkan-terrain",
"fparkan-vfs",
"fparkan-world",
]
[[package]]
name = "fparkan-script"
version = "0.1.0"
dependencies = [
"fparkan-binary",
]
[[package]]
name = "fparkan-terrain"
version = "0.1.0"
@@ -762,7 +777,7 @@ dependencies = [
"quote",
"rustc_version",
"simd_cesu8",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -790,7 +805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -934,7 +949,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -1182,7 +1197,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -1342,9 +1357,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
@@ -1352,29 +1367,29 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.0",
]
[[package]]
name = "serde_json"
version = "1.0.150"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
@@ -1446,6 +1461,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -1472,7 +1498,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -1483,7 +1509,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -1626,7 +1652,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
"wasm-bindgen-shared",
]
+1
View File
@@ -18,6 +18,7 @@ members = [
"crates/fparkan-render",
"crates/fparkan-resource",
"crates/fparkan-rsli",
"crates/fparkan-script",
"crates/fparkan-runtime",
"crates/fparkan-terrain",
"crates/fparkan-terrain-format",
+2 -2
View File
@@ -27,12 +27,12 @@ Open source проект с реализацией компонентов игр
- [apps/fparkan-cli](apps/fparkan-cli) — CLI для архивов, графов и acceptance-отчетов.
- [apps/fparkan-viewer](apps/fparkan-viewer) — inspection-only CLI для archive/model/texture/map без live Vulkan draw path.
- [apps/fparkan-headless](apps/fparkan-headless) — headless runtime composition root.
- [apps/fparkan-game](apps/fparkan-game) — mission composition root: по умолчанию выдаёт planning report; opt-in `--backend static-vulkan` использует first-root preview loader и открывает native Vulkan окно для всех подготовленных MSH-компонентов выбранного root.
- [apps/fparkan-game](apps/fparkan-game) — mission composition root: по умолчанию выдаёт planning report; opt-in `--backend static-vulkan` открывает native Vulkan окно и рисует terrain и все подготовленные MSH-компоненты выбранных mission roots. По умолчанию выбираются все roots; `--preview-roots N` оставляет bounded diagnostic scope.
## Текущий статус рендера
- `fparkan-vulkan-smoke` доказывает живой Stage 0 Vulkan triangle path с native window, swapchain и validation telemetry.
- `VulkanPlanningBackend` и default-режим `fparkan-game` подтверждают только deterministic command planning/capture, а не draw пикселей. `fparkan-game --backend static-vulkan` — узкий mission-to-native-Vulkan bridge: для первого root он объединяет все подготовленные MSH-компоненты и загружает первую MAT0 diffuse texture на каждый используемый selector с preview-local remap; полный corpus, material phases, transforms и pixel parity ещё не подтверждены.
- `VulkanPlanningBackend` и default-режим `fparkan-game` подтверждают только deterministic command planning/capture, а не draw пикселей. `fparkan-game --backend static-vulkan` — узкий mission-to-native-Vulkan bridge: он объединяет terrain и подготовленные MSH-компоненты всех выбранных roots, применяет static TMA transforms и загружает первую MAT0 diffuse texture на каждый используемый selector с preview-local remap. Legacy D3D7 camera capture поддерживается; полный corpus, material phases, dynamic ownership/visibility и pixel parity ещё не подтверждены.
- `fparkan-viewer` пока является инспектором ассетов. `fparkan-vulkan-smoke` имеет live Stage 3 bridge для original `MSH`/`Texm`/`WEAR`/`MAT0` и geometry-only `Land.msh`; полноценный viewer, исходные terrain-material states, camera и pixel parity ещё не закрыты.
- Truth table и evidence-артефакты вынесены в [`docs/rendering/renderer_truth_table.md`](docs/rendering/renderer_truth_table.md) и [`docs/evidence/`](docs/evidence).
@@ -9,6 +9,7 @@ build = "build.rs"
[dependencies]
ash = "0.38"
ash-window = "0.13"
fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
fparkan-binary = { path = "../../crates/fparkan-binary", version = "0.1.0" }
fparkan-msh = { path = "../../crates/fparkan-msh", version = "0.1.0" }
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
@@ -1 +1 @@
{"schema":2,"target_env":"vulkan1.1","compiler":{"name":"glslangValidator","version":"11:16.3.0","binary_sha256":"9bcd69d830b350aaa6e2254915ff74e46070e217b67f38daad27c1fc1f22910f"},"validator":{"name":"spirv-val","version":"SPIRV-Tools v2026.2 unknown hash, 2026-04-29T17:02:58+00:00","binary_sha256":"f6d5b96ff19f073f3af0c0bcfa0c18702d288d3ec598efc242d01cd104d8354f"},"modules":[{"name":"triangle.vert","stage":"vertex","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert","source_sha256":"82f9ee39a30b094203041205954576ca820d75771421bb27ddd300359e4e9043","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","word_count":290,"sha256":"1984662f78873b70135ec444ccd86d86fa66dfca3f615d19ddaf0cda1587d4cf","descriptor_sets":0,"push_constant_bytes":0,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S vert -e main adapters/fparkan-render-vulkan/shaders/triangle.vert -o adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","interface_hash":"23e1d3d9d32e7f7ec0b9ca87f8b86be8f8363c7eb5d745fc5a157cb8433eb138"},{"name":"triangle.frag","stage":"fragment","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag","source_sha256":"f9162fccce56111e85fdf0f58251798393c37aa23f9f3d979ba404bf6773c27b","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","word_count":296,"sha256":"536a5c9a4389f9d34ca11a25f20d0acbc53d3eb0782c18375326647319336a85","descriptor_sets":1,"push_constant_bytes":4,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S frag -e main adapters/fparkan-render-vulkan/shaders/triangle.frag -o adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","interface_hash":"7692547c3195074fefc2321e1f4d4a09a3d83c65465cefd2237c8bae1df58d88"}],"manifest_hash":"5a16fb791e86bb790cd2d85151627e0d39193c5c073a9294130f149d4ff5ba58"}
{"schema":2,"target_env":"vulkan1.1","compiler":{"name":"glslangValidator","version":"11:16.3.0","binary_sha256":"9bcd69d830b350aaa6e2254915ff74e46070e217b67f38daad27c1fc1f22910f"},"validator":{"name":"spirv-val","version":"SPIRV-Tools v2026.2 unknown hash, 2026-04-29T17:02:58+00:00","binary_sha256":"f6d5b96ff19f073f3af0c0bcfa0c18702d288d3ec598efc242d01cd104d8354f"},"modules":[{"name":"triangle.vert","stage":"vertex","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert","source_sha256":"fe3721202477220d2d9677b6572d7f3baecb374e94f04eea9d7286ed5e98b6c9","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","word_count":358,"sha256":"4e2051ac43b933a57e5557e596bcc95952b4efbfcb45315180f89581e094398d","descriptor_sets":0,"push_constant_bytes":64,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S vert -e main adapters/fparkan-render-vulkan/shaders/triangle.vert -o adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","interface_hash":"f76cc92b9aaa2261eda33bd320503e1be100a2dd7e1a9fcfe13351678dda2a06"},{"name":"triangle.frag","stage":"fragment","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag","source_sha256":"fb3ed435af48e4fb4a817f160b467f7a561c7c547c87b6f0c1e89f5f58af7329","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","word_count":296,"sha256":"8ab7bf835e166892b04b10fa1100258daf355d2f693ad311d014dbee22de5c7b","descriptor_sets":1,"push_constant_bytes":68,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S frag -e main adapters/fparkan-render-vulkan/shaders/triangle.frag -o adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","interface_hash":"91552fce09a408a2baf8a608bc642b6632372d3d2316217051384159ba14cee2"}],"manifest_hash":"cf471972978ddb5c8919711ad64c93d0d64e18b1f590a89ce014f1e2a743cfb5"}
@@ -7,6 +7,7 @@ layout(location = 0) out vec4 out_color;
layout(set = 0, binding = 0) uniform sampler2D base_color;
layout(push_constant) uniform AlphaTestConstants {
layout(offset = 64)
float alpha_cutoff;
} alpha_test;
@@ -1,14 +1,18 @@
#version 450
layout(location = 0) in vec2 in_position;
layout(location = 0) in vec3 in_position;
layout(location = 1) in vec3 in_color;
layout(location = 2) in vec2 in_uv;
layout(location = 0) out vec3 out_color;
layout(location = 1) out vec2 out_uv;
layout(push_constant) uniform CameraConstants {
mat4 clip_from_world;
} camera;
void main() {
out_color = in_color;
out_uv = in_uv;
gl_Position = vec4(in_position, 0.0, 1.0);
gl_Position = camera.clip_from_world * vec4(in_position, 1.0);
}
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -40,8 +40,15 @@ mod swapchain_resources;
mod validation;
pub use self::asset_mesh::{
project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_xy_frame,
project_msh_to_static_mesh, project_msh_to_static_mesh_in_xy_frame, VulkanAssetMeshError,
project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_legacy_world_space,
project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame,
project_msh_to_static_mesh, project_msh_to_static_mesh_in_world_space,
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
project_msh_to_static_mesh_in_world_space_with_transform,
project_msh_to_static_mesh_in_xy_frame,
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses,
project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanAssetMeshError,
VulkanStaticXyFrame,
};
pub use self::capabilities::{
@@ -71,8 +78,8 @@ pub use self::smoke_types::{
VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot,
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
VulkanStaticVertex, VulkanValidationReport,
VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh,
VulkanStaticTexture, VulkanStaticVertex, VulkanValidationReport,
};
#[cfg(test)]
use self::surface::extension_name;
@@ -497,7 +504,7 @@ const fn spirv_words<const WORD_COUNT: usize>(bytes: &[u8]) -> [u32; WORD_COUNT]
words
}
static TRIANGLE_VERTEX_SHADER_DATA: [u32; 290] =
static TRIANGLE_VERTEX_SHADER_DATA: [u32; 358] =
spirv_words(include_bytes!("../shaders/triangle.vert.spv"));
static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; 296] =
spirv_words(include_bytes!("../shaders/triangle.frag.spv"));
@@ -211,7 +211,7 @@ pub(super) fn create_static_mesh_vertex_buffer(
device: &VulkanLogicalDeviceProbe,
mesh: &VulkanStaticMesh,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
let mut bytes = Vec::with_capacity(mesh.vertices.len() * 7 * std::mem::size_of::<f32>());
let mut bytes = Vec::with_capacity(mesh.vertices.len() * 8 * std::mem::size_of::<f32>());
for vertex in &mesh.vertices {
for value in vertex
.position
@@ -236,7 +236,7 @@ pub(super) fn create_static_mesh_index_buffer(
device: &VulkanLogicalDeviceProbe,
mesh: &VulkanStaticMesh,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
let mut bytes = Vec::with_capacity(mesh.indices.len() * std::mem::size_of::<u16>());
let mut bytes = Vec::with_capacity(mesh.indices.len() * std::mem::size_of::<u32>());
for &index in &mesh.indices {
bytes.extend_from_slice(&index.to_ne_bytes());
}
+88 -11
View File
@@ -111,6 +111,11 @@ impl VulkanSmokeRenderer {
.mesh
.validate()
.map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?;
if !create_info.camera.is_finite() {
return Err(VulkanSmokeRendererError::InvalidStaticCamera {
context: "non-finite clip_from_world matrix",
});
}
let draw_texture_indices =
resolve_draw_texture_indices(&create_info.mesh.draw_ranges, &create_info.materials)
.map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?;
@@ -226,9 +231,11 @@ impl VulkanSmokeRenderer {
textures,
draw_ranges: create_info.mesh.draw_ranges.clone(),
draw_texture_indices,
camera: create_info.camera,
frame_sync: Vec::new(),
images_in_flight: Vec::new(),
current_frame: 0,
last_readback_image_index: None,
depth_request: create_info.render_request.depth,
pending_extent: None,
swapchain_recreate_count: 0,
@@ -308,6 +315,32 @@ impl VulkanSmokeRenderer {
self.swapchain_recreate_count
}
/// Returns the camera that will be uploaded for the next recorded frame.
#[must_use]
pub const fn camera(&self) -> super::VulkanStaticCamera {
self.camera
}
/// Replaces the camera for subsequent frames without recreating GPU resources.
///
/// # Errors
///
/// Returns [`VulkanSmokeRendererError::InvalidStaticCamera`] when a matrix
/// element is non-finite. The caller must update only between
/// [`Self::draw_frame`] calls on the renderer-owning thread.
pub fn set_camera(
&mut self,
camera: super::VulkanStaticCamera,
) -> Result<(), VulkanSmokeRendererError> {
if !camera.is_finite() {
return Err(VulkanSmokeRendererError::InvalidStaticCamera {
context: "non-finite clip_from_world matrix",
});
}
self.camera = camera;
Ok(())
}
/// Explicitly idles and tears down the renderer while the native window is still alive.
///
/// # Errors
@@ -487,6 +520,7 @@ impl VulkanSmokeRenderer {
})?;
if !self.resources_ref()?.readback_buffers.is_empty() {
self.report.readback_copy_count = self.report.readback_copy_count.saturating_add(1);
self.last_readback_image_index = Some(image_index_usize);
}
let present_wait = [render_finished];
@@ -589,6 +623,7 @@ impl VulkanSmokeRenderer {
let resources = resources.commit();
self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()];
self.frame_sync = frame_sync;
self.last_readback_image_index = None;
self.report.swapchain_extent = swapchain_extent;
self.report.swapchain_image_count = swapchain_image_count;
self.report.swapchain_image_format = self.swapchain_ref()?.report.plan.format.format;
@@ -669,7 +704,15 @@ impl VulkanSmokeRenderer {
command_buffer,
self.index_buffer_ref()?.buffer,
0,
vk::IndexType::UINT16,
vk::IndexType::UINT32,
);
let clip_from_world = matrix_bytes(self.camera.clip_from_world);
device.device().cmd_push_constants(
command_buffer,
resources.pipeline_layout,
vk::ShaderStageFlags::VERTEX,
0,
&clip_from_world,
);
for (range, &texture_index) in self.draw_ranges.iter().zip(&self.draw_texture_indices) {
let pipeline = resources.pipelines.get(&range.pipeline_key()).ok_or(
@@ -700,7 +743,7 @@ impl VulkanSmokeRenderer {
command_buffer,
resources.pipeline_layout,
vk::ShaderStageFlags::FRAGMENT,
0,
64,
&alpha_cutoff,
);
device.device().cmd_draw_indexed(
@@ -901,13 +944,19 @@ impl VulkanSmokeRenderer {
if resources.readback_buffers.is_empty() {
return Ok(None);
}
let mut artifact =
Vec::with_capacity(byte_len.saturating_mul(resources.readback_buffers.len()));
for buffer in &resources.readback_buffers {
let bytes = readback_buffer_bytes(device, buffer, byte_len)?;
artifact.extend_from_slice(&bytes);
}
Ok(Some(artifact))
let Some(image_index) = completed_readback_buffer_index(
self.last_readback_image_index,
resources.readback_buffers.len(),
)?
else {
return Ok(None);
};
let buffer = resources.readback_buffers.get(image_index).ok_or(
VulkanSmokeRendererError::InvariantViolation {
context: "last readback image index",
},
)?;
Ok(Some(readback_buffer_bytes(device, buffer, byte_len)?))
}
fn teardown(&mut self) {
@@ -928,6 +977,27 @@ impl VulkanSmokeRenderer {
}
}
fn completed_readback_buffer_index(
last_image_index: Option<usize>,
buffer_count: usize,
) -> Result<Option<usize>, VulkanSmokeRendererError> {
match last_image_index {
None => Ok(None),
Some(index) if index < buffer_count => Ok(Some(index)),
Some(_) => Err(VulkanSmokeRendererError::InvariantViolation {
context: "last readback image index",
}),
}
}
fn matrix_bytes(matrix: [f32; 16]) -> [u8; 64] {
let mut bytes = [0; 64];
for (index, value) in matrix.into_iter().enumerate() {
bytes[index * 4..(index + 1) * 4].copy_from_slice(&value.to_ne_bytes());
}
bytes
}
fn fnv1a64(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
@@ -943,8 +1013,8 @@ impl Drop for VulkanSmokeRenderer {
#[cfg(test)]
mod tests {
use super::{
take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order,
RollbackOnDrop,
completed_readback_buffer_index, take_runtime_children_with_validation_snapshot,
take_runtime_owners_in_dependency_order, RollbackOnDrop,
};
use std::cell::RefCell;
use std::rc::Rc;
@@ -1027,6 +1097,13 @@ mod tests {
);
}
#[test]
fn completed_readback_selects_only_the_last_submitted_image() {
assert_eq!(completed_readback_buffer_index(None, 2), Ok(None));
assert_eq!(completed_readback_buffer_index(Some(1), 2), Ok(Some(1)));
assert!(completed_readback_buffer_index(Some(2), 2).is_err());
}
#[test]
fn runtime_owners_drop_remaining_children_after_partial_init_failures() {
let cases = [
@@ -1,6 +1,6 @@
use ash::vk;
use fparkan_platform::{NativeWindowHandles, RenderRequest};
use fparkan_render::{LegacyPipelineState, PipelineKey};
use fparkan_render::{LegacyD3d7Projection, LegacyPipelineState, PipelineKey, RawCameraTransform};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
@@ -26,10 +26,11 @@ pub struct VulkanSmokeRendererCreateInfo {
/// Whether validation layers must be enabled.
pub enable_validation: bool,
/// Static indexed geometry uploaded before the first live frame.
///
/// This initial bridge keeps positions in clip-space. MSH transforms,
/// materials, and textures are deliberately higher-level Stage 3 work.
pub mesh: VulkanStaticMesh,
/// Camera contract for static geometry.
///
/// The default identity matrix retains the initial XY diagnostic viewer.
pub camera: VulkanStaticCamera,
/// Material textures uploaded before the first live frame.
///
/// An empty list retains the compatibility white fallback. A singleton list
@@ -43,21 +44,89 @@ pub struct VulkanSmokeRendererCreateInfo {
/// One vertex accepted by the initial static Vulkan geometry path.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VulkanStaticVertex {
/// Position in Vulkan clip-space XY coordinates.
pub position: [f32; 2],
/// World or clip position transformed by [`VulkanStaticCamera`].
pub position: [f32; 3],
/// Linear RGB vertex color.
pub color: [f32; 3],
/// Texture coordinate consumed by the static material bridge.
pub uv: [f32; 2],
}
/// A static geometry camera represented in the shader's matrix memory order.
///
/// `clip_from_world` contains row-major D3D7 data. GLSL's default column-major
/// `mat4` interpretation deliberately transposes that storage, making
/// `matrix * vec4(position, 1)` equivalent to D3D7's row-vector multiplication.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VulkanStaticCamera {
/// Row-major clip-from-world transform consumed by the vertex shader.
pub clip_from_world: [f32; 16],
}
impl VulkanStaticCamera {
/// Builds a static camera from the recovered Ngi32 D3D7 camera contract.
#[must_use]
pub fn from_legacy_d3d7(
transform: RawCameraTransform,
projection: LegacyD3d7Projection,
) -> Option<Self> {
let view = transform.try_direct3d7_view_row_major()?;
let projection = projection.try_direct3d7_projection_row_major()?;
Self::from_row_major_view_projection(view, projection)
}
/// Builds a camera from finite row-major view and projection matrices.
///
/// This is the runtime-facing form of the contract. It is intentionally
/// independent of the D3D7 recovery path so a future native camera
/// controller can submit its per-frame matrices without recreating Vulkan
/// resources.
#[must_use]
pub fn from_row_major_view_projection(view: [f32; 16], projection: [f32; 16]) -> Option<Self> {
view.iter()
.chain(projection.iter())
.all(|value| value.is_finite())
.then_some(Self {
clip_from_world: multiply_row_major(view, projection),
})
}
/// Returns whether every matrix element is finite.
#[must_use]
pub fn is_finite(self) -> bool {
self.clip_from_world.iter().all(|value| value.is_finite())
}
}
impl Default for VulkanStaticCamera {
fn default() -> Self {
Self {
clip_from_world: [
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
],
}
}
}
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
let mut result = [0.0; 16];
for row in 0..4 {
for column in 0..4 {
result[row * 4 + column] = (0..4)
.map(|inner| left[row * 4 + inner] * right[inner * 4 + column])
.sum();
}
}
result
}
/// Static indexed geometry uploaded to live Vulkan buffers.
#[derive(Clone, Debug, PartialEq)]
pub struct VulkanStaticMesh {
/// Vertex data in pipeline order.
pub vertices: Vec<VulkanStaticVertex>,
/// Triangle-list indices into [`Self::vertices`].
pub indices: Vec<u16>,
pub indices: Vec<u32>,
/// Source-preserving triangle draw ranges in [`Self::indices`].
pub draw_ranges: Vec<VulkanStaticDrawRange>,
}
@@ -176,17 +245,17 @@ impl VulkanStaticMesh {
Self {
vertices: vec![
VulkanStaticVertex {
position: [0.0, -0.55],
position: [0.0, -0.55, 0.0],
color: [1.0, 0.2, 0.2],
uv: [0.5, 0.0],
},
VulkanStaticVertex {
position: [0.55, 0.55],
position: [0.55, 0.55, 0.0],
color: [0.2, 1.0, 0.2],
uv: [1.0, 1.0],
},
VulkanStaticVertex {
position: [-0.55, 0.55],
position: [-0.55, 0.55, 0.0],
color: [0.2, 0.4, 1.0],
uv: [0.0, 1.0],
},
@@ -227,11 +296,11 @@ impl VulkanStaticMesh {
if usize::try_from(expected_first).ok() != Some(self.indices.len()) {
return Err("static mesh draw ranges must cover all indices");
}
if self
.indices
.iter()
.any(|&index| usize::from(index) >= self.vertices.len())
{
if self.indices.iter().any(|&index| {
usize::try_from(index)
.ok()
.is_none_or(|index| index >= self.vertices.len())
}) {
return Err("static mesh index exceeds vertex count");
}
Ok(())
@@ -250,6 +319,60 @@ mod static_mesh_tests {
assert_eq!(mesh.validate(), Ok(()));
}
#[test]
fn static_camera_composes_recovered_d3d7_view_before_projection() {
let transform = RawCameraTransform {
words: [
0.0_f32.to_bits(),
(-1.0_f32).to_bits(),
0.0_f32.to_bits(),
10.0_f32.to_bits(),
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
20.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
30.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
],
};
let projection = LegacyD3d7Projection {
viewport: [0, 0, 1024, 768],
near_plane: 0.5,
far_plane: 700.0,
field_of_view_radians: 1.3,
};
let camera = VulkanStaticCamera::from_legacy_d3d7(transform, projection)
.expect("recovered camera inputs are valid");
assert!(camera.is_finite());
assert_eq!(camera.clip_from_world[0], 0.65_f32.cos());
assert_eq!(camera.clip_from_world[6], 0.65_f32.sin() * 700.0 / 699.5);
assert_eq!(camera.clip_from_world[7], 0.65_f32.sin());
}
#[test]
fn static_camera_accepts_finite_runtime_matrices_and_rejects_nan() {
let identity = VulkanStaticCamera::default().clip_from_world;
assert_eq!(
VulkanStaticCamera::from_row_major_view_projection(identity, identity),
Some(VulkanStaticCamera::default())
);
let mut invalid = identity;
invalid[5] = f32::NAN;
assert_eq!(
VulkanStaticCamera::from_row_major_view_projection(invalid, identity),
None
);
}
#[test]
fn static_mesh_rejects_bad_triangle_topology_and_indices() {
let no_vertices = VulkanStaticMesh {
@@ -480,7 +603,7 @@ pub struct VulkanValidationReport {
pub vuids: Vec<String>,
}
/// CPU-owned copy of the final completed swapchain readback buffers.
/// CPU-owned copy of the final completed swapchain image readback.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanReadbackArtifact {
/// Vulkan swapchain format as a raw enum value.
@@ -489,7 +612,8 @@ pub struct VulkanReadbackArtifact {
pub width: u32,
/// Height of each image in pixels.
pub height: u32,
/// Concatenated raw four-byte-per-pixel images in swapchain order.
/// One raw four-byte-per-pixel image: the last successfully submitted
/// swapchain image before synchronized teardown.
pub bytes: Vec<u8>,
}
@@ -547,6 +671,11 @@ pub enum VulkanSmokeRendererError {
/// Validation failure detail.
context: &'static str,
},
/// The static camera matrix contains a non-finite element.
InvalidStaticCamera {
/// Validation failure detail.
context: &'static str,
},
/// The submitted static texture cannot be represented by this path.
InvalidStaticTexture {
/// Validation failure detail.
@@ -574,6 +703,9 @@ impl std::fmt::Display for VulkanSmokeRendererError {
write!(f, "{context}: no compatible Vulkan memory type")
}
Self::InvalidStaticMesh { context } => write!(f, "invalid static mesh: {context}"),
Self::InvalidStaticCamera { context } => {
write!(f, "invalid static camera: {context}")
}
Self::InvalidStaticTexture { context } => {
write!(f, "invalid static texture: {context}")
}
@@ -600,9 +732,14 @@ pub struct VulkanSmokeRenderer {
pub(super) textures: Vec<VulkanAllocatedImage>,
pub(super) draw_ranges: Vec<VulkanStaticDrawRange>,
pub(super) draw_texture_indices: Vec<usize>,
pub(super) camera: VulkanStaticCamera,
pub(super) frame_sync: Vec<VulkanFrameSync>,
pub(super) images_in_flight: Vec<vk::Fence>,
pub(super) current_frame: usize,
/// Last swapchain image whose color attachment was copied after a
/// successful graphics submission. Reset whenever swapchain resources are
/// replaced so a teardown never reads a buffer from an old swapchain.
pub(super) last_readback_image_index: Option<usize>,
pub(super) depth_request: fparkan_platform::DepthStencilSupport,
pub(super) pending_extent: Option<(u32, u32)>,
pub(super) swapchain_recreate_count: u32,
@@ -411,10 +411,16 @@ fn create_pipeline_layout(
descriptor_set_layout: vk::DescriptorSetLayout,
) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> {
let set_layouts = [descriptor_set_layout];
let push_constant_ranges = [vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
let push_constant_ranges = [
vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::VERTEX)
.offset(0)
.size(u32::try_from(std::mem::size_of::<f32>()).unwrap_or(u32::MAX))];
.size(64),
vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
.offset(64)
.size(u32::try_from(std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
];
let create_info = vk::PipelineLayoutCreateInfo::default()
.set_layouts(&set_layouts)
.push_constant_ranges(&push_constant_ranges);
@@ -620,24 +626,24 @@ fn create_graphics_pipeline(
];
let vertex_binding = vk::VertexInputBindingDescription::default()
.binding(0)
.stride(u32::try_from(7 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX))
.stride(u32::try_from(8 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX))
.input_rate(vk::VertexInputRate::VERTEX);
let vertex_attributes = [
vk::VertexInputAttributeDescription::default()
.binding(0)
.location(0)
.format(vk::Format::R32G32_SFLOAT)
.format(vk::Format::R32G32B32_SFLOAT)
.offset(0),
vk::VertexInputAttributeDescription::default()
.binding(0)
.location(1)
.format(vk::Format::R32G32B32_SFLOAT)
.offset(u32::try_from(2 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
.offset(u32::try_from(3 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
vk::VertexInputAttributeDescription::default()
.binding(0)
.location(2)
.format(vk::Format::R32G32_SFLOAT)
.offset(u32::try_from(5 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
.offset(u32::try_from(6 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
];
let vertex_bindings = [vertex_binding];
let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default()
@@ -669,13 +669,13 @@ fn triangle_shader_manifest_hashes_are_stable() {
TRIANGLE_VERTEX_SOURCE_SHA256
);
assert_eq!(report.modules[0].spirv_path, TRIANGLE_VERTEX_SPIRV_PATH);
assert_eq!(report.modules[0].word_count, 290);
assert_eq!(report.modules[0].word_count, 358);
assert_eq!(
report.modules[0].sha256,
"1984662f78873b70135ec444ccd86d86fa66dfca3f615d19ddaf0cda1587d4cf"
"4e2051ac43b933a57e5557e596bcc95952b4efbfcb45315180f89581e094398d"
);
assert_eq!(report.modules[0].descriptor_sets, 0);
assert_eq!(report.modules[0].push_constant_bytes, 0);
assert_eq!(report.modules[0].push_constant_bytes, 64);
assert_eq!(
report.modules[0].compile_command,
TRIANGLE_VERTEX_COMPILE_COMMAND
@@ -687,11 +687,11 @@ fn triangle_shader_manifest_hashes_are_stable() {
assert!(!report.modules[0].interface_hash.is_empty());
assert_eq!(
report.modules[1].sha256,
"536a5c9a4389f9d34ca11a25f20d0acbc53d3eb0782c18375326647319336a85"
"8ab7bf835e166892b04b10fa1100258daf355d2f693ad311d014dbee22de5c7b"
);
assert_eq!(
report.manifest_hash,
"5a16fb791e86bb790cd2d85151627e0d39193c5c073a9294130f149d4ff5ba58"
"cf471972978ddb5c8919711ad64c93d0d64e18b1f590a89ce014f1e2a743cfb5"
);
}
@@ -20,7 +20,7 @@ pub(crate) const SPIRV_VALIDATOR_BINARY_SHA256: &str =
pub(crate) const TRIANGLE_VERTEX_SOURCE_PATH: &str =
"adapters/fparkan-render-vulkan/shaders/triangle.vert";
pub(crate) const TRIANGLE_VERTEX_SOURCE_SHA256: &str =
"82f9ee39a30b094203041205954576ca820d75771421bb27ddd300359e4e9043";
"fe3721202477220d2d9677b6572d7f3baecb374e94f04eea9d7286ed5e98b6c9";
pub(crate) const TRIANGLE_VERTEX_SPIRV_PATH: &str =
"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
pub(crate) const TRIANGLE_VERTEX_COMPILE_COMMAND: &str =
@@ -29,7 +29,7 @@ pub(crate) const TRIANGLE_VERTEX_VALIDATE_COMMAND: &str =
"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
const TRIANGLE_FRAGMENT_SOURCE_PATH: &str = "adapters/fparkan-render-vulkan/shaders/triangle.frag";
const TRIANGLE_FRAGMENT_SOURCE_SHA256: &str =
"f9162fccce56111e85fdf0f58251798393c37aa23f9f3d979ba404bf6773c27b";
"fb3ed435af48e4fb4a817f160b467f7a561c7c547c87b6f0c1e89f5f58af7329";
const TRIANGLE_FRAGMENT_SPIRV_PATH: &str =
"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
const TRIANGLE_FRAGMENT_COMPILE_COMMAND: &str =
@@ -213,7 +213,7 @@ pub fn triangle_shader_manifest() -> Vec<VulkanShaderModuleManifest> {
stage: VulkanShaderStage::Vertex,
entry_point: "main",
descriptor_sets: 0,
push_constant_bytes: 0,
push_constant_bytes: 64,
source_path: TRIANGLE_VERTEX_SOURCE_PATH,
source_sha256: TRIANGLE_VERTEX_SOURCE_SHA256,
spirv_path: TRIANGLE_VERTEX_SPIRV_PATH,
@@ -226,7 +226,7 @@ pub fn triangle_shader_manifest() -> Vec<VulkanShaderModuleManifest> {
stage: VulkanShaderStage::Fragment,
entry_point: "main",
descriptor_sets: 1,
push_constant_bytes: 4,
push_constant_bytes: 68,
source_path: TRIANGLE_FRAGMENT_SOURCE_PATH,
source_sha256: TRIANGLE_FRAGMENT_SOURCE_SHA256,
spirv_path: TRIANGLE_FRAGMENT_SPIRV_PATH,
+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-resource = { path = "../../crates/fparkan-resource", version = "0.1.0" }
fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
fparkan-script = { path = "../../crates/fparkan-script", version = "0.1.0" }
fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+438 -5
View File
@@ -25,7 +25,10 @@ use fparkan_assets::{
decode_mission_payload, extend_graph_report_with_visual_dependencies, TmaProfile,
};
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
use fparkan_inspection::{inspect_archive_file, inspect_land_msh_bounds_file, ArchiveInspection};
use fparkan_inspection::{
inspect_archive_file, inspect_land_msh_bounds_file, inspect_model_from_root,
inspect_wear_from_root, load_land_msh_from_path, ArchiveInspection, ModelInspection,
};
use fparkan_path::{normalize_relative, PathPolicy};
use fparkan_prototype::build_prototype_graph_report;
use fparkan_resource::{resource_name, CachedResourceRepository};
@@ -34,14 +37,18 @@ use fparkan_runtime::{
};
use fparkan_vfs::{DirectoryVfs, Vfs};
use serde::Serialize;
use std::fmt::Write as _;
use std::path::PathBuf;
use std::sync::Arc;
const ARCHIVE_INSPECT_SCHEMA: &str = "fparkan-archive-inspect-v1";
const PROTOTYPE_INSPECT_SCHEMA: &str = "fparkan-prototype-inspect-v1";
const PROTOTYPE_INSPECT_SCHEMA: &str = "fparkan-prototype-inspect-v2";
const MISSION_GRAPH_SCHEMA: &str = "fparkan-mission-graph-v1";
const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1";
const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1";
const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1";
const WEAR_INSPECT_SCHEMA: &str = "fparkan-wear-inspect-v1";
const SCRIPT_INSPECT_SCHEMA: &str = "fparkan-script-inspect-v2";
#[derive(Serialize)]
struct ArchiveInspectOutput<'a> {
@@ -60,6 +67,8 @@ struct PrototypeInspectOutput {
roots: usize,
node_count: usize,
edge_count: usize,
unit_component_records: Vec<UnitComponentInspectOutput>,
edges: Vec<PrototypeGraphEdgeInspectOutput>,
prototype_requests: usize,
resolved: usize,
unit_references: usize,
@@ -121,6 +130,62 @@ struct TerrainInspectOutput {
positions: usize,
min: [f32; 3],
max: [f32; 3],
faces: usize,
slots: usize,
material_tags: Vec<TerrainMaterialTagCount>,
shade_pairs: usize,
#[serde(skip_serializing_if = "Option::is_none")]
shade_lookup_key_min: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
shade_lookup_key_max: Option<u16>,
shade_batch_boundaries: usize,
}
#[derive(Serialize)]
struct TerrainMaterialTagCount {
tag: u16,
faces: usize,
}
#[derive(Serialize)]
struct WearInspectOutput {
schema_version: &'static str,
archive: String,
resource: String,
materials: usize,
lightmaps: usize,
#[serde(skip_serializing_if = "Option::is_none")]
first_material: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last_material: Option<String>,
}
#[derive(Serialize)]
struct ModelInspectOutput<'a> {
schema_version: &'static str,
archive: &'a str,
resource: &'a str,
streams: usize,
nodes: usize,
node_stride: usize,
slots: usize,
positions: usize,
indices: usize,
batches: usize,
#[serde(skip_serializing_if = "Option::is_none")]
animation_keys: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
animation_frame_count: Option<u32>,
node38: Vec<ModelNodeInspectOutput>,
}
#[derive(Serialize)]
struct ModelNodeInspectOutput {
index: usize,
parent_or_link_raw: u16,
anim_map_start: u16,
fallback_key: u16,
has_lod0_group0: bool,
}
#[derive(Serialize)]
@@ -135,6 +200,62 @@ struct GraphFailureOutput {
resource: Option<String>,
}
#[derive(Serialize)]
struct UnitComponentInspectOutput {
root_index: usize,
component_index: usize,
archive_raw_hex: String,
resource_raw_hex: String,
kind: u32,
parent_or_link: i32,
description_raw_hex: String,
tail0: u32,
tail1: u32,
}
#[derive(Serialize)]
struct PrototypeGraphEdgeInspectOutput {
id: u32,
from: u32,
to: u32,
kind: &'static str,
requiredness: &'static str,
root_index: Option<usize>,
parent_edge: Option<u32>,
unit_component_index: Option<usize>,
archive: Option<String>,
resource_raw_hex: Option<String>,
}
#[derive(Serialize)]
struct ScriptInspectOutput<'a> {
schema_version: &'static str,
path: &'a str,
opcode_handler_count: u32,
events: usize,
instructions: usize,
references: usize,
trailing_bytes: usize,
first_header_word_candidates: Vec<ScriptHeaderWordCount>,
}
#[derive(Serialize)]
struct ScriptHeaderWordCount {
value: u32,
instructions: usize,
}
#[derive(Serialize)]
struct VarSetInspectOutput<'a> {
schema_version: &'static str,
path: &'a str,
declarations: usize,
float_defaults: usize,
dword_defaults: usize,
first_name: Option<&'a str>,
last_name: Option<&'a str>,
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let result = run(&args);
@@ -191,10 +312,107 @@ fn run(args: &[String]) -> Result<(), String> {
let rest = strip_format_json(rest)?;
inspect_terrain(&rest)
}
[domain, command, rest @ ..] if domain == "wear" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_wear(&rest)
}
[domain, command, rest @ ..] if domain == "model" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_model(&rest)
}
[domain, command, rest @ ..] if domain == "script" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_script(&rest)
}
[domain, command, rest @ ..] if domain == "varset" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_varset(&rest)
}
_ => Err(usage()),
}
}
fn inspect_script(args: &[String]) -> Result<(), String> {
let path = parse_file_path(args, "script inspect")?;
let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
let package =
fparkan_script::decode(&bytes).map_err(|err| format!("{}: {err}", path.display()))?;
let display_path = path.display().to_string();
println!("{}", script_inspect_json(&display_path, &package)?);
Ok(())
}
fn script_inspect_json(
path: &str,
package: &fparkan_script::ScriptPackage,
) -> Result<String, String> {
let instructions = package
.events
.iter()
.map(|event| event.instructions.len())
.sum();
let references = package
.events
.iter()
.flat_map(|event| &event.instructions)
.map(|instruction| instruction.references.len())
.sum();
let mut candidates = std::collections::BTreeMap::<u32, usize>::new();
for instruction in package.events.iter().flat_map(|event| &event.instructions) {
*candidates.entry(instruction.header_words[0]).or_insert(0) += 1;
}
serialize_json(&ScriptInspectOutput {
schema_version: SCRIPT_INSPECT_SCHEMA,
path,
opcode_handler_count: package.opcode_handler_count,
events: package.events.len(),
instructions,
references,
trailing_bytes: package.trailing_bytes.len(),
first_header_word_candidates: candidates
.into_iter()
.map(|(value, instructions)| ScriptHeaderWordCount {
value,
instructions,
})
.collect(),
})
}
fn inspect_varset(args: &[String]) -> Result<(), String> {
let path = parse_file_path(args, "varset inspect")?;
let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
let varset =
fparkan_script::parse_varset(&bytes).map_err(|err| format!("{}: {err}", path.display()))?;
let display_path = path.display().to_string();
println!("{}", varset_inspect_json(&display_path, &varset)?);
Ok(())
}
fn varset_inspect_json(path: &str, varset: &fparkan_script::VarSet) -> Result<String, String> {
let float_defaults = varset
.declarations
.iter()
.filter(|declaration| declaration.type_name == fparkan_script::VarSetType::Float)
.count();
let dword_defaults = varset.declarations.len() - float_defaults;
serialize_json(&VarSetInspectOutput {
schema_version: "fparkan-varset-inspect-v1",
path,
declarations: varset.declarations.len(),
float_defaults,
dword_defaults,
first_name: varset
.declarations
.first()
.map(|declaration| declaration.name.as_str()),
last_name: varset
.declarations
.last()
.map(|declaration| declaration.name.as_str()),
})
}
fn exit_code(result: &Result<(), String>) -> i32 {
if result.is_ok() {
0
@@ -281,6 +499,58 @@ fn prototype_inspect_json(
roots: report.root_count,
node_count: graph.nodes.len(),
edge_count: graph.edges.len(),
unit_component_records: graph
.root_unit_components
.iter()
.enumerate()
.flat_map(|(root_index, records)| {
records
.iter()
.enumerate()
.map(
move |(component_index, record)| UnitComponentInspectOutput {
root_index,
component_index,
archive_raw_hex: hex_bytes(&record.archive_raw),
resource_raw_hex: hex_bytes(&record.resource_raw),
kind: record.kind,
parent_or_link: record.parent_or_link,
description_raw_hex: hex_bytes(&record.description_raw),
tail0: record.tail0,
tail1: record.tail1,
},
)
})
.collect(),
edges: graph
.edges
.iter()
.map(|edge| PrototypeGraphEdgeInspectOutput {
id: edge.id.0,
from: edge.from.0,
to: edge.to.0,
kind: prototype_graph_edge_kind_label(edge.kind),
requiredness: prototype_graph_requiredness_label(edge.requiredness),
root_index: edge.provenance.as_ref().map(|value| value.root_index),
parent_edge: edge
.provenance
.as_ref()
.and_then(|value| value.parent_edge.map(|parent| parent.0)),
unit_component_index: edge
.provenance
.as_ref()
.and_then(|value| value.unit_component_index),
archive: edge
.provenance
.as_ref()
.and_then(|value| value.archive.clone()),
resource_raw_hex: edge
.provenance
.as_ref()
.and_then(|value| value.resource.as_ref())
.map(|raw| hex_bytes(raw)),
})
.collect(),
prototype_requests: graph.prototype_requests.len(),
resolved: report.resolved_count,
unit_references: report.unit_reference_count,
@@ -416,6 +686,28 @@ fn inspect_archive(args: &[String]) -> Result<(), String> {
fn inspect_terrain(args: &[String]) -> Result<(), String> {
let path = parse_file_path(args, "terrain inspect")?;
let bounds = inspect_land_msh_bounds_file(&path)?;
let terrain = load_land_msh_from_path(&path)?;
let mut material_tags = terrain
.faces
.iter()
.fold(std::collections::BTreeMap::new(), |mut counts, face| {
*counts.entry(face.material_tag).or_insert(0usize) += 1;
counts
})
.into_iter()
.map(|(tag, faces)| TerrainMaterialTagCount { tag, faces })
.collect::<Vec<_>>();
material_tags.sort_unstable_by_key(|entry| entry.tag);
let shade_pairs = (0..terrain.slots.slots_raw.len())
.filter_map(|slot_index| terrain.slot_material_pairs(slot_index))
.flatten()
.collect::<Vec<_>>();
let shade_lookup_key_min = shade_pairs.iter().map(|pair| pair.shade_lookup_key()).min();
let shade_lookup_key_max = shade_pairs.iter().map(|pair| pair.shade_lookup_key()).max();
let shade_batch_boundaries = shade_pairs
.iter()
.filter(|pair| pair.flags & 0x0010 != 0)
.count();
println!(
"{}",
serialize_json(&TerrainInspectOutput {
@@ -424,11 +716,79 @@ fn inspect_terrain(args: &[String]) -> Result<(), String> {
positions: bounds.positions,
min: bounds.min,
max: bounds.max,
faces: terrain.faces.len(),
slots: terrain.slots.slots_raw.len(),
material_tags,
shade_pairs: shade_pairs.len(),
shade_lookup_key_min,
shade_lookup_key_max,
shade_batch_boundaries,
})?
);
Ok(())
}
fn inspect_wear(args: &[String]) -> Result<(), String> {
let root = parse_root_alias(args)?;
let archive = parse_required(args, &["--archive"], "--archive")?;
let resource = parse_required(args, &["--resource"], "--resource")?;
let inspection = inspect_wear_from_root(&root, &archive, &resource)?;
println!(
"{}",
serialize_json(&WearInspectOutput {
schema_version: WEAR_INSPECT_SCHEMA,
archive,
resource,
materials: inspection.materials,
lightmaps: inspection.lightmaps,
first_material: inspection.first_material,
last_material: inspection.last_material,
})?
);
Ok(())
}
fn inspect_model(args: &[String]) -> Result<(), String> {
let root = parse_root_alias(args)?;
let archive = parse_required(args, &["--archive"], "--archive")?;
let resource = parse_required(args, &["--resource"], "--resource")?;
let inspection = inspect_model_from_root(&root, &archive, &resource)?;
println!("{}", model_inspect_json(&archive, &resource, &inspection)?);
Ok(())
}
fn model_inspect_json(
archive: &str,
resource: &str,
inspection: &ModelInspection,
) -> Result<String, String> {
serialize_json(&ModelInspectOutput {
schema_version: MODEL_INSPECT_SCHEMA,
archive,
resource,
streams: inspection.streams,
nodes: inspection.nodes,
node_stride: inspection.node_stride,
slots: inspection.slots,
positions: inspection.positions,
indices: inspection.indices,
batches: inspection.batches,
animation_keys: inspection.animation_keys,
animation_frame_count: inspection.animation_frame_count,
node38: inspection
.node38
.iter()
.map(|node| ModelNodeInspectOutput {
index: node.index,
parent_or_link_raw: node.parent_or_link_raw,
anim_map_start: node.anim_map_start,
fallback_key: node.fallback_key,
has_lod0_group0: node.has_lod0_group0,
})
.collect(),
})
}
fn archive_inspect_json(
path: &str,
kind: &str,
@@ -479,6 +839,29 @@ fn graph_failure_output(failure: &fparkan_prototype::PrototypeGraphFailure) -> G
}
}
fn hex_bytes(raw: &[u8]) -> String {
let mut output = String::with_capacity(raw.len().saturating_mul(2));
for byte in raw {
#[allow(clippy::expect_used)]
write!(&mut output, "{byte:02x}").expect("writing into String cannot fail");
}
output
}
fn prototype_graph_edge_kind_label(
edge: fparkan_prototype::PrototypeGraphEdgeKind,
) -> &'static str {
match edge {
fparkan_prototype::PrototypeGraphEdgeKind::MissionToRoot => "mission_to_root",
fparkan_prototype::PrototypeGraphEdgeKind::UnitDatToComponent => "unit_dat_to_component",
fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh => "prototype_to_mesh",
fparkan_prototype::PrototypeGraphEdgeKind::MeshToWear => "mesh_to_wear",
fparkan_prototype::PrototypeGraphEdgeKind::WearToMaterial => "wear_to_material",
fparkan_prototype::PrototypeGraphEdgeKind::MaterialToTexture => "material_to_texture",
fparkan_prototype::PrototypeGraphEdgeKind::WearToLightmap => "wear_to_lightmap",
}
}
fn prototype_graph_edge_label(edge: fparkan_prototype::PrototypeGraphEdge) -> &'static str {
match edge {
fparkan_prototype::PrototypeGraphEdge::MissionToUnitDat => "mission_to_unit_dat",
@@ -505,7 +888,7 @@ fn prototype_graph_requiredness_label(
}
fn usage() -> String {
"usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | terrain inspect <Land.msh> [--format json] | 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)]
@@ -534,6 +917,27 @@ mod tests {
);
}
#[test]
fn script_inspect_json_has_canonical_field_order() {
let package =
fparkan_script::decode(&[73, 0, 0, 0, 0, 0, 0, 0]).expect("minimal script package");
assert_eq!(
script_inspect_json("script.scr", &package),
Ok("{\"schema_version\":\"fparkan-script-inspect-v2\",\"path\":\"script.scr\",\"opcode_handler_count\":73,\"events\":0,\"instructions\":0,\"references\":0,\"trailing_bytes\":0,\"first_header_word_candidates\":[]}".to_string())
);
}
#[test]
fn varset_inspect_json_has_canonical_field_order() {
let varset =
fparkan_script::parse_varset(b"VAR( float, f0, 0)\nVAR( DWORD, d0, 0xffffffff)\n")
.expect("minimal varset");
assert_eq!(
varset_inspect_json("varset.var", &varset),
Ok("{\"schema_version\":\"fparkan-varset-inspect-v1\",\"path\":\"varset.var\",\"declarations\":2,\"float_defaults\":1,\"dword_defaults\":1,\"first_name\":\"f0\",\"last_name\":\"d0\"}".to_string())
);
}
#[test]
fn archive_json_has_schema_version() {
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true))
@@ -561,7 +965,7 @@ mod tests {
assert_eq!(
json,
"{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":\"root\",\"roots\":1,\"node_count\":0,\"edge_count\":0,\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear_requests\":0,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"is_success\":true,\"failures\":[]}"
"{\"schema_version\":\"fparkan-prototype-inspect-v2\",\"key\":\"root\",\"roots\":1,\"node_count\":0,\"edge_count\":0,\"unit_component_records\":[],\"edges\":[],\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear_requests\":0,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"is_success\":true,\"failures\":[]}"
);
}
@@ -626,12 +1030,41 @@ mod tests {
positions: 3,
min: [-1.0, -2.0, -3.0],
max: [4.0, 5.0, 6.0],
faces: 2,
slots: 4,
material_tags: vec![
TerrainMaterialTagCount { tag: 0, faces: 1 },
TerrainMaterialTagCount { tag: 3, faces: 1 },
],
shade_pairs: 2,
shade_lookup_key_min: Some(1),
shade_lookup_key_max: Some(2),
shade_batch_boundaries: 1,
})
.expect("serialize terrain inspection");
assert_eq!(
json,
"{\"schema_version\":\"fparkan-terrain-inspect-v1\",\"path\":\"DATA/MAPS/AutoMAP/Land.msh\",\"positions\":3,\"min\":[-1.0,-2.0,-3.0],\"max\":[4.0,5.0,6.0]}"
"{\"schema_version\":\"fparkan-terrain-inspect-v1\",\"path\":\"DATA/MAPS/AutoMAP/Land.msh\",\"positions\":3,\"min\":[-1.0,-2.0,-3.0],\"max\":[4.0,5.0,6.0],\"faces\":2,\"slots\":4,\"material_tags\":[{\"tag\":0,\"faces\":1},{\"tag\":3,\"faces\":1}],\"shade_pairs\":2,\"shade_lookup_key_min\":1,\"shade_lookup_key_max\":2,\"shade_batch_boundaries\":1}"
);
}
#[test]
fn wear_inspect_output_retains_material_bounds() {
let json = serialize_json(&WearInspectOutput {
schema_version: WEAR_INSPECT_SCHEMA,
archive: "system.rlb".to_string(),
resource: "SHADE.WEA".to_string(),
materials: 1,
lightmaps: 0,
first_material: Some("LIGHT1".to_string()),
last_material: Some("LIGHT1".to_string()),
})
.expect("serialize wear inspection");
assert_eq!(
json,
"{\"schema_version\":\"fparkan-wear-inspect-v1\",\"archive\":\"system.rlb\",\"resource\":\"SHADE.WEA\",\"materials\":1,\"lightmaps\":0,\"first_material\":\"LIGHT1\",\"last_material\":\"LIGHT1\"}"
);
}
}
+4
View File
@@ -7,6 +7,8 @@ repository.workspace = true
[dependencies]
fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
@@ -15,6 +17,8 @@ fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
fparkan-terrain = { path = "../../crates/fparkan-terrain", version = "0.1.0" }
fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
fparkan-world = { path = "../../crates/fparkan-world", version = "0.1.0" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
[lints]
File diff suppressed because it is too large Load Diff
+139 -5
View File
@@ -22,10 +22,11 @@
//! `FParkan` headless runtime entrypoint.
use fparkan_runtime::{
create, load_mission, step_headless, EngineConfig, EngineMode, EngineServices, MissionRequest,
advance_reference_movement, create, load_mission, step_headless, EngineConfig, EngineMode,
EngineServices, MissionRequest,
};
use fparkan_vfs::DirectoryVfs;
use fparkan_world::InputSnapshot;
use fparkan_world::{InputSnapshot, OriginalObjectId};
use std::path::PathBuf;
use std::sync::Arc;
@@ -55,7 +56,7 @@ fn run() -> Result<(), String> {
let loaded = load_mission(&mut engine, MissionRequest { key: mission })
.map_err(|err| format!("{err}"))?;
println!(
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} graph_failures={}",
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} script_varset_declarations={} script_init_states={} script_varset_states={} graph_failures={}",
loaded.object_count,
loaded.areal_count,
loaded.surface_count,
@@ -65,9 +66,27 @@ fn run() -> Result<(), String> {
loaded.graph_material_resolved_count,
loaded.graph_texture_resolved_count,
loaded.graph_lightmap_resolved_count,
loaded.script_bundle_count,
loaded.script_event_count,
loaded.script_varset_declaration_count,
loaded.script_init_state_count,
loaded.script_varset_state_count,
loaded.graph_failure_count
);
}
if let Some(movement) = args.reference_movement {
let reached = advance_reference_movement(
&mut engine,
OriginalObjectId(movement.original_id),
movement.target_xy,
movement.max_step,
)
.map_err(|err| format!("{err}"))?;
println!(
"reference_movement original_id={} target_xy=[{},{}] max_step={} reached={reached}",
movement.original_id, movement.target_xy[0], movement.target_xy[1], movement.max_step,
);
}
let mut last = None;
for _ in 0..args.ticks {
last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?);
@@ -81,10 +100,19 @@ fn run() -> Result<(), String> {
Ok(())
}
#[derive(Debug)]
struct Args {
root: Option<PathBuf>,
mission: Option<String>,
ticks: u64,
reference_movement: Option<ReferenceMovement>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct ReferenceMovement {
original_id: u32,
target_xy: [f32; 2],
max_step: f32,
}
impl Args {
@@ -93,6 +121,7 @@ impl Args {
root: None,
mission: None,
ticks: 1,
reference_movement: None,
};
let mut iter = args.iter();
while let Some(arg) = iter.next() {
@@ -118,16 +147,121 @@ impl Args {
.parse()
.map_err(|_| "--ticks must be an integer".to_string())?;
}
"--move-object" => {
if parsed.reference_movement.is_some() {
return Err("--move-object may be specified once".to_string());
}
let original_id = iter
.next()
.ok_or_else(|| "--move-object requires an original object id".to_string())?
.parse()
.map_err(|_| {
"--move-object object id must be an unsigned integer".to_string()
})?;
let x = parse_finite_argument(
iter.next(),
"--move-object requires a finite X target",
)?;
let y = parse_finite_argument(
iter.next(),
"--move-object requires a finite Y target",
)?;
let max_step = parse_finite_argument(
iter.next(),
"--move-object requires a finite positive maximum step",
)?;
if max_step <= 0.0 {
return Err("--move-object maximum step must be positive".to_string());
}
parsed.reference_movement = Some(ReferenceMovement {
original_id,
target_xy: [x, y],
max_step,
});
}
_ => return Err(usage()),
}
}
if parsed.mission.is_some() && parsed.root.is_none() {
return Err("--mission requires --root".to_string());
}
if parsed.reference_movement.is_some() && parsed.mission.is_none() {
return Err("--move-object requires --mission".to_string());
}
Ok(parsed)
}
}
fn usage() -> String {
"usage: fparkan-headless [--root <path> --mission <path>] [--ticks <n>]".to_string()
fn parse_finite_argument(value: Option<&String>, error: &str) -> Result<f32, String> {
let value: f32 = value
.ok_or_else(|| error.to_string())?
.parse()
.map_err(|_| error.to_string())?;
value
.is_finite()
.then_some(value)
.ok_or_else(|| error.to_string())
}
fn usage() -> String {
"usage: fparkan-headless [--root <path> --mission <path>] [--move-object <original-id> <x> <y> <max-step>] [--ticks <n>]".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(ToString::to_string).collect()
}
#[test]
fn move_object_parses_a_single_finite_reference_command() {
let parsed = Args::parse(&args(&[
"--root",
"C:/game",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--move-object",
"7",
"12.5",
"-3",
"0.25",
"--ticks",
"2",
]))
.expect("args");
assert_eq!(parsed.ticks, 2);
assert_eq!(
parsed.reference_movement,
Some(ReferenceMovement {
original_id: 7,
target_xy: [12.5, -3.0],
max_step: 0.25,
})
);
}
#[test]
fn move_object_requires_a_loaded_mission_and_valid_step() {
assert_eq!(
Args::parse(&args(&["--move-object", "7", "1", "2", "1"])).expect_err("mission"),
"--move-object requires --mission"
);
assert_eq!(
Args::parse(&args(&[
"--root",
"C:/game",
"--mission",
"M/data.tma",
"--move-object",
"7",
"1",
"2",
"0",
]))
.expect_err("step"),
"--move-object maximum step must be positive"
);
}
}
+8 -1
View File
@@ -17,7 +17,8 @@ use fparkan_render_vulkan::{
project_land_msh_to_static_mesh, project_msh_to_static_mesh, VulkanReadbackArtifact,
VulkanSmokeBootstrapProgress, VulkanSmokeFrameOutcome, VulkanSmokeRenderer,
VulkanSmokeRendererCreateInfo, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanValidationReport,
VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
VulkanValidationReport,
};
use serde::Serialize;
use std::path::PathBuf;
@@ -1175,6 +1176,7 @@ impl ApplicationHandler for SmokeApp {
render_request: RenderRequest::conservative(),
enable_validation: true,
mesh: self.mesh.clone(),
camera: VulkanStaticCamera::default(),
materials: self
.materials
.iter()
@@ -1240,6 +1242,11 @@ impl ApplicationHandler for SmokeApp {
event_loop.exit();
return;
};
if let Err(error) = renderer.set_camera(VulkanStaticCamera::default()) {
self.error = Some(error.to_string());
event_loop.exit();
return;
}
match renderer.draw_frame() {
Ok(VulkanSmokeFrameOutcome::Presented) => {
self.frames_presented = self.frames_presented.saturating_add(1);
+36 -3
View File
@@ -781,16 +781,29 @@ fn write_f32_bits(out: &mut Vec<u8>, value: f32) {
}
fn compose_pose(parent: Pose, child: Pose) -> Result<Pose, AnimationError> {
let translated = rotate_point(parent.rotation, child.translation);
Ok(Pose {
translation: [
parent.translation[0] + child.translation[0],
parent.translation[1] + child.translation[1],
parent.translation[2] + child.translation[2],
parent.translation[0] + translated[0],
parent.translation[1] + translated[1],
parent.translation[2] + translated[2],
],
rotation: normalize_quat(mul_quat(parent.rotation, child.rotation))?,
})
}
fn rotate_point(rotation: [f32; 4], point: [f32; 3]) -> [f32; 3] {
let [x, y, z, w] = rotation;
let tx = 2.0 * (y * point[2] - z * point[1]);
let ty = 2.0 * (z * point[0] - x * point[2]);
let tz = 2.0 * (x * point[1] - y * point[0]);
[
point[0] + w * tx + (y * tz - z * ty),
point[1] + w * ty + (z * tx - x * tz),
point[2] + w * tz + (x * ty - y * tx),
]
}
fn mul_quat(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
let [lx, ly, lz, lw] = left;
let [rx, ry, rz, rw] = right;
@@ -1174,6 +1187,26 @@ mod tests {
);
}
#[test]
fn hierarchy_rotates_child_translation_by_parent_orientation() {
let half = std::f32::consts::FRAC_1_SQRT_2;
let local = [
Pose {
translation: [1.0, 0.0, 0.0],
rotation: [0.0, 0.0, half, half],
},
Pose {
translation: [2.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
];
let buffer = evaluate_hierarchy(&[ParentIndex(None), ParentIndex(Some(0))], &local)
.expect("hierarchy");
assert!((buffer.poses[1].translation[0] - 1.0).abs() < 0.0001);
assert!((buffer.poses[1].translation[1] - 2.0).abs() < 0.0001);
assert!(buffer.poses[1].translation[2].abs() < 0.0001);
}
#[test]
fn generated_valid_quaternions_remain_finite() {
for index in 1..64_u16 {
+83 -4
View File
@@ -24,7 +24,7 @@ use fparkan_material::{
decode_wear, resolve_material, Mat0Document, MaterialError, ResolvedMaterial, WearTable,
MAT0_KIND, WEAR_KIND,
};
use fparkan_mission_format::{decode_tma, decode_tma_land_path};
use fparkan_mission_format::{decode_tma, decode_tma_land_path, ClanBody};
pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError};
use fparkan_nres::{decode as decode_nres, ReadProfile};
@@ -63,6 +63,17 @@ pub struct MissionTerrainPaths {
pub land_map: NormalizedPath,
}
/// Raw compiled-script base selected by one TMA clan.
///
/// The path remains raw legacy bytes until the runtime applies its path policy.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionScriptBundleBase {
/// Index in the mission clan array.
pub clan_index: usize,
/// First clan resource path, without a required `.scr` suffix.
pub path_raw: Vec<u8>,
}
/// Terrain loading errors that include runtime world construction failures.
#[derive(Debug)]
pub enum TerrainPreparationError {
@@ -121,6 +132,29 @@ pub fn decode_mission_land_path(
decode_tma_land_path(bytes, profile)
}
/// Returns non-empty script/formula bases selected by TMA clans.
///
/// The first resource has the same position in the standard and spatial clan
/// layouts. Its consumer-specific meaning is intentionally left to runtime.
#[must_use]
pub fn mission_script_bundle_bases(mission: &MissionDocument) -> Vec<MissionScriptBundleBase> {
mission
.clans
.iter()
.enumerate()
.filter_map(|(clan_index, clan)| {
let path_raw = match &clan.body {
ClanBody::Standard { first_resource, .. } => first_resource.path.raw.clone(),
ClanBody::Spatial { first_resource, .. } => first_resource.raw.clone(),
};
(!path_raw.is_empty()).then_some(MissionScriptBundleBase {
clan_index,
path_raw,
})
})
.collect()
}
/// Builds canonical mission terrain paths from the mission `Land` reference.
///
/// # Errors
@@ -1251,6 +1285,8 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
continue;
};
let root_index = root_index_for_prototype(graph, prototype_index);
let unit_component_index =
mesh_parent_edge.and_then(|edge| unit_component_index_for_edge(graph, edge));
wear_requests = wear_requests.saturating_add(1);
report_visual_dependency_progress(
@@ -1301,6 +1337,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
Some(provenance_for_resource(
root_index,
mesh_parent_edge,
unit_component_index,
&wear_key,
)),
&mut next_edge,
@@ -1375,6 +1412,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
Some(provenance_for_resource(
root_index,
Some(wear_edge_id),
unit_component_index,
&material_key,
)),
&mut next_edge,
@@ -1424,6 +1462,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
Some(provenance_for_resource(
root_index,
Some(material_edge_id),
unit_component_index,
&texture_key,
)),
&mut next_edge,
@@ -1500,6 +1539,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
Some(provenance_for_resource(
root_index,
Some(wear_edge_id),
unit_component_index,
&lightmap_key,
)),
&mut next_edge,
@@ -1622,11 +1662,13 @@ fn prototype_mesh_node_id(
fn provenance_for_resource(
root_index: usize,
parent_edge: Option<fparkan_prototype::PrototypeGraphEdgeId>,
unit_component_index: Option<usize>,
resource: &ResourceKey,
) -> PrototypeGraphProvenance {
PrototypeGraphProvenance {
root_index,
parent_edge,
unit_component_index,
archive: Some(resource.archive.display_lossy().to_string()),
resource: Some(resource.name.0.clone()),
span: None,
@@ -1863,7 +1905,7 @@ fn read_key<R: ResourceRepository>(
) -> Result<Arc<[u8]>, AssetError> {
let label = label.unwrap_or("asset");
let archive = repository
.open_archive(&key.archive)
.open_archive_unchanged(&key.archive)
.map_err(|err| map_resource_error(label, key, err))?;
let handle = repository
.find(archive, &key.name)
@@ -1928,7 +1970,7 @@ fn resolve_wear_table<R: ResourceRepository>(
mesh: &ResourceKey,
) -> Result<fparkan_material::WearTable, AssetError> {
let archive = repository
.open_archive(&mesh.archive)
.open_archive_unchanged(&mesh.archive)
.map_err(|err| map_resource_error("wear", mesh, err))?;
let wear_name = sibling_name(mesh, "wea")?;
let handle = repository
@@ -2010,6 +2052,8 @@ fn push_visual_failure(
) {
let root_index = root_index_for_prototype(graph, prototype_index);
let parent_edge = parent_edge_for_failure(graph, prototype_index, edge);
let unit_component_index =
parent_edge.and_then(|value| unit_component_index_for_edge(graph, value));
let dependency = mesh_dependency_resource(graph, prototype_index);
report.failures.push(PrototypeGraphFailure {
root_index,
@@ -2020,6 +2064,7 @@ fn push_visual_failure(
provenance: Some(PrototypeGraphProvenance {
root_index,
parent_edge,
unit_component_index,
archive: dependency.map(|resource| resource.archive.as_str().to_string()),
resource: Some(resource_raw),
span: None,
@@ -2036,6 +2081,18 @@ fn root_index_for_prototype(graph: &PrototypeGraph, prototype_index: usize) -> u
0
}
fn unit_component_index_for_edge(
graph: &PrototypeGraph,
edge_id: fparkan_prototype::PrototypeGraphEdgeId,
) -> Option<usize> {
graph
.edges
.iter()
.find(|edge| edge.id == edge_id)
.and_then(|edge| edge.provenance.as_ref())
.and_then(|provenance| provenance.unit_component_index)
}
fn parent_edge_for_failure(
graph: &PrototypeGraph,
prototype_index: usize,
@@ -2271,7 +2328,7 @@ fn read_optional_key<R: ResourceRepository>(
key: &ResourceKey,
label: Option<&str>,
) -> Result<Option<Arc<[u8]>>, AssetError> {
let archive = match repository.open_archive(&key.archive) {
let archive = match repository.open_archive_unchanged(&key.archive) {
Ok(archive) => archive,
Err(ResourceError::MissingArchive { .. } | ResourceError::MissingEntry) => return Ok(None),
Err(err) => {
@@ -2724,6 +2781,19 @@ mod tests {
dependencies: Vec::new(),
};
let mut graph = prototype_graph_for_mesh(&prototype);
graph
.edges
.iter_mut()
.find(|edge| edge.kind == fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh)
.expect("mesh edge")
.provenance = Some(PrototypeGraphProvenance {
root_index: 0,
parent_edge: Some(fparkan_prototype::PrototypeGraphEdgeId(0)),
unit_component_index: Some(3),
archive: Some("static.rlb".to_string()),
resource: Some(b"tree.msh".to_vec()),
span: None,
});
let mut report = PrototypeGraphReport {
root_count: 1,
direct_reference_count: 1,
@@ -2797,6 +2867,14 @@ mod tests {
.iter()
.filter_map(|edge| edge.provenance.as_ref())
.all(|provenance| provenance.root_index == 0));
for edge in [wear_edge, material_edge, texture_edge, lightmap_edge] {
assert_eq!(
edge.provenance
.as_ref()
.and_then(|provenance| provenance.unit_component_index),
Some(3)
);
}
}
#[test]
@@ -3528,6 +3606,7 @@ mod tests {
roots: vec![prototype.key.clone()],
prototype_requests: vec![prototype.key.clone()],
root_prototype_request_spans: std::iter::once(0..1).collect(),
root_unit_components: vec![Vec::new()],
visual_dependencies_expanded: false,
nodes: vec![root_node, prototype_node, mesh_node],
edges: vec![
+160 -1
View File
@@ -24,7 +24,9 @@ use fparkan_diagnostics::{
diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase, SourceSpan,
};
use fparkan_material::{decode_wear, resolve_material, MaterialFallback};
use fparkan_msh::{decode_msh, validate_msh, ModelAsset};
use fparkan_msh::{
decode_msh, node38_metadata, selected_slot, validate_msh, Group, Lod, ModelAsset, NodeId,
};
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
use fparkan_path::{normalize_relative, PathPolicy};
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
@@ -85,6 +87,29 @@ pub struct ModelInspection {
pub indices: usize,
/// Batch count.
pub batches: usize,
/// Original node record stride.
pub node_stride: usize,
/// Standard-node metadata in source order, when the model uses `Node38`.
pub node38: Vec<Node38Inspection>,
/// Number of decoded type-8 animation keys, when available.
pub animation_keys: Option<usize>,
/// Declared type-19 animation frame count, when available.
pub animation_frame_count: Option<u32>,
}
/// Inspection view of a standard 38-byte model node.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Node38Inspection {
/// Source node index.
pub index: usize,
/// Opaque source field at byte offset two.
pub parent_or_link_raw: u16,
/// Type-19 frame-map offset, or `0xFFFF`.
pub anim_map_start: u16,
/// Type-8 fallback key index.
pub fallback_key: u16,
/// Whether LOD zero/group zero selects a geometry slot.
pub has_lod0_group0: bool,
}
/// Texture inspection payload.
@@ -121,6 +146,19 @@ pub struct WearMaterialTexture {
pub image: fparkan_texm::RgbaImage,
}
/// Compact inspection summary of a WEAR resource stored in an archive.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WearInspection {
/// Number of material rows.
pub materials: usize,
/// Number of lightmap rows.
pub lightmaps: usize,
/// First material resource name, when present.
pub first_material: Option<String>,
/// Last material resource name, when present.
pub last_material: Option<String>,
}
/// Land map/msh inspection payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MapInspection {
@@ -312,6 +350,23 @@ pub fn inspect_model_from_root(
})?;
let msh = decode_msh(&document).map_err(|err| err.to_string())?;
let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
let node38 = if validated.node_stride == 38 {
(0..validated.node_count)
.filter_map(|index| {
let node = NodeId(u32::try_from(index).ok()?);
let metadata = node38_metadata(&validated, node)?;
Some(Node38Inspection {
index,
parent_or_link_raw: metadata.parent_or_link_raw,
anim_map_start: metadata.anim_map_start,
fallback_key: metadata.fallback_key,
has_lod0_group0: selected_slot(&validated, node, Lod(0), Group(0)).is_some(),
})
})
.collect()
} else {
Vec::new()
};
Ok(ModelInspection {
streams: msh.streams().len(),
nodes: validated.node_count,
@@ -319,6 +374,44 @@ pub fn inspect_model_from_root(
positions: validated.positions.len(),
indices: validated.indices.len(),
batches: validated.batches.len(),
node_stride: validated.node_stride,
node38,
animation_keys: validated
.animation
.as_ref()
.map(|animation| animation.keys.len()),
animation_frame_count: validated
.animation
.as_ref()
.map(|animation| animation.frame_count),
})
}
/// Inspects a WEAR resource through repository-backed lookup.
///
/// # Errors
///
/// Returns a string error when the resource cannot be resolved or parsed as a
/// valid WEAR payload.
pub fn inspect_wear_from_root(
root: &Path,
archive: &str,
resource: &str,
) -> Result<WearInspection, String> {
let bytes = read_resource_bytes_diagnostic(root, archive, resource)
.map_err(|err| render_human(&err))?;
let wear = decode_wear(&bytes).map_err(|err| err.to_string())?;
Ok(WearInspection {
materials: wear.entries.len(),
lightmaps: wear.lightmaps.len(),
first_material: wear
.entries
.first()
.map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()),
last_material: wear
.entries
.last()
.map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()),
})
}
@@ -432,6 +525,43 @@ pub fn load_wear_material_texture_mip0_rgba8_from_root(
})
}
/// Resolves phase-zero diffuse TEXM through a standalone map-local WEAR file.
///
/// Map terrain keeps its two WEAR tables adjacent to `Land.msh`, while their
/// MAT0 and TEXM resources remain in the game root's normal archives. This
/// preserves that split instead of treating the sidecar as an archive entry.
///
/// # Errors
///
/// Returns a string error if the standalone WEAR file, global material
/// resources, or selected diffuse TEXM cannot be decoded.
pub fn load_standalone_wear_material_texture_mip0_rgba8_from_root(
root: &Path,
wear_path: &Path,
material_index: u16,
) -> Result<WearMaterialTexture, String> {
let wear_bytes =
fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?;
let wear = decode_wear(&wear_bytes).map_err(|err| err.to_string())?;
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
let material =
resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?;
let texture = material.document.primary_texture().ok_or_else(|| {
"MAT0 phase zero declares an intentionally untextured material".to_string()
})?;
let texture_name = String::from_utf8_lossy(&texture.0).into_owned();
let image = load_texture_mip0_rgba8_from_root(root, "Textures.lib", &texture_name)?;
Ok(WearMaterialTexture {
wear_archive: "<standalone>".to_string(),
wear_resource: wear_path.display().to_string(),
material_index,
material_name: String::from_utf8_lossy(&material.name.0).into_owned(),
material_fallback: material.fallback,
texture_name,
image,
})
}
/// Inspects a terrain land file by path.
///
/// # Errors
@@ -761,6 +891,35 @@ mod tests {
assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
}
#[test]
fn standalone_wear_material_texture_loader_keeps_map_sidecar_separate() {
let dir = temp_dir("standalone-wear-material-texture");
let wear_path = dir.join("Land2.wea");
fs::write(&wear_path, b"1\n0 MAT\n").expect("standalone wear");
let mut mat0 = vec![0; 4 + 34];
mat0[0..2].copy_from_slice(&1_u16.to_le_bytes());
mat0[22..25].copy_from_slice(b"TEX");
fs::write(
dir.join("material.lib"),
build_single_entry_nres_with_meta(b"MAT", fparkan_material::MAT0_KIND, 0, &mat0),
)
.expect("material archive");
fs::write(
dir.join("Textures.lib"),
build_single_entry_nres(b"TEX", &texm_argb8888_pixel([0x40, 0x11, 0x22, 0x33])),
)
.expect("texture archive");
let selected =
load_standalone_wear_material_texture_mip0_rgba8_from_root(&dir, &wear_path, 0)
.expect("resolved material texture");
assert_eq!(selected.wear_archive, "<standalone>");
assert_eq!(selected.wear_resource, wear_path.display().to_string());
assert_eq!(selected.texture_name, "TEX");
assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
}
#[test]
fn land_mesh_bounds_preserve_each_source_axis() {
let mesh = LandMeshDocument {
+1 -3
View File
@@ -7,10 +7,8 @@ repository.workspace = true
[dependencies]
encoding_rs = "0.8"
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
[dev-dependencies]
fparkan-animation = { path = "../fparkan-animation", version = "0.1.0" }
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
[lints]
workspace = true
+357
View File
@@ -21,6 +21,10 @@
//! Stage-3 MSH asset contract.
use encoding_rs::WINDOWS_1251;
use fparkan_animation::{
evaluate_hierarchy, AnimKey24, AnimationTime, NodePoseBuffer, ParentIndex, Pose, TimedPoseKey,
TimedPoseTrack,
};
use fparkan_nres::{EntryMeta, NresDocument, NresError};
/// Node table stream.
@@ -110,6 +114,22 @@ pub struct ModelAsset {
pub batches: Vec<Batch>,
/// Optional decoded node names.
pub node_names: Option<Vec<Option<String>>>,
/// Optional decoded node-animation streams.
pub animation: Option<ModelAnimation>,
}
/// Decoded MSH node-animation streams.
///
/// This preserves the exact type-8/type-19 boundary used by `Node38` without
/// assigning runtime-state ownership to a static asset.
#[derive(Clone, Debug, PartialEq)]
pub struct ModelAnimation {
/// Type-8 animation keys in source order.
pub keys: Vec<AnimKey24>,
/// Type-19 frame-to-key mapping words in source order.
pub frame_map: Vec<u16>,
/// Declared frame count from type-19 `attr2`.
pub frame_count: u32,
}
/// Node id.
@@ -127,6 +147,17 @@ pub struct Node {
pub raw: Vec<u8>,
}
/// Raw fields of the standard 38-byte node layout that precede slot selection.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Node38Metadata {
/// Unassigned source link value at byte offset two.
pub parent_or_link_raw: u16,
/// Offset into the type-19 frame map, or `0xFFFF` for no map.
pub anim_map_start: u16,
/// Fallback type-8 key index.
pub fallback_key: u16,
}
/// Slot descriptor.
#[derive(Clone, Debug, PartialEq)]
pub struct Slot {
@@ -387,6 +418,151 @@ pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -
(slot < model.slots.len()).then_some(SlotId(u32::from(raw)))
}
/// Returns the undecorated metadata of a standard 38-byte node.
#[must_use]
pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadata> {
if model.node_stride != 38 {
return None;
}
let node_index = usize::try_from(node.0).ok()?;
if node_index >= model.node_count {
return None;
}
let offset = node_index.checked_mul(model.node_stride)?;
Some(Node38Metadata {
parent_or_link_raw: read_u16(&model.nodes_raw, offset + 2)?,
anim_map_start: read_u16(&model.nodes_raw, offset + 4)?,
fallback_key: read_u16(&model.nodes_raw, offset + 6)?,
})
}
/// Returns the source fallback pose of a standard node.
///
/// The legacy sampler uses this key when a node has no frame map or its current
/// frame falls outside that map. It is a static-pose input only; callers must
/// still recover animation state and the meaning of `parent_or_link_raw` before
/// assembling a runtime hierarchy.
#[must_use]
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
let metadata = node38_metadata(model, node)?;
model
.animation
.as_ref()?
.keys
.get(usize::from(metadata.fallback_key))
.map(AnimKey24::sampling_pose)
}
/// Evaluates the static fallback pose hierarchy of a standard `Node38` model.
///
/// `0xFFFF` denotes a root. Every non-root source link must name an earlier
/// node, which preserves the producer's parent-before-child ordering. Models
/// that do not meet this established `Node38` contract return `None` so callers
/// can retain an explicitly unhierarchical diagnostic path.
#[must_use]
pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
if model.node_stride != 38 || model.node_count == 0 {
return None;
}
let mut parents = Vec::with_capacity(model.node_count);
let mut poses = Vec::with_capacity(model.node_count);
for index in 0..model.node_count {
let node = NodeId(u32::try_from(index).ok()?);
let metadata = node38_metadata(model, node)?;
let parent = if metadata.parent_or_link_raw == u16::MAX {
ParentIndex(None)
} else {
let parent = usize::from(metadata.parent_or_link_raw);
(parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))?
};
parents.push(parent);
poses.push(node38_fallback_pose(model, node)?);
}
evaluate_hierarchy(&parents, &poses).ok()
}
/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
/// model at one explicitly supplied logical frame.
///
/// A node without a usable type-19 map, or a requested frame outside the
/// declared map length, retains its exact fallback key. Mapped keys are
/// sampled against their immediate successor using the decoded key times.
/// This is an offline asset-sampling contract; it does not claim ownership of
/// the original runtime's animation clock or x87 numeric profile.
///
/// Returns `None` when the model lacks a complete standard-node animation
/// representation or the map cannot be safely resolved.
#[must_use]
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
if model.node_stride != 38 || model.node_count == 0 {
return None;
}
let animation = model.animation.as_ref()?;
let mut parents = Vec::with_capacity(model.node_count);
let mut poses = Vec::with_capacity(model.node_count);
for index in 0..model.node_count {
let node = NodeId(u32::try_from(index).ok()?);
let metadata = node38_metadata(model, node)?;
let parent = if metadata.parent_or_link_raw == u16::MAX {
ParentIndex(None)
} else {
let parent = usize::from(metadata.parent_or_link_raw);
(parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))?
};
let fallback_index = usize::from(metadata.fallback_key);
let _fallback = animation.keys.get(fallback_index)?;
let key_index =
if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count {
fallback_index
} else {
let mapped_index =
usize::from(*animation.frame_map.get(
usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?,
)?);
if mapped_index < fallback_index {
mapped_index
} else {
fallback_index
}
};
let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?;
parents.push(parent);
poses.push(pose);
}
evaluate_hierarchy(&parents, &poses).ok()
}
fn sample_node38_key_pair(
keys: &[AnimKey24],
key_index: usize,
fallback_index: usize,
frame: u16,
) -> Option<Pose> {
let key = *keys.get(key_index)?;
if key_index == fallback_index {
return Some(key.sampling_pose());
}
let next = *keys.get(key_index.checked_add(1)?)?;
if next.time.0 <= key.time.0 {
return Some(key.sampling_pose());
}
let track = TimedPoseTrack::new(
key.sampling_pose(),
vec![
TimedPoseKey {
time: key.time,
pose: key.sampling_pose(),
},
TimedPoseKey {
time: next.time,
pose: next.sampling_pose(),
},
],
)
.ok()?;
track.sample(AnimationTime(f32::from(frame))).ok()
}
/// Returns draw batches for a validated slot.
///
/// # Errors
@@ -526,6 +702,7 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
let node_names = read_optional_stream(document, STREAM_NAMES)?
.map(|raw| parse_res10_names(&raw.bytes, node_count))
.transpose()?;
let animation = parse_optional_animation(document)?;
Ok(ModelAsset {
node_stride,
@@ -538,9 +715,37 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
indices,
batches,
node_names,
animation,
})
}
fn parse_optional_animation(document: &NresDocument) -> Result<Option<ModelAnimation>, MshError> {
let keys = read_optional_stream(document, STREAM_ANIMATION_KEYS)?;
let frame_map = read_optional_stream(document, STREAM_ANIMATION_FRAME_MAP)?;
let (Some(keys), Some(frame_map)) = (keys, frame_map) else {
return Ok(None);
};
if !keys.bytes.len().is_multiple_of(24) {
return Err(invalid_resource_size("Res8", keys.bytes.len(), 24));
}
if !frame_map.bytes.len().is_multiple_of(2) {
return Err(invalid_resource_size("Res19", frame_map.bytes.len(), 2));
}
let keys = keys
.bytes
.chunks_exact(24)
.map(AnimKey24::decode)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| MshError::InvalidGeometry(format!("invalid Res8 animation key: {err}")))?;
let frame_count = frame_map.attributes.attr2;
let frame_map = parse_u16_array(&frame_map.bytes, "Res19")?;
Ok(Some(ModelAnimation {
keys,
frame_map,
frame_count,
}))
}
struct RawStream {
attributes: EntryAttributes,
bytes: Vec<u8>,
@@ -1063,6 +1268,151 @@ mod tests {
assert_eq!(selected_slot(&model, NodeId(0), Lod(2), Group(4)), None);
}
#[test]
fn standard_node_exposes_fallback_pose_and_unassigned_link() {
let mut node = node38([u16::MAX; 15]);
node[2..4].copy_from_slice(&7_u16.to_le_bytes());
node[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
node[6..8].copy_from_slice(&0_u16.to_le_bytes());
let mut key = Vec::new();
push_f32(&mut key, 1.0);
push_f32(&mut key, 2.0);
push_f32(&mut key, 3.0);
push_f32(&mut key, 0.0);
push_u16(&mut key, 0);
push_u16(&mut key, 0);
push_u16(&mut key, 0);
push_u16(&mut key, 32_767);
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &node),
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_ANIMATION_KEYS, 0, b"Res8", &key),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
stream(STREAM_ANIMATION_FRAME_MAP, 0, b"Res19", &[]),
]))
.expect("nested NRes");
let model =
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
assert_eq!(
node38_metadata(&model, NodeId(0)),
Some(Node38Metadata {
parent_or_link_raw: 7,
anim_map_start: u16::MAX,
fallback_key: 0,
})
);
assert_eq!(
node38_fallback_pose(&model, NodeId(0)).map(|pose| pose.translation),
Some([1.0, 2.0, 3.0])
);
assert_eq!(
model
.animation
.as_ref()
.map(|animation| animation.keys.len()),
Some(1)
);
}
#[test]
fn standard_nodes_evaluate_fallback_parent_hierarchy() {
let mut root = node38([u16::MAX; 15]);
root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
root[6..8].copy_from_slice(&0_u16.to_le_bytes());
let mut child = node38([u16::MAX; 15]);
child[2..4].copy_from_slice(&0_u16.to_le_bytes());
child[6..8].copy_from_slice(&1_u16.to_le_bytes());
let mut nodes = root;
nodes.extend(child);
let mut keys = Vec::new();
for (x, y, z, qz, qw) in [
(1.0, 0.0, 0.0, 23_170_i16, 23_170_i16),
(2.0, 0.0, 0.0, 0_i16, 32_767_i16),
] {
push_f32(&mut keys, x);
push_f32(&mut keys, y);
push_f32(&mut keys, z);
push_f32(&mut keys, 0.0);
push_u16(&mut keys, 0);
push_u16(&mut keys, 0);
push_u16(&mut keys, qz.cast_unsigned());
push_u16(&mut keys, qw.cast_unsigned());
}
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes),
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_ANIMATION_KEYS, 0, b"Res8", &keys),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
stream(STREAM_ANIMATION_FRAME_MAP, 0, b"Res19", &[]),
]))
.expect("nested NRes");
let model =
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy");
assert!((hierarchy.poses[1].translation[0] - 1.0).abs() < 0.001);
assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001);
}
#[test]
fn standard_nodes_sample_mapped_key_before_fallback_key() {
let mut node = node38([u16::MAX; 15]);
node[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
node[4..6].copy_from_slice(&0_u16.to_le_bytes());
node[6..8].copy_from_slice(&2_u16.to_le_bytes());
let model = ModelAsset {
node_stride: 38,
node_count: 1,
nodes_raw: node,
slots: Vec::new(),
positions: Vec::new(),
normals: None,
uv0: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose {
translation: [4.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(2.0),
pose: Pose {
translation: [8.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(9.0),
pose: Pose::default(),
},
],
frame_map: vec![0, 0, 0],
frame_count: 3,
}),
};
let pose = node38_sampled_hierarchy(&model, 1)
.expect("mapped hierarchy")
.poses[0];
assert!((pose.translation[0] - 6.0).abs() < f32::EPSILON);
assert_eq!(
node38_sampled_hierarchy(&model, 9)
.expect("fallback hierarchy")
.poses[0],
Pose::default()
);
}
#[test]
fn type2_header_and_slot_tail_framing_are_exact() {
let too_small = decode_nested(&build_nres(&[
@@ -1355,6 +1705,13 @@ mod tests {
let model = validate_msh(&msh).unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
});
if model.node_stride == 38 {
assert!(
node38_fallback_hierarchy(&model).is_some(),
"{corpus} {path:?} {:?}: Node38 hierarchy is not parent-before-child",
entry.name_bytes()
);
}
let preserved = msh.preserved_streams().unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
});
+90 -6
View File
@@ -128,6 +128,13 @@ pub struct PrototypeGraph {
pub prototype_requests: Vec<PrototypeKey>,
/// Mission object-local spans of effective prototype requests.
pub root_prototype_request_spans: Vec<std::ops::Range<usize>>,
/// Ordered raw unit DAT component records for each mission root.
///
/// This vector is aligned with [`Self::roots`]. Direct roots and legacy
/// unit bindings have an empty record list. Records are preserved without
/// assigning runtime semantics to their `kind`, links, descriptions, or
/// opaque tails.
pub root_unit_components: Vec<Vec<UnitComponentRecord>>,
/// Whether visual dependency expansion has already been applied.
pub visual_dependencies_expanded: bool,
/// Materialized prototype dependency graph nodes.
@@ -162,6 +169,12 @@ pub struct PrototypeGraphProvenance {
pub root_index: usize,
/// Immediate parent edge that discovered this edge.
pub parent_edge: Option<PrototypeGraphEdgeId>,
/// Ordered Unit DAT record selected for this path, when the root is a
/// decoded multi-component unit.
///
/// The index identifies source provenance only; it does not classify the
/// component as physics, Control, animation, or any other subsystem.
pub unit_component_index: Option<usize>,
/// Source archive when available.
pub archive: Option<String>,
/// Source resource key when available.
@@ -637,6 +650,7 @@ fn resolve_direct_prototype(
struct ResolvedPrototypeRequests {
expected_count: usize,
prototypes: Vec<EffectivePrototype>,
unit_components: Vec<UnitComponentRecord>,
}
fn resolve_prototype_requests(
@@ -652,6 +666,7 @@ fn resolve_prototype_requests(
Ok(ResolvedPrototypeRequests {
expected_count: 1,
prototypes: prototype.into_iter().collect(),
unit_components: Vec::new(),
})
}
@@ -686,6 +701,7 @@ fn resolve_unit_dat_prototype_requests(
return Ok(ResolvedPrototypeRequests {
expected_count: unit.records.len(),
prototypes,
unit_components: unit.records,
});
}
@@ -697,6 +713,7 @@ fn resolve_unit_dat_prototype_requests(
Ok(ResolvedPrototypeRequests {
expected_count: 1,
prototypes: prototype.into_iter().collect(),
unit_components: Vec::new(),
})
}
@@ -741,8 +758,15 @@ pub fn build_prototype_graph(
graph.roots.push(key);
let start = graph.prototype_requests.len();
let expansion = resolve_prototype_requests(repository, vfs, root)?;
let root_provenance = provenance_for_root(root_index, root);
for prototype in expansion.prototypes {
graph
.root_unit_components
.push(expansion.unit_components.clone());
let unit_component_count = expansion.unit_components.len();
for (prototype_index, prototype) in expansion.prototypes.into_iter().enumerate() {
let unit_component_index = is_unit_dat_root
.then_some(prototype_index)
.filter(|index| *index < unit_component_count);
let root_provenance = provenance_for_root(root_index, root, unit_component_index);
let prototype_node = PrototypeGraphNode::prototype(
prototype.key.clone(),
PrototypeGraphNodeId(next_node),
@@ -781,6 +805,7 @@ pub fn build_prototype_graph(
provenance: Some(provenance_for_mesh(
root_index,
root_to_prototype_edge_id,
unit_component_index,
dependency,
)),
});
@@ -836,16 +861,23 @@ pub fn build_prototype_graph_report(
root_node,
));
let start = graph.prototype_requests.len();
let root_provenance = provenance_for_root(root_index, root);
match resolve_prototype_requests(repository, vfs, root) {
Ok(expansion) => {
let expected = expansion.expected_count;
graph
.root_unit_components
.push(expansion.unit_components.clone());
if edge == PrototypeGraphEdge::MissionToUnitDat {
report.unit_component_count += expected;
}
let actual = expansion.prototypes.len();
for prototype in expansion.prototypes {
let unit_component_count = expansion.unit_components.len();
for (prototype_index, prototype) in expansion.prototypes.into_iter().enumerate() {
let unit_component_index = is_unit_dat_root
.then_some(prototype_index)
.filter(|index| *index < unit_component_count);
let root_provenance =
provenance_for_root(root_index, root, unit_component_index);
let prototype_node = PrototypeGraphNode::prototype(
prototype.key.clone(),
PrototypeGraphNodeId(next_node),
@@ -886,6 +918,7 @@ pub fn build_prototype_graph_report(
provenance: Some(provenance_for_mesh(
root_index,
root_to_prototype_edge_id,
unit_component_index,
dependency,
)),
});
@@ -907,6 +940,7 @@ pub fn build_prototype_graph_report(
provenance: Some(PrototypeGraphProvenance {
root_index,
parent_edge: None,
unit_component_index: None,
archive: None,
resource: Some(root.0.clone()),
span: None,
@@ -923,12 +957,16 @@ pub fn build_prototype_graph_report(
provenance: Some(PrototypeGraphProvenance {
root_index,
parent_edge: None,
unit_component_index: None,
archive: None,
resource: Some(root.0.clone()),
span: None,
}),
}),
}
if graph.root_unit_components.len() <= root_index {
graph.root_unit_components.push(Vec::new());
}
let end = graph.prototype_requests.len();
graph.root_prototype_request_spans.push(start..end);
}
@@ -964,10 +1002,15 @@ fn graph_error_edge(edge: PrototypeGraphEdge, err: &PrototypeError) -> Prototype
}
}
fn provenance_for_root(root_index: usize, root: &ResourceName) -> PrototypeGraphProvenance {
fn provenance_for_root(
root_index: usize,
root: &ResourceName,
unit_component_index: Option<usize>,
) -> PrototypeGraphProvenance {
PrototypeGraphProvenance {
root_index,
parent_edge: None,
unit_component_index,
archive: None,
resource: Some(root.0.clone()),
span: None,
@@ -977,11 +1020,13 @@ fn provenance_for_root(root_index: usize, root: &ResourceName) -> PrototypeGraph
fn provenance_for_mesh(
root_index: usize,
parent_edge: PrototypeGraphEdgeId,
unit_component_index: Option<usize>,
dependency: &ResourceKey,
) -> PrototypeGraphProvenance {
PrototypeGraphProvenance {
root_index,
parent_edge: Some(parent_edge),
unit_component_index,
archive: Some(dependency.archive.as_str().to_string()),
resource: Some(dependency.name.0.clone()),
span: None,
@@ -1770,6 +1815,45 @@ mod tests {
assert_eq!(graph.prototype_requests.len(), 2);
assert_eq!(graph.prototype_requests[0].0 .0, b"component_a");
assert_eq!(graph.prototype_requests[1].0 .0, b"component_b");
assert_eq!(graph.root_unit_components.len(), 1);
assert_eq!(graph.root_unit_components[0].len(), 2);
assert_eq!(
cstr_bytes(&graph.root_unit_components[0][0].resource_raw),
b"component_a"
);
assert_eq!(
cstr_bytes(&graph.root_unit_components[0][1].resource_raw),
b"component_b"
);
let component_edges: Vec<_> = graph
.edges
.iter()
.filter(|edge| edge.kind == PrototypeGraphEdgeKind::UnitDatToComponent)
.collect();
assert_eq!(component_edges.len(), 2);
assert_eq!(
component_edges[0]
.provenance
.as_ref()
.and_then(|provenance| provenance.unit_component_index),
Some(0)
);
assert_eq!(
component_edges[1]
.provenance
.as_ref()
.and_then(|provenance| provenance.unit_component_index),
Some(1)
);
assert!(graph
.edges
.iter()
.filter(|edge| edge.kind == PrototypeGraphEdgeKind::PrototypeToMesh)
.all(|edge| edge
.provenance
.as_ref()
.and_then(|value| value.unit_component_index)
.is_some()));
assert_eq!(resolved.len(), 2);
assert_eq!(report.unit_reference_count, 1);
assert_eq!(report.unit_component_count, 2);
+492
View File
@@ -43,6 +43,299 @@ impl RawCameraTransform {
pub fn translation(self) -> [f32; 3] {
Self::TRANSLATION_WORD_INDICES.map(|index| f32::from_bits(self.words[index]))
}
/// Inverts a finite row-major affine transform without assigning it a
/// camera-space meaning.
///
/// The legacy SIMD dispatch multiplies these blocks as ordinary row-major
/// matrices and the confirmed camera samples have translation in the last
/// column. `Some` therefore means only that this block has a non-singular
/// affine inverse. Callers must still establish whether it is a
/// camera-to-world transform before using the result as a view matrix.
#[must_use]
pub fn try_inverse_affine_row_major(self) -> Option<[f32; 16]> {
let matrix = self.words.map(f32::from_bits);
if !matrix.iter().all(|value| value.is_finite())
|| matrix[12].abs() > f32::EPSILON
|| matrix[13].abs() > f32::EPSILON
|| matrix[14].abs() > f32::EPSILON
|| (matrix[15] - 1.0).abs() > f32::EPSILON
{
return None;
}
let [m00, m01, m02, _, m10, m11, m12, _, m20, m21, m22, _, _, _, _, _] = matrix;
let cofactor00 = m11.mul_add(m22, -(m12 * m21));
let cofactor01 = m02.mul_add(m21, -(m01 * m22));
let cofactor02 = m01.mul_add(m12, -(m02 * m11));
let determinant = m00.mul_add(cofactor00, m10.mul_add(cofactor01, m20 * cofactor02));
if !determinant.is_finite() || determinant == 0.0 {
return None;
}
let inverse_determinant = determinant.recip();
let inverse = [
cofactor00 * inverse_determinant,
m02.mul_add(m21, -(m01 * m22)) * inverse_determinant,
cofactor02 * inverse_determinant,
0.0,
m12.mul_add(m20, -(m10 * m22)) * inverse_determinant,
m00.mul_add(m22, -(m02 * m20)) * inverse_determinant,
m02.mul_add(m10, -(m00 * m12)) * inverse_determinant,
0.0,
m10.mul_add(m21, -(m11 * m20)) * inverse_determinant,
m01.mul_add(m20, -(m00 * m21)) * inverse_determinant,
m00.mul_add(m11, -(m01 * m10)) * inverse_determinant,
0.0,
0.0,
0.0,
0.0,
1.0,
];
let [translation_x, translation_y, translation_z] = self.translation();
let translation = [
-(inverse[0].mul_add(
translation_x,
inverse[1].mul_add(translation_y, inverse[2] * translation_z),
)),
-(inverse[4].mul_add(
translation_x,
inverse[5].mul_add(translation_y, inverse[6] * translation_z),
)),
-(inverse[8].mul_add(
translation_x,
inverse[9].mul_add(translation_y, inverse[10] * translation_z),
)),
];
Some([
inverse[0],
inverse[1],
inverse[2],
translation[0],
inverse[4],
inverse[5],
inverse[6],
translation[1],
inverse[8],
inverse[9],
inverse[10],
translation[2],
0.0,
0.0,
0.0,
1.0,
])
}
/// Reproduces Ngi32's `Direct3D7` view-matrix conversion for this transform.
///
/// The legacy renderer copies selector-0 into its camera state, then maps
/// its axes and translation in this exact order before calling
/// `IDirect3DDevice7::SetTransform(D3DTRANSFORMSTATE_VIEW, ...)`. This is
/// deliberately distinct from [`Self::try_inverse_affine_row_major`]: it
/// includes the original renderer's coordinate-system conversion.
///
/// The returned matrix is row-major D3D7 data, not yet a Vulkan view
/// matrix. A later adapter must explicitly account for clip-space and
/// shader-vector conventions.
#[must_use]
pub fn try_direct3d7_view_row_major(self) -> Option<[f32; 16]> {
let matrix = self.words.map(f32::from_bits);
if !matrix.iter().all(|value| value.is_finite())
|| matrix[12].abs() > f32::EPSILON
|| matrix[13].abs() > f32::EPSILON
|| matrix[14].abs() > f32::EPSILON
|| (matrix[15] - 1.0).abs() > f32::EPSILON
{
return None;
}
let [m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, _, _, _, _] = matrix;
Some([
-m01,
m02,
m00,
0.0,
-m11,
m12,
m10,
0.0,
-m21,
m22,
m20,
0.0,
m23.mul_add(m21, m13.mul_add(m11, m03 * m01)),
-(m23.mul_add(m22, m13.mul_add(m12, m03 * m02))),
-(m00.mul_add(m03, m23.mul_add(m20, m13 * m10))),
1.0,
])
}
}
/// Parameters consumed by Ngi32's `Direct3D7` projection-matrix builder.
///
/// These are a separate legacy-renderer boundary from
/// [`RawCameraProjection`]. The latter preserves Terrain's source ABI, while
/// this type records the already-resolved Ngi32 values submitted to `Direct3D7`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LegacyD3d7Projection {
/// Viewport rectangle as `(left, top, right, bottom)`.
pub viewport: [i32; 4],
/// Positive camera near-plane distance.
pub near_plane: f32,
/// Camera far-plane distance, greater than [`Self::near_plane`].
pub far_plane: f32,
/// Full field-of-view angle in radians.
pub field_of_view_radians: f32,
}
impl LegacyD3d7Projection {
/// Reconstructs the exact row-major matrix passed to D3D7 projection state.
///
/// Ngi32 uses the viewport's `width / height`, writes `cos(fov / 2)` to
/// the diagonal and `sin(fov / 2)` to both the depth scale and
/// homogeneous-W term. The ratio after D3D's perspective divide is
/// therefore the expected cotangent scale. This remains legacy D3D7 data
/// rather than a Vulkan projection.
#[must_use]
pub fn try_direct3d7_projection_row_major(self) -> Option<[f32; 16]> {
let width = self.viewport[2].checked_sub(self.viewport[0])?;
let height = self.viewport[3].checked_sub(self.viewport[1])?;
if width <= 0
|| height <= 0
|| !self.near_plane.is_finite()
|| !self.far_plane.is_finite()
|| !self.field_of_view_radians.is_finite()
|| self.near_plane <= 0.0
|| self.far_plane <= self.near_plane
|| self.field_of_view_radians <= 0.0
|| self.field_of_view_radians >= std::f32::consts::PI
{
return None;
}
// Ngi32 converts these signed viewport dimensions into single-precision
// arithmetic before building the legacy matrix.
#[allow(clippy::cast_precision_loss)]
let aspect = (width as f32) / (height as f32);
let half_fov = self.field_of_view_radians * 0.5;
let cosine = half_fov.cos();
let sine = half_fov.sin();
let depth_scale = sine / (1.0 - self.near_plane / self.far_plane);
[aspect, cosine, sine, depth_scale]
.iter()
.all(|value| value.is_finite())
.then_some([
cosine,
0.0,
0.0,
0.0,
0.0,
aspect * cosine,
0.0,
0.0,
0.0,
0.0,
depth_scale,
sine,
0.0,
0.0,
-(depth_scale * self.near_plane),
0.0,
])
}
}
/// Affine placement inputs consumed by `Iron3D`'s recovered Euler-matrix builder.
///
/// This records the exact matrix construction at `iron3d.dll` RVA `0x36610`.
/// It is intentionally distinct from a mission placement contract: the static
/// trace has not yet proved that TMA's three raw orientation floats reach this
/// builder unchanged for every object category.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LegacyIron3dEulerTransform {
/// World-space translation passed to the builder.
pub translation: [f32; 3],
/// Three angles in the builder's `(x, y, z)` input order, in radians.
pub orientation_radians: [f32; 3],
}
impl LegacyIron3dEulerTransform {
/// Reconstructs the builder's finite row-major affine matrix.
///
/// The recovered x87 code evaluates `Rz(z) * Ry(y) * Rx(x)` and writes
/// translation into the last column. This uses portable `f32` trigonometry;
/// any future x87-compatibility path must be separately capture-validated.
#[must_use]
pub fn try_row_major(self) -> Option<[f32; 16]> {
if !self
.translation
.iter()
.chain(self.orientation_radians.iter())
.all(|value| value.is_finite())
{
return None;
}
let [x, y, z] = self.orientation_radians;
let (sin_x, cos_x) = x.sin_cos();
let (sin_y, cos_y) = y.sin_cos();
let (sin_z, cos_z) = z.sin_cos();
let [translation_x, translation_y, translation_z] = self.translation;
let matrix = [
cos_z * cos_y,
cos_z * sin_y * sin_x - sin_z * cos_x,
cos_z * sin_y * cos_x + sin_z * sin_x,
translation_x,
sin_z * cos_y,
sin_z * sin_y * sin_x + cos_z * cos_x,
sin_z * sin_y * cos_x - cos_z * sin_x,
translation_y,
-sin_y,
cos_y * sin_x,
cos_y * cos_x,
translation_z,
0.0,
0.0,
0.0,
1.0,
];
matrix
.iter()
.all(|value| value.is_finite())
.then_some(matrix)
}
/// Applies the recovered rotation after a component-wise local scale.
///
/// This is the affine `R * (scale * point) + translation` convention used
/// by the static source-world bridge. The original dynamic transform path
/// needs its own capture evidence before it may replace this static path.
#[must_use]
pub fn try_transform_scaled_point(self, point: [f32; 3], scale: [f32; 3]) -> Option<[f32; 3]> {
if !point
.iter()
.chain(scale.iter())
.all(|value| value.is_finite())
{
return None;
}
let matrix = self.try_row_major()?;
let scaled = [
point[0] * scale[0],
point[1] * scale[1],
point[2] * scale[2],
];
let transformed = [
matrix[0] * scaled[0] + matrix[1] * scaled[1] + matrix[2] * scaled[2] + matrix[3],
matrix[4] * scaled[0] + matrix[5] * scaled[1] + matrix[6] * scaled[2] + matrix[7],
matrix[8] * scaled[0] + matrix[9] * scaled[1] + matrix[10] * scaled[2] + matrix[11],
];
transformed
.iter()
.all(|value| value.is_finite())
.then_some(transformed)
}
}
/// Raw camera state observed through the original Terrain camera interface.
@@ -58,6 +351,37 @@ pub struct RawCameraPose {
pub selector2: RawCameraTransform,
}
/// Raw projection state observed through the original `CBufferingCamera` ABI.
///
/// The five-float context block is intentionally represented as original words:
/// its indices are observed, but their semantic labels have not yet all been
/// recovered. The field-of-view value is the type-0 input to `tan(fov / 2)`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RawCameraProjection {
/// Viewport rectangle as `(left, top, right, bottom)`.
pub viewport: [i32; 4],
/// Original projection selector.
pub projection_type: u32,
/// Original IEEE-754 FOV value in radians.
pub field_of_view_radians_bits: u32,
/// Exact five-float context block returned by the primary renderer.
pub context_words: [u32; 5],
}
impl RawCameraProjection {
/// Returns the type-0 FOV input in radians.
#[must_use]
pub fn field_of_view_radians(self) -> f32 {
f32::from_bits(self.field_of_view_radians_bits)
}
/// Returns the five context values without assigning semantic labels.
#[must_use]
pub fn context_values(self) -> [f32; 5] {
self.context_words.map(f32::from_bits)
}
}
/// Immutable camera data visible to command generation.
#[derive(Clone, Debug, PartialEq)]
pub struct CameraSnapshot {
@@ -71,6 +395,11 @@ pub struct CameraSnapshot {
/// conventions have been recovered; it preserves the ABI boundary for the
/// runtime adapter and deterministic captures.
pub raw_pose: Option<RawCameraPose>,
/// Optional unconverted source-projection state.
///
/// This is retained independently of `projection`, because its legacy
/// context words have not yet been mapped to a Vulkan clip convention.
pub raw_projection: Option<RawCameraProjection>,
}
impl Default for CameraSnapshot {
@@ -79,6 +408,7 @@ impl Default for CameraSnapshot {
view: identity_transform(),
projection: identity_transform(),
raw_pose: None,
raw_projection: None,
}
}
}
@@ -745,6 +1075,28 @@ fn identity_transform() -> [f32; 16] {
mod tests {
use super::*;
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
let mut result = [0.0; 16];
for row in 0..4 {
for column in 0..4 {
result[row * 4 + column] = (0..4)
.map(|index| left[row * 4 + index] * right[index * 4 + column])
.sum();
}
}
result
}
fn assert_matrix_approximately_identity(matrix: [f32; 16]) {
for (index, value) in matrix.into_iter().enumerate() {
let expected = if index / 4 == index % 4 { 1.0 } else { 0.0 };
assert!(
(value - expected).abs() < 0.000_02,
"matrix element {index}: expected {expected}, got {value}"
);
}
}
#[test]
fn raw_camera_pose_preserves_words_and_extracts_confirmed_translation() {
let mut active = [0_u32; 16];
@@ -768,6 +1120,146 @@ mod tests {
assert_eq!(CameraSnapshot::default().raw_pose, None);
}
#[test]
fn raw_camera_projection_preserves_live_context_words_without_labeling_them() {
let projection = RawCameraProjection {
viewport: [0, 0, 1024, 768],
projection_type: 0,
field_of_view_radians_bits: 1.04_f32.to_bits(),
context_words: [
0.0_f32.to_bits(),
0.5_f32.to_bits(),
700.0_f32.to_bits(),
0.1_f32.to_bits(),
0.99_f32.to_bits(),
],
};
assert_eq!(projection.field_of_view_radians(), 1.04);
assert_eq!(projection.context_values(), [0.0, 0.5, 700.0, 0.1, 0.99]);
assert_eq!(CameraSnapshot::default().raw_projection, None);
}
#[test]
fn raw_camera_transform_inverts_only_non_singular_affine_blocks() {
let source = [
0.0, -1.0, 0.0, 433.544_7, 0.948_985, 0.0, 0.315_322, 652.292_5, -0.315_322, 0.0,
0.948_985, 10.673_42, 0.0, 0.0, 0.0, 1.0,
];
let transform = RawCameraTransform {
words: source.map(f32::to_bits),
};
let inverse = transform
.try_inverse_affine_row_major()
.expect("observed affine transform is invertible");
assert_matrix_approximately_identity(multiply_row_major(source, inverse));
assert_matrix_approximately_identity(multiply_row_major(inverse, source));
let singular = RawCameraTransform { words: [0_u32; 16] };
assert_eq!(singular.try_inverse_affine_row_major(), None);
}
#[test]
fn raw_camera_transform_reproduces_direct3d7_view_axis_conversion() {
let transform = RawCameraTransform {
words: [
0.0_f32.to_bits(),
(-1.0_f32).to_bits(),
0.0_f32.to_bits(),
10.0_f32.to_bits(),
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
20.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
30.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
],
};
assert_eq!(
transform.try_direct3d7_view_row_major(),
Some([
1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, -10.0, -30.0, -20.0,
1.0,
])
);
assert_eq!(
RawCameraTransform { words: [0_u32; 16] }.try_direct3d7_view_row_major(),
None
);
}
#[test]
fn legacy_d3d7_projection_matches_recovered_camera_formula() {
let projection = LegacyD3d7Projection {
viewport: [0, 0, 1024, 768],
near_plane: 0.5,
far_plane: 700.0,
field_of_view_radians: 1.3,
};
let matrix = projection
.try_direct3d7_projection_row_major()
.expect("live Ngi32 projection parameters are valid");
let half_fov = 0.65_f32;
let depth_scale = half_fov.sin() * 700.0_f32 / 699.5;
assert_eq!(matrix[0], half_fov.cos());
assert_eq!(matrix[5], (4.0 / 3.0) * half_fov.cos());
assert_eq!(matrix[10], depth_scale);
assert_eq!(matrix[11], half_fov.sin());
assert_eq!(matrix[14], -(depth_scale * 0.5));
assert_eq!(
LegacyD3d7Projection {
viewport: [0, 0, 0, 768],
..projection
}
.try_direct3d7_projection_row_major(),
None
);
}
#[test]
fn iron3d_euler_builder_uses_rz_ry_rx_and_last_column_translation() {
let matrix = LegacyIron3dEulerTransform {
translation: [418.103_18, 717.433, 3.040_938_9],
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
}
.try_row_major()
.expect("finite recovered builder inputs");
assert!((matrix[0]).abs() < 0.000_001);
assert!((matrix[1] + 1.0).abs() < 0.000_001);
assert!((matrix[4] - 1.0).abs() < 0.000_001);
assert!((matrix[5]).abs() < 0.000_001);
assert_eq!(matrix[3], 418.103_18);
assert_eq!(matrix[7], 717.433);
assert_eq!(matrix[11], 3.040_938_9);
assert_eq!(matrix[15], 1.0);
assert_eq!(
LegacyIron3dEulerTransform {
translation: [10.0, 20.0, 30.0],
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
}
.try_transform_scaled_point([2.0, 3.0, 4.0], [2.0, 1.0, 0.5]),
Some([7.0, 24.0, 32.0])
);
assert_eq!(
LegacyIron3dEulerTransform {
translation: [0.0, 0.0, 0.0],
orientation_radians: [f32::NAN, 0.0, 0.0],
}
.try_row_major(),
None
);
}
fn snapshot_draw(
id: u64,
phase: RenderPhase,
+45
View File
@@ -258,6 +258,21 @@ pub trait ResourceRepository {
/// Returns [`ResourceError`] when the archive is missing, unsupported, or
/// malformed.
fn open_archive(&self, path: &NormalizedPath) -> Result<ArchiveId, ResourceError>;
/// Opens an archive known to remain unchanged for the caller's bounded
/// loading transaction.
///
/// Implementations may reuse an already decoded archive without a second
/// content-fingerprint pass. Callers must use this only while their asset
/// source is immutable; the default preserves [`Self::open_archive`]'s
/// strict invalidation behavior.
///
/// # Errors
///
/// Returns [`ResourceError`] when the archive is missing, unsupported, or
/// malformed.
fn open_archive_unchanged(&self, path: &NormalizedPath) -> Result<ArchiveId, ResourceError> {
self.open_archive(path)
}
/// Finds entry.
///
/// # Errors
@@ -524,6 +539,19 @@ impl ResourceRepository for CachedResourceRepository {
}
}
fn open_archive_unchanged(&self, path: &NormalizedPath) -> Result<ArchiveId, ResourceError> {
let key = path.identity_bytes().to_vec();
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
if let Some(id) = state.paths.get(&key).copied() {
if state.archive(id)?.document.is_some() {
state.touch_archive(id)?;
return Ok(id);
}
}
drop(state);
self.open_archive(path)
}
fn find(
&self,
archive: ArchiveId,
@@ -1101,6 +1129,23 @@ mod tests {
assert_eq!(vfs.metadata_reads.load(Ordering::Relaxed), 1);
}
#[test]
fn unchanged_transaction_reuses_decoded_archive_without_rereading() {
let path = archive_path(b"archives/test.lib").expect("path");
let bytes = Arc::from(build_nres(&[("one", b"payload")]).into_boxed_slice());
let vfs = Arc::new(CountingVfs::new(bytes));
let repo = CachedResourceRepository::new(Arc::clone(&vfs) as Arc<dyn Vfs>);
let first = repo.open_archive_unchanged(&path).expect("first open");
let second = repo
.open_archive_unchanged(&path)
.expect("transactional cached open");
assert_eq!(first, second);
assert_eq!(vfs.reads.load(Ordering::Relaxed), 1);
assert_eq!(vfs.metadata_reads.load(Ordering::Relaxed), 1);
}
#[test]
fn concurrent_same_archive_open_reuses_archive_id() {
let path = archive_path(b"archives/test.lib").expect("path");
+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-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
fparkan-script = { path = "../fparkan-script", version = "0.1.0" }
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
File diff suppressed because it is too large Load Diff
+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
+256 -1
View File
@@ -62,6 +62,69 @@ pub struct CompactSurfaceMask(pub u16);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MaterialClassMask(pub u8);
/// The two positional material-table selectors packed into a terrain face tag.
///
/// The high byte selects from map-local `Land1.wea`; `0xff` is the observed
/// no-selection sentinel. The low byte selects from `Land2.wea`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialLayers {
/// Optional high-byte selector for `Land1.wea`.
pub land1_selector: Option<u8>,
/// Low-byte selector for `Land2.wea`.
pub land2_selector: u8,
}
/// One `World3D` material-manager lookup key.
///
/// The original manager selects a WEAR table in the upper 16 bits and its
/// positional material row in the lower 16 bits.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialSelection {
/// Zero-based material-manager table index.
pub table_index: u16,
/// Positional material row in that WEAR table.
pub material_index: u16,
}
impl TerrainMaterialSelection {
/// Encodes the original material-manager phase lookup key.
#[must_use]
pub fn phase_key(self) -> u32 {
(u32::from(self.table_index) << 16) | u32::from(self.material_index)
}
}
impl TerrainMaterialLayers {
/// Constructs the decoded pair from its on-disk packed tag.
#[must_use]
pub const fn from_packed_tag(material_tag: u16) -> Self {
let [land2_selector, land1] = material_tag.to_le_bytes();
Self {
land1_selector: if land1 == u8::MAX { None } else { Some(land1) },
land2_selector,
}
}
/// Returns the table-zero `Land1.wea` selection when it exists.
#[must_use]
pub fn land1_selection(self) -> Option<TerrainMaterialSelection> {
self.land1_selector
.map(|material_index| TerrainMaterialSelection {
table_index: 0,
material_index: u16::from(material_index),
})
}
/// Returns the table-one `Land2.wea` selection.
#[must_use]
pub fn land2_selection(self) -> TerrainMaterialSelection {
TerrainMaterialSelection {
table_index: 1,
material_index: u16::from(self.land2_selector),
}
}
}
/// Terrain face with 28-byte source layout.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerrainFace28 {
@@ -81,6 +144,14 @@ pub struct TerrainFace28 {
pub raw: [u8; 28],
}
impl TerrainFace28 {
/// Decodes the proven two-table selector layout of [`Self::material_tag`].
#[must_use]
pub const fn material_layers(&self) -> TerrainMaterialLayers {
TerrainMaterialLayers::from_packed_tag(self.material_tag)
}
}
/// Terrain stream descriptor.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerrainStream {
@@ -112,6 +183,59 @@ pub struct TerrainSlotTable {
pub slots_raw: Vec<[u8; SLOT_STRIDE]>,
}
/// The proven front fields of one 68-byte terrain slot record.
///
/// `Terrain.dll!CLandscape` supplies these fields to `GetShade`'s terrain
/// batch dispatcher: `pair_table_index` selects the pointer-table entry and
/// `pair_count` is the number of adjacent `u16` pairs to process. The pair
/// payload and the later material/blend interpretation remain external to the
/// disk record.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainSlotRenderDispatch {
/// Index into the runtime pair-pointer table.
pub pair_table_index: u16,
/// Number of adjacent `u16` pairs in that selected payload.
pub pair_count: u16,
}
/// One pair consumed by the original terrain material-batch dispatcher.
///
/// These four bytes live in the type-11 stream. `material_lookup` is handed to
/// `GetShade`'s material lookup, while bit `0x10` of `flags` creates a batch
/// boundary. Other flag bits remain raw.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialPair {
/// Lookup id supplied to the shade material manager.
pub material_lookup: u16,
/// Opaque pair flags; bit `0x10` is a proven batch boundary.
pub flags: u16,
}
impl TerrainMaterialPair {
/// Returns the opaque lookup key supplied to `GetShade`.
///
/// This is deliberately separate from the `Land1.wea`/`Land2.wea`
/// selectors carried by [`TerrainMaterialLayers`]. Although `GetShade`
/// owns a manager loaded from `Shade.wea`, this key is not a WEAR row:
/// observed map values exceed that table's row count.
#[must_use]
pub const fn shade_lookup_key(self) -> u16 {
self.material_lookup
}
}
impl TerrainSlotTable {
/// Returns the render-dispatch header for one decoded slot record.
#[must_use]
pub fn render_dispatch(&self, slot_index: usize) -> Option<TerrainSlotRenderDispatch> {
let raw = self.slots_raw.get(slot_index)?;
Some(TerrainSlotRenderDispatch {
pair_table_index: u16::from_le_bytes([raw[0], raw[1]]),
pair_count: u16::from_le_bytes([raw[2], raw[3]]),
})
}
}
/// Land mesh document.
#[derive(Clone, Debug, PartialEq)]
pub struct LandMeshDocument {
@@ -131,12 +255,38 @@ pub struct LandMeshDocument {
pub accelerator: Vec<[u8; 4]>,
/// Type 14 auxiliary words.
pub aux14: Vec<[u8; 4]>,
/// Type 18 auxiliary words.
/// Type 18 microtexture mapping words.
///
/// `Terrain.dll!CLandscape` requires this stream and retains it as a
/// four-byte-per-entry mapping chunk.
pub aux18: Vec<[u8; 4]>,
/// Faces.
pub faces: Vec<TerrainFace28>,
}
impl LandMeshDocument {
/// Resolves the type-11 material pairs addressed by one 68-byte slot.
///
/// Returns `None` when the slot is absent or its pair range does not fit
/// the retained type-11 stream.
#[must_use]
pub fn slot_material_pairs(&self, slot_index: usize) -> Option<Vec<TerrainMaterialPair>> {
let dispatch = self.slots.render_dispatch(slot_index)?;
let start = usize::from(dispatch.pair_table_index);
let end = start.checked_add(usize::from(dispatch.pair_count))?;
let pairs = self.accelerator.get(start..end)?;
Some(
pairs
.iter()
.map(|raw| TerrainMaterialPair {
material_lookup: u16::from_le_bytes([raw[0], raw[1]]),
flags: u16::from_le_bytes([raw[2], raw[3]]),
})
.collect(),
)
}
}
/// Decoded `Land.map` document.
#[derive(Clone, Debug, PartialEq)]
pub struct LandMapDocument {
@@ -1386,6 +1536,111 @@ mod tests {
);
}
#[test]
fn slot_render_dispatch_retains_pair_table_index_and_count() {
let mut raw = [0_u8; SLOT_STRIDE];
raw[..2].copy_from_slice(&7_u16.to_le_bytes());
raw[2..4].copy_from_slice(&3_u16.to_le_bytes());
let slots = TerrainSlotTable {
header_raw: SLOT_HEADER_ZERO.to_vec(),
slots_raw: vec![raw],
};
assert_eq!(
slots.render_dispatch(0),
Some(TerrainSlotRenderDispatch {
pair_table_index: 7,
pair_count: 3,
})
);
assert_eq!(slots.render_dispatch(1), None);
}
#[test]
fn slot_material_pairs_use_type11_pair_table_and_preserve_flags() {
let nres =
decode_nres(&minimal_land_msh(&face([0, 1, 2], [None, None, None]))).expect("nres");
let mut document = decode_land_msh(&nres).expect("land mesh");
document.slots = TerrainSlotTable {
header_raw: SLOT_HEADER_ZERO.to_vec(),
slots_raw: vec![[0_u8; SLOT_STRIDE]],
};
document.slots.slots_raw[0][..2].copy_from_slice(&1_u16.to_le_bytes());
document.slots.slots_raw[0][2..4].copy_from_slice(&2_u16.to_le_bytes());
document.accelerator = vec![[0, 0, 0, 0], [2, 1, 0, 0], [3, 2, 0x10, 0]];
assert_eq!(
document.slot_material_pairs(0),
Some(vec![
TerrainMaterialPair {
material_lookup: 0x0102,
flags: 0,
},
TerrainMaterialPair {
material_lookup: 0x0203,
flags: 0x0010,
},
])
);
assert_eq!(
document.slot_material_pairs(0).expect("material pairs")[0].shade_lookup_key(),
0x0102
);
assert_eq!(document.slot_material_pairs(1), None);
document.slots.slots_raw[0][2..4].copy_from_slice(&3_u16.to_le_bytes());
assert_eq!(document.slot_material_pairs(0), None);
}
#[test]
fn terrain_material_tag_decodes_two_sidecar_selectors() {
let mut raw_face = face([0, 1, 2], [None, None, None]);
raw_face[4..6].copy_from_slice(&0x0102_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
let document = decode_land_msh(&nres).expect("land mesh");
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
land1_selector: Some(1),
land2_selector: 2,
}
);
assert_eq!(
document.faces[0].material_layers().land1_selection(),
Some(TerrainMaterialSelection {
table_index: 0,
material_index: 1,
})
);
assert_eq!(
document.faces[0]
.material_layers()
.land2_selection()
.phase_key(),
0x0001_0002
);
raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
let document = decode_land_msh(&nres).expect("land mesh");
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
land1_selector: None,
land2_selector: 3,
}
);
assert_eq!(document.faces[0].material_layers().land1_selection(), None);
assert_eq!(
document.faces[0].material_layers().land2_selection(),
TerrainMaterialSelection {
table_index: 1,
material_index: 3,
}
);
}
#[test]
fn decodes_minimal_land_map() {
let nres = decode_nres(&minimal_land_map([(-1, -1), (-1, -1)], 0)).expect("nres");
+265 -3
View File
@@ -23,6 +23,12 @@
use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
use std::collections::VecDeque;
/// Terrain material-selector contract preserved from the decoded map mesh.
///
/// Applications access this semantic terrain API through this terrain crate
/// rather than taking a direct dependency on the binary-format parser.
pub use fparkan_terrain_format::{TerrainMaterialLayers, TerrainMaterialSelection};
/// Terrain world.
#[derive(Clone, Debug, Default)]
pub struct TerrainWorld {
@@ -30,6 +36,7 @@ pub struct TerrainWorld {
grid: RuntimeGrid,
adjacency: Vec<Vec<ArealId>>,
surfaces: Vec<RuntimeTriangle>,
surface_bvh: SurfaceBvh,
source_mesh: Option<LandMeshDocument>,
}
@@ -233,6 +240,7 @@ impl TerrainWorld {
grid,
adjacency,
surfaces: Vec::new(),
surface_bvh: SurfaceBvh::default(),
source_mesh: None,
})
}
@@ -246,9 +254,11 @@ impl TerrainWorld {
pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> {
Ok(Self {
surfaces: build_surfaces(mesh)?,
surface_bvh: SurfaceBvh::default(),
source_mesh: Some(mesh.clone()),
..Self::default()
})
}
.with_surface_bvh())
}
/// Builds terrain runtime data from decoded `Land.msh` and `Land.map`.
@@ -263,6 +273,7 @@ impl TerrainWorld {
) -> Result<Self, TerrainError> {
let mut world = Self::from_land_map(map)?;
world.surfaces = build_surfaces(mesh)?;
world.surface_bvh = SurfaceBvh::from_surfaces(&world.surfaces);
world.source_mesh = Some(mesh.clone());
Ok(world)
}
@@ -297,6 +308,11 @@ impl TerrainWorld {
.map(|mesh| mesh.positions.as_slice())
}
fn with_surface_bvh(mut self) -> Self {
self.surface_bvh = SurfaceBvh::from_surfaces(&self.surfaces);
self
}
fn locate_by_candidates(
&self,
position: [f32; 3],
@@ -393,7 +409,8 @@ impl SurfaceQuery for TerrainWorld {
return Err(TerrainError::Unsupported);
}
let mut best = None;
for triangle in &self.surfaces {
for index in self.surface_bvh.xy_candidates(position) {
let triangle = &self.surfaces[index];
if let Some(height) = triangle.height_at(position) {
best = Some(best.map_or(height, |current: f32| current.max(height)));
}
@@ -410,8 +427,11 @@ impl SurfaceQuery for TerrainWorld {
if self.surfaces.is_empty() {
return Err(TerrainError::Unsupported);
}
let mut candidates = self.surface_bvh.ray_candidates(origin, direction);
candidates.sort_unstable();
let mut best: Option<SurfaceHit> = None;
for triangle in &self.surfaces {
for index in candidates {
let triangle = &self.surfaces[index];
if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
continue;
}
@@ -466,6 +486,202 @@ struct RuntimeTriangle {
vertices: [[f32; 3]; 3],
}
const SURFACE_BVH_LEAF_SIZE: usize = 8;
const SURFACE_BOUNDS_EPSILON: f32 = 1.0e-4;
/// Deterministic CPU index over validated terrain triangles.
///
/// The index changes only candidate selection: triangle math and observable
/// tie-breaking remain in [`SurfaceQuery`] above.
#[derive(Clone, Debug, Default)]
struct SurfaceBvh {
nodes: Vec<SurfaceBvhNode>,
triangle_indices: Vec<usize>,
root: Option<usize>,
}
#[derive(Clone, Debug)]
struct SurfaceBvhNode {
min: [f32; 3],
max: [f32; 3],
kind: SurfaceBvhNodeKind,
}
#[derive(Clone, Debug)]
enum SurfaceBvhNodeKind {
Leaf { first: usize, count: usize },
Branch { left: usize, right: usize },
}
impl SurfaceBvh {
fn from_surfaces(surfaces: &[RuntimeTriangle]) -> Self {
if surfaces.is_empty() {
return Self::default();
}
let mut index = Self {
nodes: Vec::with_capacity(surfaces.len().saturating_mul(2)),
triangle_indices: Vec::with_capacity(surfaces.len()),
root: None,
};
let indices: Vec<usize> = (0..surfaces.len()).collect();
index.root = Some(index.build(surfaces, indices));
index
}
fn build(&mut self, surfaces: &[RuntimeTriangle], mut indices: Vec<usize>) -> usize {
let (min, max) = surface_bounds(surfaces, &indices);
if indices.len() <= SURFACE_BVH_LEAF_SIZE {
indices.sort_unstable();
let first = self.triangle_indices.len();
let count = indices.len();
self.triangle_indices.extend(indices);
let node = self.nodes.len();
self.nodes.push(SurfaceBvhNode {
min,
max,
kind: SurfaceBvhNodeKind::Leaf { first, count },
});
return node;
}
let axis = longest_axis(min, max);
indices.sort_by(|left, right| {
surface_centroid(&surfaces[*left])[axis]
.total_cmp(&surface_centroid(&surfaces[*right])[axis])
.then_with(|| left.cmp(right))
});
let right_indices = indices.split_off(indices.len() / 2);
let left = self.build(surfaces, indices);
let right = self.build(surfaces, right_indices);
let node = self.nodes.len();
self.nodes.push(SurfaceBvhNode {
min,
max,
kind: SurfaceBvhNodeKind::Branch { left, right },
});
node
}
fn xy_candidates(&self, point: [f32; 2]) -> Vec<usize> {
let mut candidates = Vec::new();
let Some(root) = self.root else {
return candidates;
};
let mut pending = vec![root];
while let Some(node_index) = pending.pop() {
let node = &self.nodes[node_index];
if !point_within_surface_bounds(point, node.min, node.max) {
continue;
}
match node.kind {
SurfaceBvhNodeKind::Leaf { first, count } => {
candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
}
SurfaceBvhNodeKind::Branch { left, right } => {
pending.push(right);
pending.push(left);
}
}
}
candidates
}
fn ray_candidates(&self, origin: [f32; 3], direction: [f32; 3]) -> Vec<usize> {
let mut candidates = Vec::new();
let Some(root) = self.root else {
return candidates;
};
let mut pending = vec![root];
while let Some(node_index) = pending.pop() {
let node = &self.nodes[node_index];
if !ray_intersects_surface_bounds(origin, direction, node.min, node.max) {
continue;
}
match node.kind {
SurfaceBvhNodeKind::Leaf { first, count } => {
candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
}
SurfaceBvhNodeKind::Branch { left, right } => {
pending.push(right);
pending.push(left);
}
}
}
candidates
}
}
fn surface_bounds(surfaces: &[RuntimeTriangle], indices: &[usize]) -> ([f32; 3], [f32; 3]) {
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
for index in indices {
for vertex in surfaces[*index].vertices {
for axis in 0..3 {
min[axis] = min[axis].min(vertex[axis]);
max[axis] = max[axis].max(vertex[axis]);
}
}
}
(min, max)
}
fn surface_centroid(triangle: &RuntimeTriangle) -> [f32; 3] {
let mut centroid = [0.0; 3];
for vertex in triangle.vertices {
for axis in 0..3 {
centroid[axis] += vertex[axis] / 3.0;
}
}
centroid
}
fn longest_axis(min: [f32; 3], max: [f32; 3]) -> usize {
let extent = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
if extent[1] > extent[0] && extent[1] >= extent[2] {
1
} else if extent[2] > extent[0] && extent[2] > extent[1] {
2
} else {
0
}
}
fn point_within_surface_bounds(point: [f32; 2], min: [f32; 3], max: [f32; 3]) -> bool {
point[0] >= min[0] - SURFACE_BOUNDS_EPSILON
&& point[0] <= max[0] + SURFACE_BOUNDS_EPSILON
&& point[1] >= min[1] - SURFACE_BOUNDS_EPSILON
&& point[1] <= max[1] + SURFACE_BOUNDS_EPSILON
}
fn ray_intersects_surface_bounds(
origin: [f32; 3],
direction: [f32; 3],
min: [f32; 3],
max: [f32; 3],
) -> bool {
let mut near = 0.0_f32;
let mut far = f32::INFINITY;
for axis in 0..3 {
if direction[axis].abs() <= f32::EPSILON {
if origin[axis] < min[axis] - SURFACE_BOUNDS_EPSILON
|| origin[axis] > max[axis] + SURFACE_BOUNDS_EPSILON
{
return false;
}
continue;
}
let inverse = 1.0 / direction[axis];
let first = (min[axis] - origin[axis]) * inverse;
let second = (max[axis] - origin[axis]) * inverse;
near = near.max(first.min(second));
far = far.min(first.max(second));
if far < near {
return false;
}
}
far >= 0.0
}
impl RuntimeTriangle {
fn height_at(&self, position: [f32; 2]) -> Option<f32> {
let a = [self.vertices[0][0], self.vertices[0][1]];
@@ -837,6 +1053,30 @@ mod tests {
);
}
#[test]
fn surface_bvh_reduces_candidates_without_changing_surface_math() {
let surfaces: Vec<_> = (0_u16..16)
.map(|face| {
let x = f32::from(face) * 10.0;
RuntimeTriangle {
face: usize::from(face),
mask: FullSurfaceMask(1),
vertices: [[x, 0.0, x], [x + 1.0, 0.0, x], [x, 1.0, x]],
}
})
.collect();
let index = SurfaceBvh::from_surfaces(&surfaces);
let xy = index.xy_candidates([0.25, 0.25]);
assert!(xy.contains(&0));
assert!(xy.len() < surfaces.len());
assert_eq!(surfaces[xy[0]].height_at([0.25, 0.25]), Some(0.0));
let ray = index.ray_candidates([0.25, 0.25, 5.0], [0.0, 0.0, -1.0]);
assert!(ray.contains(&0));
assert!(ray.len() < surfaces.len());
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_corpus_land_maps_build_navigation_worlds() {
@@ -901,6 +1141,7 @@ mod tests {
let root = corpus_root(corpus);
let mut files = 0usize;
let mut faces = 0usize;
let mut indexed_queries = 0usize;
for path in files_under(&root) {
if !path
.file_name()
@@ -919,12 +1160,33 @@ mod tests {
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
let world = TerrainWorld::from_land_msh(&mesh)
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
if world.surface_count() > SURFACE_BVH_LEAF_SIZE {
let triangle = &world.surfaces[0];
let point = [
(triangle.vertices[0][0]
+ triangle.vertices[1][0]
+ triangle.vertices[2][0])
/ 3.0,
(triangle.vertices[0][1]
+ triangle.vertices[1][1]
+ triangle.vertices[2][1])
/ 3.0,
];
let candidates = world.surface_bvh.xy_candidates(point);
assert!(candidates.contains(&0), "{corpus} {path:?} face zero");
assert!(
candidates.len() < world.surface_count(),
"{corpus} {path:?} surface index did not reduce candidates"
);
indexed_queries += 1;
}
files += 1;
faces += world.surface_count();
}
assert_eq!(files, expected_files, "{corpus} Land.msh count");
assert_eq!(faces, expected_faces, "{corpus} surface face count");
assert_eq!(indexed_queries, files, "{corpus} indexed Land.msh count");
}
}
+112 -1
View File
@@ -23,7 +23,7 @@
use fparkan_binary::sha256;
use std::collections::VecDeque;
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v2\0";
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v3\0";
/// Object handle with generation.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -50,6 +50,20 @@ pub struct Tick(pub u64);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StateHash(pub [u8; 32]);
/// Exact source transform carried by one world object.
///
/// Values are IEEE-754 bit patterns so mission transforms retain their input
/// identity in deterministic state before a controller interprets them.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TransformState {
/// Position words in source axis order.
pub position: [u32; 3],
/// Orientation words in source axis order.
pub orientation: [u32; 3],
/// Non-uniform scale words in source axis order.
pub scale: [u32; 3],
}
/// World phase.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorldPhase {
@@ -110,6 +124,8 @@ pub struct WorldSnapshot {
pub tick: Tick,
/// Live object handles.
pub objects: Vec<ObjectHandle>,
/// Exact transforms for live registered objects in object-handle order.
pub transforms: Vec<(ObjectHandle, TransformState)>,
/// Commands processed during this step.
pub events: Vec<WorldEvent>,
/// State hash.
@@ -167,6 +183,7 @@ struct Slot {
owner_id: Option<OwnerId>,
mirror_id: Option<OriginalObjectId>,
registration_sequence: Option<u64>,
transform: TransformState,
}
/// World.
@@ -248,6 +265,7 @@ pub fn construct_object(world: &mut World, draft: ObjectDraft) -> Result<ObjectH
owner_id: None,
mirror_id: None,
registration_sequence: None,
transform: TransformState::default(),
});
Ok(handle)
}
@@ -335,6 +353,43 @@ pub fn identity_metadata(
})
}
/// Sets the exact source transform for a live object.
///
/// # Errors
///
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
pub fn set_transform(
world: &mut World,
handle: ObjectHandle,
transform: TransformState,
) -> Result<(), WorldError> {
checked_slot_mut(world, handle)?.transform = transform;
Ok(())
}
/// Returns the exact source transform for a live object.
///
/// # Errors
///
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
pub fn transform_state(world: &World, handle: ObjectHandle) -> Result<TransformState, WorldError> {
Ok(checked_slot(world, handle)?.transform)
}
/// Returns the live registered handle carrying an original mission id.
#[must_use]
pub fn handle_by_original_id(world: &World, original_id: OriginalObjectId) -> Option<ObjectHandle> {
world.slots.iter().enumerate().find_map(|(index, slot)| {
let index = u32::try_from(index).ok()?;
(slot.live && slot.registered && slot.original_id == Some(original_id)).then_some(
ObjectHandle {
generation: slot.generation,
slot: index,
},
)
})
}
/// Requests deletion.
///
/// # Errors
@@ -423,6 +478,7 @@ where
let snapshot = WorldSnapshot {
tick: world.tick,
objects: live_registered(world),
transforms: live_registered_with_transforms(world),
events,
hash: canonical_state_hash(world),
};
@@ -459,6 +515,7 @@ fn canonical_state_bytes(world: &World) -> Vec<u8> {
push_optional_u32(&mut out, slot.mirror_id.map(|id| id.0));
push_optional_u16(&mut out, slot.owner_id.map(|id| id.0));
push_optional_u64(&mut out, slot.registration_sequence);
push_transform(&mut out, slot.transform);
}
push_len(&mut out, world.queue.len());
for command in &world.queue {
@@ -639,6 +696,17 @@ fn push_handle(out: &mut Vec<u8>, handle: ObjectHandle) {
push_u32(out, handle.slot);
}
fn push_transform(out: &mut Vec<u8>, transform: TransformState) {
for word in transform
.position
.into_iter()
.chain(transform.orientation)
.chain(transform.scale)
{
push_u32(out, word);
}
}
fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> {
let slot = world
.slots
@@ -689,6 +757,24 @@ fn live_registered(world: &World) -> Vec<ObjectHandle> {
.collect()
}
fn live_registered_with_transforms(world: &World) -> Vec<(ObjectHandle, TransformState)> {
world
.slots
.iter()
.enumerate()
.filter_map(|(idx, slot)| {
let slot_index = u32::try_from(idx).ok()?;
(slot.live && slot.registered).then_some((
ObjectHandle {
generation: slot.generation,
slot: slot_index,
},
slot.transform,
))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -704,6 +790,25 @@ mod tests {
assert_eq!(after.objects, vec![handle]);
}
#[test]
fn transform_is_exactly_snapshotted_and_changes_state_hash() {
let mut world = new(WorldConfig);
let handle =
construct_object(&mut world, ObjectDraft { original_id: None }).expect("object");
register_object(&mut world, handle).expect("register");
let before = step(&mut world, &InputSnapshot).expect("before");
let transform = TransformState {
position: [1.0_f32.to_bits(), (-2.0_f32).to_bits(), 3.0_f32.to_bits()],
orientation: [0.0_f32.to_bits(), 0.5_f32.to_bits(), (-0.25_f32).to_bits()],
scale: [1.0_f32.to_bits(), 2.0_f32.to_bits(), 0.5_f32.to_bits()],
};
set_transform(&mut world, handle, transform).expect("set transform");
let after = step(&mut world, &InputSnapshot).expect("after");
assert_eq!(transform_state(&world, handle), Ok(transform));
assert_eq!(after.transforms, vec![(handle, transform)]);
assert_ne!(before.hash, after.hash);
}
#[test]
fn registration_sequence_stale_and_duplicate_original_contracts() {
let mut world = new(WorldConfig);
@@ -723,10 +828,16 @@ mod tests {
.expect("second");
register_object(&mut world, first).expect("register first");
register_object(&mut world, second).expect("register second");
assert_eq!(
handle_by_original_id(&world, OriginalObjectId(7)),
Some(first)
);
assert_eq!(handle_by_original_id(&world, OriginalObjectId(9)), None);
assert_eq!(registration_sequence(&world, first), Ok(Some(0)));
assert_eq!(registration_sequence(&world, second), Ok(Some(1)));
request_delete(&mut world, first).expect("delete");
assert_eq!(handle_by_original_id(&world, OriginalObjectId(7)), None);
assert_eq!(
register_object(&mut world, first),
Err(WorldError::StaleHandle)
+13 -27
View File
@@ -113,36 +113,22 @@ key, configuration, device profile, initial state, input/time script и верс
## Local evidence requests
На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
Для локально воспроизводимого Desktop backend подтверждено только command/state trace
в существующем GL-воркфлоу:
Поддерживаемая desktop-платформа — только Windows/Vulkan. Локальный smoke
`fparkan-vulkan-smoke` уже подтверждает настоящий Win32 surface/swapchain,
300 кадров, controlled resize и отсутствие validation warnings/errors.
Это закрывает фундамент Stage 0, но не доказывает визуальный паритет игры.
- `fixtures/acceptance/macos-gl33-triangle-capture.json`;
Следующие доказательства всё ещё нужны именно для Iron3D-compatible рендера:
`S3-GL-001` пока не закрыт: текущая evidence не отражает полноценный
`winit`+`fparkan-render-vulkan` path с real surface/present pipeline.
Для закрытия требования требуется постоянный workspace-владельческий backend на
`winit`/`fparkan-platform-winit` + `fparkan-render-vulkan` с реальным
surface/present pipeline, command/state parity и licensed frame capture.
- capability-gated capture из оригинального GOG процесса с camera/matrix,
draw/state и frame-boundary provenance;
- фиксированные Windows Vulkan captures статической модели, lightmapped модели
и terrain после совпадения backend-neutral command capture;
- затем controlled captures анимации, FX, прозрачности, теней и атмосферы.
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
command/state trace. Локальный Docker probe существующего Rust image не нашёл
`libGL`, `libEGL`, `libGLES` или `libOSMesa`, поэтому закрытие этого gate требует
отдельно предоставленного Docker image с Rust + Mesa/EGL/OSMesa либо разрешения
на установку соответствующего проверочного окружения.
Для текущей macOS-focused цели `S3-GL-002`, `L3-DEVICE-001` и `L5-RG40-001`
помечены как `omitted`: они остаются требованиями portable target scope, но не
блокируют локальный macOS acceptance-аудит. При возврате RG40XX/GLES2 в область
цели эти gates снова должны требовать внешнего evidence.
`L3-DEVICE-001` и `L5-RG40-001` не закрываются локально без RG40XX H или
эквивалентного удалённого runner-а. Требуемое доказательство: запуск выбранной
миссии при 640x480 на целевом профиле, сохранённые stdout/stderr, build
fingerprint, manifest игрового каталога, frame/tick budget, memory budget и
итоговый pass/fail report. Desktop/headless результаты не считаются заменой
on-device smoke.
Linux/macOS, GLES2, RG40XX и удалённые portability runners не являются
acceptance-гейтами этого проекта. Они не должны появляться в списке блокеров
или подменять Windows evidence.
## Closure criteria
+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.
+47
View File
@@ -34,3 +34,50 @@ Scope labels:
- Stage 0 codebase gates: locally evidenced.
- Stage 0 Windows native runtime: locally evidenced.
- Linux/macOS runtime and cross-platform hosted CI: out of scope.
## Current architecture contract
The standalone engine keeps binary formats, the resource graph, simulation,
animation math and backend-neutral render commands independent from the GPU
adapter. Windows presentation uses `winit`, `raw-window-handle`, `ash-window`
and `ash`; only the Vulkan/FFI adapters may contain narrowly documented
`unsafe` code. Raw Vulkan handles do not cross that boundary.
The baseline is Vulkan 1.1 with surface, Win32-surface and swapchain support,
binary semaphores/fences and a classic render-pass path. Device capability is
queried at runtime; dynamic rendering, descriptor indexing, synchronization2,
timeline semaphores and extended dynamic state are optional capability-gated
enhancements, not requirements. The canonical initial texture upload is RGBA8
UNORM. Headless builds remain independent of `winit`, Vulkan and a window
system.
## Current stage model
The canonical Vulkan-revision plan has six dependency-ordered stages numbered
0--5. Keeping its original numbering matters: Stage 4 is an evidence-gated
animation/FX runtime, while Stage 5 is the mission/world vertical slice that
depends on it. They must not be reported as one completed stage.
0. reproducible Windows/Vulkan foundation;
1. paths, VFS and lossless archives;
2. prototype graph and prepared CPU assets;
3. static Vulkan model/terrain viewer;
4. animation and FX runtime, with reference-only semantics until runtime
captures close the x87 and effect-lifecycle evidence gaps;
5. transactional map, mission and world vertical slice, rendered from the
same immutable snapshot through Vulkan.
This is a local, Windows-only adoption of the Notion page "План реализации
stage 0--5: Vulkan revision" (reviewed on 2026-07-18). Its former
Linux/macOS portability and hosted-CI goals are intentionally not imported:
they conflict with the current supported-platform boundary above. The
portable architectural rules that do apply -- backend-neutral commands,
runtime capability queries, narrow Vulkan/FFI `unsafe`, offline shader
validation, and command capture before pixel comparison -- are retained in
this audit and the rendering tome.
Contract tests and failure tests precede implementation. Synthetic checks never
read licensed roots; licensed corpus checks use absolute paths from the local
manifest. Backend-neutral command capture precedes pixel comparison, and GPU
addresses, allocator addresses and driver timing are excluded from deterministic
state hashes.
+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`.
+21
View File
@@ -9,6 +9,27 @@ Iron3D из *Parkan: Iron Strategy*. Она ведёт от запуска ор
используется как быстрый доступ к форматам, проверочным правилам и границам
доказанного знания.
## Источник истины и синхронизация
Каталог `docs/` — единственный рабочий источник истины для FParkan. 18 июля
2026 года его содержательно сопоставили с книгой FParkan в Notion; результат,
маршрутизация статей и сознательно исключённые цели зафиксированы в
[сверке](baseline/notion-reconciliation.md). Актуальные локальные материалы
содержат и последующие результаты разработки. Notion больше не
является местом внесения изменений: новые факты, исправления и решения
фиксируются только здесь, в томе, справочнике или evidence-документе, которому
они принадлежат.
При расхождении приоритет имеют более новое локальное доказательство и текущий
scope проекта: самостоятельный runtime ориентирован исключительно на Windows и
Vulkan. Исторические упоминания других ОС в старых внешних заметках не
расширяют поддерживаемую платформу.
Действующий dependency-ordered план `stage 0--5` находится в
[Vulkan revision для Windows](baseline/vulkan-revision-plan.md). Это
редакторская локальная версия: она включает недостающие контрактные требования
из Notion, но не переносит снятые с проекта Linux/macOS и hosted-CI цели.
## Как читать
Если вы впервые разбираете игровой движок, начните с тома I и II. Там вводится
+19
View File
@@ -43,6 +43,25 @@ struct Node38 {
`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает
отсутствие геометрии для комбинации LOD/group.
Validated `ModelAsset` также сохраняет decoded type 8 keys и type 19 map как
`ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`,
то есть доказанный static input. `parent_or_link == 0xFFFF` означает root;
иначе это parent index, обязательно меньший индекса child. Этот контракт
подтверждён на licensed animation gates обеих частей и защищён fallback-ом:
модель с нарушенным порядком не получает придуманную hierarchy.
В legacy-camera static preview стандартный узел уже получает свой fallback pose
до внешнего TMA/Iron3D transform. Parent pose поворачивает child translation,
затем translation суммируется, а rotations умножаются; после полученной global
pose применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия
намеренно дублируется на draw-range узла, потому что один source vertex может
быть нарисован разными node poses. Это static fallback hierarchy, а не полная
animation parity: dynamic type-19 frame-map sampling остаётся отдельной задачей.
Для воспроизводимого исследования `fparkan-cli model inspect --root <game>
--archive <archive> --resource <model.msh>` выводит Node38 metadata, включая
parent index, fallback key и наличие LOD0/group0 geometry.
## Slot and batch
Type 2 содержит header `0x8C`, затем `Slot68`:
+12 -15
View File
@@ -13,7 +13,7 @@
| `RecordingBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Stable command capture for backend-neutral tests | Native window, Vulkan, GPU resource lifetime, pixels |
| `NullBackend` | No | No | Optional CPU-side IDs only | Usually `covered` for validation-only rows | Command stream framing and bounds validation | Capture stability, GPU execution, pixels |
| `VulkanAssetRenderer` | Yes | Yes | Yes | `covered-gpu` | Static original asset rendering: MSH/Texm/WEAR/MAT0/terrain through Vulkan | Animation/FX parity unless explicitly wired |
| `fparkan-game --backend static-vulkan` | Yes, GOG/Part 1/Part 2 `Autodemo.00` | Yes, merged static MSH component draws | All prepared MSH components from first mission root; first MAT0 diffuse TEXM per source selector | `covered-gpu` only for the narrow static-preview bridge | Opt-in mission-to-native-window bootstrap, component mesh merge, preview-local selector remap, diffuse descriptor upload and synchronized teardown telemetry | Full mission scene, later MAT0 phases/animation, lightmaps, placed transforms/orientation, camera, gameplay, original-runtime parity |
| `fparkan-game --backend static-vulkan` | Yes, GOG `Autodemo.00` | Yes, full static AutoDemo preview | Eight mission objects, 66 MSH components, original diffuse TEXM and Land2 terrain base layer | `covered-gpu` for the bounded static-scene bridge | Diagnostic XY framing with recovered translation/scale/Z Euler placement, fallback node hierarchy, optional captured legacy camera, 32-bit GPU indices, diffuse descriptors, terrain base draws and synchronized teardown telemetry | Material phases, Land1 blend/microtexture/lightmaps, animated keys, FX, camera control and gameplay parity |
| Future rendered `fparkan-game` mode | Yes | Yes | Yes | `covered-gpu` plus original-evidence IDs | Mission-driven render snapshot execution and pixel capture | Original-runtime parity for animation/FX/x87 without dedicated captures |
## Rules
@@ -32,21 +32,18 @@
- Реальный Vulkan в репозитории имеет smoke triangle path и узкий static asset bridge. `VulkanStaticDrawRange` сохраняет исходный `Batch20.material_index`; когда smoke запускается с `--wear-root`, `--wear-archive`, `--wear-name` без override, он дедуплицирует selectors, проходит каждый через `WEAR → MAT0 → Textures.lib`, создаёт по одному image/descriptor set и бинит set непосредственно перед соответствующим indexed draw. Direct TEXM и `--material-index` — намеренно однотекстурные compatibility modes. Fresh GOG `fortif.rlb::FR_L_MTP.msh` подтверждает 237 batch draws, но его selectors все `0`, поэтому live report содержит один binding `MTP_01.0`; unit contract подтверждает точное сопоставление двух разных selectors с разными descriptor sets. Это не доказывает material phase animation, lightmaps, alpha/depth/cull state, terrain, camera/node transforms или pixel approval.
- Lightmap остаётся отдельным, не реализованным contract: оригинальный `World3D.dll` экспортирует самостоятельный `SetLightMapLib` наряду с `SetTexturesLib` и `SetMaterialLib`; WEAR содержит независимый блок `LIGHTMAPS`. Текущая документация не подтверждает связь этих slots с `Batch20.material_index` или их UV/channel semantics, поэтому viewer не подменяет lightmap diffuse texture и не добавляет недоказанное binding.
- `apps/fparkan-game` по умолчанию выдает `render-planning` JSON report поверх
- `apps/fparkan-game` по умолчанию выдаёт `render-planning` JSON report поверх
synthetic window descriptor и `VulkanPlanningBackend`. Opt-in `--backend static-vulkan`
уже создаёт native `winit` window и передаёт все подготовленные MSH-компоненты первого root в
`VulkanSmokeRenderer`. Каждый исходный `Batch20.material_index` сначала разрешается внутри
собственного WEAR/MAT0 visual, затем получает уникальный preview-local selector и первый
diffuse TEXM. Режим использует
отдельный first-root preview loader: normal `load_mission` по-прежнему готовит все reachable
assets и весь graph, тогда как preview строит graph и готовит assets только для первого
mission root. `--load-progress <file>` writes the last entered loader phase synchronously for
timeout diagnosis. Fresh GOG `MISSIONS/Autodemo.00/data.tma` run passed in 38.7 seconds with
one presented frame, native 1280×720 swapchain (2 images), 14 mesh components and 14 original
diffuse material descriptors, 7,372,800-byte readback hash `16595193636416981301`, and
validation warnings/errors `0/0`. Part 1 matches that artifact; Part 2 passes validation with
14/14 but has a distinct hash `18268338333658342130`. This is `covered-gpu` evidence for that
narrow static-preview bridge only, not full-scene or original-renderer parity.
создаёт native `winit` window. Для GOG `MISSIONS/Autodemo.00/data.tma` он уже
рендерит весь статический набор из восьми mission objects и 66 MSH components с
diagnostic XY camera (or an optional captured legacy camera), доказанным `Rz * Ry * Rx` placement transform,
fallback node hierarchy и Land2 base terrain. Последний validation-clean
текущий трёхкадровый запуск после применения placement transform в XY path
имел 71 material descriptor, `clip_visible_vertices=75543` и readback hash
`5444013368935681345`. Это `covered-gpu` для ограниченного
static-scene bridge, но не pixel parity и не доказательство material phase,
Land1 blend, microtexture, lightmap, dynamic animation, FX, live camera
selection или gameplay rendering.
- `apps/fparkan-viewer` сейчас inspection-only CLI и не открывает live Vulkan
asset viewer.
- Следующий реальный milestone для rendered acceptance: `VulkanAssetRenderer`
+31
View File
@@ -468,6 +468,37 @@ string длиной 32 байта. Требование обязательног
Части 2. Все соответствуют формуле размера, `kind == 1` и
`archive_name == "objects.rlb"`.
При построении mission prototype graph FParkan сохраняет для каждого root
упорядоченный список этих raw records. Список передаётся в
`MissionObjectDraft` без преобразования `kind`, `parent_or_link`, description
или tails в предполагаемые роли. Так runtime уже располагает исходными данными
составного unit для будущих Control/physics/AniMesh consumers, но не выдаёт
структурное сходство за доказанную семантику.
Каждый graph edge от `UnitDatRoot` к effective component и далее к его MSH
dependency также несёт `unit_component_index`. Это индекс исходной записи в
порядке файла, а не тип компонента; по нему diagnostics и будущие consumers
могут однозначно вернуться к raw 112-byte record.
Visual dependency expansion наследует этот индекс на edges к WEAR, MAT0,
diffuse texture и lightmap. Поэтому ошибка или asset в любой из этих фаз
сохраняет путь до конкретной записи unit DAT, а не только до миссионного object.
Для воспроизводимой проверки используется `fparkan-cli prototype inspect`.
Схема JSON `fparkan-prototype-inspect-v2` содержит lossless hex каждого
32-byte поля unit record и materialized graph edges с `unit_component_index`.
Например, для GOG AutoDemo:
```powershell
cargo run -p fparkan-cli -- prototype inspect `
--root 'C:\GOG Games\Parkan - Iron Strategy' `
--key 'UNITS\UNITS\AutoDEMO\w_m_wlk2.dat' --format json
```
Вывод фиксирует 18 component records, их MSH/WEAR/MAT0/Texm dependencies и
точные parent edges. JSON предназначен для анализа и regression evidence, а
не для интерпретации `kind`, links или opaque tails как готовой game logic.
## Вспомогательные форматы
MSH, материал и текстура отвечают за видимую форму. Полноценный прототип
+29
View File
@@ -135,6 +135,14 @@ repeat relation_count:
TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как
raw-поля, пока их потребительская семантика не закрыта.
Runtime уже использует первую строку как доказанный путь выбора пакета: к ней
добавляется `.scr`, если расширение отсутствует, и полученный compiled package
декодируется losslessly до регистрации мира. Для каждого клана сохраняются
индекс, исходный base path, нормализованный `.scr` path и пакет событий. Это
не запускает opcode и не приписывает ему gameplay-эффект, но делает сценарный
input частью транзакции загрузки: missing/invalid package отменяет mission
load до публикации частичного world state.
Mode `0` имеет отдельный count-driven layout:
```text
@@ -555,6 +563,27 @@ candidate areas, затем выполняется точная геометри
Если область не найдена, caller получает явный miss и решает, допустим ли
fallback к ближайшей области.
### Индекс поверхности
`TerrainWorld` хранит отдельный deterministic BVH по валидированным
`TerrainFace28` triangles. Он не заменяет `Land.map` grid: BVH отвечает за
низкоуровневые surface queries (`height_at` и `raycast`), тогда как grid и
areal graph остаются навигационным контрактом.
При построении индекс делит triangles по centroid вдоль самой протяжённой оси;
в leaf остаётся не более восьми face indices. XY query проходит только листья,
чьи AABB покрывают точку, а raycast — только AABB, пересечённые лучом. После
выбора кандидатов raycast сортирует их по исходному face index, поэтому при
равной дистанции сохраняется прежний deterministic tie-break; `height_at`
по-прежнему выбирает максимальную высоту среди действительно покрывающих
точку triangles. Индекс меняет стоимость запроса, но не геометрическую
семантику.
Synthetic test проверяет сокращение candidate set и прежние height/raycast
results. Licensed run проходит все 33 `Land.msh` Части 1 и 32 файла Части 2:
для каждого mesh с числом faces выше leaf limit query у центра первого face
включает этот face и использует меньше кандидатов, чем полный mesh.
### Маршрут
После определения начальной и целевой областей маршрут строится по графу
+575 -13
View File
@@ -78,6 +78,19 @@ drivers и video modes, проверяет поддержку 3D, перевод
функции DirectDraw/Direct3D семейства 5-7 и публикует refcounted renderer.
`niGet3DRender` возвращает уже созданный объект и увеличивает число владельцев.
Статическая проверка подтверждает этот вывод без подмены его runtime-паритетом:
`Ngi32.dll` импортирует `LoadLibraryA`, `GetProcAddress` и `FreeLibrary`, но не
имеет статического импорта `DDRAW.dll`; в нём присутствуют строки `DDRAW`,
`DirectDrawCreate`, `DirectDrawCreateEx`, `DirectDrawEnumerateA` и
`DirectDrawEnumerateExA`, а также GUID семейств `IDirectDraw` 1/2/4/7,
`IDirect3D` 1/2/3/7 и соответствующих `IDirect3DDevice`. Следовательно,
современная реализация заменяет наблюдаемую fixed-function семантику над этой
границей, а не должна эмулировать COM-объекты DirectX. Равный SHA-256 Ngi32 в
Частях 1 и 2 зафиксирован в
[evidence](../evidence/original_engine_hashes.md); это поддерживает единый
Vulkan backend contract, но не отменяет раздельных corpus/capture baselines
для изменённых assets и gameplay DLL.
```text
enumerate adapters and video modes
-> choose CURRENT_D3DCARD
@@ -877,6 +890,14 @@ bytes с зафиксированным reference capture.
### First synchronized Vulkan pixel-readback artifact
The current artifact contract is one final swapchain image, not a
concatenation of every swapchain allocation. After the final successful
graphics submission, the renderer retains that image index; synchronized
teardown reads only its matching buffer. The raw payload is therefore exactly
`width * height * 4` bytes for the current format-50 path. Historical
multi-image byte counts below describe the superseded concatenation behavior
and are not comparable to this final-image contract.
Stage 3 static viewer теперь выполняет фактический readback для surface с
`TRANSFER_SRC`: на каждый swapchain image создаётся host-visible coherent
`TRANSFER_DST` buffer. После render pass command buffer переводит image из
@@ -895,8 +916,11 @@ static viewer. Он ещё не захватывает original DirectDraw frame
fixed original camera и не сравнивает два изображения, поэтому pixel-parity
acceptance остаётся blocked.
Smoke также сохраняет raw artifact рядом с JSON: `<report-stem>.readback-vkformat-<raw>.raw`.
Это concatenated current-swapchain images in Vulkan order, каждый с dimensions
The following paragraph records the superseded multi-image export only as
historical bootstrap evidence; it is not the current artifact contract.
Smoke также сохранял raw artifact рядом с JSON: `<report-stem>.readback-vkformat-<raw>.raw`.
Это были concatenated current-swapchain images in Vulkan order, каждый с dimensions
из JSON и четырьмя bytes per pixel; format намеренно указан в имени файла,
потому что bytes не перекодируются; JSON содержит actual raw enum. GOG selected `50` and produced a 4,147,200-byte file
for two 960x540 images. Артефакт остаётся локальным output и не попадает в Git.
@@ -972,11 +996,12 @@ of the first-root static-preview bridge, not a claim that the two games' full
mission renderers are compatible.
The preview now has an explicit root-prefix contract. `--preview-roots N`
prepares the first non-zero `N` TMA roots for the opt-in static Vulkan path;
the default remains one root. Normal mission loading remains full and
transactional, so this is not a hidden relaxation of gameplay validation. The
previous all-root probe timed out at `Graph`; the prefix makes broader scene
work measurable without pretending that a bounded preview is the full game.
prepares the first non-zero `N` TMA roots for the opt-in static Vulkan path.
Without that diagnostic limiter, the static Vulkan path now requests every TMA
root (the runtime clamps the request to the decoded mission length), so a
normal native preview no longer silently displays just root zero. Normal mission
loading remains full and transactional; the prefix is retained for bounded
investigation rather than as a hidden relaxation of gameplay validation.
### Shared terrain/root diagnostic frame
@@ -985,8 +1010,11 @@ The bounded native preview now retains the already validated `Land.msh` inside
runtime boundary instead of decoding archive formats a second time. It merges
one terrain component with the first TMA root's 14 reachable MSH components in
one explicit top-down XY frame. The frame is computed from terrain positions
and the root models after applying only the decoded TMA `position` and `scale`.
Raw TMA orientation is deliberately excluded: its convention is not proven.
and the root models after applying decoded TMA placement: non-uniform `scale`,
then the recovered `Rz * Ry * Rx` orientation, then `position`. The XY camera
itself remains diagnostic; applying the established placement transform here
prevents differently oriented mission objects from being framed or rasterized
at a false location.
Terrain is assigned an explicit 1x1 white diagnostic texture, rather than a
guessed terrain material. `Land.msh` slot selection, `material_tag`, masks,
@@ -1004,10 +1032,11 @@ geometry and descriptor set changed; it is not an original-frame comparison.
### Multiple static-preview roots
The native bridge now combines every mesh-backed visual of each selected root,
using that root's preserved TMA `position` and `scale` in the shared diagnostic
XY frame. It reports the actual selected root count as `preview_roots`, rather
than implying that all mission objects are rendered. Raw orientation, original
camera/frustum and visibility remain unresolved.
using that root's recovered static TMA placement (`Rz * Ry * Rx` after scale)
in the shared diagnostic XY frame. It reports the actual selected root count as
`preview_roots`, rather than implying that all mission objects are rendered.
The original camera/frustum, dynamic orientation ownership and visibility
remain unresolved.
Fresh canonical GOG `Autodemo.00` evidence with `--preview-roots 2` completed
in 59.7 seconds: 25 submitted MSH components, one terrain component and 26
@@ -1328,6 +1357,249 @@ to use its existing view/projection matrices: raw pose capture is intentionally
not converted into a view matrix until the original transform/projection
convention has evidence.
The next live sample strengthens the representation contract but still does
not name the transform's camera-space direction. For six no-input samples of
one auto-demo camera object, selectors `0` and `2` were byte-identical,
finite affine blocks with bottom row `(0, 0, 0, 1)`. Their upper 3x3 basis
was normalized and their last-column translations changed from approximately
`(441.038, 687.481, 10.754)` to `(433.545, 652.293, 10.673)`.
This storage/order is not guessed: live `NGI32.dll!g_FastProc[23]` dispatches
to RVA `0x1D9A0`, whose SSE implementation writes each output row as the sum
of four scalar-weighted rows. Thus a contiguous 16-float block is multiplied
as a conventional row-major 4x4 matrix. The render contract now exposes a
mathematical `try_inverse_affine_row_major` helper for such finite,
non-singular `[R | t; 0 0 0 1]` blocks. It returns no result for a singular or
non-affine block and, crucially, does **not** identify that inverse as a view
matrix. Whether a selector result is camera-to-world or already a renderer
transform remains an evidence boundary.
The active camera now also has a proven live projection-interface boundary.
`LoadCamera` stores its selector-18 camera interface at outer offset `+0x19C`;
in the elevated GOG AutoDemo sample it was a `CBufferingCamera` object with
relocated vtable `0x02576344`. Its rectangle at `+0x10` was exactly
`(left=0, top=0, right=1024, bottom=768)`, its projection-type field at
`+0x230` was `0`, and its FOV field at `+0x234` was `1.04` radians.
`CBufferingCamera` passes that FOV to `tan(fov / 2)` on its type-0 projection
path. This proves the live angle input and viewport for this sample;
it does not yet assign the two remaining projection values, near/far planes,
depth range, clip-space Y direction, or a Vulkan projection matrix.
The unresolved values are now located rather than guessed. `CBufferingCamera`
uses its primary-render interface at component offset `+0x220` to select a
render context, then asks that context for a five-float projection block. The
sample's primary vtable was relocated `0x02574DF0`; its selector at `+0x0C`
is only a table lookup keyed by two still-unnamed inputs. No virtual call was
made by the probe. Therefore the block's values cannot yet be assigned as
near/far or reused by the Vulkan adapter.
The runtime render model now retains this evidence as `RawCameraProjection` on
`CameraSnapshot`: the viewport rectangle, projection selector, exact FOV bits,
and all five context words travel through the backend-neutral boundary without
changing the current Vulkan projection. Its float accessors deliberately leave
the context fields unnamed; preserving a raw ABI value is safer than encoding a
near/far or clip-space convention before it is demonstrated.
The next downstream boundary is now also identified. In the canonical static
`CBufferingCamera` vtable (`0x10066344`), slot `+0x4C` is Terrain RVA
`0x4D9C0`; it packages the camera transform, rectangle-derived viewport data,
FOV-derived tangent, and primary-render context values for another renderer
component. It does not itself assemble the final projection matrix. The GOG
`Ngi32.dll` export named `vrtSetCameraParam` resolves to a no-op compatibility
stub, while the live process loads the Direct3D7 runtime (`D3DIM700.DLL`). The
next recovery target is therefore the Direct3D transform consumer in
`iron3d.dll`, not a guessed Vulkan perspective formula. The observed `0.5`,
`700`, `0.1`, and `0.99` values remain unlabelled until that consumer proves
their roles.
### Ngi32 Direct3D7 camera matrices recovered
The downstream renderer is now concrete. `iron3d.dll` obtains an opaque
render interface through `Ngi32!niGet3DRender`; Ngi32 RVA `0x5640` returns the
global renderer object at RVA `0x3A460`. Its constructor at RVA `0x5E10` gives
the `0x8CC`-byte object vtable `0x100315E0`. During device setup, Ngi32 RVA
`0x8E70` calls the Direct3D7 device vtable at `+0x2C` (`SetTransform`) with
state `3` for the matrix made by RVA `0x7030` and state `2` for the matrix made
by RVA `0x9450`: projection then view.
RVA `0x7030` exactly builds the row-major D3D7 projection from renderer FOV
`f`, near `n`, far `z` and viewport width/height `w`/`h`:
```text
[ cos(f/2), 0, 0, 0 ]
[ 0, w/h * cos(f/2), 0, 0 ]
[ 0, 0, sin(f/2)*z/(z-n), sin(f/2) ]
[ 0, 0, -sin(f/2)*n*z/(z-n), 0 ]
```
The sine in the depth scale and homogeneous-W term is intentional: after the
D3D perspective divide the diagonal has the expected cotangent scale and the
depth range remains finite. RVA `0x9450` applies a
specific axis permutation/sign change and translated dot products to selector
0 before the view `SetTransform`; it is not merely the generic affine inverse.
An elevated read-only AutoDemo probe sampled a 1024×768 renderer with
near=`0.5`, far=`700`, FOV=`1.3` radians and showed its view-source pointer
byte-identical to the active Terrain outer camera's selector-0 block. This
proves ownership and the D3D7 boundary, while keeping the earlier
`CBufferingCamera` FOV=`1.04` as a distinct upstream interface value.
The live GOG layout is now independently repeatable without invoking any game
method: `Terrain.dll + 0x7355c` contains the current outer camera-object
pointer, whose vtable relocates to `Terrain.dll` RVA `0x665b4`. Selector 0
returns the affine block beginning at outer `+0x20`; its sixteen row-major
words have translations at indices 3, 7 and 11. The public `LoadCamera`
interface at outer `+0x134` is a different view and must not be substituted
for that block. Captures for `RawCameraTransform` therefore contain the sixteen
selector-0 words from outer `+0x20`. A passive AutoDemo sample produced a
finite affine camera matrix and passed through the offline Vulkan adapter as
source-world geometry, rather than relying on a guessed identity view.
The Vulkan static path now accepts this recovered camera as
`VulkanStaticCamera`. It composes the row-major D3D7 result as
`view * projection`, uploads its 64 bytes through a vertex-stage push constant,
and changes static vertices from XY to XYZ. GLSL reads the same bytes as its
default column-major `mat4`; therefore `matrix * vec4(position, 1)` is the
transpose-equivalent of the original D3D7 row-vector multiplication. Both APIs
use the 0..1 depth range, and this renderer uses a positive viewport height, so
this narrow bridge needs no extra depth or Y correction. The fragment alpha
cutoff moved to byte offset 64 in its own push-constant range. The default
identity camera deliberately preserves the old XY diagnostic preview; feeding
live terrain camera/projection values into mission rendering remains later
runtime wiring, not a claim of scene parity.
The geometry side of that handoff is also explicit. The static mesh adapter now
has source-world projections for `Land.msh` and MSH: terrain positions retain
all three decoded coordinates; model positions retain Z and apply the recovered
static mission placement. The diagnostic XY counterpart uses that same placement
before normalizing X/Y, so it no longer silently omits authored object rotation.
These APIs preserve triangle order, batch ranges and packed UV decoding. The
current game preview deliberately stays on its XY diagnostic projection until a
runtime supplies a real legacy camera; selecting source-world coordinates with
identity camera would be a misleading empty/off-screen viewer rather than a
compatibility result.
Static analysis of GOG `iron3d.dll` recovers the placement primitive at RVA
`0x36610`: it evaluates a row-major affine `Rz(z) * Ry(y) * Rx(x)` and writes
translation in the final column. The backend-neutral
`LegacyIron3dEulerTransform` preserves that finite portable formula with golden
yaw and scaled-point tests. A read-only elevated AutoDemo scan closes the
previous TMA binding gap for static placement: it finds the exact raw mission
records (position, `(0, 0, z)`, scale) and live object blocks at the same
world positions. For example, `z = 1.6262363` produces the observed pair
`-sin(z) = -0.99846`, `cos(z) = -0.05541`; matching pairs occur for the other
observed objects. The opt-in captured-camera path therefore applies
`R * (scale * local_position) + translation` before Vulkan upload. It remains
a static-placement contract: dynamic/animated transforms, physics ownership,
terrain material selection, lighting and gameplay parity are still separate
evidence tasks.
`VulkanSmokeRenderer::set_camera` now accepts a new finite camera between frame
submissions and uses it for the next command buffer without rebuilding the
swapchain, buffers, descriptors or pipelines. `VulkanStaticCamera` also accepts
generic finite row-major view/projection pairs, so the final game camera
controller need not be coupled to the D3D7 recovery type. The native Windows
smoke executes that update before each of 300 validation-clean frames. This is
the renderer-side cadence contract only; it does not yet supply a gameplay
camera controller or claim camera movement parity.
For controlled differential work, `fparkan-game --backend static-vulkan` now
accepts `--legacy-camera-capture <path>`. The JSON capture stores only the
selector-0 words and the already recovered Ngi32 viewport/near/far/FOV inputs;
the application reconstructs the D3D7 camera offline, selects source-world
geometry, and never attaches to or controls the original process. A live
read-only AutoDemo confirmation corrected an ABI ambiguity: the Terrain global
is the base of the `0x1A4` camera object (vtables at `+0` and `+4`), while
`LoadCamera`'s public return is the separate interface view at `base + 0x134`
with its own vtable. This capture format deliberately preserves the former
base-object interpretation and does not invent camera ownership.
A three-frame GOG AutoDemo run with one such read-only capture selected the
`legacy-d3d7-capture` path, rendered all eight mission roots / 66 MSH
components plus terrain, reported 5,226 vertices inside the recovered clip
volume, produced a 7,372,800-byte readback with FNV-1a
`1737487240747766901`, and ended with zero Vulkan validation warnings and
errors. This validates the offline handoff for one captured instant; it does
not claim live camera tracking, material parity, or an original-frame pixel
comparison.
`tools/capture-original-camera.ps1` makes that handoff repeatable for a live
GOG process. It finds the actual `Terrain.dll` base with Toolhelp module
enumeration, opens the chosen PID with only
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, verifies the expected outer
vtable, and reads exactly 64 selector-0 bytes at outer `+0x20`. AutoDemo can
briefly select a normalized/reflection-like camera object whose finite
translation is near the origin. The tool therefore re-reads the global on
each attempt and accepts a sample only when the proven translation length
exceeds its explicit `-MinimumWorldTranslation` threshold (default `100`);
the JSON retains the accepted outer pointer and translation for audit. It emits the
`fparkan-legacy-camera-v1` JSON accepted by `fparkan-game`; it does not write
to the process, send window messages, inject code, suspend threads, or invoke
an original method. PowerShell execution policy may require the explicit
one-shot bypass below; that changes neither the script nor the game.
```powershell
$capture = powershell -NoProfile -ExecutionPolicy Bypass -File `
.\tools\capture-original-camera.ps1 -ProcessId 5632
$capture | Set-Content -NoNewline target\fparkan\live-camera.json
cargo run -q -p fparkan-game -- --root 'C:\GOG Games\Parkan - Iron Strategy' `
--mission 'MISSIONS\Autodemo.00\data.tma' --backend static-vulkan --frames 3 `
--legacy-camera-capture target\fparkan\live-camera.json `
--readback-out target\fparkan\autodemo-live-camera.raw
```
On the unattended live AutoDemo the accepted world-space capture completed the
same all-root static bridge with 10,152 clip-visible vertices, 71 descriptors,
a 7,372,800-byte format-50 readback, and zero Vulkan validation
warnings/errors. A passive desktop-surface capture of that original window also
proved that the selected instant contains the textured terrain, atmospheric
sky, HUD and weapon FX; `PrintWindow` itself returns a black Direct3D buffer,
so it must not be treated as an original-frame oracle. The paired images expose
the next real renderer gap: sharing the raw pose and Ngi32 projection inputs is
not yet pixel parity, because the static bridge still lacks the full runtime
camera-selection timing, terrain composition, culling, lighting, UI and FX.
For visual regression work, `fparkan-game --backend static-vulkan` also accepts
`--readback-out <path>`. It writes the final synchronized Vulkan swapchain
image only after the renderer has completed its normal teardown evidence; the
JSON report records its raw Vulkan format, byte count, hash, and requested
path. At 1280x720, format `50` (`VK_FORMAT_B8G8R8A8_UNORM`) produces exactly
3,686,400 bytes. The artifact is a Vulkan-side comparison input, not an
asserted original-frame image.
The first bounded launch showed that repeated fingerprinting, rather than
Vulkan initialization, was the load-path bottleneck: each new MAT0/TEXM request
re-opened an already decoded archive and re-hashed its entire source file.
`ResourceRepository::open_archive_unchanged` now makes the *explicit* bounded
loading-transaction contract available. The normal `open_archive` remains the
strict public path and still invalidates stale entry handles after an external
archive replacement; only mission asset preparation uses the transactional
variant because it consumes an immutable snapshot of the selected game root.
With that scoped cache, canonical GOG
`MISSIONS/Autodemo.00/data.tma --backend static-vulkan --preview-roots 1`
completed preparation in 6.308 seconds (the visual graph completed in 6.012
seconds) and produced a 1280×720 native Vulkan frame with the captured D3D7
camera, 15 material descriptors, and zero validation warnings or errors. This
is a real original-asset rendering milestone, but not pixel parity or gameplay
camera parity: it intentionally renders the bounded first preview root and
uses a one-time offline camera capture.
Expanding that same preview to all eight AutoDemo mission objects exposed a
different, renderer-local limit: the merged static scene crossed 65,535
vertices before any Vulkan command was recorded. `VulkanStaticMesh` now uses a
32-bit GPU index buffer (`VK_INDEX_TYPE_UINT32`), while source MSH/TerrainFace28
indices remain decoded in their original 16-bit representations and are widened
only at the static-render bridge. A regression test crosses the former boundary.
The full captured-camera AutoDemo preview completed at 13.369 seconds and
rendered three 1280×720 native frames: 8 mission objects, 66 mesh components,
67 material descriptors, and zero Vulkan validation warnings or errors. A later
orientation-enabled run rendered the same full scene for three 1280×720 frames
with `validation_warnings=0`, `validation_errors=0` and readback hash
`6406107678925513509`. It is still a static source-world preview: terrain
material selection, lighting, animation, physics and gameplay behavior have
not yet been claimed as parity.
A fresh no-input launch of the canonical `iron_3d.exe` did create a responsive
window titled `Parkan. Железная Стратегия`. A read-only probe then requested
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and attempted to read the known
@@ -1360,6 +1632,171 @@ temporary vertex buffers или заменять render representation. UI/shell
диагностики полезно уметь сохранять world-only command list и финальный
framebuffer отдельно.
Terrain material discovery is now reproducible instead of inferred from the
mesh shape. The original AutoDemo map places two textual sidecar WEAR tables,
`Land1.wea` and `Land2.wea`, beside `Land.msh`; `Terrain.dll` contains the
corresponding `1.wea` and `2.wea` loading suffixes. The new terrain inspector
reports all packed face tags. For `DATA/MAPS/AutoMAP/Land.msh`, its 3,174 faces
use `0x0102` (592), `0x0203` (386), `0xff01` (918), `0xff02` (970), and
`0xff03` (308). The low byte is always a valid positional selector 1..3 in
`Land2.wea`; the high byte is a `Land1.wea` selector or sentinel `0xff`.
This is evidence for a two-layer terrain-material contract, but not yet for
its blend equation, so the Vulkan bridge intentionally remains texture-agnostic
until the base/overlay composition is recovered.
Static recovery of `Terrain.dll!CLandscape` now fixes the loader contract behind
that observation. The constructor opens `Land.msh`, creates one material manager
from `Land1.wea`, then invokes that manager's second-table load with `Land2.wea`.
It requires NRes type 18 with the original diagnostic “microtexture mapping
chunk” and retains it as four-byte entries; type 14 is optional. Therefore the
two WEAR selectors must not be collapsed into an invented one-layer material,
and the type-18 `aux18` stream is now documented as microtexture mapping data.
`TerrainFace28::material_layers` exposes the evidenced high-byte `Land1`
selector (with `0xff` absent sentinel) and low-byte `Land2` selector. The
manager's actual blend/pass operation remains unrecovered. `World3D.dll`
supplies the manager ABI: its load slot appends `Land1` as table 0 and `Land2`
as table 1, while its material-phase lookup receives the exact 32-bit selector
`(table_index << 16) | material_index`. `TerrainMaterialSelection` preserves
that key for the renderer without assuming how the two phases are blended.
The same `World3D!GetMaterialPhase` recovery establishes the next boundary. It
validates the table/row selector, chooses a MAT0 phase (or interpolates two
phases for an animated material), returns the selected material record, and
hands its caller a 76-byte expanded phase-state block. It does not submit a
draw or compose the two terrain tables. The later consumer of that record and
the type-18 microtexture mapping remains the required evidence before adding
an overlay, blend, or microtexture shader.
The terrain render traversal is now located at `Terrain.dll` RVA `0x11340`.
After visibility/cell work, it derives a 68-byte source-material record and
dispatches `Terrain!GetShade()` slot `+16` with four arguments: zero, the
record's word at `+2`, a pointer selected from a four-byte table by its word at
`+0`, and zero. `GetShade` is a Terrain singleton, not `niGet3DRender`; the
preceding slot `+60` call sets its visibility mode. Slot `+16` consumes the
second word as the count of adjacent `u16` pairs and the first as the pair-table
index, then groups the pairs by material lookup before constructing draw work.
`TerrainSlotTable::render_dispatch` exposes exactly those two proven disk
fields, while `LandMeshDocument::slot_material_pairs` decodes the selected
type-11 entries as `{ material_lookup: u16, flags: u16 }`. Flag `0x0010` is a
proven batch boundary in `GetShade`. Crucially, this lookup does **not** select
either map-local Land table: the `GetShade` constructor loads its own one-table
manager from `system.rlb` resource `Shade.wea`. That WEAR table has one row,
`LIGHT1`, whereas AutoDemo's 3,174 pairs use keys `0..3173`; therefore the
16-bit value is an opaque manager lookup/cache key, **not** a table-row
selector. `TerrainMaterialPair::shade_lookup_key` records this separate key
without conflating it with `Land1.wea`/`Land2.wea`. The AutoDemo inspector now
reports 128 slots and 392 `0x0010` batch boundaries. The meanings of remaining
flags, this cache mapping, and the final blend operation remain unassigned.
The cache boundary is now mechanically separated from the WEAR loader. In the
`GetShade` object, the `Shade.wea` manager is stored at byte offset `3092`, but
the dispatcher calls a distinct object at byte offset `3244`. Its virtual slot
`+16` receives every type-11 key repeatedly with mode values `512`, `16`,
`20497`, and `20752`. The cache object itself has an eight-byte entry table at
`+316` and its exclusive key count at `+320`: the first dword of an entry is a
stored-record pointer and its byte at `+4` selects a 212-byte profile bank. A
key outside that count, or an entry pointer below `0x1000`, fails the lookup.
The static constructor now identifies this object as the embedded `CLandscape`
subobject whose vtable is `Terrain.dll` RVA `0x643D0`. It installs that vtable
at parent offset `+316`, allocates the entry table at parent `+632` (subobject
`+316`) as `0x1f40` bytes / 1,000 eight-byte entries, and initializes its count
to zero. The profile-bank region starts at subobject `+332`: every one of the
100 banks has a 116-byte header followed by a 96-byte initialized stream, hence
the recovered 212-byte stride. The byte selected from each table entry is
therefore a bank index into a constructor-owned, fixed 100-bank region. This
proves the cache's allocation layout. Its population path is also now located.
`Terrain.dll` RVA `0x10280` resets the count, recreates the base entries from
the 28-byte stream at parent `+756`, and assigns bank zero. RVA `0x12e20`
copies one `0xd4`-byte profile header into bank `profile_count`, increments that
count, and remaps supplied keys to 28-byte records through the entry table. A
mapped record is stored as `profile_record_base + 28 * record_index` with the
new bank byte; a mapping whose second index is `-1` stores only the profile
ordinal, below `0x1000`, and is deliberately rejected by the lookup routine.
This explains the runtime sentinel without interpreting it as a missing WEAR
row. The input map is not a direct `Shade.wea` decode: the sole call site is
the terrain loader at `Terrain.dll` RVA `0x0a3f0`, after it has generated and
stitched 28-byte terrain records. That loader first appends pairs copied from
an intermediate primitive-key list with second value `-1`, then appends one
`(-1, generated_record_index)` pair for every generated record. Before calling
the cache builder it runs a three-edge matching pass over those records, using
their quantized positions and adjacency selectors. The cache profile therefore
describes loader-produced geometry and its generated correspondence map, not a
named on-disk `Shade.wea` section. The source of the intermediate primitive-key
list and the exact semantics of the 28-byte record's non-position fields remain
unassigned; this is sufficient to rule out an invented static WEAR parser, but
not to reproduce the profile generator yet.
On success the method returns the shared result view at cache offset `+24`, not
the stored-record pointer and not a WEAR row. Thus the dispatcher's observed
flags at result-view `+60`, secondary selector `+24`, optional byte `+88`,
referenced state block `+104`, three signed shorts `+108`, and another selector
`+112` are materialized lookup state, not yet proven on-disk record offsets.
Mode `0x0200` (`512`) only validates the key in the recovered path; mode
`0x5011` includes the recovered selector-table transfer and mode `0x5110`
includes the recovered bank-byte transfer. The meanings of their remaining
bits, record population, and the result fields remain unrecovered, so the Vulkan
path must not map any of them to invented pipeline state.
A read-only elevated AutoDemo probe confirms that this is a lifecycle boundary,
not a permanently allocated table: the live `GetShade` singleton is initialized
and its `Shade.wea` manager pointer is non-null, while its byte-offset `3244`
cache pointer is normally null. Passive 100-ms sampling of the unattended
AutoDemo caught it briefly at `0x035C64B4`; its relocated vtable is
`Terrain.dll` RVA `0x643D0` and slot `+16` is RVA `0x10910`, matching the
recovered lookup routine. Therefore a future runtime implementation must treat
cache availability as contextual and cannot dereference or prebuild the cache
solely from constructor state.
`tools/capture-terrain-shade-cache.ps1` now makes the same evidence repeatable
without debugger attachment. It discovers `Terrain.dll` with Toolhelp, opens
only `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, searches readable
32-bit-process regions for the already proven cache vtable, and excludes the
Terrain image itself because relocations there also contain that pointer value.
It reports only addresses and count metadata; it does not alter the process or
save game resources. On the unattended GOG AutoDemo process 5632 on 2026-07-18
it found the live cache at `0x035C64B4`, with entry table `0x17284590` and
exclusive count `3462`. Two adjacent passive captures retained the cache
address/table/count but returned result views `0x0274BD89` and `0x008A60DC`.
That changing result view is direct evidence that it is transient lookup
state, not a persistent cache identity or a WEAR row. The observation narrows
the next terrain task to profiling the cache's generated entries and their
consumer; it still does not establish a Land1 blend equation.
The probe now also reads the bounded eight-byte entry table and only hashes
the fixed 212-byte profile banks it observes; raw process bytes never enter
the repository. The same AutoDemo cache had 3,426 materialized record pointers
among 3,462 entries and referenced banks `0..4`. Two consecutive captures
kept that count, bank set, and all five FNV-1a-64 bank hashes stable:
`9426539192111566946`, `2915543853261903601`, `13156371703608727216`,
`11280405573172155968`, and `18234288329145104612`. Their result views changed
from `0x008A565C` to `0x008A55A8`. Thus the generated cache/profile state is
stable over these render samples while the shared result view is not. This
still identifies neither the record-field semantics nor the final terrain
blend/pass operation.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File `
tools\capture-terrain-shade-cache.ps1 -ProcessId <iron_3d-pid>
```
The static Vulkan bridge now carries each contiguous face run as a separate
draw range keyed by the original packed `material_tag`; it neither reorders
triangles nor collapses the high byte. For the first usable visual layer, the
low byte resolves positionally through the adjacent standalone `Land2.wea`,
then the existing `MAT0 -> Textures.lib -> TEXM` chain supplies the RGBA8
descriptor. `Land1.wea` remains preserved as the high byte/overlay channel and
is deliberately not drawn until its blend equation is recovered. A canonical
three-frame AutoDemo run now uses 71 descriptors (66 MSH plus five terrain
tags), produces readback hash `17034026600547661445`, and stays at zero Vulkan
validation warnings/errors. This is original terrain texture upload, not
terrain visual parity.
```powershell
cargo run -q -p fparkan-cli -- terrain inspect `
'C:\GOG Games\Parkan - Iron Strategy\DATA\MAPS\AutoMAP\Land.msh' --format json
```
## Проверки паритета
Главные риски совпадения кадра:
@@ -1384,3 +1821,128 @@ hashes промежуточных buffers. Без такой трассиров
Связанные справочные страницы с таблицами форматов: [MSH](../reference/msh.md),
[materials](../reference/materials.md), [Texm](../reference/texm.md) и
[render frame](../reference/render-frame.md).
### Live AutoDemo: empty frame устранён, но parity ещё не достигнут
Read-only `PrintWindow` capture работающего GOG AutoDemo даёт полноценный
оригинальный кадр (terrain, варбот и близкая geometry), поэтому теперь есть
безвводный source для будущего visual comparison. Текущий полный static-Vulkan
preview раньше состоял только из clear color. Validation `0/0` тогда доказывал
только GPU lifecycle, но не совпадение с original renderer.
Новая CPU-диагностика применяет к тем же source vertices ту же row-major D3D7
matrix, которую GLSL получает как column-major push constant. Для свежего
captured Ngi32 camera (`viewport 1024x768`, near `5`, far `700`, FOV `1.3`)
она насчитала `clip_visible_vertices=0`. Последующая сверка с
`Ngi32!sub_10009450` и `sub_10007030` нашла два literal translation defects:
Y translation view matrix должна быть `-(m23*m22 + m13*m12 + m03*m02)`, а
projection использует `width/height` и множитель `sin(fov/2)` в обоих depth
coefficients. После исправления того же capture диагностировал
`clip_visible_vertices=938`, а `PrintWindow` native Vulkan window показал
реальную rasterized terrain/model geometry. Это исключает прежний empty-frame
барьер; face culling и texture selection не были его причиной.
Получившийся кадр всё ещё явно не похож на оригинал: geometry сосредоточена у
границы и material/terrain representation грубая. Значит, следующие задачи —
camera timing/selection, source model-node transforms, terrain material phases,
lighting и visibility, а не объявление pixel parity.
MSH static bridge теперь также перестал отправлять каждый `Batch20` файла.
Для стандартного `Node38` он выбирает `slot_index[LOD0, group0]`, затем
соответствующий `Slot68.batch_start..batch_count`; alternate LOD/groups больше
не подмешиваются в initial static pose. Regression test фиксирует selection,
а fresh AutoDemo run меняет readback hash при тех же 938 clip-visible vertices
и остаётся validation-clean. Новый capture по-прежнему показывает сильное
смещение частей, поэтому одной selection недостаточно: нужны local node poses,
parent hierarchy и animation-key/fallback semantics. Модели без layout Node38
сохраняют прежний all-batches diagnostic fallback.
Чтобы следующий шаг не перечитывал raw NRes streams в renderer, validated
`ModelAsset` теперь переносит `ModelAnimation` с decoded type-8 keys, type-19
map и declared frame count. `node38_fallback_pose` возвращает exact fallback
key pose. Licensed gates Part 1/Part 2 подтвердили 435/511 models, 157/200
animated models, 3,469/5,233 node samples и утверждённые captures.
Следующий узкий шаг применяет этот уже декодированный `fallback_key` в
legacy-camera static preview. Для каждого выбранного `Node38` bridge поворачивает
вершины normalized quaternion, прибавляет local translation pose и только затем
применяет доказанный TMA `Rz * Ry * Rx`, scale и mission translation. Вершины
намеренно разворачиваются per node/draw range, чтобы один source vertex мог
получить разные poses. `parent_or_link_raw == 0xFFFF` теперь доказан как root,
а любое меньшее child значение — parent index: inspected `R_B_01.msh` содержит
34-node parent-before-child tree, а Part 1/Part 2 animation gate подтвердил
этот контракт для всех standard nodes. Parent rotation корректно поворачивает
child translation до суммирования. Frame map всё ещё не семплируется, поэтому
это static fallback hierarchy, а не full animation parity.
На той же GOG `MISSIONS/Autodemo.00/data.tma` и offline Ngi32 camera capture
full preview из 8 objects / 66 mesh components / 67 descriptors завершился за
один native Vulkan run с `clip_visible_vertices=3415`, `validation_warnings=0`,
`validation_errors=0` и readback hash `1275533143935640133`. Рост с 2584
видимых вершин подтверждает, что composed parent poses реально вошли в geometry
path; он не является оценкой сходства с original frame.
Параллельно bridge для legacy-camera path теперь переводит только высоту
`Land.msh` в world units с масштабом `1/32`: raw AutoDemo heights
`95.29412..288.23532` становятся `2.978..9.007`, что согласуется с TMA Z и
live camera height. Это доказанное coordinate conversion, но оно само по себе
не сделало вершины видимыми и не является заявлением о pixel parity.
### Explicit offline Node38 animation-frame preview
The static Vulkan path now accepts `--static-animation-frame <u16>`. For each
standard `Node38` model it resolves the already-decoded type-19 map at that
logical frame, selects its mapped type-8 key when it is valid before the
node's fallback key, samples the selected key and its immediate successor with
the portable reference sampler, then composes the resulting parent hierarchy.
Nodes with no usable map, or a requested frame outside the declared map,
retain their exact fallback pose. An invalid/incomplete hierarchy deliberately
falls back to the existing unposed static geometry path instead of guessing a
runtime state.
This option is a reproducible asset-viewer probe, not a gameplay clock: it
does not identify the original animation controller, x87 rounding profile,
clip selection, LOD/group controller, blending or FX timing. The JSON report
contains `animation_frame` (`null` for the established fallback preview) so
readbacks from the two modes cannot be mistaken for each other. On 2026-07-18,
the licensed GOG `MISSIONS/Autodemo.00/data.tma` run with all eight roots,
`--static-animation-frame 1`, two frames and format `50` completed with 66
mesh components, 75,543 clip-visible vertices, a 1280×720 / 3,686,400-byte
readback hash `4638497144561211935`, and validation warnings/errors `0/0`.
### Windows PowerShell camera-capture hand-off
`tools/capture-original-camera.ps1` is an observer: it opens the original
process only with `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, samples the
active Terrain camera and emits `fparkan-legacy-camera-v1` JSON. Windows
PowerShell 5.1 commonly redirects or pipes that text as UTF-16LE with a BOM.
The Vulkan viewer now accepts BOM-marked UTF-16LE and UTF-16BE in addition to
UTF-8 (including an UTF-8 BOM), so a normal PowerShell capture can be used
directly as `--legacy-camera-capture` without a lossy manual re-encoding step.
Malformed byte lengths and invalid Unicode remain explicit load errors.
Fresh read-only evidence from the running licensed GOG AutoDemo sampled outer
camera `0x0B368FD8` in `Terrain.dll` at base `0x02510000`; selector-0
translation was `(548.020752, 559.672852, 3.00516248)`. Passing the captured
UTF-16LE file to the native Vulkan viewer rendered all eight mission roots
with `camera_mode="legacy-d3d7-capture"`, 66 mesh components, 6,787
clip-visible vertices, 71 material descriptors, a 1280x720 / 3,686,400-byte
format-50 readback hash `7904810245714997753`, and validation warnings/errors
`0/0`. This proves the live-camera hand-off and projection path; it does not
establish frame-by-frame camera-selection parity, terrain shading or pixel
parity with the original renderer.
### Explicit MAT0 phase preview
`fparkan-game --backend static-vulkan --static-material-phase <u16>` now
selects the exact decoded MAT0 texture for that phase of each MSH material.
The override is deliberately per material: when the requested phase is absent
or intentionally untextured, that material retains its existing first prepared
diffuse request, rather than fabricating a texture or aborting the whole
mission. Terrain remains on its separately proven Land2 base path. The JSON
report records `material_phase` so the artifact's selector is explicit. This
is an asset-viewer control, not a claim about the original material clock,
phase mode, interpolation, random offset, lighting or Land1 composition.
On 2026-07-18 the licensed GOG AutoDemo run with all eight roots, phase `1`,
two frames and format `50` completed validation-clean with 71 descriptors and
a 1280×720 / 3,686,400-byte readback hash `3152437928708207395`.
+173
View File
@@ -133,6 +133,33 @@ Wizard получает желаемое направление и corridor, а
описывает типы, defaults, ranges и строки через макросоподобные формы
`VAR(...)` и `STRING(...)`.
Compiled package больше не opaque blob: `fparkan-script` losslessly читает
проверенный внешний framing. Сначала идут `opcode_handler_count` и
`event_count` (`u32 LE`), затем именованные события с NUL-terminated raw
именем и nested records. Reader сохраняет все seven raw header words и
references каждого record, жёстко ограничивает counts/allocations и отдельно
сохраняет trailing bytes. На `c1m2p.scr` GOG reader получает `73` handlers,
`9` events, `17` records и `20` references без trailing bytes. Это структура
файла, но не таблица семантик: названия opcode/words появятся только после
handler contracts и runtime traces.
Связь первого header word с dispatch теперь доказана статически: `ai.dll`
создаёт 73 handler pointers в известном порядке и копирует table без
перестановки. По всем 58 GOG packages первый word — индекс `0..72` либо
`0xffff_ffff` sentinel; `fparkan-script` отражает это как typed
`ScriptDispatchSelector`, сохраняя неожиданные значения `Unknown`. Первый
handler лишь инициирует execution context (`+0x50 = 1`); его gameplay meaning
пока не назван.
Первый часто встречающийся table entry с side effect — `Handler(2)` (176
records в corpus). Он берёт active instruction, разрешает семь slots через
varset, приводит три значения к float по observed kinds `5`/`3`, затем
materializes или refresh-ит внутренний event record. Его base string связывает
`<base>_Start` и `<base>_Continue` с event table; прямого World3D/Behavior
call на этой ветке не найдено. Slot names и consumer record ещё не установлены
динамически, поэтому Rust не исполняет handler как гипотетическую команду
движения/атаки/строительства.
Безопасная runtime-модель:
```text
@@ -154,6 +181,36 @@ IDs и отправляет команды через игровые interfaces,
открытым направлением. До появления decompiler-а `.scr` binary body сохраняется
lossless, а доказанные symbol/event tables документируются отдельно.
### Подтверждённый evaluator выражений
Ghidra 12.1.2 decompile GOG `ai.dll` фиксирует отдельный evaluator по VA
`0x10005180` (не dispatcher инструкций `.scr`). Он получает индекс записи,
ищет её в контейнере `this + 0x34`, переключается по `u32 tag` в offset `+0`
и при успешном результате пишет completion byte в `+0x0c`. Из кода доказаны
ровно пять ветвей `tag = 1..5`; `tag = 1..3` требуют `u32` subtype в
`+0x04 == 0`, а `tag = 4..5``+0x04 == 1`. Поле payload находится по
`+0x08`.
Ветки 1--3 делают lookup через object/interface, достижимый от
`this + 0x60 + 0x35c`, и используют его virtual slot `+0x1c`. Ветка 2
дополнительно читает virtual slots `+0x10`/`+0x18` возвращённого объекта и
разрешает result tags `1`, `0x12`, `0x13`. Ветка 3 сравнивает virtual slot
`+0x44` с текущим object value. Ветки 4--5 используют `payload` как index в
контейнере `this + 0x4c`, обходят его child indices и делают тот же lookup;
их разные success-guards пока не именуются семантически. Это доказывает
typed condition/evaluation layer, но **не** формат `.scr`, размеры инструкций
или связь чисел tag с языковыми операторами.
Выгрузка воспроизводится без изменения PE:
```powershell
& 'C:\Tools\ghidra_12.1.2_PUBLIC\support\analyzeHeadless.bat' `
C:\temp\fparkan-ghidra ai -import 'C:\GOG Games\Parkan - Iron Strategy\ai.dll' `
-processor x86:LE:32:default `
-scriptPath C:\Develop\fparkan\tools\ghidra `
-postScript ExportAiExpressionDispatcher.java -deleteProject
```
### TRF и preload-данные
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
@@ -206,6 +263,82 @@ CreateCollObject
тригонометрию и `g_FastProc`. Это подтверждает его положение между gameplay
object и геометрией мира.
Статическая сверка GOG `Control.dll` уточняет границу, но пока не раскрывает
per-tick solver. PE32 image base — `0x10000000`; exports имеют следующие RVA:
```text
InitializeSettings 0x32260
LoadControlSystem 0x32280
LoadPhysicalModel 0x32580
CreateCollManager 0x325d0
CreateCollObject 0x32600
```
`LoadControlSystem` возвращается `ret 0x20`, следовательно ABI снимает со
стека восемь 32-bit аргументов. Оно выбирает allocation размером `0x668` при
mode `9` и `0x670` для другого mode, создаёт внутренний reader через virtual
dispatch с selector `10`, переносит несколько caller strings в локальные
buffers и передаёт собранную settings-структуру дальше через virtual slot
`+0x08` с key `0x80000020`. Это доказывает loader/configuration boundary и
два layout variants, но не даёт права назвать поля скоростью или acceleration.
`LoadPhysicalModel` аналогично создаёт `0xa0`-byte reader и возвращается
`ret 0x0c`; `CreateCollManager` и `CreateCollObject` возвращают interface
pointer с поправкой `+4` после внутренней инициализации.
Headless Ghidra 12.1.2 decompile GOG binary подтверждает ABI формой экспортов
`LoadControlSystem(char*, char*, char*, char*, char*, u32, void*, i32)`,
`LoadPhysicalModel(u32, u32, u32)`, `CreateCollManager(u32)` и
`CreateCollObject(u32, u32)`. Первые пять параметров Control loader — строки,
а mode передаётся последним; decompiler не восстанавливает предметные имена
остальных слов. `InitializeSettings` получает `CreateGameSettings()` из
World3D и делает virtual call slot `+0x24` с literal `0x15` и строкой по RVA
`0x42478`. Reproducible extractor находится в
`tools/ghidra/ExportControlFunctions.java`; он декомпилирует только эти exports
в локальном Ghidra project и не изменяет оригинальную DLL.
Именно update methods этих private objects, а не пять exports, остаются
следующим объектом динамической трассировки. Поэтому reference movement в
новом runtime намеренно не использует неподтверждённые параметры Control.
### Связь с AniMesh
PE import table GOG `AniMesh.dll` показывает, что это прямой consumer
`CreateCollManager`, `CreateCollObject` и `LoadControlSystem`; других DLL,
которые статически импортируют эти три named exports, не найдено. Внутренний
AniMesh path по VA `0x100032e7` вызывает Control thunks в строгой наблюдаемой
последовательности:
```text
LoadControlSystem (thunk 0x1001934e)
-> CreateCollObject (thunk 0x10019348)
-> CreateCollManager (thunk 0x10019342)
```
После factory calls caller immediately берёт returned interface vtable и
делает дальнейшие virtual calls; это связывает Control с загрузкой
AniMesh/unit components, а не с одной глобальной настройкой процесса. В
частности, `CreateCollObject` получает два stack arguments, а
`CreateCollManager` — один. Предметные значения этих arguments, ownership
private objects и per-tick update slots пока не восстановлены; порядок
создания не доказывает скорость, collision algorithm или AI semantics.
Headless Ghidra call-site decompile уточняет configuration provenance:
AniMesh передаёт в `LoadControlSystem` шесть 32-byte strings из одного
configuration block по offsets `+0x80`, `+0xa0`, `+0xc0`, `+0xe0`, `+0x100` и
`+0x120`, затем resource context и mode из owner `+0x6d8`. Returned Control
interface сразу получает пять virtual calls, связывающих его с owner slots
`+0x158`, `+0x160`, `+0x164`, `+0x168` и `+0x18c`; collision object затем
связывается с `+0x170`. Это достаточное основание хранить будущий Control
component как ordered raw-string/resource provenance, но не для присвоения
этим строкам смысловых имён до трассировки private update methods. Extractor:
`tools/ghidra/ExportAniMeshControlCaller.java`.
Runtime сохраняет ordered raw Unit DAT records рядом с каждым mission object
draft. Это создаёт проверяемую границу передачи данных от loader-а к будущему
Control consumer-у: никакой компонент пока не получает имя `Control` только
по `kind`, `parent_or_link` или description; semantic binding появится лишь
после trace private update/load methods.
### Control system и physical model
`LoadControlSystem` загружает настройки controller-а: ограничения скорости,
@@ -290,6 +423,46 @@ Control получает world-interface Terrain и использует пов
последовательности, а интеграция использует одну политику `dt` и округления.
Иначе одинаковая миссия постепенно расходится даже без сети.
#### Reference controller в текущем runtime
`fparkan-runtime::advance_reference_movement` — намеренно маленький
детерминированный мост между сохранённым mission transform и `TerrainWorld`.
Он получает `OriginalObjectId`, явную XY-цель и положительный максимум шага,
находит только live/registered object по исходному ID, двигает XY не более чем
на этот шаг и записывает высоту из `TerrainWorld::height_at`. Orientation и
scale остаются исходными IEEE-754 words; функция не изменяет clock, очередь
World3D или animation state. Возвращаемое значение означает достижение именно
заданной XY-цели.
Это **не** восстановленный Control, Behavior или navigation controller:
функция не строит маршрут, не использует `dt`, скорость, terrain normal,
коллизии, acceleration и оригинальные AI decisions. Она существует как
проверяемый reference path, который фиксирует границу будущих controller-ов и
не позволяет renderer-у или gameplay обходить terrain query. Non-finite input,
неположительный шаг, отсутствующая миссия/объект и XY вне поверхности дают
явную ошибку без частичного изменения transform.
Licensed test на GOG `Autodemo.00` запускает этот путь для live mission object,
находит существующую поверхность и проверяет точные XY/Z words после snap. Это
доказывает связывание current runtime data, но не доказывает семантику
оригинального движения.
Путь доступен и через самостоятельный composition root:
```powershell
fparkan-headless --root "C:\GOG Games\Parkan - Iron Strategy" `
--mission MISSIONS/Autodemo.00/data.tma `
--move-object 0 419.10318 717.433 0.25 --ticks 1
```
`--move-object` принимает original object ID, target X/Y и maximum step;
требует `--root` и `--mission`, допускается один раз за запуск и отвергает
non-finite/неположительный шаг ещё при разборе аргументов. Приложение печатает
`reached`, затем normal headless tick/hash. Проверка на GOG 18 июля 2026 года
загрузила 8 objects, 343 areals и 3 174 terrain surfaces без graph failures;
команда для объекта `0` вернула `reached=false`, что подтверждает именно
ограниченный шаг, а не телепортацию к цели.
### Различия Control в Части 2
`Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals
+23
View File
@@ -374,6 +374,29 @@ Demo mission total: 201 objects -> 501 prototypes -> 501 object MSH/WEAR.
### Deterministic simulation replay
#### Mission transform state in the world contract
`fparkan-world` now carries a `TransformState` for every live object: the
three TMA position words, three orientation words and three scale words are
preserved as exact IEEE-754 bit patterns. This stores source identity before a
movement or physics controller interprets axes, units or Euler order.
`WorldSnapshot` publishes transforms in stable object-handle order and the
canonical SHA-256 state hash includes every transform word.
Mission loading assigns this state after construction and before registration.
A headless licensed GOG AutoDemo run on 2026-07-18 loaded eight objects, 343
areals and 3,174 terrain surfaces with zero graph failures, then completed two
deterministic ticks. This is the state foundation for a future route/movement
controller; it does not claim recovered velocity, collision or original
behavior-controller semantics.
The ordinary planning renderer now consumes this snapshot state before falling
back to a mission draft. Its current transform bridge applies the preserved
position and non-uniform scale only; raw orientation remains uninterpreted in
this backend-neutral path. A GOG AutoDemo planning run on 2026-07-18 completed
two ticks with eight objects, 66 draws and state hash
`a54855a4f47ffa380911228f295dd49a9a7b88d6ff271a23db48ba318b1fbbb4`.
Записывается начальная миссия, seed, input events, network messages и значения
внешних часов. На контрольных ticks сохраняется canonical state hash:
+1
View File
@@ -35,6 +35,7 @@ schema = 1
"4" = [
"fparkan-animation",
"fparkan-fx",
"fparkan-script",
]
"5" = [
"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();
}
}