137 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
Valentin Popov 0506a45d57 feat(render): retain raw camera poses 2026-07-18 14:03:18 +04:00
Valentin Popov 50a55461e7 docs(evidence): recover camera transform adjustment 2026-07-18 13:59:48 +04:00
Valentin Popov 59d810e5e7 docs(evidence): trace autoplay camera switches 2026-07-18 13:55:17 +04:00
Valentin Popov 1bd50662b1 docs(evidence): confirm live camera global relocation 2026-07-18 13:49:15 +04:00
Valentin Popov 4ea4be8cad docs(evidence): record blocked live camera probe 2026-07-18 13:38:49 +04:00
Valentin Popov 3d9a324954 docs(evidence): recover buffering camera projection path 2026-07-18 13:34:05 +04:00
Valentin Popov 564edf0bc1 feat(runtime): trace visual cache sizes 2026-07-18 13:28:27 +04:00
Valentin Popov 2474e48020 feat(runtime): trace visual graph sizes 2026-07-18 13:24:00 +04:00
Valentin Popov 2e14b1d5dd perf(assets): cache visual wear validation 2026-07-18 13:17:06 +04:00
Valentin Popov 67308d4019 docs(render): profile part2 visual graph timing 2026-07-18 13:10:46 +04:00
Valentin Popov d3e8747c18 feat(game): timestamp load progress trace 2026-07-18 13:06:44 +04:00
Valentin Popov 9fd11dc11c docs(render): record complete part2 load trace 2026-07-18 13:03:08 +04:00
Valentin Popov 2048b3695d docs(evidence): locate current camera global 2026-07-18 12:53:42 +04:00
Valentin Popov 36a1749179 docs(render): record failed demo capture 2026-07-18 12:47:41 +04:00
Valentin Popov a3b19b4349 feat(game): retain load progress milestones 2026-07-18 12:46:00 +04:00
Valentin Popov b744a9d880 feat(runtime): trace material validation requests
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 4m14s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 12:31:30 +04:00
Valentin Popov f4fd8f4fce perf(assets): cache visual graph anchors
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Test / Lint (push) Failing after 3m24s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 12:24:35 +04:00
Valentin Popov bcf1ce9971 feat(runtime): trace visual dependency requests
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 3m0s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 12:16:45 +04:00
Valentin Popov 6b4fefc21f perf(resource): avoid copying nres payloads
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m50s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 12:03:56 +04:00
Valentin Popov f952593e27 chore(toolchain): limit installed targets to windows
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m15s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:55:16 +04:00
Valentin Popov e166b3afff perf(runtime): retain more decoded payloads
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Test / Lint (push) Failing after 2m33s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:49:55 +04:00
Valentin Popov 781241742d feat(runtime): trace visual graph phases
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Test / Lint (push) Failing after 2m37s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:43:33 +04:00
Valentin Popov 66b224a65c docs(render): record part2 graph timeout
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m49s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:37:26 +04:00
Valentin Popov 3684397dae docs(render): record part1 xy vulkan probe
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m13s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:33:11 +04:00
Valentin Popov 29daf8720c docs(render): record corrected xy vulkan probe
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m24s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:29:22 +04:00
Valentin Popov 04a7bfabd0 fix(assets): satisfy material cache lint
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m16s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:26:19 +04:00
Valentin Popov ceb7ea1fab perf(assets): cache graph material validation
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m9s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:24:31 +04:00
Valentin Popov 421c9d7aa3 feat(runtime): split model asset load checkpoints
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m17s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:20:13 +04:00
Valentin Popov c55d835d35 feat(runtime): trace asset preparation phases
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m3s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:15:43 +04:00
Valentin Popov 8f2e9d9308 perf(assets): cache prepared model dependencies
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Test / Lint (push) Failing after 2m18s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:10:47 +04:00
Valentin Popov 2cef0c62b9 fix(assets): retain texture resolver test helpers
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m21s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:07:12 +04:00
Valentin Popov 2a85ae5121 perf(assets): cache graph texm validation
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m19s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:06:38 +04:00
Valentin Popov 2969a41bf5 feat(runtime): split visual graph load phase
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Test / Lint (push) Failing after 2m11s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 11:02:46 +04:00
Valentin Popov 6b007d658f fix(terrain): use xy ground coordinates
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
Test / Lint (push) Failing after 2m10s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 10:57:21 +04:00
Valentin Popov 265d118387 fix(render): project static scene on xy plane
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
Test / Lint (push) Failing after 2m4s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 10:53:03 +04:00
Valentin Popov ea02915695 feat(cli): inspect mission transforms
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m12s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 10:41:39 +04:00
Valentin Popov 5df5814488 feat(game): preview multiple mission roots
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m6s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 10:36:29 +04:00
Valentin Popov 6e077f6c9d docs(render): recover camera reflection factory ABI
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m11s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 10:30:21 +04:00
Valentin Popov 8ac6a11100 feat(render): compose terrain with preview root
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
Test / Lint (push) Failing after 2m6s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 10:21:43 +04:00
Valentin Popov 46851518fe docs(render): map camera selection flow
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Test / Lint (push) Failing after 2m13s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 10:12:18 +04:00
Valentin Popov f86b848af5 docs(render): trace GOG camera ownership
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Test / Lint (push) Failing after 2m7s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 10:08:56 +04:00
Valentin Popov 1b62c2c315 feat(game): merge preview root components
Docs Deploy / Build and Deploy MkDocs (push) Successful in 33s
Test / Lint (push) Failing after 2m4s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 10:06:34 +04:00
Valentin Popov 3633c91a9b docs(render): record part preview probes
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m6s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:59:27 +04:00
Valentin Popov c8dd5f989f feat(game): bind preview diffuse materials 2026-07-18 09:52:20 +04:00
95 changed files with 12281 additions and 451 deletions
Generated
+45 -17
View File
@@ -359,7 +359,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -400,9 +400,11 @@ dependencies = [
"fparkan-assets",
"fparkan-corpus",
"fparkan-inspection",
"fparkan-path",
"fparkan-prototype",
"fparkan-resource",
"fparkan-runtime",
"fparkan-script",
"fparkan-vfs",
"serde",
"serde_json",
@@ -446,13 +448,18 @@ name = "fparkan-game"
version = "0.1.0"
dependencies = [
"fparkan-assets",
"fparkan-inspection",
"fparkan-path",
"fparkan-platform",
"fparkan-platform-winit",
"fparkan-render",
"fparkan-render-vulkan",
"fparkan-runtime",
"fparkan-terrain",
"fparkan-vfs",
"fparkan-world",
"serde",
"serde_json",
"winit",
]
@@ -562,6 +569,7 @@ version = "0.1.0"
dependencies = [
"ash",
"ash-window",
"fparkan-animation",
"fparkan-binary",
"fparkan-msh",
"fparkan-platform",
@@ -600,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"
@@ -760,7 +777,7 @@ dependencies = [
"quote",
"rustc_version",
"simd_cesu8",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -788,7 +805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -932,7 +949,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -1180,7 +1197,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -1340,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",
@@ -1350,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",
@@ -1444,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"
@@ -1470,7 +1498,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -1481,7 +1509,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.119",
]
[[package]]
@@ -1624,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 без подтверждённого полного corpus run, текстур, 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
+13 -4
View File
@@ -40,7 +40,16 @@ mod swapchain_resources;
mod validation;
pub use self::asset_mesh::{
project_land_msh_to_static_mesh, project_msh_to_static_mesh, 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::{
probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request,
@@ -69,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;
@@ -495,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,
+2
View File
@@ -10,8 +10,10 @@ fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
fparkan-corpus = { path = "../../crates/fparkan-corpus", version = "0.1.0" }
fparkan-prototype = { path = "../../crates/fparkan-prototype", 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-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"
+568 -8
View File
@@ -21,23 +21,34 @@
#![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` command-line tools.
use fparkan_assets::extend_graph_report_with_visual_dependencies;
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;
use fparkan_inspection::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};
use fparkan_runtime::{
create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
};
use fparkan_vfs::DirectoryVfs;
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> {
@@ -56,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,
@@ -94,6 +107,87 @@ struct MissionGraphOutput {
failures: usize,
}
#[derive(Serialize)]
struct MissionInspectOutput {
schema_version: &'static str,
mission: String,
objects: Vec<MissionObjectInspectOutput>,
}
#[derive(Serialize)]
struct MissionObjectInspectOutput {
index: usize,
resource: String,
position: [f32; 3],
orientation_raw: [f32; 3],
scale: [f32; 3],
}
#[derive(Serialize)]
struct TerrainInspectOutput {
schema_version: &'static str,
path: String,
positions: usize,
min: [f32; 3],
max: [f32; 3],
faces: usize,
slots: usize,
material_tags: Vec<TerrainMaterialTagCount>,
shade_pairs: usize,
#[serde(skip_serializing_if = "Option::is_none")]
shade_lookup_key_min: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
shade_lookup_key_max: Option<u16>,
shade_batch_boundaries: usize,
}
#[derive(Serialize)]
struct TerrainMaterialTagCount {
tag: u16,
faces: usize,
}
#[derive(Serialize)]
struct WearInspectOutput {
schema_version: &'static str,
archive: String,
resource: String,
materials: usize,
lightmaps: usize,
#[serde(skip_serializing_if = "Option::is_none")]
first_material: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last_material: Option<String>,
}
#[derive(Serialize)]
struct ModelInspectOutput<'a> {
schema_version: &'static str,
archive: &'a str,
resource: &'a str,
streams: usize,
nodes: usize,
node_stride: usize,
slots: usize,
positions: usize,
indices: usize,
batches: usize,
#[serde(skip_serializing_if = "Option::is_none")]
animation_keys: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
animation_frame_count: Option<u32>,
node38: Vec<ModelNodeInspectOutput>,
}
#[derive(Serialize)]
struct ModelNodeInspectOutput {
index: usize,
parent_or_link_raw: u16,
anim_map_start: u16,
fallback_key: u16,
has_lod0_group0: bool,
}
#[derive(Serialize)]
struct GraphFailureOutput {
root_index: usize,
@@ -106,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);
@@ -154,10 +304,115 @@ fn run(args: &[String]) -> Result<(), String> {
let rest = strip_format_json(rest)?;
graph_mission(&rest)
}
[domain, command, rest @ ..] if domain == "mission" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_mission(&rest)
}
[domain, command, rest @ ..] if domain == "terrain" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_terrain(&rest)
}
[domain, command, rest @ ..] if domain == "wear" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_wear(&rest)
}
[domain, command, rest @ ..] if domain == "model" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_model(&rest)
}
[domain, command, rest @ ..] if domain == "script" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_script(&rest)
}
[domain, command, rest @ ..] if domain == "varset" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_varset(&rest)
}
_ => Err(usage()),
}
}
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
@@ -244,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,
@@ -310,6 +617,38 @@ fn graph_mission(args: &[String]) -> Result<(), String> {
Ok(())
}
fn inspect_mission(args: &[String]) -> Result<(), String> {
let root = parse_root_alias(args)?;
let mission = parse_required(args, &["--mission"], "--mission")?;
let mission_path = normalize_relative(mission.as_bytes(), PathPolicy::StrictLegacy)
.map_err(|err| err.to_string())?;
let vfs = DirectoryVfs::new(root);
let bytes = vfs.read(&mission_path).map_err(|err| err.to_string())?;
let document =
decode_mission_payload(bytes, TmaProfile::Strict).map_err(|err| err.to_string())?;
let objects = document
.objects
.iter()
.enumerate()
.map(|(index, object)| MissionObjectInspectOutput {
index,
resource: String::from_utf8_lossy(&object.resource_name.raw).into_owned(),
position: object.position,
orientation_raw: object.orientation,
scale: object.scale,
})
.collect();
println!(
"{}",
serialize_json(&MissionInspectOutput {
schema_version: MISSION_INSPECT_SCHEMA,
mission,
objects,
})?
);
Ok(())
}
fn inspect_archive(args: &[String]) -> Result<(), String> {
let path = parse_archive_path(args)?;
let inspection = inspect_archive_file(&path, 0).map_err(|err| err.clone())?;
@@ -344,6 +683,112 @@ fn inspect_archive(args: &[String]) -> Result<(), String> {
}
}
fn inspect_terrain(args: &[String]) -> Result<(), String> {
let path = parse_file_path(args, "terrain inspect")?;
let bounds = inspect_land_msh_bounds_file(&path)?;
let terrain = load_land_msh_from_path(&path)?;
let mut material_tags = terrain
.faces
.iter()
.fold(std::collections::BTreeMap::new(), |mut counts, face| {
*counts.entry(face.material_tag).or_insert(0usize) += 1;
counts
})
.into_iter()
.map(|(tag, faces)| TerrainMaterialTagCount { tag, faces })
.collect::<Vec<_>>();
material_tags.sort_unstable_by_key(|entry| entry.tag);
let shade_pairs = (0..terrain.slots.slots_raw.len())
.filter_map(|slot_index| terrain.slot_material_pairs(slot_index))
.flatten()
.collect::<Vec<_>>();
let shade_lookup_key_min = shade_pairs.iter().map(|pair| pair.shade_lookup_key()).min();
let shade_lookup_key_max = shade_pairs.iter().map(|pair| pair.shade_lookup_key()).max();
let shade_batch_boundaries = shade_pairs
.iter()
.filter(|pair| pair.flags & 0x0010 != 0)
.count();
println!(
"{}",
serialize_json(&TerrainInspectOutput {
schema_version: TERRAIN_INSPECT_SCHEMA,
path: path.display().to_string(),
positions: bounds.positions,
min: bounds.min,
max: bounds.max,
faces: terrain.faces.len(),
slots: terrain.slots.slots_raw.len(),
material_tags,
shade_pairs: shade_pairs.len(),
shade_lookup_key_min,
shade_lookup_key_max,
shade_batch_boundaries,
})?
);
Ok(())
}
fn inspect_wear(args: &[String]) -> Result<(), String> {
let root = parse_root_alias(args)?;
let archive = parse_required(args, &["--archive"], "--archive")?;
let resource = parse_required(args, &["--resource"], "--resource")?;
let inspection = inspect_wear_from_root(&root, &archive, &resource)?;
println!(
"{}",
serialize_json(&WearInspectOutput {
schema_version: WEAR_INSPECT_SCHEMA,
archive,
resource,
materials: inspection.materials,
lightmaps: inspection.lightmaps,
first_material: inspection.first_material,
last_material: inspection.last_material,
})?
);
Ok(())
}
fn inspect_model(args: &[String]) -> Result<(), String> {
let root = parse_root_alias(args)?;
let archive = parse_required(args, &["--archive"], "--archive")?;
let resource = parse_required(args, &["--resource"], "--resource")?;
let inspection = inspect_model_from_root(&root, &archive, &resource)?;
println!("{}", model_inspect_json(&archive, &resource, &inspection)?);
Ok(())
}
fn model_inspect_json(
archive: &str,
resource: &str,
inspection: &ModelInspection,
) -> Result<String, String> {
serialize_json(&ModelInspectOutput {
schema_version: MODEL_INSPECT_SCHEMA,
archive,
resource,
streams: inspection.streams,
nodes: inspection.nodes,
node_stride: inspection.node_stride,
slots: inspection.slots,
positions: inspection.positions,
indices: inspection.indices,
batches: inspection.batches,
animation_keys: inspection.animation_keys,
animation_frame_count: inspection.animation_frame_count,
node38: inspection
.node38
.iter()
.map(|node| ModelNodeInspectOutput {
index: node.index,
parent_or_link_raw: node.parent_or_link_raw,
anim_map_start: node.anim_map_start,
fallback_key: node.fallback_key,
has_lod0_group0: node.has_lod0_group0,
})
.collect(),
})
}
fn archive_inspect_json(
path: &str,
kind: &str,
@@ -360,10 +805,14 @@ fn archive_inspect_json(
}
fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> {
parse_file_path(args, "archive inspect")
}
fn parse_file_path(args: &[String], command: &str) -> Result<PathBuf, String> {
match args {
[path] => Ok(PathBuf::from(path)),
[flag, path] if flag == "--file" => Ok(PathBuf::from(path)),
_ => Err("archive inspect requires <file> or --file <file>".to_string()),
_ => Err(format!("{command} requires <file> or --file <file>")),
}
}
@@ -390,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",
@@ -416,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] | prototype inspect --root <path> --key <key> [--format json] | mission graph --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)]
@@ -445,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))
@@ -472,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\":[]}"
);
}
@@ -507,4 +1000,71 @@ mod tests {
"{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":\"MISSIONS/Autodemo.00/data.tma\",\"objects\":2,\"paths\":3,\"clans\":4,\"extras\":5,\"roots\":6,\"node_count\":7,\"edge_count\":8,\"direct_references\":9,\"unit_references\":10,\"unit_components\":11,\"prototype_requests\":12,\"wear_requests\":13,\"wear\":14,\"materials\":15,\"textures\":16,\"lightmaps\":17,\"is_success\":true,\"failures\":0}"
);
}
#[test]
fn mission_inspect_output_retains_raw_transform_fields() {
let json = serialize_json(&MissionInspectOutput {
schema_version: MISSION_INSPECT_SCHEMA,
mission: "MISSIONS/test/data.tma".to_string(),
objects: vec![MissionObjectInspectOutput {
index: 1,
resource: "unit.dat".to_string(),
position: [1.0, 2.0, 3.0],
orientation_raw: [4.0, 5.0, 6.0],
scale: [7.0, 8.0, 9.0],
}],
})
.expect("serialize mission inspection");
assert_eq!(
json,
"{\"schema_version\":\"fparkan-mission-inspect-v1\",\"mission\":\"MISSIONS/test/data.tma\",\"objects\":[{\"index\":1,\"resource\":\"unit.dat\",\"position\":[1.0,2.0,3.0],\"orientation_raw\":[4.0,5.0,6.0],\"scale\":[7.0,8.0,9.0]}]}"
);
}
#[test]
fn terrain_inspect_output_retains_axis_bounds() {
let json = serialize_json(&TerrainInspectOutput {
schema_version: TERRAIN_INSPECT_SCHEMA,
path: "DATA/MAPS/AutoMAP/Land.msh".to_string(),
positions: 3,
min: [-1.0, -2.0, -3.0],
max: [4.0, 5.0, 6.0],
faces: 2,
slots: 4,
material_tags: vec![
TerrainMaterialTagCount { tag: 0, faces: 1 },
TerrainMaterialTagCount { tag: 3, faces: 1 },
],
shade_pairs: 2,
shade_lookup_key_min: Some(1),
shade_lookup_key_max: Some(2),
shade_batch_boundaries: 1,
})
.expect("serialize terrain inspection");
assert_eq!(
json,
"{\"schema_version\":\"fparkan-terrain-inspect-v1\",\"path\":\"DATA/MAPS/AutoMAP/Land.msh\",\"positions\":3,\"min\":[-1.0,-2.0,-3.0],\"max\":[4.0,5.0,6.0],\"faces\":2,\"slots\":4,\"material_tags\":[{\"tag\":0,\"faces\":1},{\"tag\":3,\"faces\":1}],\"shade_pairs\":2,\"shade_lookup_key_min\":1,\"shade_lookup_key_max\":2,\"shade_batch_boundaries\":1}"
);
}
#[test]
fn wear_inspect_output_retains_material_bounds() {
let json = serialize_json(&WearInspectOutput {
schema_version: WEAR_INSPECT_SCHEMA,
archive: "system.rlb".to_string(),
resource: "SHADE.WEA".to_string(),
materials: 1,
lightmaps: 0,
first_material: Some("LIGHT1".to_string()),
last_material: Some("LIGHT1".to_string()),
})
.expect("serialize wear inspection");
assert_eq!(
json,
"{\"schema_version\":\"fparkan-wear-inspect-v1\",\"archive\":\"system.rlb\",\"resource\":\"SHADE.WEA\",\"materials\":1,\"lightmaps\":0,\"first_material\":\"LIGHT1\",\"last_material\":\"LIGHT1\"}"
);
}
}
+5
View File
@@ -7,13 +7,18 @@ 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" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version = "0.1.0" }
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 {
+587 -27
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};
@@ -39,7 +39,7 @@ use fparkan_resource::{ResourceError, ResourceKey, ResourceRepository};
pub use fparkan_terrain::{TerrainError, TerrainWorld};
use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh};
pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError};
use fparkan_texm::{decode_texm, TexmDocument, TexmError};
use fparkan_texm::{decode_mip_rgba8, decode_texm, RgbaImage, TexmDocument, TexmError};
use std::collections::{hash_map::Entry, HashMap, HashSet};
use std::fmt;
use std::hash::{Hash, Hasher};
@@ -48,6 +48,11 @@ use std::sync::Arc;
const TEXTURES_ARCHIVE: &str = "textures.lib";
const LIGHTMAP_ARCHIVE: &str = "lightmap.lib";
const VISUAL_DEPENDENCY_PROGRESS_INTERVAL: usize = 64;
type WearValidationCache =
HashMap<(NormalizedPath, Vec<u8>), Result<fparkan_material::WearTable, String>>;
type TextureValidationCache = HashMap<Vec<u8>, Result<(), String>>;
/// Canonical terrain archive paths derived from a mission land reference.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -58,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 {
@@ -116,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
@@ -307,6 +346,18 @@ pub struct PreparedTexture {
pub usage: PreparedTextureUsage,
}
impl PreparedTexture {
/// Decodes one prepared texture mip into the renderer upload representation.
///
/// # Errors
///
/// Returns the original TEXM decode error when the requested mip is invalid
/// or malformed.
pub fn decode_mip_rgba8(&self, level: u32) -> Result<RgbaImage, TexmError> {
decode_mip_rgba8(&self.texm, level)
}
}
impl PreparedVisual {
/// Returns the primary material id, if any.
#[must_use]
@@ -443,6 +494,56 @@ pub struct AssetPreparationReport {
pub texture_pixels: u64,
}
/// Asset preparation checkpoint emitted while resolving visual dependencies.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AssetPreparationPhase {
/// Decode and validate an MSH model.
ModelMesh,
/// Decode the corresponding WEAR table.
WearTable,
/// Resolve MAT0 material documents referenced by a WEAR table.
Materials,
/// Decode diffuse textures and baked lightmaps.
Textures,
}
/// Visual dependency class currently being expanded into a prototype graph.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VisualDependencyPhase {
/// Resolve a mesh's WEAR table.
Wear,
/// Resolve MAT0 material documents selected by WEAR.
Material,
/// Validate diffuse TEXM textures and lightmaps.
Texture,
}
/// Validation-cache entries retained while expanding visual dependencies.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct VisualDependencyCacheCounts {
/// Distinct WEAR archive/name keys resolved so far.
pub wear_entries: usize,
/// Distinct MAT0 raw names resolved so far.
pub material_entries: usize,
/// Distinct TEXM archive/name keys resolved so far.
pub texture_entries: usize,
}
/// Throttled visual-dependency expansion progress.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VisualDependencyProgress {
/// Dependency class currently being expanded.
pub phase: VisualDependencyPhase,
/// Number of requests reached in this class during the current expansion.
pub request_count: usize,
/// Resource graph nodes materialized before this request is resolved.
pub graph_node_count: usize,
/// Resource graph edges materialized before this request is resolved.
pub graph_edge_count: usize,
/// Validation-cache entries retained before this request is resolved.
pub cache_entries: VisualDependencyCacheCounts,
}
/// Errors raised while preparing CPU-side assets.
#[derive(Debug)]
pub enum AssetError {
@@ -559,6 +660,25 @@ impl<R: ResourceRepository> AssetManager<R> {
prepare_mission_assets_with_repository(&self.repository, root_prototype_spans, prototypes)
}
/// Builds mission assets and reports the active dependency class.
///
/// # Errors
///
/// Returns [`AssetError`] if any visual dependency is missing or malformed.
pub fn prepare_mission_assets_with_progress(
&self,
root_prototype_spans: &[std::ops::Range<usize>],
prototypes: &[EffectivePrototype],
on_phase: impl FnMut(AssetPreparationPhase),
) -> Result<MissionAssets, AssetError> {
prepare_mission_assets_with_repository_with_progress(
&self.repository,
root_prototype_spans,
prototypes,
on_phase,
)
}
/// Builds mission assets together with a bounded preparation report.
///
/// # Errors
@@ -644,6 +764,28 @@ pub fn prepare_mission_assets_with_repository<R: ResourceRepository>(
.0)
}
/// Builds mission assets and reports the active dependency class.
///
/// # Errors
///
/// Returns [`AssetError`] if any visual dependency is missing or malformed.
pub fn prepare_mission_assets_with_repository_with_progress<R: ResourceRepository>(
repository: &R,
root_prototype_spans: &[std::ops::Range<usize>],
prototypes: &[EffectivePrototype],
mut on_phase: impl FnMut(AssetPreparationPhase),
) -> Result<MissionAssets, AssetError> {
Ok(prepare_mission_assets_with_repository_internal(
repository,
root_prototype_spans,
prototypes,
AssetIdentityPolicy::default(),
AssetPreparationLimits::default(),
&mut on_phase,
)?
.0)
}
/// Builds mission assets while enforcing explicit preparation limits.
///
/// # Errors
@@ -656,12 +798,14 @@ pub fn prepare_mission_assets_profiled_with_repository<R: ResourceRepository>(
prototypes: &[EffectivePrototype],
limits: AssetPreparationLimits,
) -> Result<(MissionAssets, AssetPreparationReport), AssetError> {
let mut ignore_phase = |_| {};
prepare_mission_assets_with_repository_internal(
repository,
root_prototype_spans,
prototypes,
AssetIdentityPolicy::default(),
limits,
&mut ignore_phase,
)
}
@@ -674,6 +818,7 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>(
prototypes: &[EffectivePrototype],
identity_policy: AssetIdentityPolicy,
limits: AssetPreparationLimits,
on_phase: &mut dyn FnMut(AssetPreparationPhase),
) -> Result<(MissionAssets, AssetPreparationReport), AssetError> {
if prototypes.is_empty() {
return Ok((MissionAssets::default(), AssetPreparationReport::default()));
@@ -713,6 +858,7 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>(
proto,
identity_policy,
&mut preparation_cache,
on_phase,
)?;
if bundle.visual.id != visual_id {
// Defensive check. stable IDs are deterministic for the same inputs.
@@ -1033,6 +1179,62 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
report: &mut PrototypeGraphReport,
graph: &mut PrototypeGraph,
prototypes: &[EffectivePrototype],
) {
extend_graph_report_with_visual_dependencies_and_progress(
repository,
report,
graph,
prototypes,
|_| {},
);
}
/// Extends a prototype dependency report while reporting each visual dependency class.
///
/// # Panics
///
/// Panics only if the hard-coded, host-compatible `material.lib` path stops
/// satisfying the path policy, which indicates a programming error in this module.
#[allow(clippy::too_many_lines)]
pub fn extend_graph_report_with_visual_dependencies_and_progress<R: ResourceRepository>(
repository: &R,
report: &mut PrototypeGraphReport,
graph: &mut PrototypeGraph,
prototypes: &[EffectivePrototype],
mut on_phase: impl FnMut(VisualDependencyPhase),
) {
let mut last_phase = None;
extend_graph_report_with_visual_dependencies_with_progress(
repository,
report,
graph,
prototypes,
|progress| {
if last_phase != Some(progress.phase) {
on_phase(progress.phase);
last_phase = Some(progress.phase);
}
},
);
}
/// Extends a prototype dependency report with throttled request progress.
///
/// The callback receives each dependency-class entrance, its first request,
/// and every subsequent multiple of 64 requests. Graph traversal, edge order
/// and validation remain unchanged.
///
/// # Panics
///
/// Panics only if the hard-coded, host-compatible `material.lib` path stops
/// satisfying the path policy, which indicates a programming error in this module.
#[allow(clippy::too_many_lines)]
pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRepository>(
repository: &R,
report: &mut PrototypeGraphReport,
graph: &mut PrototypeGraph,
prototypes: &[EffectivePrototype],
mut on_progress: impl FnMut(VisualDependencyProgress),
) {
if graph.visual_dependencies_expanded {
return;
@@ -1052,22 +1254,57 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
.map(|edge| edge.id.0)
.max()
.map_or(0, |value| value.saturating_add(1));
let mut diffuse_texture_validation = HashMap::new();
let mut lightmap_texture_validation = HashMap::new();
let mut material_validation = HashMap::new();
let mut wear_validation = HashMap::new();
let mut wear_requests = 0usize;
let mut material_requests = 0usize;
let mut texture_requests = 0usize;
// Capture all base-graph anchors before this traversal appends visual
// resources and edges. Looking them up again from the growing graph would
// make each later prototype scan an ever larger collection.
let prototype_mesh_anchors: Vec<_> = (0..prototypes.len())
.map(|prototype_index| {
let prototype_node_id = prototype_node_id(graph, prototype_index)?;
let mesh_node_id = prototype_mesh_node_id(graph, prototype_node_id)?;
Some((mesh_node_id, mesh_edge_id(graph, prototype_node_id)))
})
.collect();
for (prototype_index, prototype) in prototypes.iter().enumerate() {
let PrototypeGeometry::Mesh(mesh) = &prototype.geometry else {
continue;
};
report.wear_request_count += 1;
let Some(prototype_node_id) = prototype_node_id(graph, prototype_index) else {
let Some((mesh_node_id, mesh_parent_edge)) = prototype_mesh_anchors
.get(prototype_index)
.copied()
.flatten()
else {
continue;
};
let Some(mesh_node_id) = prototype_mesh_node_id(graph, prototype_node_id) else {
continue;
};
let mesh_parent_edge = mesh_edge_id(graph, prototype_node_id);
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));
match resolve_wear_table(repository, mesh) {
wear_requests = wear_requests.saturating_add(1);
report_visual_dependency_progress(
&mut on_progress,
VisualDependencyProgress {
phase: VisualDependencyPhase::Wear,
request_count: wear_requests,
graph_node_count: graph.nodes.len(),
graph_edge_count: graph.edges.len(),
cache_entries: visual_dependency_cache_counts(
&wear_validation,
&material_validation,
&diffuse_texture_validation,
&lightmap_texture_validation,
),
},
);
match resolve_wear_table_cached(repository, mesh, &mut wear_validation) {
Ok(table) => {
report.wear_resolved_count += 1;
let wear_key = match wear_resource_key(mesh) {
@@ -1100,12 +1337,41 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
Some(provenance_for_resource(
root_index,
mesh_parent_edge,
unit_component_index,
&wear_key,
)),
&mut next_edge,
);
report.material_slot_count += table.entries.len();
on_progress(VisualDependencyProgress {
phase: VisualDependencyPhase::Material,
request_count: material_requests,
graph_node_count: graph.nodes.len(),
graph_edge_count: graph.edges.len(),
cache_entries: visual_dependency_cache_counts(
&wear_validation,
&material_validation,
&diffuse_texture_validation,
&lightmap_texture_validation,
),
});
for (material_index, _entry) in table.entries.iter().enumerate() {
material_requests = material_requests.saturating_add(1);
report_visual_dependency_progress(
&mut on_progress,
VisualDependencyProgress {
phase: VisualDependencyPhase::Material,
request_count: material_requests,
graph_node_count: graph.nodes.len(),
graph_edge_count: graph.edges.len(),
cache_entries: visual_dependency_cache_counts(
&wear_validation,
&material_validation,
&diffuse_texture_validation,
&lightmap_texture_validation,
),
},
);
let Ok(material_index) = u16::try_from(material_index) else {
push_visual_failure(
report,
@@ -1118,7 +1384,12 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
);
continue;
};
match resolve_material(repository, &table, material_index) {
match resolve_material_validation_cached(
repository,
&table,
material_index,
&mut material_validation,
) {
Ok(material) => {
report.material_resolved_count += 1;
let material_key = ResourceKey {
@@ -1141,13 +1412,36 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
Some(provenance_for_resource(
root_index,
Some(wear_edge_id),
unit_component_index,
&material_key,
)),
&mut next_edge,
);
for texture in material.document.texture_requests() {
texture_requests = texture_requests.saturating_add(1);
report_visual_dependency_progress(
&mut on_progress,
VisualDependencyProgress {
phase: VisualDependencyPhase::Texture,
request_count: texture_requests,
graph_node_count: graph.nodes.len(),
graph_edge_count: graph.edges.len(),
cache_entries: visual_dependency_cache_counts(
&wear_validation,
&material_validation,
&diffuse_texture_validation,
&lightmap_texture_validation,
),
},
);
report.texture_request_count += 1;
match resolve_texture(repository, &texture) {
match resolve_texm_validation_cached(
repository,
&texture,
TEXTURES_ARCHIVE,
"texture",
&mut diffuse_texture_validation,
) {
Ok(()) => {
report.texture_resolved_count += 1;
if let Ok(texture_key) =
@@ -1168,6 +1462,7 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
Some(provenance_for_resource(
root_index,
Some(material_edge_id),
unit_component_index,
&texture_key,
)),
&mut next_edge,
@@ -1175,7 +1470,6 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
}
}
Err(message) => {
let message = message.to_string();
push_visual_failure(
report,
graph,
@@ -1196,13 +1490,35 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
mesh.name.0.clone(),
PrototypeGraphEdge::WearToMaterial,
PrototypeGraphRequiredness::Required,
&message.to_string(),
&message.clone(),
),
}
}
for lightmap in &table.lightmaps {
texture_requests = texture_requests.saturating_add(1);
report_visual_dependency_progress(
&mut on_progress,
VisualDependencyProgress {
phase: VisualDependencyPhase::Texture,
request_count: texture_requests,
graph_node_count: graph.nodes.len(),
graph_edge_count: graph.edges.len(),
cache_entries: visual_dependency_cache_counts(
&wear_validation,
&material_validation,
&diffuse_texture_validation,
&lightmap_texture_validation,
),
},
);
report.lightmap_request_count += 1;
match resolve_lightmap(repository, &lightmap.lightmap) {
match resolve_texm_validation_cached(
repository,
&lightmap.lightmap,
LIGHTMAP_ARCHIVE,
"lightmap",
&mut lightmap_texture_validation,
) {
Ok(()) => {
report.lightmap_resolved_count += 1;
if let Ok(lightmap_key) =
@@ -1223,6 +1539,7 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
Some(provenance_for_resource(
root_index,
Some(wear_edge_id),
unit_component_index,
&lightmap_key,
)),
&mut next_edge,
@@ -1230,7 +1547,6 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
}
}
Err(message) => {
let message = message.to_string();
push_visual_failure(
report,
graph,
@@ -1251,13 +1567,41 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
mesh.name.0.clone(),
PrototypeGraphEdge::MeshToWear,
PrototypeGraphRequiredness::Required,
&message.to_string(),
&message,
),
}
}
graph.visual_dependencies_expanded = true;
}
fn report_visual_dependency_progress(
on_progress: &mut impl FnMut(VisualDependencyProgress),
progress: VisualDependencyProgress,
) {
if progress.request_count == 1
|| progress
.request_count
.is_multiple_of(VISUAL_DEPENDENCY_PROGRESS_INTERVAL)
{
on_progress(progress);
}
}
fn visual_dependency_cache_counts(
wear_validation: &WearValidationCache,
material_validation: &HashMap<Vec<u8>, Result<ResolvedMaterial, String>>,
diffuse_texture_validation: &TextureValidationCache,
lightmap_texture_validation: &TextureValidationCache,
) -> VisualDependencyCacheCounts {
VisualDependencyCacheCounts {
wear_entries: wear_validation.len(),
material_entries: material_validation.len(),
texture_entries: diffuse_texture_validation
.len()
.saturating_add(lightmap_texture_validation.len()),
}
}
fn push_graph_resource_node(
graph: &mut PrototypeGraph,
kind: PrototypeGraphNodeKind,
@@ -1318,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,
@@ -1392,6 +1738,7 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>(
proto,
AssetIdentityPolicy::default(),
&mut preparation_cache,
&mut |_| {},
)?
.visual)
}
@@ -1406,6 +1753,8 @@ struct PreparedVisualBundle {
#[derive(Default)]
struct PreparationCache {
models: Vec<(ResourceKey, ModelAsset)>,
wears: Vec<(ResourceKey, WearTable)>,
diffuse: HashMap<Vec<u8>, PreparedTexture>,
lightmaps: HashMap<Vec<u8>, PreparedTexture>,
materials: HashMap<Vec<u8>, ResolvedMaterial>,
@@ -1418,6 +1767,7 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
proto: &EffectivePrototype,
identity_policy: AssetIdentityPolicy,
preparation_cache: &mut PreparationCache,
on_phase: &mut dyn FnMut(AssetPreparationPhase),
) -> Result<PreparedVisualBundle, AssetError> {
let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else {
return Ok(PreparedVisualBundle {
@@ -1429,13 +1779,8 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
});
};
let nres = decode_nres(
read_key(repository, mesh_key, Some("mesh"))?,
ReadProfile::Compatible,
)
.map_err(AssetError::Nres)?;
let msh_document = decode_msh(&nres).map_err(AssetError::Msh)?;
let model = validate_msh(&msh_document).map_err(AssetError::Msh)?;
on_phase(AssetPreparationPhase::ModelMesh);
let model = prepare_model_cached(repository, mesh_key, preparation_cache)?;
let model_id = AssetId::new((identity_policy.model)(proto));
let prepared_model = PreparedModel {
id: model_id,
@@ -1450,8 +1795,8 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
name: wear_name,
type_id: Some(WEAR_KIND),
};
let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?)
.map_err(AssetError::Material)?;
on_phase(AssetPreparationPhase::WearTable);
let wear = prepare_wear_cached(repository, &wear_key, preparation_cache)?;
let wear_id = AssetId::new((identity_policy.wear)(proto));
let prepared_wear = PreparedWear {
id: wear_id,
@@ -1472,6 +1817,7 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
.iter()
.map(|lightmap| lightmap.lightmap.clone())
.collect();
on_phase(AssetPreparationPhase::Materials);
for material_index in 0..wear.entries.len() {
let material_index = u16::try_from(material_index).map_err(|_| {
AssetError::InvalidPrototype("material index does not fit archive format".to_string())
@@ -1501,6 +1847,7 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
});
for texture in texture_requests {
on_phase(AssetPreparationPhase::Textures);
let prepared_texture = prepare_texture_cached(
repository,
&texture,
@@ -1515,6 +1862,7 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
}
for lightmap in &wear.lightmaps {
on_phase(AssetPreparationPhase::Textures);
let prepared_lightmap = prepare_texture_cached(
repository,
&lightmap.lightmap,
@@ -1557,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)
@@ -1569,6 +1917,43 @@ fn read_key<R: ResourceRepository>(
Ok(Arc::from(bytes.into_owned()))
}
fn prepare_model_cached<R: ResourceRepository>(
repository: &R,
key: &ResourceKey,
cache: &mut PreparationCache,
) -> Result<ModelAsset, AssetError> {
if let Some((_, model)) = cache
.models
.iter()
.find(|(cached_key, _)| cached_key == key)
{
return Ok(model.clone());
}
let nres = decode_nres(
read_key(repository, key, Some("mesh"))?,
ReadProfile::Compatible,
)
.map_err(AssetError::Nres)?;
let document = decode_msh(&nres).map_err(AssetError::Msh)?;
let model = validate_msh(&document).map_err(AssetError::Msh)?;
cache.models.push((key.clone(), model.clone()));
Ok(model)
}
fn prepare_wear_cached<R: ResourceRepository>(
repository: &R,
key: &ResourceKey,
cache: &mut PreparationCache,
) -> Result<WearTable, AssetError> {
if let Some((_, wear)) = cache.wears.iter().find(|(cached_key, _)| cached_key == key) {
return Ok(wear.clone());
}
let wear =
decode_wear(&read_key(repository, key, Some("wear"))?).map_err(AssetError::Material)?;
cache.wears.push((key.clone(), wear.clone()));
Ok(wear)
}
fn map_resource_error(label: &str, key: &ResourceKey, source: ResourceError) -> AssetError {
AssetError::Resource {
context: format!(
@@ -1585,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
@@ -1641,6 +2026,21 @@ fn resolve_wear_table<R: ResourceRepository>(
decode_wear(&bytes).map_err(AssetError::Material)
}
fn resolve_wear_table_cached<R: ResourceRepository>(
repository: &R,
mesh: &ResourceKey,
cache: &mut WearValidationCache,
) -> Result<fparkan_material::WearTable, String> {
let wear_key = wear_resource_key(mesh).map_err(|error| error.to_string())?;
let cache_key = (wear_key.archive.clone(), wear_key.name.0.clone());
if let Some(result) = cache.get(&cache_key) {
return result.clone();
}
let result = resolve_wear_table(repository, mesh).map_err(|error| error.to_string());
cache.insert(cache_key, result.clone());
result
}
fn push_visual_failure(
report: &mut PrototypeGraphReport,
graph: &PrototypeGraph,
@@ -1652,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,
@@ -1662,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,
@@ -1678,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,
@@ -1764,6 +2179,47 @@ fn mesh_dependency_resource(
.and_then(|node| node.resource.as_ref())
}
fn resolve_texm_validation_cached<R: ResourceRepository>(
repository: &R,
name: &ResourceName,
archive: &str,
label: &'static str,
cache: &mut HashMap<Vec<u8>, Result<(), String>>,
) -> Result<(), String> {
if let Some(result) = cache.get(&name.0) {
return result.clone();
}
let result = resolve_texm(repository, name, archive, label).map_err(|error| error.to_string());
cache.insert(name.0.clone(), result.clone());
result
}
fn resolve_material_validation_cached<R: ResourceRepository>(
repository: &R,
table: &WearTable,
index: u16,
cache: &mut HashMap<Vec<u8>, Result<ResolvedMaterial, String>>,
) -> Result<ResolvedMaterial, String> {
let request = table
.entries
.get(usize::from(index))
.ok_or(MaterialError::WearIndexOutOfBounds {
index,
count: table.entries.len(),
})
.map_err(|error| error.to_string())?
.material
.0
.clone();
if let Some(result) = cache.get(&request) {
return result.clone();
}
let result = resolve_material(repository, table, index).map_err(|error| error.to_string());
cache.insert(request, result.clone());
result
}
#[cfg(test)]
fn resolve_texture<R: ResourceRepository>(
repository: &R,
name: &ResourceName,
@@ -1771,6 +2227,7 @@ fn resolve_texture<R: ResourceRepository>(
resolve_texm(repository, name, TEXTURES_ARCHIVE, "texture")
}
#[cfg(test)]
fn resolve_lightmap<R: ResourceRepository>(
repository: &R,
name: &ResourceName,
@@ -1871,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) => {
@@ -1993,6 +2450,86 @@ mod tests {
use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs};
use std::path::PathBuf;
#[test]
fn visual_dependency_progress_is_throttled_at_fixed_request_intervals() {
let mut progress = Vec::new();
for request_count in 1..=129 {
report_visual_dependency_progress(
&mut |event| progress.push(event),
VisualDependencyProgress {
phase: VisualDependencyPhase::Texture,
request_count,
graph_node_count: 37,
graph_edge_count: 41,
cache_entries: VisualDependencyCacheCounts {
wear_entries: 3,
material_entries: 5,
texture_entries: 7,
},
},
);
}
assert_eq!(
progress,
vec![
VisualDependencyProgress {
phase: VisualDependencyPhase::Texture,
request_count: 1,
graph_node_count: 37,
graph_edge_count: 41,
cache_entries: VisualDependencyCacheCounts {
wear_entries: 3,
material_entries: 5,
texture_entries: 7,
},
},
VisualDependencyProgress {
phase: VisualDependencyPhase::Texture,
request_count: 64,
graph_node_count: 37,
graph_edge_count: 41,
cache_entries: VisualDependencyCacheCounts {
wear_entries: 3,
material_entries: 5,
texture_entries: 7,
},
},
VisualDependencyProgress {
phase: VisualDependencyPhase::Texture,
request_count: 128,
graph_node_count: 37,
graph_edge_count: 41,
cache_entries: VisualDependencyCacheCounts {
wear_entries: 3,
material_entries: 5,
texture_entries: 7,
},
},
]
);
}
#[test]
fn wear_validation_cache_replays_archive_qualified_failure() {
let repository = repository_with_archives(&[]);
let mesh = ResourceKey {
archive: parse_path("static.rlb").expect("archive"),
name: resource_name(b"tree.msh"),
type_id: Some(0x4853_454D),
};
let mut cache = WearValidationCache::new();
cache.insert(
(mesh.archive.clone(), b"tree.wea".to_vec()),
Err("cached WEAR failure".to_string()),
);
assert_eq!(
resolve_wear_table_cached(&repository, &mesh, &mut cache),
Err("cached WEAR failure".to_string())
);
}
#[test]
fn count_only_plan_uses_graph_requests() {
let graph = PrototypeGraph::default();
@@ -2244,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,
@@ -2317,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]
@@ -2757,6 +3315,7 @@ mod tests {
&prototypes,
policy,
AssetPreparationLimits::default(),
&mut |_| {},
)
.expect_err("collision should fail");
@@ -3047,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![
+237 -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 {
@@ -142,6 +180,17 @@ pub struct MapInspection {
pub grid_height: u32,
}
/// Axis-aligned position bounds of a decoded `Land.msh`.
#[derive(Clone, Debug, PartialEq)]
pub struct LandMeshBoundsInspection {
/// Number of source positions covered by the bounds.
pub positions: usize,
/// Per-axis inclusive minimum position.
pub min: [f32; 3],
/// Per-axis inclusive maximum position.
pub max: [f32; 3],
}
/// Supported land file kinds.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LandFileKind {
@@ -301,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,
@@ -308,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()),
})
}
@@ -421,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
@@ -450,6 +591,46 @@ pub fn load_land_msh_from_path(path: &Path) -> Result<LandMeshDocument, String>
decode_land_msh(&document).map_err(|err| err.to_string())
}
/// Inspects the source-coordinate bounds of a standalone `Land.msh` file.
///
/// # Errors
///
/// Returns a string error when the file cannot be read, decoded, or contains
/// no finite source positions.
pub fn inspect_land_msh_bounds_file(path: &Path) -> Result<LandMeshBoundsInspection, String> {
let mesh = load_land_msh_from_path(path)?;
inspect_land_msh_bounds(&mesh)
}
/// Computes source-coordinate bounds for an already validated `Land.msh`.
///
/// # Errors
///
/// Returns a string error when the mesh has no finite source positions.
pub fn inspect_land_msh_bounds(
mesh: &LandMeshDocument,
) -> Result<LandMeshBoundsInspection, String> {
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
for position in &mesh.positions {
if !position.iter().all(|value| value.is_finite()) {
return Err("Land.msh contains a non-finite source position".to_string());
}
for axis in 0..3 {
min[axis] = min[axis].min(position[axis]);
max[axis] = max[axis].max(position[axis]);
}
}
if mesh.positions.is_empty() {
return Err("Land.msh contains no source positions".to_string());
}
Ok(LandMeshBoundsInspection {
positions: mesh.positions.len(),
min,
max,
})
}
fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> {
let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?;
Ok(MapInspection {
@@ -599,6 +780,7 @@ fn resource_parse_diagnostic(
#[cfg(test)]
mod tests {
use super::*;
use fparkan_terrain_format::TerrainSlotTable;
use std::io::Write as _;
use std::path::PathBuf;
@@ -709,6 +891,60 @@ 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 {
streams: Vec::new(),
nodes_raw: Vec::new(),
slots: TerrainSlotTable {
header_raw: Vec::new(),
slots_raw: Vec::new(),
},
positions: vec![[4.0, -2.0, 8.0], [-3.0, 6.0, 1.0]],
normals: Vec::new(),
uv0: Vec::new(),
accelerator: Vec::new(),
aux14: Vec::new(),
aux18: Vec::new(),
faces: Vec::new(),
};
let bounds = inspect_land_msh_bounds(&mesh).expect("bounds");
assert_eq!(bounds.positions, 2);
assert_eq!(bounds.min, [-3.0, -2.0, 1.0]);
assert_eq!(bounds.max, [4.0, 6.0, 8.0]);
}
fn temp_dir(name: &str) -> PathBuf {
let base = PathBuf::from("/tmp")
.join("fparkan-inspection-tests")
+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())
});
+19
View File
@@ -479,6 +479,23 @@ impl NresDocument {
Ok(&self.bytes[entry.data_range.clone()])
}
/// Returns an owned view of an entry payload without copying its bytes.
///
/// The returned owner is the immutable archive buffer. Consumers may retain
/// the view after dropping this document.
///
/// # Errors
///
/// Returns [`NresError::EntryIdOutOfRange`] when `id` is not present in
/// this document.
pub fn payload_view(&self, id: EntryId) -> Result<(Arc<[u8]>, Range<usize>), NresError> {
let entry = self.entry(id).ok_or_else(|| NresError::EntryIdOutOfRange {
id: id.0,
entry_count: saturating_u32_len(self.entries.len()),
})?;
Ok((Arc::clone(&self.bytes), entry.data_range.clone()))
}
/// Encodes the document according to the selected write profile.
#[must_use]
pub fn encode(&self, profile: WriteProfile) -> Vec<u8> {
@@ -1213,6 +1230,8 @@ mod tests {
assert_eq!(entry.data_range().end, HEADER_LEN + 1);
assert_eq!(doc.header().directory_offset % 8, 0);
assert_eq!(doc.payload(EntryId(0)).expect("payload"), b"x");
let (owner, range) = doc.payload_view(EntryId(0)).expect("payload view");
assert_eq!(&owner[range], b"x");
}
#[test]
+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);
+558
View File
@@ -22,6 +22,366 @@
use fparkan_world::OriginalObjectId;
/// A 64-byte transform block returned by the original Terrain camera ABI.
///
/// The original uses two selector-dependent transform pointers. Their exact
/// matrix convention is still under recovery, so words are deliberately kept
/// losslessly rather than treated as a renderer-ready matrix. The three
/// translation words have been confirmed at indices 3, 7, and 11.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RawCameraTransform {
/// The exact 16 little-endian dwords returned by the legacy camera.
pub words: [u32; 16],
}
impl RawCameraTransform {
/// Indices of the confirmed X, Y, and Z translation floats.
pub const TRANSLATION_WORD_INDICES: [usize; 3] = [3, 7, 11];
/// Returns the confirmed legacy world position (X, Y, Z).
#[must_use]
pub fn translation(self) -> [f32; 3] {
Self::TRANSLATION_WORD_INDICES.map(|index| f32::from_bits(self.words[index]))
}
/// Inverts a finite row-major affine transform without assigning it a
/// camera-space meaning.
///
/// The legacy SIMD dispatch multiplies these blocks as ordinary row-major
/// matrices and the confirmed camera samples have translation in the last
/// column. `Some` therefore means only that this block has a non-singular
/// affine inverse. Callers must still establish whether it is a
/// camera-to-world transform before using the result as a view matrix.
#[must_use]
pub fn try_inverse_affine_row_major(self) -> Option<[f32; 16]> {
let matrix = self.words.map(f32::from_bits);
if !matrix.iter().all(|value| value.is_finite())
|| matrix[12].abs() > f32::EPSILON
|| matrix[13].abs() > f32::EPSILON
|| matrix[14].abs() > f32::EPSILON
|| (matrix[15] - 1.0).abs() > f32::EPSILON
{
return None;
}
let [m00, m01, m02, _, m10, m11, m12, _, m20, m21, m22, _, _, _, _, _] = matrix;
let cofactor00 = m11.mul_add(m22, -(m12 * m21));
let cofactor01 = m02.mul_add(m21, -(m01 * m22));
let cofactor02 = m01.mul_add(m12, -(m02 * m11));
let determinant = m00.mul_add(cofactor00, m10.mul_add(cofactor01, m20 * cofactor02));
if !determinant.is_finite() || determinant == 0.0 {
return None;
}
let inverse_determinant = determinant.recip();
let inverse = [
cofactor00 * inverse_determinant,
m02.mul_add(m21, -(m01 * m22)) * inverse_determinant,
cofactor02 * inverse_determinant,
0.0,
m12.mul_add(m20, -(m10 * m22)) * inverse_determinant,
m00.mul_add(m22, -(m02 * m20)) * inverse_determinant,
m02.mul_add(m10, -(m00 * m12)) * inverse_determinant,
0.0,
m10.mul_add(m21, -(m11 * m20)) * inverse_determinant,
m01.mul_add(m20, -(m00 * m21)) * inverse_determinant,
m00.mul_add(m11, -(m01 * m10)) * inverse_determinant,
0.0,
0.0,
0.0,
0.0,
1.0,
];
let [translation_x, translation_y, translation_z] = self.translation();
let translation = [
-(inverse[0].mul_add(
translation_x,
inverse[1].mul_add(translation_y, inverse[2] * translation_z),
)),
-(inverse[4].mul_add(
translation_x,
inverse[5].mul_add(translation_y, inverse[6] * translation_z),
)),
-(inverse[8].mul_add(
translation_x,
inverse[9].mul_add(translation_y, inverse[10] * translation_z),
)),
];
Some([
inverse[0],
inverse[1],
inverse[2],
translation[0],
inverse[4],
inverse[5],
inverse[6],
translation[1],
inverse[8],
inverse[9],
inverse[10],
translation[2],
0.0,
0.0,
0.0,
1.0,
])
}
/// Reproduces Ngi32's `Direct3D7` view-matrix conversion for this transform.
///
/// The legacy renderer copies selector-0 into its camera state, then maps
/// its axes and translation in this exact order before calling
/// `IDirect3DDevice7::SetTransform(D3DTRANSFORMSTATE_VIEW, ...)`. This is
/// deliberately distinct from [`Self::try_inverse_affine_row_major`]: it
/// includes the original renderer's coordinate-system conversion.
///
/// The returned matrix is row-major D3D7 data, not yet a Vulkan view
/// matrix. A later adapter must explicitly account for clip-space and
/// shader-vector conventions.
#[must_use]
pub fn try_direct3d7_view_row_major(self) -> Option<[f32; 16]> {
let matrix = self.words.map(f32::from_bits);
if !matrix.iter().all(|value| value.is_finite())
|| matrix[12].abs() > f32::EPSILON
|| matrix[13].abs() > f32::EPSILON
|| matrix[14].abs() > f32::EPSILON
|| (matrix[15] - 1.0).abs() > f32::EPSILON
{
return None;
}
let [m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, _, _, _, _] = matrix;
Some([
-m01,
m02,
m00,
0.0,
-m11,
m12,
m10,
0.0,
-m21,
m22,
m20,
0.0,
m23.mul_add(m21, m13.mul_add(m11, m03 * m01)),
-(m23.mul_add(m22, m13.mul_add(m12, m03 * m02))),
-(m00.mul_add(m03, m23.mul_add(m20, m13 * m10))),
1.0,
])
}
}
/// Parameters consumed by Ngi32's `Direct3D7` projection-matrix builder.
///
/// These are a separate legacy-renderer boundary from
/// [`RawCameraProjection`]. The latter preserves Terrain's source ABI, while
/// this type records the already-resolved Ngi32 values submitted to `Direct3D7`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LegacyD3d7Projection {
/// Viewport rectangle as `(left, top, right, bottom)`.
pub viewport: [i32; 4],
/// Positive camera near-plane distance.
pub near_plane: f32,
/// Camera far-plane distance, greater than [`Self::near_plane`].
pub far_plane: f32,
/// Full field-of-view angle in radians.
pub field_of_view_radians: f32,
}
impl LegacyD3d7Projection {
/// Reconstructs the exact row-major matrix passed to D3D7 projection state.
///
/// Ngi32 uses the viewport's `width / height`, writes `cos(fov / 2)` to
/// the diagonal and `sin(fov / 2)` to both the depth scale and
/// homogeneous-W term. The ratio after D3D's perspective divide is
/// therefore the expected cotangent scale. This remains legacy D3D7 data
/// rather than a Vulkan projection.
#[must_use]
pub fn try_direct3d7_projection_row_major(self) -> Option<[f32; 16]> {
let width = self.viewport[2].checked_sub(self.viewport[0])?;
let height = self.viewport[3].checked_sub(self.viewport[1])?;
if width <= 0
|| height <= 0
|| !self.near_plane.is_finite()
|| !self.far_plane.is_finite()
|| !self.field_of_view_radians.is_finite()
|| self.near_plane <= 0.0
|| self.far_plane <= self.near_plane
|| self.field_of_view_radians <= 0.0
|| self.field_of_view_radians >= std::f32::consts::PI
{
return None;
}
// Ngi32 converts these signed viewport dimensions into single-precision
// arithmetic before building the legacy matrix.
#[allow(clippy::cast_precision_loss)]
let aspect = (width as f32) / (height as f32);
let half_fov = self.field_of_view_radians * 0.5;
let cosine = half_fov.cos();
let sine = half_fov.sin();
let depth_scale = sine / (1.0 - self.near_plane / self.far_plane);
[aspect, cosine, sine, depth_scale]
.iter()
.all(|value| value.is_finite())
.then_some([
cosine,
0.0,
0.0,
0.0,
0.0,
aspect * cosine,
0.0,
0.0,
0.0,
0.0,
depth_scale,
sine,
0.0,
0.0,
-(depth_scale * self.near_plane),
0.0,
])
}
}
/// Affine placement inputs consumed by `Iron3D`'s recovered Euler-matrix builder.
///
/// This records the exact matrix construction at `iron3d.dll` RVA `0x36610`.
/// It is intentionally distinct from a mission placement contract: the static
/// trace has not yet proved that TMA's three raw orientation floats reach this
/// builder unchanged for every object category.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LegacyIron3dEulerTransform {
/// World-space translation passed to the builder.
pub translation: [f32; 3],
/// Three angles in the builder's `(x, y, z)` input order, in radians.
pub orientation_radians: [f32; 3],
}
impl LegacyIron3dEulerTransform {
/// Reconstructs the builder's finite row-major affine matrix.
///
/// The recovered x87 code evaluates `Rz(z) * Ry(y) * Rx(x)` and writes
/// translation into the last column. This uses portable `f32` trigonometry;
/// any future x87-compatibility path must be separately capture-validated.
#[must_use]
pub fn try_row_major(self) -> Option<[f32; 16]> {
if !self
.translation
.iter()
.chain(self.orientation_radians.iter())
.all(|value| value.is_finite())
{
return None;
}
let [x, y, z] = self.orientation_radians;
let (sin_x, cos_x) = x.sin_cos();
let (sin_y, cos_y) = y.sin_cos();
let (sin_z, cos_z) = z.sin_cos();
let [translation_x, translation_y, translation_z] = self.translation;
let matrix = [
cos_z * cos_y,
cos_z * sin_y * sin_x - sin_z * cos_x,
cos_z * sin_y * cos_x + sin_z * sin_x,
translation_x,
sin_z * cos_y,
sin_z * sin_y * sin_x + cos_z * cos_x,
sin_z * sin_y * cos_x - cos_z * sin_x,
translation_y,
-sin_y,
cos_y * sin_x,
cos_y * cos_x,
translation_z,
0.0,
0.0,
0.0,
1.0,
];
matrix
.iter()
.all(|value| value.is_finite())
.then_some(matrix)
}
/// Applies the recovered rotation after a component-wise local scale.
///
/// This is the affine `R * (scale * point) + translation` convention used
/// by the static source-world bridge. The original dynamic transform path
/// needs its own capture evidence before it may replace this static path.
#[must_use]
pub fn try_transform_scaled_point(self, point: [f32; 3], scale: [f32; 3]) -> Option<[f32; 3]> {
if !point
.iter()
.chain(scale.iter())
.all(|value| value.is_finite())
{
return None;
}
let matrix = self.try_row_major()?;
let scaled = [
point[0] * scale[0],
point[1] * scale[1],
point[2] * scale[2],
];
let transformed = [
matrix[0] * scaled[0] + matrix[1] * scaled[1] + matrix[2] * scaled[2] + matrix[3],
matrix[4] * scaled[0] + matrix[5] * scaled[1] + matrix[6] * scaled[2] + matrix[7],
matrix[8] * scaled[0] + matrix[9] * scaled[1] + matrix[10] * scaled[2] + matrix[11],
];
transformed
.iter()
.all(|value| value.is_finite())
.then_some(transformed)
}
}
/// Raw camera state observed through the original Terrain camera interface.
///
/// Selector 0 supplies the currently active transform and selector 2 supplies
/// its paired transform. Keeping both lets a later compatibility layer derive
/// a view/projection convention without re-reading the legacy process.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RawCameraPose {
/// Transform returned by selector 0.
pub selector0: RawCameraTransform,
/// Transform returned by selector 2.
pub selector2: RawCameraTransform,
}
/// Raw projection state observed through the original `CBufferingCamera` ABI.
///
/// The five-float context block is intentionally represented as original words:
/// its indices are observed, but their semantic labels have not yet all been
/// recovered. The field-of-view value is the type-0 input to `tan(fov / 2)`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RawCameraProjection {
/// Viewport rectangle as `(left, top, right, bottom)`.
pub viewport: [i32; 4],
/// Original projection selector.
pub projection_type: u32,
/// Original IEEE-754 FOV value in radians.
pub field_of_view_radians_bits: u32,
/// Exact five-float context block returned by the primary renderer.
pub context_words: [u32; 5],
}
impl RawCameraProjection {
/// Returns the type-0 FOV input in radians.
#[must_use]
pub fn field_of_view_radians(self) -> f32 {
f32::from_bits(self.field_of_view_radians_bits)
}
/// Returns the five context values without assigning semantic labels.
#[must_use]
pub fn context_values(self) -> [f32; 5] {
self.context_words.map(f32::from_bits)
}
}
/// Immutable camera data visible to command generation.
#[derive(Clone, Debug, PartialEq)]
pub struct CameraSnapshot {
@@ -29,6 +389,17 @@ pub struct CameraSnapshot {
pub view: [f32; 16],
/// Projection matrix, row-major.
pub projection: [f32; 16],
/// Optional unconverted source-camera state.
///
/// This does not alter rendering until the original matrix and projection
/// conventions have been recovered; it preserves the ABI boundary for the
/// runtime adapter and deterministic captures.
pub raw_pose: Option<RawCameraPose>,
/// Optional unconverted source-projection state.
///
/// This is retained independently of `projection`, because its legacy
/// context words have not yet been mapped to a Vulkan clip convention.
pub raw_projection: Option<RawCameraProjection>,
}
impl Default for CameraSnapshot {
@@ -36,6 +407,8 @@ impl Default for CameraSnapshot {
Self {
view: identity_transform(),
projection: identity_transform(),
raw_pose: None,
raw_projection: None,
}
}
}
@@ -702,6 +1075,191 @@ 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];
active[0] = 0x7FC0_0001;
active[3] = 491.562_5_f32.to_bits();
active[7] = 761.550_8_f32.to_bits();
active[11] = 7.361_0_f32.to_bits();
let paired = [0xA5A5_5A5A; 16];
let pose = RawCameraPose {
selector0: RawCameraTransform { words: active },
selector2: RawCameraTransform { words: paired },
};
assert_eq!(pose.selector0.words, active);
assert_eq!(pose.selector2.words, paired);
assert_eq!(
pose.selector0.translation(),
[491.562_5, 761.550_8, 7.361_0]
);
assert_eq!(CameraSnapshot::default().raw_pose, None);
}
#[test]
fn raw_camera_projection_preserves_live_context_words_without_labeling_them() {
let projection = RawCameraProjection {
viewport: [0, 0, 1024, 768],
projection_type: 0,
field_of_view_radians_bits: 1.04_f32.to_bits(),
context_words: [
0.0_f32.to_bits(),
0.5_f32.to_bits(),
700.0_f32.to_bits(),
0.1_f32.to_bits(),
0.99_f32.to_bits(),
],
};
assert_eq!(projection.field_of_view_radians(), 1.04);
assert_eq!(projection.context_values(), [0.0, 0.5, 700.0, 0.1, 0.99]);
assert_eq!(CameraSnapshot::default().raw_projection, None);
}
#[test]
fn raw_camera_transform_inverts_only_non_singular_affine_blocks() {
let source = [
0.0, -1.0, 0.0, 433.544_7, 0.948_985, 0.0, 0.315_322, 652.292_5, -0.315_322, 0.0,
0.948_985, 10.673_42, 0.0, 0.0, 0.0, 1.0,
];
let transform = RawCameraTransform {
words: source.map(f32::to_bits),
};
let inverse = transform
.try_inverse_affine_row_major()
.expect("observed affine transform is invertible");
assert_matrix_approximately_identity(multiply_row_major(source, inverse));
assert_matrix_approximately_identity(multiply_row_major(inverse, source));
let singular = RawCameraTransform { words: [0_u32; 16] };
assert_eq!(singular.try_inverse_affine_row_major(), None);
}
#[test]
fn raw_camera_transform_reproduces_direct3d7_view_axis_conversion() {
let transform = RawCameraTransform {
words: [
0.0_f32.to_bits(),
(-1.0_f32).to_bits(),
0.0_f32.to_bits(),
10.0_f32.to_bits(),
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
20.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
30.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
],
};
assert_eq!(
transform.try_direct3d7_view_row_major(),
Some([
1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, -10.0, -30.0, -20.0,
1.0,
])
);
assert_eq!(
RawCameraTransform { words: [0_u32; 16] }.try_direct3d7_view_row_major(),
None
);
}
#[test]
fn legacy_d3d7_projection_matches_recovered_camera_formula() {
let projection = LegacyD3d7Projection {
viewport: [0, 0, 1024, 768],
near_plane: 0.5,
far_plane: 700.0,
field_of_view_radians: 1.3,
};
let matrix = projection
.try_direct3d7_projection_row_major()
.expect("live Ngi32 projection parameters are valid");
let half_fov = 0.65_f32;
let depth_scale = half_fov.sin() * 700.0_f32 / 699.5;
assert_eq!(matrix[0], half_fov.cos());
assert_eq!(matrix[5], (4.0 / 3.0) * half_fov.cos());
assert_eq!(matrix[10], depth_scale);
assert_eq!(matrix[11], half_fov.sin());
assert_eq!(matrix[14], -(depth_scale * 0.5));
assert_eq!(
LegacyD3d7Projection {
viewport: [0, 0, 0, 768],
..projection
}
.try_direct3d7_projection_row_major(),
None
);
}
#[test]
fn iron3d_euler_builder_uses_rz_ry_rx_and_last_column_translation() {
let matrix = LegacyIron3dEulerTransform {
translation: [418.103_18, 717.433, 3.040_938_9],
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
}
.try_row_major()
.expect("finite recovered builder inputs");
assert!((matrix[0]).abs() < 0.000_001);
assert!((matrix[1] + 1.0).abs() < 0.000_001);
assert!((matrix[4] - 1.0).abs() < 0.000_001);
assert!((matrix[5]).abs() < 0.000_001);
assert_eq!(matrix[3], 418.103_18);
assert_eq!(matrix[7], 717.433);
assert_eq!(matrix[11], 3.040_938_9);
assert_eq!(matrix[15], 1.0);
assert_eq!(
LegacyIron3dEulerTransform {
translation: [10.0, 20.0, 30.0],
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
}
.try_transform_scaled_point([2.0, 3.0, 4.0], [2.0, 1.0, 0.5]),
Some([7.0, 24.0, 32.0])
);
assert_eq!(
LegacyIron3dEulerTransform {
translation: [0.0, 0.0, 0.0],
orientation_radians: [f32::NAN, 0.0, 0.0],
}
.try_row_major(),
None
);
}
fn snapshot_draw(
id: u64,
phase: RenderPhase,
+60 -13
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
@@ -381,7 +396,7 @@ struct DecodedPayloadCache {
#[derive(Clone, Debug)]
struct PayloadCacheEntry {
bytes: Arc<[u8]>,
bytes: ResourceBytes,
last_access: u64,
}
@@ -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,
@@ -562,7 +590,7 @@ impl ResourceRepository for CachedResourceRepository {
let task = {
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
if let Some(bytes) = state.payload_cache.get(entry) {
return Ok(ResourceBytes::Shared(bytes));
return Ok(bytes);
}
state.payload_decode_task(entry)?
};
@@ -573,15 +601,14 @@ impl ResourceRepository for CachedResourceRepository {
key: task.key,
source,
})?;
let shared = Arc::from(payload.into_boxed_slice());
let mut state = self.state.lock().map_err(|_| ResourceError::Poisoned)?;
if let Some(bytes) = state.payload_cache.get(entry) {
return Ok(ResourceBytes::Shared(bytes));
return Ok(bytes);
}
state.entry_archive(entry)?;
state.payload_cache.insert(entry, Arc::clone(&shared));
Ok(ResourceBytes::Shared(shared))
state.payload_cache.insert(entry, payload.clone());
Ok(payload)
}
fn entry_info(&self, entry: EntryHandle) -> Result<ResourceEntryInfo, ResourceError> {
@@ -638,14 +665,14 @@ impl DecodedPayloadCache {
}
}
fn get(&mut self, handle: EntryHandle) -> Option<Arc<[u8]>> {
fn get(&mut self, handle: EntryHandle) -> Option<ResourceBytes> {
let entry = self.entries.get_mut(&handle)?;
self.generation = self.generation.saturating_add(1);
entry.last_access = self.generation;
Some(Arc::clone(&entry.bytes))
Some(entry.bytes.clone())
}
fn insert(&mut self, handle: EntryHandle, bytes: Arc<[u8]>) {
fn insert(&mut self, handle: EntryHandle, bytes: ResourceBytes) {
let len = bytes.len();
if self.max_entries == 0 || len > self.max_bytes {
return;
@@ -815,14 +842,15 @@ impl ArchiveSlot {
}
impl ArchiveDocument {
fn read_payload(&self, local: u32) -> Result<Vec<u8>, String> {
fn read_payload(&self, local: u32) -> Result<ResourceBytes, String> {
match self {
ArchiveDocument::Nres(document) => document
.payload(fparkan_nres::EntryId(local))
.map(<[u8]>::to_vec)
.payload_view(fparkan_nres::EntryId(local))
.map(|(owner, range)| ResourceBytes::Slice { owner, range })
.map_err(|err| err.to_string()),
ArchiveDocument::Rsli(document) => document
.load(fparkan_rsli::EntryId(local))
.map(|payload| ResourceBytes::Shared(Arc::from(payload.into_boxed_slice())))
.map_err(|err| err.to_string()),
}
}
@@ -1070,7 +1098,9 @@ mod tests {
.find(first, &resource_name(b"alpha.txt"))
.expect("find")
.expect("entry");
assert_eq!(repo.read(handle).expect("read").as_slice(), b"alpha");
let payload = repo.read(handle).expect("read");
assert_eq!(payload.as_slice(), b"alpha");
assert!(matches!(payload, ResourceBytes::Slice { .. }));
let info = repo.entry_info(handle).expect("entry info");
assert_eq!(info.key.archive, path);
assert!(info.key.name.0.eq_ignore_ascii_case(b"Alpha.TXT"));
@@ -1099,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");
+323 -39
View File
@@ -18,11 +18,17 @@
clippy::unwrap_used
)
)]
//! Validated terrain runtime queries.
//! Validated terrain runtime queries using XY ground coordinates and Z height.
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,12 +36,14 @@ pub struct TerrainWorld {
grid: RuntimeGrid,
adjacency: Vec<Vec<ArealId>>,
surfaces: Vec<RuntimeTriangle>,
surface_bvh: SurfaceBvh,
source_mesh: Option<LandMeshDocument>,
}
/// Surface hit.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SurfaceHit {
/// Height.
/// Source Z height.
pub height: f32,
/// Hit position.
pub position: [f32; 3],
@@ -134,7 +142,7 @@ impl std::error::Error for TerrainError {}
/// Surface query.
pub trait SurfaceQuery {
/// Height at position.
/// Source Z height at an XY ground position.
///
/// # Errors
///
@@ -156,7 +164,7 @@ pub trait SurfaceQuery {
/// Navigation query.
pub trait NavigationQuery {
/// Locate areal.
/// Locates an areal from the XY components of a world position.
///
/// # Errors
///
@@ -194,7 +202,7 @@ impl TerrainWorld {
polygon: areal
.vertices
.iter()
.map(|vertex| [vertex[0], vertex[2]])
.map(|vertex| [vertex[0], vertex[1]])
.collect(),
});
}
@@ -232,6 +240,8 @@ impl TerrainWorld {
grid,
adjacency,
surfaces: Vec::new(),
surface_bvh: SurfaceBvh::default(),
source_mesh: None,
})
}
@@ -244,8 +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`.
@@ -260,6 +273,8 @@ 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)
}
@@ -275,12 +290,35 @@ impl TerrainWorld {
self.surfaces.len()
}
/// Returns the validated source mesh retained for renderer integration.
///
/// Runtime collision queries use their own compact triangle representation;
/// keeping this document preserves the original geometry and UV streams for
/// rendering without asking the application layer to decode an archive again.
#[must_use]
pub fn source_mesh(&self) -> Option<&LandMeshDocument> {
self.source_mesh.as_ref()
}
/// Returns source terrain positions without exposing parser ownership to apps.
#[must_use]
pub fn source_positions(&self) -> Option<&[[f32; 3]]> {
self.source_mesh
.as_ref()
.map(|mesh| mesh.positions.as_slice())
}
fn with_surface_bvh(mut self) -> Self {
self.surface_bvh = SurfaceBvh::from_surfaces(&self.surfaces);
self
}
fn locate_by_candidates(
&self,
position: [f32; 3],
candidates: &[ArealId],
) -> Result<Option<ArealId>, TerrainError> {
let point = [position[0], position[2]];
let point = [position[0], position[1]];
for candidate in candidates {
let Some(areal) = usize::try_from(candidate.0)
.ok()
@@ -371,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)));
}
@@ -388,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;
}
@@ -405,7 +447,7 @@ impl SurfaceQuery for TerrainWorld {
origin[2] + direction[2] * distance,
];
best = Some(SurfaceHit {
height: position[1],
height: position[2],
position,
distance,
face: triangle.face,
@@ -444,20 +486,216 @@ 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][2]];
let b = [self.vertices[1][0], self.vertices[1][2]];
let c = [self.vertices[2][0], self.vertices[2][2]];
let a = [self.vertices[0][0], self.vertices[0][1]];
let b = [self.vertices[1][0], self.vertices[1][1]];
let c = [self.vertices[2][0], self.vertices[2][1]];
let weights = barycentric_2d(position, a, b, c)?;
if weights
.iter()
.all(|weight| *weight >= -1.0e-4 && *weight <= 1.0001)
{
Some(
weights[0] * self.vertices[0][1]
+ weights[1] * self.vertices[1][1]
+ weights[2] * self.vertices[2][1],
weights[0] * self.vertices[0][2]
+ weights[1] * self.vertices[1][2]
+ weights[2] * self.vertices[2][2],
)
} else {
None
@@ -548,9 +786,9 @@ impl RuntimeGrid {
for areal in &map.areals {
for vertex in &areal.vertices {
bounds_min[0] = bounds_min[0].min(vertex[0]);
bounds_min[1] = bounds_min[1].min(vertex[2]);
bounds_min[1] = bounds_min[1].min(vertex[1]);
bounds_max[0] = bounds_max[0].max(vertex[0]);
bounds_max[1] = bounds_max[1].max(vertex[2]);
bounds_max[1] = bounds_max[1].max(vertex[1]);
}
}
if !bounds_min[0].is_finite()
@@ -600,7 +838,7 @@ impl RuntimeGrid {
if self.cells_x == 0 || self.cells_y == 0 || self.cells.is_empty() {
return None;
}
let point = [position[0], position[2]];
let point = [position[0], position[1]];
if point[0] < self.min[0]
|| point[0] > self.max[0]
|| point[1] < self.min[1]
@@ -748,18 +986,18 @@ mod tests {
assert_eq!(world.areal_count(), 2);
assert_eq!(
world.locate_areal([0.25, 0.0, 0.25]).expect("locate"),
world.locate_areal([0.25, 0.25, 41.0]).expect("locate"),
Some(ArealId(0))
);
assert_eq!(
world.locate_areal([1.75, 0.0, 0.25]).expect("locate"),
world.locate_areal([1.75, 0.25, -12.0]).expect("locate"),
Some(ArealId(1))
);
assert_eq!(
world
.route(RouteRequest {
start: [0.25, 0.0, 0.25],
goal: [1.75, 0.0, 0.25],
start: [0.25, 0.25, 0.0],
goal: [1.75, 0.25, 0.0],
})
.expect("route"),
Some(ArealRoute {
@@ -775,8 +1013,8 @@ mod tests {
assert_eq!(
world
.route(RouteRequest {
start: [10.0, 0.0, 10.0],
goal: [1.75, 0.0, 0.25],
start: [10.0, 10.0, 0.0],
goal: [1.75, 0.25, 0.0],
})
.expect("route"),
None
@@ -793,8 +1031,8 @@ mod tests {
let hit = world
.raycast(
[0.25, 2.0, 0.25],
[0.0, -1.0, 0.0],
[0.25, 0.25, 2.0],
[0.0, 0.0, -1.0],
FullSurfaceMask(0x0000_0001),
)
.expect("raycast")
@@ -806,8 +1044,8 @@ mod tests {
assert_eq!(
world
.raycast(
[0.25, 2.0, 0.25],
[0.0, -1.0, 0.0],
[0.25, 0.25, 2.0],
[0.0, 0.0, -1.0],
FullSurfaceMask(0x8000_0000)
)
.expect("raycast"),
@@ -815,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() {
@@ -879,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()
@@ -897,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");
}
}
@@ -920,9 +1204,9 @@ mod tests {
},
positions: vec![
[0.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[1.0, 0.0, 1.0],
[0.0, 1.0, 1.0],
[1.0, 2.0, 1.0],
[1.0, 1.0, 2.0],
],
normals: Vec::new(),
uv0: Vec::new(),
@@ -965,10 +1249,10 @@ mod tests {
areals: vec![
Areal {
prefix_raw: [0; 56],
anchor: [0.5, 0.0, 0.5],
anchor: [0.5, 0.5, 0.0],
reserved_12: 0.0,
area_metric: 1.0,
normal: [0.0, 1.0, 0.0],
normal: [0.0, 0.0, 1.0],
logic_flag: 0,
reserved_36: 0,
class_id: 0,
@@ -976,8 +1260,8 @@ mod tests {
vertices: vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 0.0, 1.0],
[0.0, 0.0, 1.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
],
links: vec![
EdgeLink {
@@ -1009,10 +1293,10 @@ mod tests {
},
Areal {
prefix_raw: [0; 56],
anchor: [1.5, 0.0, 0.5],
anchor: [1.5, 0.5, 0.0],
reserved_12: 0.0,
area_metric: 1.0,
normal: [0.0, 1.0, 0.0],
normal: [0.0, 0.0, 1.0],
logic_flag: 0,
reserved_36: 0,
class_id: 0,
@@ -1020,8 +1304,8 @@ mod tests {
vertices: vec![
[1.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[2.0, 0.0, 1.0],
[1.0, 0.0, 1.0],
[2.0, 1.0, 0.0],
[1.0, 1.0, 0.0],
],
links: vec![
EdgeLink {
+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.
+50
View File
@@ -11,6 +11,9 @@ Result on 2026-07-18:
- canonical pipeline passes locally with Rust 1.97.1: formatting, policy,
shader provenance, workspace tests, `clippy`, docs and `cargo deny` are
executed through `cargo xtask ci`;
- `rust-toolchain.toml` pins Rust 1.97.1 and installs only the
`x86_64-pc-windows-msvc` target; Linux and macOS target installation is not
part of the default developer setup;
- internal path dependencies are version-pinned and the Windows-only winit
graph enables only `rwh_06`, excluding Unix window-system dependencies;
- `cargo deny` runs without advisory exceptions in the supported Windows graph.
@@ -31,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`:
+10
View File
@@ -57,6 +57,16 @@ selector `18` on the supplied object and invokes slot `+12` on that result. It
does not store the supplied pointer in `stdGetCurrentCamera2`; that getter
returns a Terrain global populated by selector `8` during Terrain initialization.
`Terrain.dll!LoadCamera` adds a separate factory-side contract. Its GOG entry
at RVA `0x4EBE0` is `__stdcall` with four machine-word arguments, allocates
`0x1A4` bytes, calls the constructor at RVA `0x4EC60`, and returns the interface
at object offset `+0x134`. The constructor passes arguments 3 and 4 into base
initialization at `this + 4`, writes argument 3 to `this + 0x138`, and calls a
helper at RVA `0x4CEF0` with arguments 1, 2 and 4 plus `this`. Argument 1 is a
mode string: only `REFLECTION` and `REFLECTION_SHIFTED` produce a non-zero mode
value at `this + 0x1A0`. This is factory ABI evidence, not a recovered view or
projection matrix contract.
## Parity risks
- x87 precision and rounding;
+12 -11
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 `Autodemo.00` | Yes, static MSH draw | First MSH from first mission root | `covered-gpu` only for the narrow static-preview bridge | Opt-in mission-to-native-window bootstrap, static MSH projection and synchronized teardown telemetry | Full mission scene, texture/material binding, 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,17 +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 модели миссии в
`VulkanSmokeRenderer` с пустым списком materials (явный white-fallback). Режим использует
отдельный 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), 7,372,800-byte readback hash
`10681850560830502773`, and validation warnings/errors `0/0`. 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`
+37
View File
@@ -128,6 +128,12 @@ directory_offset = total_size - directory_size
Reader проверяет, что `directory_offset >= 16`, умножение не переполнено, а
каталог заканчивается точно на `total_size`.
В repository NRes payload возвращается как slice с `Arc`-владельцем полного
неизменяемого архива: валидация формата не создаёт промежуточную копию каждого
payload. Такой view учитывается в том же bounded decoded-payload cache по
длине диапазона и сохраняет deterministic eviction. Это применимо только к
raw NRes; сжатый RsLi по-прежнему материализуется после декодирования.
### Запись каталога NRes
```c
@@ -462,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.
### Маршрут
После определения начальной и целевой областей маршрут строится по графу
+956 -19
View File
File diff suppressed because it is too large Load Diff
+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:
+54
View File
@@ -254,6 +254,60 @@ Terrain object. Следовательно, selector `18`, slot `+12` и global
должны оставаться именованными evidence boundary до dynamic capture; считать
`stdGetCurrentCamera2` getter-ом переданной camera было бы ошибкой.
Повторный headless-IDA review той же GOG базы уточняет рабочий static contract
`CBufferingCamera`. Метод Terrain RVA `0x4D740` копирует ровно 64 байта
(16 dword) в component offset `+0x10`. Frame-preparation метод RVA `0x4D9C0`
получает viewport rectangle через virtual slot `+0x3C`, выводит width, height,
centre и aspect, а projection читает через camera interface. Для projection type
`0` он использует float по component offset `+0x234` в `tan(angle / 2)`; для
type `2` получает five-float block через slot `+0x70`; иной non-zero type
завершается `Not supported projection type`. Это доказывает зависимость
projection от live camera/viewport, но не row/column convention, единицы угла,
near/far mapping, handedness или initial camera selection. Важно, что строки
`ICamera::SetTransformMatrix` (RVA `0x4F830`) и
`ICamera::GetTransformMatrix` (RVA `0x4F850`) ведут только в obsolete-call
stubs и не дают usable ABI.
Live elevated read-only probe впервые подтвердил relocation-aware runtime связь:
`Terrain.dll` был загружен по `0x02510000`, global `base + 0x7355C` содержал
non-null `0x0B37DF08`, а первый dword этого объекта был `0x025765B4` — ровно
relocated `off_100665B4` из `LoadCamera` construction path. Это доказывает
live camera object с outer vtable, но одновременно исправляет прежнее слишком
сильное сопоставление offsets: raw read `global + 0x10` не дал finite 4x4 matrix,
а `+0x234` был zero в данном sample. Receiver static procedures `0x4D740`/
`0x4D9C0` и exported global ещё не доказаны как один layout без interface
adjustment; их offsets остаются unassigned до recovery selector relationship.
Локальная IDA-база уточняет адреса этой связи: `stdGetCurrentCamera2` — это
шестибайтный getter по RVA `0x4FD80`, который возвращает `dword` по RVA
`0x7355C`. Единственный найденный **direct static** initializer этого global — функция Terrain
по RVA `0x4D4D0`: она запрашивает selector `8` у своего `this` и сохраняет
полученный interface pointer. Это доказывает адрес хранения и путь заполнения,
но не разрешает трактовать pointer как конкретный layout камеры либо читать его
как runtime evidence без доступа к процессу на том же уровне привилегий.
Elevated live sampling теперь доказывает, что direct static xref не исчерпывает
runtime writers: за 25 секунд autoplay global переключился между тремя heap
pointer, все с relocated outer vtables `0x025765B4`/`0x02576558`. У двух объектов
paired blocks `+0x2C/+0x3C/+0x4C` и `+0x6C/+0x7C/+0x8C` синхронно несли
world-like translation, например `(491.562, 761.551, 7.361)`; третий давал
normalized-looking `(0.098, 0.018, 0.856)` и не совпадал с paired block.
Наблюдение согласуется с автоматическими camera switches, но не маркирует mode;
оно запрещает называть `0x4D4D0` единственным runtime writer и требует recovery
indirect/unanalyzed write path.
Outer vtable `off_100665B4` теперь даёт exact transform adjustment для
world-like sample. Slot `+0x54` вызывает у subobject `outer + 4` slot `+0x20`
с selector `0`, затем копирует returned `+0x0C/+0x1C/+0x2C`. В live объекте
selector field `outer + 0x10` был `0xFFFFFFFF`; реализация selector `0` при
этом возвращает `outer + 0x20`. Значит observed triple
`outer + 0x2C/+0x3C/+0x4C` — доказанная translation часть active affine
transform, а не корреляция. Selector `2` возвращает paired block `outer + 0x60`;
outer slot `+0x70` применяет `atan2` к его axis values, что доказывает
orientation-angle path. Названия полей, angle order и handedness пока не
установлены, но raw affine transform и translation можно сохранять как
backend-neutral camera pose без догадок.
### Vtable и interface negotiation
Вызовы вида `object->vfunc(offset)` доказывают порядок slots, даже когда имя
+1
View File
@@ -35,6 +35,7 @@ schema = 1
"4" = [
"fparkan-animation",
"fparkan-fx",
"fparkan-script",
]
"5" = [
"fparkan-game",
-3
View File
@@ -2,8 +2,5 @@
channel = "1.97.1"
components = ["clippy", "rustfmt", "rust-docs"]
targets = [
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
"aarch64-apple-darwin",
"x86_64-apple-darwin",
]
+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();
}
}