Author SHA1 Message Date
renovate[bot] cc0b2f9f4a chore(deps): update actions/setup-python action to v7
Test / Lint (pull_request) Failing after 2m23s
Test / Test (pull_request) Skipped
Test / Render parity (pull_request) Skipped
Test / Lint (push) Failing after 12s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-21 00:08:43 +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
Valentin Popov 2772c39e41 feat(runtime): bound static preview graph roots
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
Test / Lint (push) Failing after 2m4s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:45:57 +04:00
Valentin Popov 0eaa56c10b feat(runtime): report mission load phases
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m1s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:42:34 +04:00
Valentin Popov 697cbbfd36 feat(runtime): bound static preview asset loading
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Failing after 1m57s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:36:29 +04:00
Valentin Popov f75601a86a feat(game): add static mission Vulkan mode
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Failing after 2m9s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:31:32 +04:00
Valentin Popov bf34bf9dd9 feat(runtime): preserve mission object IDs in draws
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m1s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:24:45 +04:00
Valentin Popov e0c8656146 feat(runtime): use mission placement in render snapshots
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m7s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:21:58 +04:00
Valentin Popov 2b735c693b feat(runtime): retain all object visuals in snapshots
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 1m59s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:19:56 +04:00
Valentin Popov 8296400cad fix(vulkan): validate readback formats
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m5s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:15:23 +04:00
Valentin Popov 172f667bc6 feat(vulkan): compare readback artifacts
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m8s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:13:52 +04:00
Valentin Popov d4aa7a223f fix(vulkan): label readback image formats
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m5s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:11:43 +04:00
Valentin Popov b24594c6a2 feat(vulkan): export readback artifacts
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m4s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:08:07 +04:00
Valentin Popov f9a13298c0 feat(vulkan): capture swapchain pixels
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m5s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 09:05:20 +04:00
Valentin Popov 9f6b21638d feat(vulkan): select readback-capable swapchains
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
Test / Lint (push) Failing after 2m7s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 08:59:28 +04:00
Valentin Popov b500e32ca2 docs(evidence): correct Terrain camera receiver
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Test / Lint (push) Failing after 2m12s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 08:54:52 +04:00
Valentin Popov e7d869d8d0 docs(evidence): correct World3D render RVAs
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m8s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 08:52:18 +04:00
Valentin Popov b1408b0907 feat(vulkan): draw original terrain geometry
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m11s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 08:46:16 +04:00
Valentin Popov 4feada1bf4 feat(vulkan): add alpha test shader path
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 1m59s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 08:40:40 +04:00
Valentin Popov 4b741fb19c feat(vulkan): add depth attachment pipeline support
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 1m58s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 08:31:19 +04:00
Valentin Popov 645eca5510 feat(vulkan): select canonical depth attachment formats
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m0s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 08:26:37 +04:00
Valentin Popov e7c1664171 feat(vulkan): cache pipeline variants by state key
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Test / Lint (push) Failing after 2m4s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 08:22:29 +04:00
Valentin Popov 1afe1698db feat(render): add deterministic pipeline state keys
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 08:18:39 +04:00
Valentin Popov 2745b938d8 docs(render): record lightmap evidence gap
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Test / Lint (push) Failing after 2m2s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 08:11:25 +04:00
Valentin Popov efaba9001f feat(render): bind materials per mesh batch
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 08:07:45 +04:00
Valentin Popov b915554f3c feat(render): preserve original mesh batch draws
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m7s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 07:56:49 +04:00
Valentin Popov 116e7716b9 feat(viewer): select Vulkan texture through WEAR
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m2s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 07:51:06 +04:00
Valentin Popov 1d9d8a1d29 feat(assets): expose WEAR material texture selection
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m8s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 07:46:01 +04:00
Valentin Popov a436190059 fix(render): use original MSH UV scale
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 07:41:46 +04:00
Valentin Popov fd9ce461c2 feat(render): sample original TEXM in Vulkan 2026-07-18 07:39:38 +04:00
Valentin Popov 6e6496eebf feat(render): upload original TEXM to Vulkan
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m5s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 07:29:31 +04:00
Valentin Popov 283b48e8c5 feat(render): draw original MSH through Vulkan
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Failing after 1m58s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 07:21:29 +04:00
Valentin Popov 97b1199b9e feat(render): accept static indexed Vulkan meshes
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 07:14:47 +04:00
Valentin Popov 223b3b948a test(render): refresh licensed planning captures
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Test / Lint (push) Failing after 2m2s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 07:09:24 +04:00
Valentin Popov a4c7e4b0ba perf(resource): skip duplicate archive metadata hashing
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m5s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 06:52:54 +04:00
Valentin Popov 57d8b654e9 perf(assets): reuse materials during mission preparation
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
Test / Lint (push) Failing after 1m58s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 06:35:20 +04:00
Valentin Popov 3f14e1ad7d perf(assets): reuse textures during mission preparation
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Test / Lint (push) Failing after 2m6s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 06:29:29 +04:00
Valentin Popov 597ffef19c refactor(render): build game commands from snapshots
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m7s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 06:16:44 +04:00
Valentin Popov 598e983a8e feat(xtask): persist corpus baseline reports
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
Test / Lint (push) Failing after 2m1s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 06:06:08 +04:00
Valentin Popov 75c2154b02 docs(stage0): confirm native GPU smoke coverage
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m1s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 06:01:06 +04:00
Valentin Popov 6ca7ab4f42 refactor(xtask): derive closure from Cargo metadata
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m1s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 05:59:59 +04:00
Valentin Popov cd57d16d60 refactor(xtask): parse Cargo manifests with toml
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m1s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 05:58:21 +04:00
Valentin Popov fd4be6c825 fix(stage0): require Windows native smoke
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m1s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 05:53:45 +04:00
Valentin Popov 3c44a12fc4 build(platform): restrict runtime scope to Windows
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m5s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 05:50:10 +04:00
Valentin Popov 958a5a3263 build(deps): harden winit feature graph
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m22s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 05:44:50 +04:00
Valentin Popov ba5992cc56 build(deps): pin internal path dependency versions
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Failing after 2m23s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 05:40:24 +04:00
Valentin Popov 41a2746804 fix(build): align stage zero gates with Rust 1.97
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m18s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 05:38:27 +04:00
Valentin Popov fbe5a8497a fix(clippy): address Rust 1.97 lints
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
Test / Lint (push) Failing after 2m19s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 05:31:39 +04:00
Valentin Popov 2da28d0495 style: format workspace with Rust 1.97
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 2m31s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 05:28:07 +04:00
Valentin Popov 02b4da2e3d build: support Windows shader provenance
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
Test / Lint (push) Failing after 7m8s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 05:27:10 +04:00
Valentin Popov 9f7a94b0d9 fix(policy): normalize Windows FFI paths
Docs Deploy / Build and Deploy MkDocs (push) Successful in 40s
Test / Lint (push) Failing after 3m34s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 05:22:20 +04:00
Valentin Popov 337894528c build: upgrade Rust toolchain to 1.97.1
Docs Deploy / Build and Deploy MkDocs (push) Successful in 49s
Test / Lint (push) Failing after 11m30s
Test / Test (push) Skipped
Test / Render parity (push) Skipped
2026-07-18 05:20:43 +04:00
Valentin Popov 93cddf1851 docs(stage0): record Windows Vulkan smoke 2026-07-18 05:13:59 +04:00
Valentin Popov ff7993223f docs: align renderer and evidence status
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Failing after 1m49s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-07-04 01:54:25 +04:00
Valentin Popov 08550849c7 chore: clarify planning acceptance semantics 2026-07-04 01:50:59 +04:00
Valentin Popov 0c54142ad5 refactor: harden stage 2 graph and asset limits
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 5m24s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-07-03 21:54:09 +04:00
Valentin Popov 44627cd532 fix: guard prepared asset id collisions 2026-07-03 21:49:42 +04:00
Valentin Popov f34c422b20 test: realign stage 2 acceptance coverage 2026-07-03 21:44:26 +04:00
Valentin Popov a89bd9af2a docs: clarify stage 0 acceptance scope 2026-07-03 21:28:18 +04:00
Valentin Popov b2e193f234 feat(stage2): persist prepared mission assets and reports
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m17s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-07-03 17:11:49 +04:00
Valentin Popov 66804e0bd2 feat(stage2): materialize visual dependency graph 2026-07-03 17:03:40 +04:00
Valentin Popov ee9b318172 fix(stage2): preserve byte-exact prototype identities 2026-07-03 16:58:17 +04:00
Valentin Popov 29bf4830cf fix(vulkan): capture shutdown validation after owner drop 2026-07-03 16:55:19 +04:00
Valentin Popov 8a8ef614f2 refactor(cli): limit json refactor to stage1 scope
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 1m54s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-30 04:43:27 +04:00
Valentin Popov 51b54c155c test(inspection): cover archive entry diagnostic context 2026-06-30 03:10:54 +04:00
Valentin Popov 0fd96faf54 fix(nres): align licensed closure with corpus limits 2026-06-30 02:55:23 +04:00
Valentin Popov c7a9c43b5b test(inspection): cover archive diagnostic span context 2026-06-30 02:47:24 +04:00
Valentin Popov c0116d32be test(nres): cover input byte limit enforcement 2026-06-30 02:45:09 +04:00
Valentin Popov 0a7ba55b44 test(resource): cover archive byte-budget eviction 2026-06-30 02:43:25 +04:00
Valentin Popov 29f7a398ff test(acceptance): record remaining stage1 coverage gaps 2026-06-30 02:41:43 +04:00
Valentin Popov 6f84761b83 fix(resource): prevent stale archive refresh overwrite 2026-06-30 02:38:01 +04:00
Valentin Popov 2b16e5b118 fix(resource): type archive and vfs error mapping 2026-06-30 02:34:25 +04:00
Valentin Popov 716cde2072 refactor(cli): replace manual json assembly 2026-06-30 02:29:42 +04:00
Valentin Popov 7337492c30 fix: route archive inspection through byte-safe boundaries 2026-06-30 01:54:57 +04:00
Valentin Popov d0bc7f2f26 fix: tighten nres and rsli decode contracts 2026-06-30 01:49:03 +04:00
Valentin Popov 146446d3e2 fix: harden stage1 path and archive identity 2026-06-30 01:40:47 +04:00
renovate[bot] 7db0bf8e3d fix(deps): update rust crate cargo_metadata to 0.23.0
Test / Lint (pull_request) Failing after 1m52s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m3s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-26 00:02:18 +00:00
136 changed files with 21560 additions and 2244 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v7
with: with:
python-version: "3.14" python-version: "3.14"
+4 -4
View File
@@ -9,7 +9,7 @@ on:
jobs: jobs:
msrv-backend-neutral: msrv-backend-neutral:
name: MSRV backend-neutral crates name: Pinned Rust backend-neutral crates
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
env: env:
@@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
with: with:
toolchain: 1.87.0 toolchain: 1.97.1
- name: Test backend-neutral crates - name: Test backend-neutral crates
run: > run: >
cargo test cargo test
@@ -66,7 +66,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
with: with:
toolchain: 1.87.0 toolchain: 1.97.1
components: clippy,rustfmt components: clippy,rustfmt
- name: Provision macOS Vulkan runtime - name: Provision macOS Vulkan runtime
shell: bash shell: bash
@@ -147,7 +147,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
with: with:
toolchain: 1.87.0 toolchain: 1.97.1
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with: with:
pattern: native-smoke-* pattern: native-smoke-*
Generated
+136 -900
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -18,6 +18,7 @@ members = [
"crates/fparkan-render", "crates/fparkan-render",
"crates/fparkan-resource", "crates/fparkan-resource",
"crates/fparkan-rsli", "crates/fparkan-rsli",
"crates/fparkan-script",
"crates/fparkan-runtime", "crates/fparkan-runtime",
"crates/fparkan-terrain", "crates/fparkan-terrain",
"crates/fparkan-terrain-format", "crates/fparkan-terrain-format",
@@ -38,7 +39,7 @@ members = [
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
rust-version = "1.87" rust-version = "1.97"
license = "GPL-2.0-only" license = "GPL-2.0-only"
repository = "https://github.com/valentineus/fparkan" repository = "https://github.com/valentineus/fparkan"
+17 -21
View File
@@ -24,7 +24,17 @@ Open source проект с реализацией компонентов игр
- [crates/fparkan-rsli](crates/fparkan-rsli) — чтение, lookup и lossless roundtrip архивов RsLi. - [crates/fparkan-rsli](crates/fparkan-rsli) — чтение, lookup и lossless roundtrip архивов RsLi.
- [crates/fparkan-msh](crates/fparkan-msh) — validated static MSH geometry. - [crates/fparkan-msh](crates/fparkan-msh) — validated static MSH geometry.
- [crates/fparkan-runtime](crates/fparkan-runtime) — transactional mission loading и headless runtime foundation. - [crates/fparkan-runtime](crates/fparkan-runtime) — transactional mission loading и headless runtime foundation.
- [apps/fparkan-cli](apps/fparkan-cli), [apps/fparkan-viewer](apps/fparkan-viewer), [apps/fparkan-headless](apps/fparkan-headless), [apps/fparkan-game](apps/fparkan-game) — composition roots. - [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` открывает native Vulkan окно и рисует terrain и все подготовленные MSH-компоненты выбранных mission roots. По умолчанию выбираются все roots; `--preview-roots N` оставляет bounded diagnostic scope.
## Текущий статус рендера
- `fparkan-vulkan-smoke` доказывает живой Stage 0 Vulkan triangle path с native window, swapchain и validation telemetry.
- `VulkanPlanningBackend` и default-режим `fparkan-game` подтверждают только deterministic command planning/capture, а не draw пикселей. `fparkan-game --backend static-vulkan` — узкий mission-to-native-Vulkan bridge: он объединяет terrain и подготовленные MSH-компоненты всех выбранных roots, применяет static TMA transforms и загружает первую MAT0 diffuse texture на каждый используемый selector с preview-local remap. Legacy D3D7 camera capture поддерживается; полный corpus, material phases, dynamic ownership/visibility и pixel parity ещё не подтверждены.
- `fparkan-viewer` пока является инспектором ассетов. `fparkan-vulkan-smoke` имеет live Stage 3 bridge для original `MSH`/`Texm`/`WEAR`/`MAT0` и geometry-only `Land.msh`; полноценный viewer, исходные terrain-material states, camera и pixel parity ещё не закрыты.
- Truth table и evidence-артефакты вынесены в [`docs/rendering/renderer_truth_table.md`](docs/rendering/renderer_truth_table.md) и [`docs/evidence/`](docs/evidence).
## Тестирование ## Тестирование
@@ -80,21 +90,10 @@ cargo run -p fparkan-vulkan-smoke --locked -- \
--timeout-seconds 120 --timeout-seconds 120
``` ```
Перед запуском убедитесь, что на машине доступен Vulkan loader и рабочий ICD: Поддерживается только Windows. Перед запуском убедитесь, что установлен Vulkan runtime
от GPU vendor или LunarG Vulkan SDK и активный runtime предоставляет
- macOS: используйте ту же схему, что и GitHub CI (`macos-15` arm64): `VK_LAYER_KHRONOS_validation`. Linux, macOS/MoltenVK и их smoke-пути не входят в
проектный scope.
```bash
brew install molten-vk vulkan-loader vulkan-tools vulkan-validationlayers
export VK_ICD_FILENAMES="$(brew --prefix)/opt/molten-vk/etc/vulkan/icd.d/MoltenVK_icd.json"
export VK_LAYER_PATH="$(brew --prefix)/opt/vulkan-validationlayers/share/vulkan/explicit_layer.d"
export DYLD_FALLBACK_LIBRARY_PATH="$(brew --prefix)/opt/vulkan-loader/lib:$(brew --prefix)/opt/molten-vk/lib"
vulkaninfo --summary
```
Workflow fail-closed проверяет exact formula versions и ожидает наличие `VK_LAYER_KHRONOS_validation`.
- Linux: установлен `libvulkan` и драйвер/ICD (`mesa-vulkan-drivers`, Lavapipe или vendor GPU stack); smoke нужно запускать из активной графической сессии X11/Wayland.
- Windows: установлен Vulkan runtime от GPU vendor или LunarG Vulkan SDK; validation layer должен быть доступен из активного runtime.
Для полного локального closure gate используйте: Для полного локального closure gate используйте:
@@ -102,11 +101,8 @@ cargo run -p fparkan-vulkan-smoke --locked -- \
cargo xtask ci cargo xtask ci
``` ```
В текущем macOS-only цикле GitHub workflow собирает только macOS report и проверяет его через `native-smoke audit`. Windows и Linux smoke stages сознательно не входят в этот closure: Windows native smoke — единственный platform acceptance path. Вопросы hosted CI
и других операционных систем намеренно находятся вне scope разработки.
```bash
cargo xtask native-smoke audit --dir target/fparkan/native-smoke-artifacts
```
## Contributing & Support ## Contributing & Support
+2 -2
View File
@@ -6,9 +6,9 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-platform = { path = "../../crates/fparkan-platform" } fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
raw-window-handle = "0.6" raw-window-handle = "0.6"
winit = "0.30" winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
[lints] [lints]
workspace = true workspace = true
+6 -3
View File
@@ -9,9 +9,12 @@ build = "build.rs"
[dependencies] [dependencies]
ash = "0.38" ash = "0.38"
ash-window = "0.13" ash-window = "0.13"
fparkan-binary = { path = "../../crates/fparkan-binary" } fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
fparkan-platform = { path = "../../crates/fparkan-platform" } fparkan-binary = { path = "../../crates/fparkan-binary", version = "0.1.0" }
fparkan-render = { path = "../../crates/fparkan-render" } fparkan-msh = { path = "../../crates/fparkan-msh", 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-terrain-format = { path = "../../crates/fparkan-terrain-format", version = "0.1.0" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
@@ -1 +1 @@
{"schema":2,"target_env":"vulkan1.1","compiler":{"name":"glslangValidator","version":"11:16.3.0","binary_sha256":"9bcd69d830b350aaa6e2254915ff74e46070e217b67f38daad27c1fc1f22910f"},"validator":{"name":"spirv-val","version":"SPIRV-Tools v2026.2 unknown hash, 2026-04-29T17:02:58+00:00","binary_sha256":"f6d5b96ff19f073f3af0c0bcfa0c18702d288d3ec598efc242d01cd104d8354f"},"modules":[{"name":"triangle.vert","stage":"vertex","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert","source_sha256":"1e57f14d193fc61457c0749081c452ad25669998913107df12f3ccc3c33e0341","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","word_count":253,"sha256":"4d3ceca7b42ebc971d831b0a0d816457397bd9aeda47fb8d44c4b1aeaa5e7ba0","descriptor_sets":0,"push_constant_bytes":0,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S vert -e main adapters/fparkan-render-vulkan/shaders/triangle.vert -o adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","interface_hash":"23e1d3d9d32e7f7ec0b9ca87f8b86be8f8363c7eb5d745fc5a157cb8433eb138"},{"name":"triangle.frag","stage":"fragment","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag","source_sha256":"f19e74d001d07fb537d4b0f9e621f9b8bc40eeb68816130220853abea6bd4445","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","word_count":125,"sha256":"5a7441be03cd3c25d557268b2e58d5aa50504c87bffcb4c3fd7cbcf007db0b96","descriptor_sets":0,"push_constant_bytes":0,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S frag -e main adapters/fparkan-render-vulkan/shaders/triangle.frag -o adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","interface_hash":"f09342c22d58c8768151ab8579e54e49af586434a4005d16a24e816d881a64f0"}],"manifest_hash":"11e3feb65200ebd2ac87b7e776e9c6433a5da9d71a651bfadea89a51be17ff05"} {"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"}
@@ -1,8 +1,19 @@
#version 450 #version 450
layout(location = 0) in vec3 in_color; layout(location = 0) in vec3 in_color;
layout(location = 1) in vec2 in_uv;
layout(location = 0) out vec4 out_color; layout(location = 0) out vec4 out_color;
layout(set = 0, binding = 0) uniform sampler2D base_color;
layout(push_constant) uniform AlphaTestConstants {
layout(offset = 64)
float alpha_cutoff;
} alpha_test;
void main() { void main() {
out_color = vec4(in_color, 1.0); out_color = texture(base_color, in_uv) * vec4(in_color, 1.0);
if (out_color.a < alpha_test.alpha_cutoff) {
discard;
}
} }
@@ -1,11 +1,18 @@
#version 450 #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 = 1) in vec3 in_color;
layout(location = 2) in vec2 in_uv;
layout(location = 0) out vec3 out_color; layout(location = 0) out vec3 out_color;
layout(location = 1) out vec2 out_uv;
layout(push_constant) uniform CameraConstants {
mat4 clip_from_world;
} camera;
void main() { void main() {
out_color = in_color; out_color = in_color;
gl_Position = vec4(in_position, 0.0, 1.0); out_uv = in_uv;
gl_Position = camera.clip_from_world * vec4(in_position, 1.0);
} }
File diff suppressed because it is too large Load Diff
+51 -8
View File
@@ -26,6 +26,8 @@
//! //!
//! This crate is the declared low-level Vulkan boundary. //! This crate is the declared low-level Vulkan boundary.
#[path = "asset_mesh.rs"]
mod asset_mesh;
mod capabilities; mod capabilities;
mod instance; mod instance;
mod resources; mod resources;
@@ -37,6 +39,18 @@ mod swapchain;
mod swapchain_resources; mod swapchain_resources;
mod validation; mod validation;
pub use self::asset_mesh::{
project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_legacy_world_space,
project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame,
project_msh_to_static_mesh, project_msh_to_static_mesh_in_world_space,
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
project_msh_to_static_mesh_in_world_space_with_transform,
project_msh_to_static_mesh_in_xy_frame,
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses,
project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanAssetMeshError,
VulkanStaticXyFrame,
};
pub use self::capabilities::{ pub use self::capabilities::{
probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request, probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request,
VulkanRuntimeCapabilityError, VulkanRuntimeCapabilityProbe, VulkanRuntimeCapabilityError, VulkanRuntimeCapabilityProbe,
@@ -50,18 +64,22 @@ pub use self::instance::{
#[cfg(test)] #[cfg(test)]
use self::instance::{cstring_vec, ensure_instance_extensions_available}; use self::instance::{cstring_vec, ensure_instance_extensions_available};
use self::resources::{ use self::resources::{
color_subresource_range, create_command_pool, create_frame_sync, create_triangle_index_buffer, color_subresource_range, create_command_pool, create_depth_attachment, create_frame_sync,
create_triangle_vertex_buffer, destroy_allocated_buffer, VulkanAllocatedBuffer, create_readback_buffer, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer,
VulkanFrameSync, create_static_texture_image, destroy_allocated_buffer, destroy_allocated_image,
destroy_depth_attachment, readback_buffer_bytes, VulkanAllocatedBuffer, VulkanAllocatedImage,
VulkanDepthAttachment, VulkanFrameSync,
}; };
pub use self::runtime::{ pub use self::runtime::{
create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request, create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request,
VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport, VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport,
}; };
pub use self::smoke_types::{ pub use self::smoke_types::{
VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot, VulkanSmokeFrameOutcome, VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot,
VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanValidationReport, VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh,
VulkanStaticTexture, VulkanStaticVertex, VulkanValidationReport,
}; };
#[cfg(test)] #[cfg(test)]
use self::surface::extension_name; use self::surface::extension_name;
@@ -85,7 +103,8 @@ const EXT_DEBUG_UTILS_EXTENSION: &str = "VK_EXT_debug_utils";
const VALIDATION_LAYER_NAME: &str = "VK_LAYER_KHRONOS_validation"; const VALIDATION_LAYER_NAME: &str = "VK_LAYER_KHRONOS_validation";
pub(crate) const SPIRV_MAGIC: u32 = 0x0723_0203; pub(crate) const SPIRV_MAGIC: u32 = 0x0723_0203;
pub(crate) const SPIRV_VERSION_1_0: u32 = 0x0001_0000; pub(crate) const SPIRV_VERSION_1_0: u32 = 0x0001_0000;
pub(crate) const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &[ #[allow(dead_code)]
const LEGACY_TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &[
0x0723_0203, 0x0723_0203,
0x0001_0300, 0x0001_0300,
0x0008_000b, 0x0008_000b,
@@ -340,7 +359,8 @@ pub(crate) const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &[
0x0001_00fd, 0x0001_00fd,
0x0001_0038, 0x0001_0038,
]; ];
pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[ #[allow(dead_code)]
const LEGACY_TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[
0x0723_0203, 0x0723_0203,
0x0001_0300, 0x0001_0300,
0x0008_000b, 0x0008_000b,
@@ -468,5 +488,28 @@ pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[
0x0001_0038, 0x0001_0038,
]; ];
const fn spirv_words<const WORD_COUNT: usize>(bytes: &[u8]) -> [u32; WORD_COUNT] {
let mut words = [0_u32; WORD_COUNT];
let mut index = 0;
while index < WORD_COUNT {
let offset = index * 4;
words[index] = u32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
]);
index += 1;
}
words
}
static TRIANGLE_VERTEX_SHADER_DATA: [u32; 358] =
spirv_words(include_bytes!("../shaders/triangle.vert.spv"));
static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; 296] =
spirv_words(include_bytes!("../shaders/triangle.frag.spv"));
pub(crate) const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &TRIANGLE_VERTEX_SHADER_DATA;
pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &TRIANGLE_FRAGMENT_SHADER_DATA;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
@@ -32,7 +32,9 @@ impl VulkanInstanceConfig {
Self { Self {
application_name: application_name.into(), application_name: application_name.into(),
required_extensions: Vec::new(), required_extensions: Vec::new(),
enable_portability_enumeration: cfg!(target_os = "macos"), // The runtime is Windows-only, so portability enumeration is not part of
// the supported instance contract.
enable_portability_enumeration: false,
enable_validation: false, enable_validation: false,
} }
} }
@@ -1,14 +1,190 @@
#![allow(unsafe_code)] #![allow(unsafe_code)]
use ash::vk; use ash::vk;
use fparkan_platform::DepthStencilSupport;
use super::{VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError}; use super::{
VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanStaticMesh,
VulkanStaticTexture,
};
use crate::select_depth_stencil_attachment_format;
pub(super) struct VulkanAllocatedBuffer { pub(super) struct VulkanAllocatedBuffer {
pub(super) buffer: vk::Buffer, pub(super) buffer: vk::Buffer,
pub(super) memory: vk::DeviceMemory, pub(super) memory: vk::DeviceMemory,
} }
pub(super) struct VulkanAllocatedImage {
pub(super) image: vk::Image,
pub(super) memory: vk::DeviceMemory,
pub(super) view: vk::ImageView,
}
/// Depth/stencil image and the exact format selected for its render pass.
pub(super) struct VulkanDepthAttachment {
pub(super) image: VulkanAllocatedImage,
pub(super) format: vk::Format,
}
#[allow(clippy::too_many_lines)]
pub(super) fn create_depth_attachment(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
extent: (u32, u32),
request: DepthStencilSupport,
) -> Result<VulkanDepthAttachment, VulkanSmokeRendererError> {
let supported_formats = depth_attachment_formats(instance, device);
let format = select_depth_stencil_attachment_format(&supported_formats, request)
.map(vk::Format::from_raw)
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
context: "logical device has no selected depth attachment format",
})?;
let image_info = vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(format)
.extent(vk::Extent3D {
width: extent.0,
height: extent.1,
depth: 1,
})
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
.usage(vk::ImageUsageFlags::DEPTH_STENCIL_ATTACHMENT)
.sharing_mode(vk::SharingMode::EXCLUSIVE)
.initial_layout(vk::ImageLayout::UNDEFINED);
// SAFETY: The creation info is stack-owned and uses the live non-zero swapchain extent.
let image = unsafe { device.device().create_image(&image_info, None) }.map_err(|result| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateImage(depth attachment)",
result,
}
})?;
// SAFETY: The newly created image belongs to this device.
let requirements = unsafe { device.device().get_image_memory_requirements(image) };
let Some(memory_type_index) = find_memory_type(
instance,
device.physical_device(),
requirements.memory_type_bits,
vk::MemoryPropertyFlags::DEVICE_LOCAL,
) else {
// SAFETY: The unbound image is rolled back on its owning device.
unsafe { device.device().destroy_image(image, None) };
return Err(VulkanSmokeRendererError::MissingMemoryType {
context: "depth attachment",
});
};
let allocation = vk::MemoryAllocateInfo::default()
.allocation_size(requirements.size)
.memory_type_index(memory_type_index);
// SAFETY: Allocation parameters are derived from this image's requirements.
let allocation_result = unsafe { device.device().allocate_memory(&allocation, None) };
let memory = match allocation_result {
Ok(memory) => memory,
Err(result) => {
// SAFETY: The unbound image is rolled back on allocation failure.
unsafe { device.device().destroy_image(image, None) };
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkAllocateMemory(depth attachment)",
result,
});
}
};
// SAFETY: The allocation satisfies this image's queried requirements.
if let Err(result) = unsafe { device.device().bind_image_memory(image, memory, 0) } {
// SAFETY: Both newly created resources are rolled back together.
unsafe {
device.device().destroy_image(image, None);
device.device().free_memory(memory, None);
}
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkBindImageMemory(depth attachment)",
result,
});
}
let range = vk::ImageSubresourceRange::default()
.aspect_mask(depth_aspect(format))
.base_mip_level(0)
.level_count(1)
.base_array_layer(0)
.layer_count(1);
let view_info = vk::ImageViewCreateInfo::default()
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(format)
.subresource_range(range);
// SAFETY: The image is bound and the depth/stencil aspect matches its selected format.
let view_result = unsafe { device.device().create_image_view(&view_info, None) };
let view = match view_result {
Ok(view) => view,
Err(result) => {
// SAFETY: Both newly created resources are rolled back together.
unsafe {
device.device().destroy_image(image, None);
device.device().free_memory(memory, None);
}
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateImageView(depth attachment)",
result,
});
}
};
Ok(VulkanDepthAttachment {
image: VulkanAllocatedImage {
image,
memory,
view,
},
format,
})
}
pub(super) fn destroy_depth_attachment(
device: &VulkanLogicalDeviceProbe,
attachment: &VulkanDepthAttachment,
) {
destroy_allocated_image(device, &attachment.image);
}
fn depth_attachment_formats(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
) -> Vec<i32> {
[
vk::Format::D16_UNORM,
vk::Format::X8_D24_UNORM_PACK32,
vk::Format::D32_SFLOAT,
vk::Format::D16_UNORM_S8_UINT,
vk::Format::D24_UNORM_S8_UINT,
vk::Format::D32_SFLOAT_S8_UINT,
]
.into_iter()
.filter(|format| {
// SAFETY: The physical device belongs to this instance and the query returns a value copy.
unsafe {
instance
.instance
.get_physical_device_format_properties(device.physical_device(), *format)
}
.optimal_tiling_features
.contains(vk::FormatFeatureFlags::DEPTH_STENCIL_ATTACHMENT)
})
.map(vk::Format::as_raw)
.collect()
}
fn depth_aspect(format: vk::Format) -> vk::ImageAspectFlags {
match format {
vk::Format::D16_UNORM_S8_UINT
| vk::Format::D24_UNORM_S8_UINT
| vk::Format::D32_SFLOAT_S8_UINT => {
vk::ImageAspectFlags::DEPTH | vk::ImageAspectFlags::STENCIL
}
_ => vk::ImageAspectFlags::DEPTH,
}
}
pub(super) struct VulkanFrameSync { pub(super) struct VulkanFrameSync {
pub(super) image_available: vk::Semaphore, pub(super) image_available: vk::Semaphore,
pub(super) render_finished: vk::Semaphore, pub(super) render_finished: vk::Semaphore,
@@ -30,18 +206,19 @@ pub(super) fn create_command_pool(
}) })
} }
pub(super) fn create_triangle_vertex_buffer( pub(super) fn create_static_mesh_vertex_buffer(
instance: &VulkanInstanceProbe, instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
mesh: &VulkanStaticMesh,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> { ) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
let vertices: [[f32; 5]; 3] = [ let mut bytes = Vec::with_capacity(mesh.vertices.len() * 8 * std::mem::size_of::<f32>());
[0.0, -0.55, 1.0, 0.2, 0.2], for vertex in &mesh.vertices {
[0.55, 0.55, 0.2, 1.0, 0.2], for value in vertex
[-0.55, 0.55, 0.2, 0.4, 1.0], .position
]; .into_iter()
let mut bytes = Vec::with_capacity(vertices.len() * 5 * std::mem::size_of::<f32>()); .chain(vertex.color)
for vertex in vertices { .chain(vertex.uv)
for value in vertex { {
bytes.extend_from_slice(&value.to_ne_bytes()); bytes.extend_from_slice(&value.to_ne_bytes());
} }
} }
@@ -50,17 +227,17 @@ pub(super) fn create_triangle_vertex_buffer(
device, device,
&bytes, &bytes,
vk::BufferUsageFlags::VERTEX_BUFFER, vk::BufferUsageFlags::VERTEX_BUFFER,
"triangle vertex buffer", "static mesh vertex buffer",
) )
} }
pub(super) fn create_triangle_index_buffer( pub(super) fn create_static_mesh_index_buffer(
instance: &VulkanInstanceProbe, instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
mesh: &VulkanStaticMesh,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> { ) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
let indices = [0_u16, 1_u16, 2_u16]; let mut bytes = Vec::with_capacity(mesh.indices.len() * std::mem::size_of::<u32>());
let mut bytes = Vec::with_capacity(indices.len() * std::mem::size_of::<u16>()); for &index in &mesh.indices {
for index in indices {
bytes.extend_from_slice(&index.to_ne_bytes()); bytes.extend_from_slice(&index.to_ne_bytes());
} }
create_host_visible_buffer( create_host_visible_buffer(
@@ -68,10 +245,276 @@ pub(super) fn create_triangle_index_buffer(
device, device,
&bytes, &bytes,
vk::BufferUsageFlags::INDEX_BUFFER, vk::BufferUsageFlags::INDEX_BUFFER,
"triangle index buffer", "static mesh index buffer",
) )
} }
#[allow(clippy::too_many_lines)]
pub(super) fn create_static_texture_image(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
texture: &VulkanStaticTexture,
) -> Result<VulkanAllocatedImage, VulkanSmokeRendererError> {
let staging = create_host_visible_buffer(
instance,
device,
&texture.rgba8,
vk::BufferUsageFlags::TRANSFER_SRC,
"static texture staging buffer",
)?;
let image_info = vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(vk::Format::R8G8B8A8_UNORM)
.extent(vk::Extent3D {
width: texture.width,
height: texture.height,
depth: 1,
})
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
.usage(vk::ImageUsageFlags::TRANSFER_DST | vk::ImageUsageFlags::SAMPLED)
.sharing_mode(vk::SharingMode::EXCLUSIVE)
.initial_layout(vk::ImageLayout::UNDEFINED);
// SAFETY: The create info only contains stack-owned values and a validated non-zero extent.
let image = unsafe { device.device().create_image(&image_info, None) }.map_err(|result| {
destroy_allocated_buffer(device, &staging);
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateImage(static texture)",
result,
}
})?;
// SAFETY: The image belongs to this device and is queried immediately after creation.
let requirements = unsafe { device.device().get_image_memory_requirements(image) };
let Some(memory_type_index) = find_memory_type(
instance,
device.physical_device(),
requirements.memory_type_bits,
vk::MemoryPropertyFlags::DEVICE_LOCAL,
) else {
// SAFETY: Both resources were created on this device and are being rolled back once.
unsafe { device.device().destroy_image(image, None) };
destroy_allocated_buffer(device, &staging);
return Err(VulkanSmokeRendererError::MissingMemoryType {
context: "static texture image",
});
};
let allocate_info = vk::MemoryAllocateInfo::default()
.allocation_size(requirements.size)
.memory_type_index(memory_type_index);
// SAFETY: The allocation matches the image memory requirements queried above.
let memory = {
// SAFETY: The allocation matches the image memory requirements queried above.
unsafe { device.device().allocate_memory(&allocate_info, None) }
}
.map_err(|result| {
// SAFETY: Both resources were created on this device and are being rolled back once.
unsafe { device.device().destroy_image(image, None) };
destroy_allocated_buffer(device, &staging);
VulkanSmokeRendererError::VulkanOperation {
context: "vkAllocateMemory(static texture)",
result,
}
})?;
// SAFETY: The allocation matches this image's queried requirements.
if let Err(result) = unsafe { device.device().bind_image_memory(image, memory, 0) } {
// SAFETY: Both resources were created on this device and are being rolled back once.
unsafe {
device.device().destroy_image(image, None);
device.device().free_memory(memory, None);
};
destroy_allocated_buffer(device, &staging);
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkBindImageMemory(static texture)",
result,
});
}
if let Err(error) = upload_static_texture(
device,
command_pool,
staging.buffer,
image,
texture.width,
texture.height,
) {
// SAFETY: Both resources were created on this device and are being rolled back once.
unsafe {
device.device().destroy_image(image, None);
device.device().free_memory(memory, None);
};
destroy_allocated_buffer(device, &staging);
return Err(error);
}
destroy_allocated_buffer(device, &staging);
let view_info = vk::ImageViewCreateInfo::default()
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(vk::Format::R8G8B8A8_UNORM)
.subresource_range(color_subresource_range());
// SAFETY: The image is live, initialized and has the stated color subresource.
let view = {
// SAFETY: The image is live, initialized and has the stated color subresource.
unsafe { device.device().create_image_view(&view_info, None) }
}
.map_err(|result| {
// SAFETY: Both resources were created on this device and are being rolled back once.
unsafe {
device.device().destroy_image(image, None);
device.device().free_memory(memory, None);
};
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateImageView(static texture)",
result,
}
})?;
Ok(VulkanAllocatedImage {
image,
memory,
view,
})
}
#[allow(clippy::too_many_lines)]
fn upload_static_texture(
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
staging_buffer: vk::Buffer,
image: vk::Image,
width: u32,
height: u32,
) -> Result<(), VulkanSmokeRendererError> {
let allocate_info = vk::CommandBufferAllocateInfo::default()
.command_pool(command_pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1);
// SAFETY: The command pool is live and owned by the current logical device.
let command_buffer = unsafe { device.device().allocate_command_buffers(&allocate_info) }
.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
context: "vkAllocateCommandBuffers(texture upload)",
result,
})?[0];
let begin =
vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT);
// SAFETY: The command buffer is freshly allocated from the current pool.
if let Err(result) = unsafe { device.device().begin_command_buffer(command_buffer, &begin) } {
// SAFETY: The command buffer belongs to this pool and is released on failure.
unsafe {
device
.device()
.free_command_buffers(command_pool, &[command_buffer]);
};
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkBeginCommandBuffer(texture upload)",
result,
});
}
let range = color_subresource_range();
let to_transfer = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::UNDEFINED)
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
.src_access_mask(vk::AccessFlags::empty())
.dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
.image(image)
.subresource_range(range);
let region = vk::BufferImageCopy::default()
.image_subresource(
vk::ImageSubresourceLayers::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)
.mip_level(0)
.base_array_layer(0)
.layer_count(1),
)
.image_extent(vk::Extent3D {
width,
height,
depth: 1,
});
let to_sampled = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
.src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
.dst_access_mask(vk::AccessFlags::SHADER_READ)
.image(image)
.subresource_range(range);
// SAFETY: The commands operate on live, exclusively owned resources and the stated color subresource.
unsafe {
device.device().cmd_pipeline_barrier(
command_buffer,
vk::PipelineStageFlags::TOP_OF_PIPE,
vk::PipelineStageFlags::TRANSFER,
vk::DependencyFlags::empty(),
&[],
&[],
&[to_transfer],
);
device.device().cmd_copy_buffer_to_image(
command_buffer,
staging_buffer,
image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
&[region],
);
device.device().cmd_pipeline_barrier(
command_buffer,
vk::PipelineStageFlags::TRANSFER,
vk::PipelineStageFlags::FRAGMENT_SHADER,
vk::DependencyFlags::empty(),
&[],
&[],
&[to_sampled],
);
}
// SAFETY: Command recording is complete on the current command buffer.
if let Err(result) = unsafe { device.device().end_command_buffer(command_buffer) } {
// SAFETY: The command buffer belongs to this pool and is released on failure.
unsafe {
device
.device()
.free_command_buffers(command_pool, &[command_buffer]);
};
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkEndCommandBuffer(texture upload)",
result,
});
}
let command_buffers = [command_buffer];
let submit = [vk::SubmitInfo::default().command_buffers(&command_buffers)];
// SAFETY: The graphics queue and submitted command buffer are live for the duration of the wait.
let submit_result = unsafe {
device
.device()
.queue_submit(device.graphics_queue(), &submit, vk::Fence::null())
};
let result = submit_result.and_then(|()| {
// SAFETY: The graphics queue remains live until the synchronous idle wait completes.
unsafe { device.device().queue_wait_idle(device.graphics_queue()) }
});
// SAFETY: Submission completed or failed synchronously and the command buffer is no longer needed.
unsafe {
device
.device()
.free_command_buffers(command_pool, &[command_buffer]);
};
result.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
context: "vkQueueSubmit/WaitIdle(texture upload)",
result,
})
}
pub(super) fn destroy_allocated_image(
device: &VulkanLogicalDeviceProbe,
image: &VulkanAllocatedImage,
) {
// SAFETY: The image, view and allocation belong to this device and are destroyed once after idle.
unsafe {
device.device().destroy_image_view(image.view, None);
device.device().destroy_image(image.image, None);
device.device().free_memory(image.memory, None);
};
}
fn create_host_visible_buffer( fn create_host_visible_buffer(
instance: &VulkanInstanceProbe, instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
@@ -255,6 +698,49 @@ pub(super) fn destroy_allocated_buffer(
} }
} }
/// Allocates a host-coherent transfer destination for a swapchain image copy.
pub(super) fn create_readback_buffer(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
byte_len: usize,
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
create_host_visible_buffer(
instance,
device,
&vec![0; byte_len],
vk::BufferUsageFlags::TRANSFER_DST,
"vkCreateBuffer(readback)",
)
}
/// Copies a completed host-coherent readback allocation into CPU-owned bytes.
pub(super) fn readback_buffer_bytes(
device: &VulkanLogicalDeviceProbe,
buffer: &VulkanAllocatedBuffer,
byte_len: usize,
) -> Result<Vec<u8>, VulkanSmokeRendererError> {
// SAFETY: The caller waits for device idle before mapping this host-visible allocation.
let mapped = unsafe {
device.device().map_memory(
buffer.memory,
0,
u64::try_from(byte_len).unwrap_or(u64::MAX),
vk::MemoryMapFlags::empty(),
)
}
.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
context: "vkMapMemory(readback)",
result,
})?;
let mut bytes = vec![0; byte_len];
// SAFETY: Both ranges contain exactly byte_len initialized bytes and do not overlap.
unsafe {
std::ptr::copy_nonoverlapping(mapped.cast::<u8>(), bytes.as_mut_ptr(), byte_len);
device.device().unmap_memory(buffer.memory);
}
Ok(bytes)
}
pub(super) fn color_subresource_range() -> vk::ImageSubresourceRange { pub(super) fn color_subresource_range() -> vk::ImageSubresourceRange {
vk::ImageSubresourceRange::default() vk::ImageSubresourceRange::default()
.aspect_mask(vk::ImageAspectFlags::COLOR) .aspect_mask(vk::ImageAspectFlags::COLOR)
+368 -57
View File
@@ -2,13 +2,15 @@
use ash::vk; use ash::vk;
use super::smoke_types::resolve_draw_texture_indices;
use super::{ use super::{
create_command_pool, create_frame_sync, create_swapchain_resources, create_command_pool, create_frame_sync, create_static_mesh_index_buffer,
create_triangle_index_buffer, create_triangle_vertex_buffer, create_validation_messenger, create_static_mesh_vertex_buffer, create_static_texture_image, create_swapchain_resources,
create_vulkan_instance_probe, create_vulkan_logical_device_probe_for_request, create_validation_messenger, create_vulkan_instance_probe,
create_vulkan_surface_probe, create_vulkan_swapchain_probe_for_extent, create_vulkan_logical_device_probe_for_request, create_vulkan_surface_probe,
destroy_allocated_buffer, destroy_swapchain_resources, plan_vulkan_surface, create_vulkan_swapchain_probe_for_extent, destroy_allocated_buffer, destroy_allocated_image,
VulkanAllocatedBuffer, VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe, destroy_swapchain_resources, plan_vulkan_surface, readback_buffer_bytes, VulkanAllocatedBuffer,
VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanReadbackArtifact,
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainResources, VulkanValidationMessenger, VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainResources, VulkanValidationMessenger,
@@ -17,6 +19,7 @@ use super::{
use crate::policy::KHR_PORTABILITY_SUBSET_EXTENSION; use crate::policy::KHR_PORTABILITY_SUBSET_EXTENSION;
use crate::shader_manifest::{triangle_shader_manifest, validate_shader_manifest}; use crate::shader_manifest::{triangle_shader_manifest, validate_shader_manifest};
#[cfg(test)]
fn take_runtime_owners_in_dependency_order<Instance, Validation, Surface, Device, Swapchain>( fn take_runtime_owners_in_dependency_order<Instance, Validation, Surface, Device, Swapchain>(
instance: &mut Option<Instance>, instance: &mut Option<Instance>,
validation: &mut Option<Validation>, validation: &mut Option<Validation>,
@@ -31,28 +34,27 @@ fn take_runtime_owners_in_dependency_order<Instance, Validation, Surface, Device
instance.take(); instance.take();
} }
fn take_runtime_owners_with_validation_snapshot< fn take_runtime_children_with_validation_snapshot<
Instance,
Validation,
Surface, Surface,
Device, Device,
Swapchain, Swapchain,
Validation,
Snapshot, Snapshot,
Capture, Capture,
>( >(
instance: &mut Option<Instance>,
validation: &mut Option<Validation>,
surface: &mut Option<Surface>, surface: &mut Option<Surface>,
device: &mut Option<Device>, device: &mut Option<Device>,
swapchain: &mut Option<Swapchain>, swapchain: &mut Option<Swapchain>,
validation: Option<&Validation>,
capture: Capture, capture: Capture,
) -> Option<Snapshot> ) -> Option<Snapshot>
where where
Capture: FnOnce(&Validation) -> Snapshot, Capture: FnOnce(&Validation) -> Snapshot,
{ {
let snapshot = validation.as_ref().map(capture); swapchain.take();
take_runtime_owners_in_dependency_order(instance, validation, surface, device, swapchain); device.take();
snapshot surface.take();
validation.map(capture)
} }
struct RollbackOnDrop<T, F> struct RollbackOnDrop<T, F>
@@ -105,6 +107,18 @@ impl VulkanSmokeRenderer {
pub fn new( pub fn new(
create_info: &VulkanSmokeRendererCreateInfo, create_info: &VulkanSmokeRendererCreateInfo,
) -> Result<Self, VulkanSmokeRendererError> { ) -> Result<Self, VulkanSmokeRendererError> {
create_info
.mesh
.validate()
.map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?;
if !create_info.camera.is_finite() {
return Err(VulkanSmokeRendererError::InvalidStaticCamera {
context: "non-finite clip_from_world matrix",
});
}
let draw_texture_indices =
resolve_draw_texture_indices(&create_info.mesh.draw_ranges, &create_info.materials)
.map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?;
let bootstrap_progress = create_info.bootstrap_progress.as_ref(); let bootstrap_progress = create_info.bootstrap_progress.as_ref();
let shader_manifest = validate_shader_manifest(&triangle_shader_manifest()) let shader_manifest = validate_shader_manifest(&triangle_shader_manifest())
.map_err(VulkanSmokeRendererError::ShaderManifest)?; .map_err(VulkanSmokeRendererError::ShaderManifest)?;
@@ -153,23 +167,57 @@ impl VulkanSmokeRenderer {
progress.mark_swapchain_created(); progress.mark_swapchain_created();
} }
let command_pool = create_command_pool(&device)?; let command_pool = create_command_pool(&device)?;
let vertex_buffer = match create_triangle_vertex_buffer(&instance, &device) { let vertex_buffer =
Ok(buffer) => buffer, match create_static_mesh_vertex_buffer(&instance, &device, &create_info.mesh) {
Err(error) => { Ok(buffer) => buffer,
// SAFETY: The command pool belongs to this live logical device and is destroyed on setup failure. Err(error) => {
unsafe { device.device().destroy_command_pool(command_pool, None) }; // SAFETY: The command pool belongs to this live logical device and is destroyed on setup failure.
return Err(error); unsafe { device.device().destroy_command_pool(command_pool, None) };
} return Err(error);
}
};
let index_buffer =
match create_static_mesh_index_buffer(&instance, &device, &create_info.mesh) {
Ok(buffer) => buffer,
Err(error) => {
// SAFETY: The command pool belongs to this live logical device and is destroyed on setup failure.
unsafe { device.device().destroy_command_pool(command_pool, None) };
destroy_allocated_buffer(&device, &vertex_buffer);
return Err(error);
}
};
// Keep the compatibility triangle path valid: it samples a white texel
// when no original TEXM was supplied.
let fallback_texture = super::VulkanStaticTexture {
width: 1,
height: 1,
rgba8: vec![255, 255, 255, 255],
}; };
let index_buffer = match create_triangle_index_buffer(&instance, &device) { let texture_sources = if create_info.materials.is_empty() {
Ok(buffer) => buffer, vec![&fallback_texture]
Err(error) => { } else {
// SAFETY: The command pool belongs to this live logical device and is destroyed on setup failure. create_info
unsafe { device.device().destroy_command_pool(command_pool, None) }; .materials
destroy_allocated_buffer(&device, &vertex_buffer); .iter()
return Err(error); .map(|material| &material.texture)
} .collect()
}; };
let mut textures = Vec::with_capacity(texture_sources.len());
for texture_source in texture_sources {
match create_static_texture_image(&instance, &device, command_pool, texture_source) {
Ok(image) => textures.push(image),
Err(error) => {
for texture in &textures {
destroy_allocated_image(&device, texture);
}
// SAFETY: These resources belong to this live device and are rolled back before it drops.
unsafe { device.device().destroy_command_pool(command_pool, None) };
destroy_allocated_buffer(&device, &index_buffer);
destroy_allocated_buffer(&device, &vertex_buffer);
return Err(error);
}
}
}
let mut renderer = Self { let mut renderer = Self {
instance: Some(instance), instance: Some(instance),
validation, validation,
@@ -180,9 +228,15 @@ impl VulkanSmokeRenderer {
swapchain_resources: None, swapchain_resources: None,
vertex_buffer: Some(vertex_buffer), vertex_buffer: Some(vertex_buffer),
index_buffer: Some(index_buffer), index_buffer: Some(index_buffer),
textures,
draw_ranges: create_info.mesh.draw_ranges.clone(),
draw_texture_indices,
camera: create_info.camera,
frame_sync: Vec::new(), frame_sync: Vec::new(),
images_in_flight: Vec::new(), images_in_flight: Vec::new(),
current_frame: 0, current_frame: 0,
last_readback_image_index: None,
depth_request: create_info.render_request.depth,
pending_extent: None, pending_extent: None,
swapchain_recreate_count: 0, swapchain_recreate_count: 0,
report: VulkanSmokeRendererReport { report: VulkanSmokeRendererReport {
@@ -195,6 +249,11 @@ impl VulkanSmokeRenderer {
enabled_extension_count: 0, enabled_extension_count: 0,
swapchain_extent: (0, 0), swapchain_extent: (0, 0),
swapchain_image_count: 0, swapchain_image_count: 0,
swapchain_image_format: 0,
swapchain_image_usage: 0,
readback_copy_count: 0,
readback_byte_count: 0,
readback_fnv1a64: 0,
}, },
}; };
renderer.rebuild_swapchain_resources(false)?; renderer.rebuild_swapchain_resources(false)?;
@@ -222,6 +281,11 @@ impl VulkanSmokeRenderer {
.unwrap_or(u32::MAX), .unwrap_or(u32::MAX),
swapchain_extent: swapchain_ref.report.plan.extent, swapchain_extent: swapchain_ref.report.plan.extent,
swapchain_image_count: swapchain_ref.report.image_count, swapchain_image_count: swapchain_ref.report.image_count,
swapchain_image_format: swapchain_ref.report.plan.format.format,
swapchain_image_usage: swapchain_ref.report.plan.image_usage,
readback_copy_count: 0,
readback_byte_count: 0,
readback_fnv1a64: 0,
}; };
Ok(renderer) Ok(renderer)
} }
@@ -251,6 +315,32 @@ impl VulkanSmokeRenderer {
self.swapchain_recreate_count self.swapchain_recreate_count
} }
/// Returns the camera that will be uploaded for the next recorded frame.
#[must_use]
pub const fn camera(&self) -> super::VulkanStaticCamera {
self.camera
}
/// Replaces the camera for subsequent frames without recreating GPU resources.
///
/// # Errors
///
/// Returns [`VulkanSmokeRendererError::InvalidStaticCamera`] when a matrix
/// element is non-finite. The caller must update only between
/// [`Self::draw_frame`] calls on the renderer-owning thread.
pub fn set_camera(
&mut self,
camera: super::VulkanStaticCamera,
) -> Result<(), VulkanSmokeRendererError> {
if !camera.is_finite() {
return Err(VulkanSmokeRendererError::InvalidStaticCamera {
context: "non-finite clip_from_world matrix",
});
}
self.camera = camera;
Ok(())
}
/// Explicitly idles and tears down the renderer while the native window is still alive. /// Explicitly idles and tears down the renderer while the native window is still alive.
/// ///
/// # Errors /// # Errors
@@ -290,6 +380,16 @@ impl VulkanSmokeRenderer {
}) })
} }
fn textures_ref(&self) -> Result<&[super::VulkanAllocatedImage], VulkanSmokeRendererError> {
if self.textures.is_empty() {
Err(VulkanSmokeRendererError::InvariantViolation {
context: "static material textures",
})
} else {
Ok(&self.textures)
}
}
fn surface_ref(&self) -> Result<&VulkanSurfaceProbe, VulkanSmokeRendererError> { fn surface_ref(&self) -> Result<&VulkanSurfaceProbe, VulkanSmokeRendererError> {
self.surface self.surface
.as_ref() .as_ref()
@@ -418,6 +518,10 @@ impl VulkanSmokeRenderer {
context: "vkQueueSubmit", context: "vkQueueSubmit",
result: error, result: error,
})?; })?;
if !self.resources_ref()?.readback_buffers.is_empty() {
self.report.readback_copy_count = self.report.readback_copy_count.saturating_add(1);
self.last_readback_image_index = Some(image_index_usize);
}
let present_wait = [render_finished]; let present_wait = [render_finished];
let swapchains = [self.swapchain_ref()?.swapchain()]; let swapchains = [self.swapchain_ref()?.swapchain()];
@@ -499,11 +603,15 @@ impl VulkanSmokeRenderer {
let swapchain = self.swapchain_ref()?; let swapchain = self.swapchain_ref()?;
let resources = RollbackOnDrop::new( let resources = RollbackOnDrop::new(
create_swapchain_resources( create_swapchain_resources(
self.instance_ref()?,
device, device,
swapchain, swapchain,
self.command_pool, self.command_pool,
self.vertex_buffer_ref()?, self.vertex_buffer_ref()?,
self.index_buffer_ref()?, self.index_buffer_ref()?,
self.textures_ref()?,
&self.draw_ranges,
self.depth_request,
reuse_command_pool, reuse_command_pool,
)?, )?,
|resources| destroy_swapchain_resources(device, self.command_pool, resources), |resources| destroy_swapchain_resources(device, self.command_pool, resources),
@@ -511,11 +619,15 @@ impl VulkanSmokeRenderer {
let frame_sync = create_frame_sync(device)?; let frame_sync = create_frame_sync(device)?;
let swapchain_extent = self.swapchain_ref()?.report.plan.extent; let swapchain_extent = self.swapchain_ref()?.report.plan.extent;
let swapchain_image_count = self.swapchain_ref()?.report.image_count; let swapchain_image_count = self.swapchain_ref()?.report.image_count;
let swapchain_image_usage = self.swapchain_ref()?.report.plan.image_usage;
let resources = resources.commit(); let resources = resources.commit();
self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()]; self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()];
self.frame_sync = frame_sync; self.frame_sync = frame_sync;
self.last_readback_image_index = None;
self.report.swapchain_extent = swapchain_extent; self.report.swapchain_extent = swapchain_extent;
self.report.swapchain_image_count = swapchain_image_count; self.report.swapchain_image_count = swapchain_image_count;
self.report.swapchain_image_format = self.swapchain_ref()?.report.plan.format.format;
self.report.swapchain_image_usage = swapchain_image_usage;
self.swapchain_resources = Some(resources); self.swapchain_resources = Some(resources);
Ok(()) Ok(())
} }
@@ -551,11 +663,19 @@ impl VulkanSmokeRenderer {
result: error, result: error,
})?; })?;
let clear_values = [vk::ClearValue { let clear_values = [
color: vk::ClearColorValue { vk::ClearValue {
float32: [0.05, 0.08, 0.11, 1.0], color: vk::ClearColorValue {
float32: [0.05, 0.08, 0.11, 1.0],
},
}, },
}]; vk::ClearValue {
depth_stencil: vk::ClearDepthStencilValue {
depth: 1.0,
stencil: 0,
},
},
];
let render_area = vk::Rect2D { let render_area = vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 }, offset: vk::Offset2D { x: 0, y: 0 },
extent: vk::Extent2D { extent: vk::Extent2D {
@@ -575,11 +695,6 @@ impl VulkanSmokeRenderer {
&render_pass_info, &render_pass_info,
vk::SubpassContents::INLINE, vk::SubpassContents::INLINE,
); );
device.device().cmd_bind_pipeline(
command_buffer,
vk::PipelineBindPoint::GRAPHICS,
resources.pipeline,
);
let vertex_buffers = [self.vertex_buffer_ref()?.buffer]; let vertex_buffers = [self.vertex_buffer_ref()?.buffer];
let offsets = [0_u64]; let offsets = [0_u64];
device device
@@ -589,12 +704,116 @@ impl VulkanSmokeRenderer {
command_buffer, command_buffer,
self.index_buffer_ref()?.buffer, self.index_buffer_ref()?.buffer,
0, 0,
vk::IndexType::UINT16, vk::IndexType::UINT32,
); );
device let clip_from_world = matrix_bytes(self.camera.clip_from_world);
.device() device.device().cmd_push_constants(
.cmd_draw_indexed(command_buffer, 3, 1, 0, 0, 0); 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(
VulkanSmokeRendererError::InvariantViolation {
context: "static draw pipeline variant",
},
)?;
device.device().cmd_bind_pipeline(
command_buffer,
vk::PipelineBindPoint::GRAPHICS,
*pipeline,
);
let descriptor_set = resources.descriptor_sets.get(texture_index).ok_or(
VulkanSmokeRendererError::InvariantViolation {
context: "static material descriptor set",
},
)?;
device.device().cmd_bind_descriptor_sets(
command_buffer,
vk::PipelineBindPoint::GRAPHICS,
resources.pipeline_layout,
0,
std::slice::from_ref(descriptor_set),
&[],
);
let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes();
device.device().cmd_push_constants(
command_buffer,
resources.pipeline_layout,
vk::ShaderStageFlags::FRAGMENT,
64,
&alpha_cutoff,
);
device.device().cmd_draw_indexed(
command_buffer,
range.index_count,
1,
range.first_index,
0,
0,
);
}
device.device().cmd_end_render_pass(command_buffer); device.device().cmd_end_render_pass(command_buffer);
if let (Some(image), Some(readback)) = (
resources.images.get(image_index),
resources.readback_buffers.get(image_index),
) {
let range = super::color_subresource_range();
let to_transfer = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::PRESENT_SRC_KHR)
.new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
.src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
.dst_access_mask(vk::AccessFlags::TRANSFER_READ)
.image(*image)
.subresource_range(range);
let back_to_present = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
.new_layout(vk::ImageLayout::PRESENT_SRC_KHR)
.src_access_mask(vk::AccessFlags::TRANSFER_READ)
.dst_access_mask(vk::AccessFlags::empty())
.image(*image)
.subresource_range(range);
let region = vk::BufferImageCopy::default()
.image_subresource(
vk::ImageSubresourceLayers::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)
.mip_level(0)
.base_array_layer(0)
.layer_count(1),
)
.image_extent(vk::Extent3D {
width: swapchain.report.plan.extent.0,
height: swapchain.report.plan.extent.1,
depth: 1,
});
device.device().cmd_pipeline_barrier(
command_buffer,
vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT,
vk::PipelineStageFlags::TRANSFER,
vk::DependencyFlags::empty(),
&[],
&[],
&[to_transfer],
);
device.device().cmd_copy_image_to_buffer(
command_buffer,
*image,
vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
readback.buffer,
&[region],
);
device.device().cmd_pipeline_barrier(
command_buffer,
vk::PipelineStageFlags::TRANSFER,
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
vk::DependencyFlags::empty(),
&[],
&[],
&[back_to_present],
);
}
} }
// SAFETY: The render pass owns the attachment layout transitions for this clear-and-present path. // SAFETY: The render pass owns the attachment layout transitions for this clear-and-present path.
@@ -633,6 +852,9 @@ impl VulkanSmokeRenderer {
fn destroy_device_owned_resources(&mut self) { fn destroy_device_owned_resources(&mut self) {
self.destroy_swapchain_resources(); self.destroy_swapchain_resources();
if let Some(device) = self.device.as_ref() { if let Some(device) = self.device.as_ref() {
for texture in self.textures.drain(..) {
destroy_allocated_image(device, &texture);
}
if let Some(buffer) = self.index_buffer.take() { if let Some(buffer) = self.index_buffer.take() {
// SAFETY: Buffer and memory belong to this device and are destroyed once after the device has been idled and frame work has been torn down. // SAFETY: Buffer and memory belong to this device and are destroyed once after the device has been idled and frame work has been torn down.
unsafe { unsafe {
@@ -658,7 +880,7 @@ impl VulkanSmokeRenderer {
} }
fn shutdown_inner(&mut self) -> Result<VulkanSmokeShutdownReport, VulkanSmokeRendererError> { fn shutdown_inner(&mut self) -> Result<VulkanSmokeShutdownReport, VulkanSmokeRendererError> {
if let Some(device) = self.device.as_ref() { let completed_readback = if let Some(device) = self.device.as_ref() {
// SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts. // SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts.
unsafe { device.device().device_wait_idle() }.map_err(|error| { unsafe { device.device().device_wait_idle() }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation { VulkanSmokeRendererError::VulkanOperation {
@@ -666,41 +888,122 @@ impl VulkanSmokeRenderer {
result: error, result: error,
} }
})?; })?;
} self.completed_readback(device)?
} else {
None
};
let readback_artifact = completed_readback.map(|bytes| {
self.report.readback_byte_count = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
self.report.readback_fnv1a64 = fnv1a64(&bytes);
VulkanReadbackArtifact {
format: self
.swapchain_ref()
.map_or(0, |swapchain| swapchain.report.plan.format.format),
width: self.report.swapchain_extent.0,
height: self.report.swapchain_extent.1,
bytes,
}
});
self.destroy_device_owned_resources(); self.destroy_device_owned_resources();
let validation = take_runtime_owners_with_validation_snapshot( let validation = take_runtime_children_with_validation_snapshot(
&mut self.instance,
&mut self.validation,
&mut self.surface, &mut self.surface,
&mut self.device, &mut self.device,
&mut self.swapchain, &mut self.swapchain,
self.validation.as_ref(),
VulkanValidationMessenger::report, VulkanValidationMessenger::report,
) )
.unwrap_or_default(); .unwrap_or_default();
self.validation.take();
self.instance.take();
Ok(VulkanSmokeShutdownReport { Ok(VulkanSmokeShutdownReport {
renderer_report: self.report.clone(), renderer_report: self.report.clone(),
readback_artifact,
swapchain_recreate_count: self.swapchain_recreate_count, swapchain_recreate_count: self.swapchain_recreate_count,
validation, validation,
}) })
} }
fn completed_readback(
&self,
device: &VulkanLogicalDeviceProbe,
) -> Result<Option<Vec<u8>>, VulkanSmokeRendererError> {
let Some(resources) = self.swapchain_resources.as_ref() else {
return Ok(None);
};
let byte_len = usize::try_from(self.report.swapchain_extent.0)
.ok()
.and_then(|width| {
usize::try_from(self.report.swapchain_extent.1)
.ok()
.and_then(|height| width.checked_mul(height))
})
.and_then(|pixels| pixels.checked_mul(4))
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
context: "completed readback byte length",
})?;
if resources.readback_buffers.is_empty() {
return Ok(None);
}
let Some(image_index) = completed_readback_buffer_index(
self.last_readback_image_index,
resources.readback_buffers.len(),
)?
else {
return Ok(None);
};
let buffer = resources.readback_buffers.get(image_index).ok_or(
VulkanSmokeRendererError::InvariantViolation {
context: "last readback image index",
},
)?;
Ok(Some(readback_buffer_bytes(device, buffer, byte_len)?))
}
fn teardown(&mut self) { fn teardown(&mut self) {
if let Some(device) = self.device.as_ref() { if let Some(device) = self.device.as_ref() {
// SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts. // SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts.
let _ = unsafe { device.device().device_wait_idle() }; let _ = unsafe { device.device().device_wait_idle() };
} }
self.destroy_device_owned_resources(); self.destroy_device_owned_resources();
let _ = take_runtime_owners_with_validation_snapshot( let _ = take_runtime_children_with_validation_snapshot(
&mut self.instance,
&mut self.validation,
&mut self.surface, &mut self.surface,
&mut self.device, &mut self.device,
&mut self.swapchain, &mut self.swapchain,
self.validation.as_ref(),
VulkanValidationMessenger::report, VulkanValidationMessenger::report,
); );
self.validation.take();
self.instance.take();
} }
} }
fn completed_readback_buffer_index(
last_image_index: Option<usize>,
buffer_count: usize,
) -> Result<Option<usize>, VulkanSmokeRendererError> {
match last_image_index {
None => Ok(None),
Some(index) if index < buffer_count => Ok(Some(index)),
Some(_) => Err(VulkanSmokeRendererError::InvariantViolation {
context: "last readback image index",
}),
}
}
fn matrix_bytes(matrix: [f32; 16]) -> [u8; 64] {
let mut bytes = [0; 64];
for (index, value) in matrix.into_iter().enumerate() {
bytes[index * 4..(index + 1) * 4].copy_from_slice(&value.to_ne_bytes());
}
bytes
}
fn fnv1a64(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
})
}
impl Drop for VulkanSmokeRenderer { impl Drop for VulkanSmokeRenderer {
fn drop(&mut self) { fn drop(&mut self) {
self.teardown(); self.teardown();
@@ -710,8 +1013,8 @@ impl Drop for VulkanSmokeRenderer {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
take_runtime_owners_in_dependency_order, take_runtime_owners_with_validation_snapshot, completed_readback_buffer_index, take_runtime_children_with_validation_snapshot,
RollbackOnDrop, take_runtime_owners_in_dependency_order, RollbackOnDrop,
}; };
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@@ -794,6 +1097,13 @@ mod tests {
); );
} }
#[test]
fn completed_readback_selects_only_the_last_submitted_image() {
assert_eq!(completed_readback_buffer_index(None, 2), Ok(None));
assert_eq!(completed_readback_buffer_index(Some(1), 2), Ok(Some(1)));
assert!(completed_readback_buffer_index(Some(2), 2).is_err());
}
#[test] #[test]
fn runtime_owners_drop_remaining_children_after_partial_init_failures() { fn runtime_owners_drop_remaining_children_after_partial_init_failures() {
let cases = [ let cases = [
@@ -850,7 +1160,7 @@ mod tests {
} }
#[test] #[test]
fn final_validation_snapshot_is_captured_before_validation_drop() { fn final_validation_snapshot_is_captured_after_surface_device_swapchain_drop() {
let log = Rc::new(RefCell::new(Vec::new())); let log = Rc::new(RefCell::new(Vec::new()));
let mut instance = Some(tracker(TeardownStep::Instance, &log)); let mut instance = Some(tracker(TeardownStep::Instance, &log));
let mut validation = Some(tracker(TeardownStep::Validation, &log)); let mut validation = Some(tracker(TeardownStep::Validation, &log));
@@ -858,17 +1168,18 @@ mod tests {
let mut device = Some(tracker(TeardownStep::Device, &log)); let mut device = Some(tracker(TeardownStep::Device, &log));
let mut swapchain = Some(tracker(TeardownStep::Swapchain, &log)); let mut swapchain = Some(tracker(TeardownStep::Swapchain, &log));
let snapshot = take_runtime_owners_with_validation_snapshot( let snapshot = take_runtime_children_with_validation_snapshot(
&mut instance,
&mut validation,
&mut surface, &mut surface,
&mut device, &mut device,
&mut swapchain, &mut swapchain,
validation.as_ref(),
|_| { |_| {
log.borrow_mut().push(TeardownStep::Snapshot); log.borrow_mut().push(TeardownStep::Snapshot);
TeardownStep::Validation TeardownStep::Validation
}, },
); );
validation.take();
instance.take();
assert_eq!(snapshot, Some(TeardownStep::Validation)); assert_eq!(snapshot, Some(TeardownStep::Validation));
assert_eq!( assert_eq!(
@@ -876,10 +1187,10 @@ mod tests {
.expect("all drop trackers released") .expect("all drop trackers released")
.into_inner(), .into_inner(),
vec![ vec![
TeardownStep::Snapshot,
TeardownStep::Swapchain, TeardownStep::Swapchain,
TeardownStep::Device, TeardownStep::Device,
TeardownStep::Surface, TeardownStep::Surface,
TeardownStep::Snapshot,
TeardownStep::Validation, TeardownStep::Validation,
TeardownStep::Instance, TeardownStep::Instance,
] ]
@@ -1,12 +1,13 @@
use ash::vk; use ash::vk;
use fparkan_platform::{NativeWindowHandles, RenderRequest}; use fparkan_platform::{NativeWindowHandles, RenderRequest};
use fparkan_render::{LegacyD3d7Projection, LegacyPipelineState, PipelineKey, RawCameraTransform};
use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc; use std::sync::Arc;
use super::{ use super::{
VulkanAllocatedBuffer, VulkanFrameSync, VulkanInstanceError, VulkanInstanceProbe, VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanFrameSync, VulkanInstanceError,
VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanSurfaceError, VulkanSurfaceProbe, VulkanInstanceProbe, VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanSurfaceError,
VulkanSwapchainProbe, VulkanSwapchainProbeError, VulkanSwapchainResources, VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainProbeError, VulkanSwapchainResources,
VulkanValidationMessenger, VulkanValidationMessenger,
}; };
use crate::shader_manifest::VulkanShaderManifestError; use crate::shader_manifest::VulkanShaderManifestError;
@@ -24,10 +25,464 @@ pub struct VulkanSmokeRendererCreateInfo {
pub render_request: RenderRequest, pub render_request: RenderRequest,
/// Whether validation layers must be enabled. /// Whether validation layers must be enabled.
pub enable_validation: bool, pub enable_validation: bool,
/// Static indexed geometry uploaded before the first live frame.
pub mesh: VulkanStaticMesh,
/// Camera contract for static geometry.
///
/// The default identity matrix retains the initial XY diagnostic viewer.
pub camera: VulkanStaticCamera,
/// Material textures uploaded before the first live frame.
///
/// An empty list retains the compatibility white fallback. A singleton list
/// is a deliberately explicit one-material compatibility mode; otherwise
/// every source batch selector must resolve to one entry in this list.
pub materials: Vec<VulkanStaticMaterial>,
/// Optional shared bootstrap progress tracker for failure evidence. /// Optional shared bootstrap progress tracker for failure evidence.
pub bootstrap_progress: Option<Arc<VulkanSmokeBootstrapProgress>>, pub bootstrap_progress: Option<Arc<VulkanSmokeBootstrapProgress>>,
} }
/// One vertex accepted by the initial static Vulkan geometry path.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VulkanStaticVertex {
/// World or clip position transformed by [`VulkanStaticCamera`].
pub position: [f32; 3],
/// Linear RGB vertex color.
pub color: [f32; 3],
/// Texture coordinate consumed by the static material bridge.
pub uv: [f32; 2],
}
/// A static geometry camera represented in the shader's matrix memory order.
///
/// `clip_from_world` contains row-major D3D7 data. GLSL's default column-major
/// `mat4` interpretation deliberately transposes that storage, making
/// `matrix * vec4(position, 1)` equivalent to D3D7's row-vector multiplication.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VulkanStaticCamera {
/// Row-major clip-from-world transform consumed by the vertex shader.
pub clip_from_world: [f32; 16],
}
impl VulkanStaticCamera {
/// Builds a static camera from the recovered Ngi32 D3D7 camera contract.
#[must_use]
pub fn from_legacy_d3d7(
transform: RawCameraTransform,
projection: LegacyD3d7Projection,
) -> Option<Self> {
let view = transform.try_direct3d7_view_row_major()?;
let projection = projection.try_direct3d7_projection_row_major()?;
Self::from_row_major_view_projection(view, projection)
}
/// Builds a camera from finite row-major view and projection matrices.
///
/// This is the runtime-facing form of the contract. It is intentionally
/// independent of the D3D7 recovery path so a future native camera
/// controller can submit its per-frame matrices without recreating Vulkan
/// resources.
#[must_use]
pub fn from_row_major_view_projection(view: [f32; 16], projection: [f32; 16]) -> Option<Self> {
view.iter()
.chain(projection.iter())
.all(|value| value.is_finite())
.then_some(Self {
clip_from_world: multiply_row_major(view, projection),
})
}
/// Returns whether every matrix element is finite.
#[must_use]
pub fn is_finite(self) -> bool {
self.clip_from_world.iter().all(|value| value.is_finite())
}
}
impl Default for VulkanStaticCamera {
fn default() -> Self {
Self {
clip_from_world: [
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
],
}
}
}
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
let mut result = [0.0; 16];
for row in 0..4 {
for column in 0..4 {
result[row * 4 + column] = (0..4)
.map(|inner| left[row * 4 + inner] * right[inner * 4 + column])
.sum();
}
}
result
}
/// Static indexed geometry uploaded to live Vulkan buffers.
#[derive(Clone, Debug, PartialEq)]
pub struct VulkanStaticMesh {
/// Vertex data in pipeline order.
pub vertices: Vec<VulkanStaticVertex>,
/// Triangle-list indices into [`Self::vertices`].
pub indices: Vec<u32>,
/// Source-preserving triangle draw ranges in [`Self::indices`].
pub draw_ranges: Vec<VulkanStaticDrawRange>,
}
/// One indexed triangle-list draw retained from an original mesh batch.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanStaticDrawRange {
/// First index in the shared index buffer.
pub first_index: u32,
/// Number of indices in this draw.
pub index_count: u32,
/// Original positional `Batch20.material_index` selector.
pub material_index: u16,
/// Backend-neutral fixed-function state for this source range.
pub pipeline_state: LegacyPipelineState,
/// Alpha-test reference in legacy 0..=255 units; dynamic material data.
pub alpha_test_reference: u8,
}
impl VulkanStaticDrawRange {
/// Returns the canonical key used for Vulkan pipeline selection.
#[must_use]
pub fn pipeline_key(self) -> PipelineKey {
self.pipeline_state.into()
}
/// Returns the normalized cutoff consumed by the fragment shader.
#[must_use]
pub fn alpha_test_cutoff(self) -> f32 {
if self.pipeline_state.alpha_test {
f32::from(self.alpha_test_reference) / 255.0
} else {
0.0
}
}
}
/// One diffuse material texture keyed by an original MSH batch selector.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanStaticMaterial {
/// Positional material selector used by one or more source batches.
pub material_index: u16,
/// Decoded RGBA8 diffuse texture for this selector.
pub texture: VulkanStaticTexture,
}
/// Decoded RGBA8 image accepted by the initial Vulkan texture upload path.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanStaticTexture {
/// Image width in texels.
pub width: u32,
/// Image height in texels.
pub height: u32,
/// Row-major RGBA8 pixels.
pub rgba8: Vec<u8>,
}
impl VulkanStaticTexture {
pub(super) fn validate(&self) -> Result<(), &'static str> {
let pixels = usize::try_from(self.width)
.ok()
.and_then(|width| {
usize::try_from(self.height)
.ok()
.and_then(|height| width.checked_mul(height))
})
.and_then(|pixels| pixels.checked_mul(4));
match pixels {
None => Err("static texture dimensions overflow address space"),
Some(0) => Err("static texture has zero extent"),
Some(expected) if expected != self.rgba8.len() => {
Err("static texture rgba8 byte count does not match extent")
}
Some(_) => Ok(()),
}
}
}
/// Resolves each source range to its descriptor-set material index.
///
/// Empty material input selects the renderer's white fallback. A singleton is
/// the explicit direct-TEXM compatibility mode. Multiple materials must map
/// each `Batch20.material_index` exactly and never duplicate a selector.
pub(super) fn resolve_draw_texture_indices(
draw_ranges: &[VulkanStaticDrawRange],
materials: &[VulkanStaticMaterial],
) -> Result<Vec<usize>, &'static str> {
for (index, material) in materials.iter().enumerate() {
material.texture.validate()?;
if materials[..index]
.iter()
.any(|previous| previous.material_index == material.material_index)
{
return Err("static material selectors must be unique");
}
}
draw_ranges
.iter()
.map(|range| {
if materials.len() <= 1 {
Ok(0)
} else {
materials
.iter()
.position(|material| material.material_index == range.material_index)
.ok_or("static mesh draw range has no material texture")
}
})
.collect()
}
impl VulkanStaticMesh {
/// Returns the compatibility triangle used by the native Stage 0 smoke app.
#[must_use]
pub fn smoke_triangle() -> Self {
Self {
vertices: vec![
VulkanStaticVertex {
position: [0.0, -0.55, 0.0],
color: [1.0, 0.2, 0.2],
uv: [0.5, 0.0],
},
VulkanStaticVertex {
position: [0.55, 0.55, 0.0],
color: [0.2, 1.0, 0.2],
uv: [1.0, 1.0],
},
VulkanStaticVertex {
position: [-0.55, 0.55, 0.0],
color: [0.2, 0.4, 1.0],
uv: [0.0, 1.0],
},
],
indices: vec![0, 1, 2],
draw_ranges: vec![VulkanStaticDrawRange {
first_index: 0,
index_count: 3,
material_index: 0,
pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0,
}],
}
}
pub(super) fn validate(&self) -> Result<(), &'static str> {
if self.vertices.is_empty() {
return Err("static mesh has no vertices");
}
if self.indices.is_empty() || !self.indices.len().is_multiple_of(3) {
return Err("static mesh indices must contain complete triangles");
}
if self.draw_ranges.is_empty() {
return Err("static mesh has no draw ranges");
}
let mut expected_first = 0_u32;
for range in &self.draw_ranges {
if range.first_index != expected_first
|| range.index_count == 0
|| !range.index_count.is_multiple_of(3)
{
return Err("static mesh draw ranges must be contiguous complete triangles");
}
expected_first = expected_first
.checked_add(range.index_count)
.ok_or("static mesh draw range exceeds index count")?;
}
if usize::try_from(expected_first).ok() != Some(self.indices.len()) {
return Err("static mesh draw ranges must cover all indices");
}
if self.indices.iter().any(|&index| {
usize::try_from(index)
.ok()
.is_none_or(|index| index >= self.vertices.len())
}) {
return Err("static mesh index exceeds vertex count");
}
Ok(())
}
}
#[cfg(test)]
mod static_mesh_tests {
use super::*;
#[test]
fn smoke_triangle_is_valid_complete_geometry() {
let mesh = VulkanStaticMesh::smoke_triangle();
assert_eq!(mesh.indices, vec![0, 1, 2]);
assert_eq!(mesh.validate(), Ok(()));
}
#[test]
fn static_camera_composes_recovered_d3d7_view_before_projection() {
let transform = RawCameraTransform {
words: [
0.0_f32.to_bits(),
(-1.0_f32).to_bits(),
0.0_f32.to_bits(),
10.0_f32.to_bits(),
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
20.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
30.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
],
};
let projection = LegacyD3d7Projection {
viewport: [0, 0, 1024, 768],
near_plane: 0.5,
far_plane: 700.0,
field_of_view_radians: 1.3,
};
let camera = VulkanStaticCamera::from_legacy_d3d7(transform, projection)
.expect("recovered camera inputs are valid");
assert!(camera.is_finite());
assert_eq!(camera.clip_from_world[0], 0.65_f32.cos());
assert_eq!(camera.clip_from_world[6], 0.65_f32.sin() * 700.0 / 699.5);
assert_eq!(camera.clip_from_world[7], 0.65_f32.sin());
}
#[test]
fn static_camera_accepts_finite_runtime_matrices_and_rejects_nan() {
let identity = VulkanStaticCamera::default().clip_from_world;
assert_eq!(
VulkanStaticCamera::from_row_major_view_projection(identity, identity),
Some(VulkanStaticCamera::default())
);
let mut invalid = identity;
invalid[5] = f32::NAN;
assert_eq!(
VulkanStaticCamera::from_row_major_view_projection(invalid, identity),
None
);
}
#[test]
fn static_mesh_rejects_bad_triangle_topology_and_indices() {
let no_vertices = VulkanStaticMesh {
vertices: Vec::new(),
indices: vec![0, 1, 2],
draw_ranges: VulkanStaticMesh::smoke_triangle().draw_ranges,
};
let incomplete_triangle = VulkanStaticMesh {
vertices: VulkanStaticMesh::smoke_triangle().vertices,
indices: vec![0, 1],
draw_ranges: vec![VulkanStaticDrawRange {
first_index: 0,
index_count: 2,
material_index: 0,
pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0,
}],
};
let out_of_range_index = VulkanStaticMesh {
vertices: VulkanStaticMesh::smoke_triangle().vertices,
indices: vec![0, 1, 3],
draw_ranges: VulkanStaticMesh::smoke_triangle().draw_ranges,
};
assert_eq!(no_vertices.validate(), Err("static mesh has no vertices"));
assert_eq!(
incomplete_triangle.validate(),
Err("static mesh indices must contain complete triangles")
);
assert_eq!(
out_of_range_index.validate(),
Err("static mesh index exceeds vertex count")
);
}
#[test]
fn material_descriptors_follow_source_batch_selectors() {
let ranges = [
VulkanStaticDrawRange {
first_index: 0,
index_count: 3,
material_index: 7,
pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0,
},
VulkanStaticDrawRange {
first_index: 3,
index_count: 3,
material_index: 2,
pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0,
},
];
let texture = || VulkanStaticTexture {
width: 1,
height: 1,
rgba8: vec![255; 4],
};
let materials = [
VulkanStaticMaterial {
material_index: 2,
texture: texture(),
},
VulkanStaticMaterial {
material_index: 7,
texture: texture(),
},
];
assert_eq!(
resolve_draw_texture_indices(&ranges, &materials),
Ok(vec![1, 0])
);
}
#[test]
fn draw_range_pipeline_key_follows_backend_neutral_state() {
let base = VulkanStaticMesh::smoke_triangle().draw_ranges[0];
let blended = VulkanStaticDrawRange {
pipeline_state: LegacyPipelineState {
blend: fparkan_render::LegacyBlendMode::SourceAlpha,
..LegacyPipelineState::default()
},
..base
};
assert_eq!(base.pipeline_key().packed(), 0);
assert_ne!(base.pipeline_key(), blended.pipeline_key());
}
#[test]
fn alpha_cutoff_is_dynamic_and_disabled_outside_alpha_test_pipeline() {
let base = VulkanStaticMesh::smoke_triangle().draw_ranges[0];
let disabled = VulkanStaticDrawRange {
alpha_test_reference: 200,
..base
};
let enabled = VulkanStaticDrawRange {
pipeline_state: LegacyPipelineState {
alpha_test: true,
..LegacyPipelineState::default()
},
alpha_test_reference: 128,
..base
};
assert_eq!(disabled.alpha_test_cutoff(), 0.0);
assert_eq!(enabled.alpha_test_cutoff(), 128.0 / 255.0);
}
}
/// Shared bootstrap progress used to report partial renderer startup evidence. /// Shared bootstrap progress used to report partial renderer startup evidence.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct VulkanSmokeBootstrapProgress { pub struct VulkanSmokeBootstrapProgress {
@@ -125,6 +580,16 @@ pub struct VulkanSmokeRendererReport {
pub swapchain_extent: (u32, u32), pub swapchain_extent: (u32, u32),
/// Current swapchain image count. /// Current swapchain image count.
pub swapchain_image_count: u32, pub swapchain_image_count: u32,
/// Current swapchain image format as a raw Vulkan enum value.
pub swapchain_image_format: i32,
/// Current swapchain image-usage flags as raw Vulkan bits.
pub swapchain_image_usage: u32,
/// Number of submitted swapchain image-to-buffer copy commands.
pub readback_copy_count: u64,
/// Byte length of the final completed readback artifact.
pub readback_byte_count: u64,
/// Stable FNV-1a hash of the final completed readback artifact.
pub readback_fnv1a64: u64,
} }
/// Measured validation counters from the live smoke loop. /// Measured validation counters from the live smoke loop.
@@ -138,11 +603,27 @@ pub struct VulkanValidationReport {
pub vuids: Vec<String>, pub vuids: Vec<String>,
} }
/// CPU-owned copy of the final completed swapchain image readback.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanReadbackArtifact {
/// Vulkan swapchain format as a raw enum value.
pub format: i32,
/// Width of each image in pixels.
pub width: u32,
/// Height of each image in pixels.
pub height: u32,
/// One raw four-byte-per-pixel image: the last successfully submitted
/// swapchain image before synchronized teardown.
pub bytes: Vec<u8>,
}
/// Final smoke renderer shutdown evidence captured after explicit teardown. /// Final smoke renderer shutdown evidence captured after explicit teardown.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSmokeShutdownReport { pub struct VulkanSmokeShutdownReport {
/// Stable renderer bootstrap and swapchain report. /// Stable renderer bootstrap and swapchain report.
pub renderer_report: VulkanSmokeRendererReport, pub renderer_report: VulkanSmokeRendererReport,
/// Optional raw GPU readback retained after synchronization and before teardown.
pub readback_artifact: Option<VulkanReadbackArtifact>,
/// Measured swapchain recreation count for the completed smoke loop. /// Measured swapchain recreation count for the completed smoke loop.
pub swapchain_recreate_count: u32, pub swapchain_recreate_count: u32,
/// Final validation snapshot captured before the debug messenger is destroyed. /// Final validation snapshot captured before the debug messenger is destroyed.
@@ -185,6 +666,21 @@ pub enum VulkanSmokeRendererError {
/// Operation context. /// Operation context.
context: &'static str, context: &'static str,
}, },
/// The submitted static geometry cannot be represented by this path.
InvalidStaticMesh {
/// Validation failure detail.
context: &'static str,
},
/// The static camera matrix contains a non-finite element.
InvalidStaticCamera {
/// Validation failure detail.
context: &'static str,
},
/// The submitted static texture cannot be represented by this path.
InvalidStaticTexture {
/// Validation failure detail.
context: &'static str,
},
/// Internal smoke renderer state was unexpectedly absent. /// Internal smoke renderer state was unexpectedly absent.
InvariantViolation { InvariantViolation {
/// Missing state context. /// Missing state context.
@@ -206,6 +702,13 @@ impl std::fmt::Display for VulkanSmokeRendererError {
Self::MissingMemoryType { context } => { Self::MissingMemoryType { context } => {
write!(f, "{context}: no compatible Vulkan memory type") write!(f, "{context}: no compatible Vulkan memory type")
} }
Self::InvalidStaticMesh { context } => write!(f, "invalid static mesh: {context}"),
Self::InvalidStaticCamera { context } => {
write!(f, "invalid static camera: {context}")
}
Self::InvalidStaticTexture { context } => {
write!(f, "invalid static texture: {context}")
}
Self::InvariantViolation { context } => { Self::InvariantViolation { context } => {
write!(f, "renderer invariant violated: {context}") write!(f, "renderer invariant violated: {context}")
} }
@@ -226,9 +729,18 @@ pub struct VulkanSmokeRenderer {
pub(super) swapchain_resources: Option<VulkanSwapchainResources>, pub(super) swapchain_resources: Option<VulkanSwapchainResources>,
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>, pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
pub(super) index_buffer: Option<VulkanAllocatedBuffer>, pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
pub(super) textures: Vec<VulkanAllocatedImage>,
pub(super) draw_ranges: Vec<VulkanStaticDrawRange>,
pub(super) draw_texture_indices: Vec<usize>,
pub(super) camera: VulkanStaticCamera,
pub(super) frame_sync: Vec<VulkanFrameSync>, pub(super) frame_sync: Vec<VulkanFrameSync>,
pub(super) images_in_flight: Vec<vk::Fence>, pub(super) images_in_flight: Vec<vk::Fence>,
pub(super) current_frame: usize, pub(super) current_frame: usize,
/// Last swapchain image whose color attachment was copied after a
/// successful graphics submission. Reset whenever swapchain resources are
/// replaced so a teardown never reads a buffer from an old swapchain.
pub(super) last_readback_image_index: Option<usize>,
pub(super) depth_request: fparkan_platform::DepthStencilSupport,
pub(super) pending_extent: Option<(u32, u32)>, pub(super) pending_extent: Option<(u32, u32)>,
pub(super) swapchain_recreate_count: u32, pub(super) swapchain_recreate_count: u32,
pub(super) report: VulkanSmokeRendererReport, pub(super) report: VulkanSmokeRendererReport,
@@ -185,7 +185,7 @@ pub fn create_vulkan_swapchain_probe_for_extent(
height: plan.extent.1, height: plan.extent.1,
}) })
.image_array_layers(1) .image_array_layers(1)
.image_usage(vk::ImageUsageFlags::COLOR_ATTACHMENT) .image_usage(vk::ImageUsageFlags::from_raw(plan.image_usage))
.image_sharing_mode(sharing_mode) .image_sharing_mode(sharing_mode)
.queue_family_indices(&queue_family_indices) .queue_family_indices(&queue_family_indices)
.pre_transform(raw_capabilities.current_transform) .pre_transform(raw_capabilities.current_transform)
@@ -1,37 +1,61 @@
#![allow(unsafe_code)] #![allow(unsafe_code)]
use ash::vk; use ash::vk;
use fparkan_platform::DepthStencilSupport;
use fparkan_render::{
LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState, PipelineKey,
};
use std::collections::BTreeMap;
use super::{ use super::{
color_subresource_range, VulkanAllocatedBuffer, VulkanLogicalDeviceProbe, color_subresource_range, create_depth_attachment, destroy_depth_attachment,
VulkanSmokeRendererError, VulkanSwapchainProbe, TRIANGLE_FRAGMENT_SHADER_WORDS, VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment, VulkanInstanceProbe,
TRIANGLE_VERTEX_SHADER_WORDS, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe,
TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS,
}; };
pub(super) struct VulkanSwapchainResources { pub(super) struct VulkanSwapchainResources {
pub(super) image_views: Vec<vk::ImageView>, pub(super) image_views: Vec<vk::ImageView>,
pub(super) images: Vec<vk::Image>,
pub(super) readback_buffers: Vec<VulkanAllocatedBuffer>,
pub(super) depth_attachment: VulkanDepthAttachment,
pub(super) render_pass: vk::RenderPass, pub(super) render_pass: vk::RenderPass,
pub(super) pipeline_layout: vk::PipelineLayout, pub(super) pipeline_layout: vk::PipelineLayout,
pub(super) pipeline: vk::Pipeline, pub(super) descriptor_set_layout: vk::DescriptorSetLayout,
pub(super) descriptor_pool: vk::DescriptorPool,
pub(super) descriptor_sets: Vec<vk::DescriptorSet>,
pub(super) sampler: vk::Sampler,
/// Live graphics pipelines indexed by canonical backend-neutral state.
pub(super) pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
pub(super) framebuffers: Vec<vk::Framebuffer>, pub(super) framebuffers: Vec<vk::Framebuffer>,
pub(super) command_buffers: Vec<vk::CommandBuffer>, pub(super) command_buffers: Vec<vk::CommandBuffer>,
} }
struct PartialSwapchainResources { struct PartialSwapchainResources {
image_views: Vec<vk::ImageView>, image_views: Vec<vk::ImageView>,
readback_buffers: Vec<VulkanAllocatedBuffer>,
depth_attachment: Option<VulkanDepthAttachment>,
render_pass: Option<vk::RenderPass>, render_pass: Option<vk::RenderPass>,
pipeline_layout: Option<vk::PipelineLayout>, pipeline_layout: Option<vk::PipelineLayout>,
pipeline: Option<vk::Pipeline>, descriptor_set_layout: Option<vk::DescriptorSetLayout>,
descriptor_pool: Option<vk::DescriptorPool>,
sampler: Option<vk::Sampler>,
pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
framebuffers: Vec<vk::Framebuffer>, framebuffers: Vec<vk::Framebuffer>,
command_buffers: Vec<vk::CommandBuffer>, command_buffers: Vec<vk::CommandBuffer>,
} }
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub(super) fn create_swapchain_resources( pub(super) fn create_swapchain_resources(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
swapchain: &VulkanSwapchainProbe, swapchain: &VulkanSwapchainProbe,
command_pool: vk::CommandPool, command_pool: vk::CommandPool,
vertex_buffer: &VulkanAllocatedBuffer, vertex_buffer: &VulkanAllocatedBuffer,
index_buffer: &VulkanAllocatedBuffer, index_buffer: &VulkanAllocatedBuffer,
textures: &[VulkanAllocatedImage],
draw_ranges: &[super::VulkanStaticDrawRange],
depth_request: DepthStencilSupport,
reuse_command_pool: bool, reuse_command_pool: bool,
) -> Result<VulkanSwapchainResources, VulkanSmokeRendererError> { ) -> Result<VulkanSwapchainResources, VulkanSmokeRendererError> {
// SAFETY: The swapchain is live and owned by this renderer for the duration of the query. // SAFETY: The swapchain is live and owned by this renderer for the duration of the query.
@@ -50,16 +74,34 @@ pub(super) fn create_swapchain_resources(
&images, &images,
swapchain.report.plan.format.format, swapchain.report.plan.format.format,
)?, )?,
readback_buffers: Vec::new(),
depth_attachment: None,
render_pass: None, render_pass: None,
pipeline_layout: None, pipeline_layout: None,
pipeline: None, descriptor_set_layout: None,
descriptor_pool: None,
sampler: None,
pipelines: BTreeMap::new(),
framebuffers: Vec::new(), framebuffers: Vec::new(),
command_buffers: Vec::new(), command_buffers: Vec::new(),
}; };
let (render_pass, pipeline_layout, pipeline) = match create_swapchain_pipeline_bundle( let (
depth_attachment,
render_pass,
pipeline_layout,
pipelines,
descriptor_set_layout,
descriptor_pool,
descriptor_sets,
sampler,
) = match create_swapchain_pipeline_bundle(
instance,
device, device,
swapchain.report.plan.format.format, swapchain.report.plan.format.format,
swapchain.report.plan.extent, swapchain.report.plan.extent,
textures,
draw_ranges,
depth_request,
) { ) {
Ok(bundle) => bundle, Ok(bundle) => bundle,
Err(error) => { Err(error) => {
@@ -67,13 +109,19 @@ pub(super) fn create_swapchain_resources(
return Err(error); return Err(error);
} }
}; };
let depth_view = depth_attachment.image.view;
partial.depth_attachment = Some(depth_attachment);
partial.render_pass = Some(render_pass); partial.render_pass = Some(render_pass);
partial.pipeline_layout = Some(pipeline_layout); partial.pipeline_layout = Some(pipeline_layout);
partial.pipeline = Some(pipeline); partial.descriptor_set_layout = Some(descriptor_set_layout);
partial.descriptor_pool = Some(descriptor_pool);
partial.sampler = Some(sampler);
partial.pipelines = pipelines;
let framebuffers = match create_swapchain_framebuffers( let framebuffers = match create_swapchain_framebuffers(
device, device,
render_pass, render_pass,
&partial.image_views, &partial.image_views,
depth_view,
swapchain.report.plan.extent, swapchain.report.plan.extent,
) { ) {
Ok(framebuffers) => framebuffers, Ok(framebuffers) => framebuffers,
@@ -83,6 +131,30 @@ pub(super) fn create_swapchain_resources(
} }
}; };
partial.framebuffers = framebuffers; partial.framebuffers = framebuffers;
if vk::ImageUsageFlags::from_raw(swapchain.report.plan.image_usage)
.contains(vk::ImageUsageFlags::TRANSFER_SRC)
{
let byte_len = usize::try_from(swapchain.report.plan.extent.0)
.ok()
.and_then(|width| {
usize::try_from(swapchain.report.plan.extent.1)
.ok()
.and_then(|height| width.checked_mul(height))
})
.and_then(|pixels| pixels.checked_mul(4))
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
context: "swapchain readback byte length",
})?;
for _ in &images {
match super::create_readback_buffer(instance, device, byte_len) {
Ok(buffer) => partial.readback_buffers.push(buffer),
Err(error) => {
destroy_partial_swapchain_resources(device, command_pool, partial);
return Err(error);
}
}
}
}
reset_reusable_command_pool(device, command_pool, reuse_command_pool)?; reset_reusable_command_pool(device, command_pool, reuse_command_pool)?;
let command_buffers = match allocate_command_buffers( let command_buffers = match allocate_command_buffers(
device, device,
@@ -97,11 +169,25 @@ pub(super) fn create_swapchain_resources(
}; };
partial.command_buffers = command_buffers; partial.command_buffers = command_buffers;
let _ = (vertex_buffer, index_buffer); let _ = (vertex_buffer, index_buffer);
let depth_attachment =
partial
.depth_attachment
.take()
.ok_or(VulkanSmokeRendererError::InvariantViolation {
context: "depth attachment ownership after swapchain setup",
})?;
Ok(VulkanSwapchainResources { Ok(VulkanSwapchainResources {
image_views: partial.image_views, image_views: partial.image_views,
images,
readback_buffers: partial.readback_buffers,
depth_attachment,
render_pass, render_pass,
pipeline_layout, pipeline_layout,
pipeline, descriptor_set_layout,
descriptor_pool,
descriptor_sets,
sampler,
pipelines: partial.pipelines,
framebuffers: partial.framebuffers, framebuffers: partial.framebuffers,
command_buffers: partial.command_buffers, command_buffers: partial.command_buffers,
}) })
@@ -119,38 +205,98 @@ fn create_swapchain_image_views(
Ok(image_views) Ok(image_views)
} }
#[allow(clippy::type_complexity)]
fn create_swapchain_pipeline_bundle( fn create_swapchain_pipeline_bundle(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
format: i32, format: i32,
extent: (u32, u32), extent: (u32, u32),
) -> Result<(vk::RenderPass, vk::PipelineLayout, vk::Pipeline), VulkanSmokeRendererError> { textures: &[VulkanAllocatedImage],
let render_pass = create_render_pass(device, format)?; draw_ranges: &[super::VulkanStaticDrawRange],
let pipeline_layout = create_pipeline_layout(device).inspect_err(|_| { depth_request: DepthStencilSupport,
// SAFETY: The render pass was created above on this live logical device and is destroyed on setup failure. ) -> Result<
unsafe { device.device().destroy_render_pass(render_pass, None) }; (
})?; VulkanDepthAttachment,
let pipeline = create_graphics_pipeline(device, render_pass, pipeline_layout, extent) vk::RenderPass,
.inspect_err(|_| { vk::PipelineLayout,
BTreeMap<PipelineKey, vk::Pipeline>,
vk::DescriptorSetLayout,
vk::DescriptorPool,
Vec<vk::DescriptorSet>,
vk::Sampler,
),
VulkanSmokeRendererError,
> {
let depth_attachment = create_depth_attachment(instance, device, extent, depth_request)?;
let render_pass = match create_render_pass(device, format, depth_attachment.format) {
Ok(render_pass) => render_pass,
Err(error) => {
destroy_depth_attachment(device, &depth_attachment);
return Err(error);
}
};
let (descriptor_set_layout, descriptor_pool, descriptor_sets, sampler) =
create_texture_descriptor_bundle(device, textures).inspect_err(|_| {
// SAFETY: The render pass was created above on this live logical device and is destroyed on setup failure.
unsafe { device.device().destroy_render_pass(render_pass, None) };
destroy_depth_attachment(device, &depth_attachment);
})?;
let pipeline_layout =
create_pipeline_layout(device, descriptor_set_layout).inspect_err(|_| {
// SAFETY: The descriptor resources and render pass were created above and are rolled back on this device.
unsafe {
device.device().destroy_sampler(sampler, None);
device
.device()
.destroy_descriptor_pool(descriptor_pool, None);
device
.device()
.destroy_descriptor_set_layout(descriptor_set_layout, None);
device.device().destroy_render_pass(render_pass, None);
}
destroy_depth_attachment(device, &depth_attachment);
})?;
let pipelines =
create_graphics_pipeline_cache(device, render_pass, pipeline_layout, extent, draw_ranges)
.inspect_err(|_| {
// SAFETY: These objects were created above on this live logical device and are destroyed on setup failure. // SAFETY: These objects were created above on this live logical device and are destroyed on setup failure.
unsafe { unsafe {
device device
.device() .device()
.destroy_pipeline_layout(pipeline_layout, None); .destroy_pipeline_layout(pipeline_layout, None);
device.device().destroy_sampler(sampler, None);
device
.device()
.destroy_descriptor_pool(descriptor_pool, None);
device
.device()
.destroy_descriptor_set_layout(descriptor_set_layout, None);
device.device().destroy_render_pass(render_pass, None); device.device().destroy_render_pass(render_pass, None);
} }
destroy_depth_attachment(device, &depth_attachment);
})?; })?;
Ok((render_pass, pipeline_layout, pipeline)) Ok((
depth_attachment,
render_pass,
pipeline_layout,
pipelines,
descriptor_set_layout,
descriptor_pool,
descriptor_sets,
sampler,
))
} }
fn create_swapchain_framebuffers( fn create_swapchain_framebuffers(
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass, render_pass: vk::RenderPass,
image_views: &[vk::ImageView], image_views: &[vk::ImageView],
depth_view: vk::ImageView,
extent: (u32, u32), extent: (u32, u32),
) -> Result<Vec<vk::Framebuffer>, VulkanSmokeRendererError> { ) -> Result<Vec<vk::Framebuffer>, VulkanSmokeRendererError> {
let mut framebuffers = Vec::with_capacity(image_views.len()); let mut framebuffers = Vec::with_capacity(image_views.len());
for image_view in image_views.iter().copied() { for image_view in image_views.iter().copied() {
match create_framebuffer(device, render_pass, image_view, extent) { match create_framebuffer(device, render_pass, image_view, depth_view, extent) {
Ok(framebuffer) => framebuffers.push(framebuffer), Ok(framebuffer) => framebuffers.push(framebuffer),
Err(error) => { Err(error) => {
// SAFETY: These framebuffers were created above on this live logical device and are destroyed on setup failure. // SAFETY: These framebuffers were created above on this live logical device and are destroyed on setup failure.
@@ -208,6 +354,7 @@ fn create_image_view(
fn create_render_pass( fn create_render_pass(
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
format: i32, format: i32,
depth_format: vk::Format,
) -> Result<vk::RenderPass, VulkanSmokeRendererError> { ) -> Result<vk::RenderPass, VulkanSmokeRendererError> {
let color_attachment = vk::AttachmentDescription::default() let color_attachment = vk::AttachmentDescription::default()
.format(vk::Format::from_raw(format)) .format(vk::Format::from_raw(format))
@@ -219,10 +366,23 @@ fn create_render_pass(
let color_attachment_ref = vk::AttachmentReference::default() let color_attachment_ref = vk::AttachmentReference::default()
.attachment(0) .attachment(0)
.layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL); .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL);
let depth_attachment = vk::AttachmentDescription::default()
.format(depth_format)
.samples(vk::SampleCountFlags::TYPE_1)
.load_op(vk::AttachmentLoadOp::CLEAR)
.store_op(vk::AttachmentStoreOp::DONT_CARE)
.stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
.stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
.initial_layout(vk::ImageLayout::UNDEFINED)
.final_layout(vk::ImageLayout::DEPTH_STENCIL_ATTACHMENT_OPTIMAL);
let depth_attachment_ref = vk::AttachmentReference::default()
.attachment(1)
.layout(vk::ImageLayout::DEPTH_STENCIL_ATTACHMENT_OPTIMAL);
let color_attachments = [color_attachment_ref]; let color_attachments = [color_attachment_ref];
let subpass = vk::SubpassDescription::default() let subpass = vk::SubpassDescription::default()
.pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS) .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
.color_attachments(&color_attachments); .color_attachments(&color_attachments)
.depth_stencil_attachment(&depth_attachment_ref);
let dependency = vk::SubpassDependency::default() let dependency = vk::SubpassDependency::default()
.src_subpass(vk::SUBPASS_EXTERNAL) .src_subpass(vk::SUBPASS_EXTERNAL)
.dst_subpass(0) .dst_subpass(0)
@@ -230,7 +390,7 @@ fn create_render_pass(
.dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT) .dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
.src_access_mask(vk::AccessFlags::empty()) .src_access_mask(vk::AccessFlags::empty())
.dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE); .dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE);
let attachments = [color_attachment]; let attachments = [color_attachment, depth_attachment];
let subpasses = [subpass]; let subpasses = [subpass];
let dependencies = [dependency]; let dependencies = [dependency];
let create_info = vk::RenderPassCreateInfo::default() let create_info = vk::RenderPassCreateInfo::default()
@@ -248,9 +408,23 @@ fn create_render_pass(
fn create_pipeline_layout( fn create_pipeline_layout(
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
descriptor_set_layout: vk::DescriptorSetLayout,
) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> { ) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> {
let create_info = vk::PipelineLayoutCreateInfo::default(); let set_layouts = [descriptor_set_layout];
// SAFETY: The pipeline layout has no descriptor sets or push constants in Stage 0 smoke. let push_constant_ranges = [
vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::VERTEX)
.offset(0)
.size(64),
vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
.offset(64)
.size(u32::try_from(std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
];
let create_info = vk::PipelineLayoutCreateInfo::default()
.set_layouts(&set_layouts)
.push_constant_ranges(&push_constant_ranges);
// SAFETY: The descriptor-set layout belongs to this live logical device.
unsafe { device.device().create_pipeline_layout(&create_info, None) }.map_err(|error| { unsafe { device.device().create_pipeline_layout(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation { VulkanSmokeRendererError::VulkanOperation {
context: "vkCreatePipelineLayout", context: "vkCreatePipelineLayout",
@@ -259,17 +433,176 @@ fn create_pipeline_layout(
}) })
} }
#[allow(clippy::too_many_lines)]
fn create_texture_descriptor_bundle(
device: &VulkanLogicalDeviceProbe,
textures: &[VulkanAllocatedImage],
) -> Result<
(
vk::DescriptorSetLayout,
vk::DescriptorPool,
Vec<vk::DescriptorSet>,
vk::Sampler,
),
VulkanSmokeRendererError,
> {
let binding = vk::DescriptorSetLayoutBinding::default()
.binding(0)
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(1)
.stage_flags(vk::ShaderStageFlags::FRAGMENT);
let bindings = [binding];
let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings);
// SAFETY: The layout description is stack-owned and references no external memory.
let layout = unsafe {
device
.device()
.create_descriptor_set_layout(&layout_info, None)
}
.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateDescriptorSetLayout",
result,
})?;
let texture_count = u32::try_from(textures.len()).map_err(|_| {
VulkanSmokeRendererError::InvariantViolation {
context: "static material texture count exceeds Vulkan descriptor limit",
}
})?;
if texture_count == 0 {
return Err(VulkanSmokeRendererError::InvariantViolation {
context: "static material texture list is empty",
});
}
let pool_size = vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(texture_count);
let pool_sizes = [pool_size];
let pool_info = vk::DescriptorPoolCreateInfo::default()
.pool_sizes(&pool_sizes)
.max_sets(texture_count);
let pool =
// SAFETY: The pool description is stack-owned and reserves exactly one descriptor.
unsafe { device.device().create_descriptor_pool(&pool_info, None) }.map_err(|result| {
// SAFETY: The layout was created above on this device and is rolled back once.
unsafe { device.device().destroy_descriptor_set_layout(layout, None) };
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateDescriptorPool",
result,
}
})?;
let layouts = vec![layout; textures.len()];
let allocate_info = vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(pool)
.set_layouts(&layouts);
// SAFETY: The pool and layout are live on this logical device.
let descriptor_sets = unsafe { device.device().allocate_descriptor_sets(&allocate_info) }
.map_err(|result| {
// SAFETY: Resources were created above on this device and are rolled back once.
unsafe {
device.device().destroy_descriptor_pool(pool, None);
device.device().destroy_descriptor_set_layout(layout, None);
};
VulkanSmokeRendererError::VulkanOperation {
context: "vkAllocateDescriptorSets",
result,
}
})?;
let sampler_info = vk::SamplerCreateInfo::default()
.mag_filter(vk::Filter::LINEAR)
.min_filter(vk::Filter::LINEAR)
.mipmap_mode(vk::SamplerMipmapMode::LINEAR)
.address_mode_u(vk::SamplerAddressMode::REPEAT)
.address_mode_v(vk::SamplerAddressMode::REPEAT)
.address_mode_w(vk::SamplerAddressMode::REPEAT)
.max_lod(0.0);
let sampler =
// SAFETY: The sampler create info is stack-owned and has no unsupported optional features.
unsafe { device.device().create_sampler(&sampler_info, None) }.map_err(|result| {
// SAFETY: Resources were created above on this device and are rolled back once.
unsafe {
device.device().destroy_descriptor_pool(pool, None);
device.device().destroy_descriptor_set_layout(layout, None);
};
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateSampler",
result,
}
})?;
let image_infos = textures
.iter()
.map(|texture| {
vk::DescriptorImageInfo::default()
.sampler(sampler)
.image_view(texture.view)
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
})
.collect::<Vec<_>>();
let writes = descriptor_sets
.iter()
.copied()
.zip(image_infos.iter())
.map(|(descriptor_set, image_info)| {
vk::WriteDescriptorSet::default()
.dst_set(descriptor_set)
.dst_binding(0)
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.image_info(std::slice::from_ref(image_info))
})
.collect::<Vec<_>>();
// SAFETY: Descriptor sets, sampler and image views are live; every texture upload completed its shader-read transition.
unsafe { device.device().update_descriptor_sets(&writes, &[]) };
Ok((layout, pool, descriptor_sets, sampler))
}
#[allow(clippy::cast_precision_loss)] #[allow(clippy::cast_precision_loss)]
fn extent_component_to_f32(value: u32) -> f32 { fn extent_component_to_f32(value: u32) -> f32 {
value as f32 value as f32
} }
#[allow(clippy::too_many_lines)]
fn create_graphics_pipeline_cache(
device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass,
pipeline_layout: vk::PipelineLayout,
extent: (u32, u32),
draw_ranges: &[super::VulkanStaticDrawRange],
) -> Result<BTreeMap<PipelineKey, vk::Pipeline>, VulkanSmokeRendererError> {
let mut pipelines = BTreeMap::new();
for range in draw_ranges {
let key = range.pipeline_key();
if pipelines.contains_key(&key) {
continue;
}
let pipeline = match create_graphics_pipeline(
device,
render_pass,
pipeline_layout,
extent,
range.pipeline_state,
) {
Ok(pipeline) => pipeline,
Err(error) => {
// SAFETY: All previously created pipelines belong to this live device and are rolled back with their bundle.
unsafe {
for pipeline in pipelines.into_values() {
device.device().destroy_pipeline(pipeline, None);
}
}
return Err(error);
}
};
pipelines.insert(key, pipeline);
}
Ok(pipelines)
}
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
fn create_graphics_pipeline( fn create_graphics_pipeline(
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass, render_pass: vk::RenderPass,
pipeline_layout: vk::PipelineLayout, pipeline_layout: vk::PipelineLayout,
extent: (u32, u32), extent: (u32, u32),
state: LegacyPipelineState,
) -> Result<vk::Pipeline, VulkanSmokeRendererError> { ) -> Result<vk::Pipeline, VulkanSmokeRendererError> {
let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?; let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?;
let fragment_shader = match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS) { let fragment_shader = match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS) {
@@ -293,19 +626,24 @@ fn create_graphics_pipeline(
]; ];
let vertex_binding = vk::VertexInputBindingDescription::default() let vertex_binding = vk::VertexInputBindingDescription::default()
.binding(0) .binding(0)
.stride(u32::try_from(5 * 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); .input_rate(vk::VertexInputRate::VERTEX);
let vertex_attributes = [ let vertex_attributes = [
vk::VertexInputAttributeDescription::default() vk::VertexInputAttributeDescription::default()
.binding(0) .binding(0)
.location(0) .location(0)
.format(vk::Format::R32G32_SFLOAT) .format(vk::Format::R32G32B32_SFLOAT)
.offset(0), .offset(0),
vk::VertexInputAttributeDescription::default() vk::VertexInputAttributeDescription::default()
.binding(0) .binding(0)
.location(1) .location(1)
.format(vk::Format::R32G32B32_SFLOAT) .format(vk::Format::R32G32B32_SFLOAT)
.offset(u32::try_from(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(6 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
]; ];
let vertex_bindings = [vertex_binding]; let vertex_bindings = [vertex_binding];
let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default() let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default()
@@ -337,12 +675,22 @@ fn create_graphics_pipeline(
.rasterizer_discard_enable(false) .rasterizer_discard_enable(false)
.polygon_mode(vk::PolygonMode::FILL) .polygon_mode(vk::PolygonMode::FILL)
.line_width(1.0) .line_width(1.0)
.cull_mode(vk::CullModeFlags::BACK) .cull_mode(match state.cull {
LegacyCullMode::Disabled => vk::CullModeFlags::NONE,
LegacyCullMode::BackFace => vk::CullModeFlags::BACK,
LegacyCullMode::FrontFace => vk::CullModeFlags::FRONT,
})
.front_face(vk::FrontFace::CLOCKWISE) .front_face(vk::FrontFace::CLOCKWISE)
.depth_bias_enable(false); .depth_bias_enable(false);
let multisample_state = vk::PipelineMultisampleStateCreateInfo::default() let multisample_state = vk::PipelineMultisampleStateCreateInfo::default()
.sample_shading_enable(false) .sample_shading_enable(false)
.rasterization_samples(vk::SampleCountFlags::TYPE_1); .rasterization_samples(vk::SampleCountFlags::TYPE_1);
let depth_stencil_state = vk::PipelineDepthStencilStateCreateInfo::default()
.depth_test_enable(state.depth != LegacyDepthMode::Disabled)
.depth_write_enable(state.depth == LegacyDepthMode::TestWrite)
.depth_compare_op(vk::CompareOp::LESS_OR_EQUAL)
.depth_bounds_test_enable(false)
.stencil_test_enable(false);
let color_blend_attachment = vk::PipelineColorBlendAttachmentState::default() let color_blend_attachment = vk::PipelineColorBlendAttachmentState::default()
.color_write_mask( .color_write_mask(
vk::ColorComponentFlags::R vk::ColorComponentFlags::R
@@ -350,7 +698,13 @@ fn create_graphics_pipeline(
| vk::ColorComponentFlags::B | vk::ColorComponentFlags::B
| vk::ColorComponentFlags::A, | vk::ColorComponentFlags::A,
) )
.blend_enable(false); .blend_enable(state.blend == LegacyBlendMode::SourceAlpha)
.src_color_blend_factor(vk::BlendFactor::SRC_ALPHA)
.dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
.color_blend_op(vk::BlendOp::ADD)
.src_alpha_blend_factor(vk::BlendFactor::ONE)
.dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
.alpha_blend_op(vk::BlendOp::ADD);
let color_blend_attachments = [color_blend_attachment]; let color_blend_attachments = [color_blend_attachment];
let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default() let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default()
.logic_op_enable(false) .logic_op_enable(false)
@@ -362,6 +716,7 @@ fn create_graphics_pipeline(
.viewport_state(&viewport_state) .viewport_state(&viewport_state)
.rasterization_state(&rasterization_state) .rasterization_state(&rasterization_state)
.multisample_state(&multisample_state) .multisample_state(&multisample_state)
.depth_stencil_state(&depth_stencil_state)
.color_blend_state(&color_blend_state) .color_blend_state(&color_blend_state)
.layout(pipeline_layout) .layout(pipeline_layout)
.render_pass(render_pass) .render_pass(render_pass)
@@ -404,9 +759,10 @@ fn create_framebuffer(
device: &VulkanLogicalDeviceProbe, device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass, render_pass: vk::RenderPass,
image_view: vk::ImageView, image_view: vk::ImageView,
depth_view: vk::ImageView,
extent: (u32, u32), extent: (u32, u32),
) -> Result<vk::Framebuffer, VulkanSmokeRendererError> { ) -> Result<vk::Framebuffer, VulkanSmokeRendererError> {
let attachments = [image_view]; let attachments = [image_view, depth_view];
let create_info = vk::FramebufferCreateInfo::default() let create_info = vk::FramebufferCreateInfo::default()
.render_pass(render_pass) .render_pass(render_pass)
.attachments(&attachments) .attachments(&attachments)
@@ -455,16 +811,30 @@ pub(super) fn destroy_swapchain_resources(
for framebuffer in resources.framebuffers { for framebuffer in resources.framebuffers {
device.device().destroy_framebuffer(framebuffer, None); device.device().destroy_framebuffer(framebuffer, None);
} }
device.device().destroy_pipeline(resources.pipeline, None); for pipeline in resources.pipelines.into_values() {
device.device().destroy_pipeline(pipeline, None);
}
device device
.device() .device()
.destroy_pipeline_layout(resources.pipeline_layout, None); .destroy_pipeline_layout(resources.pipeline_layout, None);
device.device().destroy_sampler(resources.sampler, None);
device
.device()
.destroy_descriptor_pool(resources.descriptor_pool, None);
device
.device()
.destroy_descriptor_set_layout(resources.descriptor_set_layout, None);
device device
.device() .device()
.destroy_render_pass(resources.render_pass, None); .destroy_render_pass(resources.render_pass, None);
destroy_depth_attachment(device, &resources.depth_attachment);
for image_view in resources.image_views { for image_view in resources.image_views {
device.device().destroy_image_view(image_view, None); device.device().destroy_image_view(image_view, None);
} }
for buffer in resources.readback_buffers {
device.device().destroy_buffer(buffer.buffer, None);
device.device().free_memory(buffer.memory, None);
}
} }
} }
@@ -483,7 +853,7 @@ fn destroy_partial_swapchain_resources(
for framebuffer in partial.framebuffers { for framebuffer in partial.framebuffers {
device.device().destroy_framebuffer(framebuffer, None); device.device().destroy_framebuffer(framebuffer, None);
} }
if let Some(pipeline) = partial.pipeline { for pipeline in partial.pipelines.into_values() {
device.device().destroy_pipeline(pipeline, None); device.device().destroy_pipeline(pipeline, None);
} }
if let Some(pipeline_layout) = partial.pipeline_layout { if let Some(pipeline_layout) = partial.pipeline_layout {
@@ -491,11 +861,31 @@ fn destroy_partial_swapchain_resources(
.device() .device()
.destroy_pipeline_layout(pipeline_layout, None); .destroy_pipeline_layout(pipeline_layout, None);
} }
if let Some(sampler) = partial.sampler {
device.device().destroy_sampler(sampler, None);
}
if let Some(descriptor_pool) = partial.descriptor_pool {
device
.device()
.destroy_descriptor_pool(descriptor_pool, None);
}
if let Some(descriptor_set_layout) = partial.descriptor_set_layout {
device
.device()
.destroy_descriptor_set_layout(descriptor_set_layout, None);
}
if let Some(render_pass) = partial.render_pass { if let Some(render_pass) = partial.render_pass {
device.device().destroy_render_pass(render_pass, None); device.device().destroy_render_pass(render_pass, None);
} }
if let Some(depth_attachment) = partial.depth_attachment {
destroy_depth_attachment(device, &depth_attachment);
}
for image_view in partial.image_views { for image_view in partial.image_views {
device.device().destroy_image_view(image_view, None); device.device().destroy_image_view(image_view, None);
} }
for buffer in partial.readback_buffers {
device.device().destroy_buffer(buffer.buffer, None);
device.device().free_memory(buffer.memory, None);
}
} }
} }
@@ -10,7 +10,8 @@ use crate::shader_manifest::{
use crate::*; use crate::*;
use fparkan_platform::{DepthStencilSupport, RenderRequest}; use fparkan_platform::{DepthStencilSupport, RenderRequest};
use fparkan_render::{ use fparkan_render::{
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderCommand, RenderPhase, DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, LegacyPipelineState, RenderCommand,
RenderPhase,
}; };
use fparkan_render::{RenderBackend, RenderError}; use fparkan_render::{RenderBackend, RenderError};
@@ -35,6 +36,7 @@ fn planning_backend_tracks_render_request_and_simulated_present() -> Result<(),
object_id: None, object_id: None,
mesh: GpuMeshId(1), mesh: GpuMeshId(1),
material: GpuMaterialId(2), material: GpuMaterialId(2),
pipeline_key: LegacyPipelineState::default().into(),
transform: [1.0; 16], transform: [1.0; 16],
range: IndexRange { start: 0, count: 3 }, range: IndexRange { start: 0, count: 3 },
stable_order: 7, stable_order: 7,
@@ -75,6 +77,7 @@ fn frame_submission_plan_json_is_stable() -> Result<(), RenderError> {
object_id: None, object_id: None,
mesh: GpuMeshId(1), mesh: GpuMeshId(1),
material: GpuMaterialId(2), material: GpuMaterialId(2),
pipeline_key: LegacyPipelineState::default().into(),
transform: [1.0; 16], transform: [1.0; 16],
range: IndexRange { start: 0, count: 3 }, range: IndexRange { start: 0, count: 3 },
stable_order: 7, stable_order: 7,
@@ -91,6 +94,7 @@ fn frame_submission_plan_json_is_stable() -> Result<(), RenderError> {
}, },
present_mode: vk::PresentModeKHR::FIFO.as_raw(), present_mode: vk::PresentModeKHR::FIFO.as_raw(),
image_count: 3, image_count: 3,
image_usage: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(),
}; };
let plan = plan_vulkan_frame_submission(&swapchain, &commands)?; let plan = plan_vulkan_frame_submission(&swapchain, &commands)?;
@@ -317,6 +321,45 @@ fn capability_gate_respects_request_specific_depth_profiles() {
assert!(report.rejected_devices.is_empty()); assert!(report.rejected_devices.is_empty());
} }
#[test]
fn depth_attachment_selection_is_canonical_and_request_scoped() {
let supported = [
vk::Format::D32_SFLOAT_S8_UINT.as_raw(),
vk::Format::D24_UNORM_S8_UINT.as_raw(),
vk::Format::D32_SFLOAT.as_raw(),
];
assert_eq!(
select_depth_stencil_attachment_format(
&supported,
DepthStencilSupport {
depth_bits: 24,
stencil_bits: 8,
},
),
Some(vk::Format::D24_UNORM_S8_UINT.as_raw())
);
assert_eq!(
select_depth_stencil_attachment_format(
&supported,
DepthStencilSupport {
depth_bits: 32,
stencil_bits: 0,
},
),
Some(vk::Format::D32_SFLOAT.as_raw())
);
assert_eq!(
select_depth_stencil_attachment_format(
&supported,
DepthStencilSupport {
depth_bits: 0,
stencil_bits: 0,
},
),
None
);
}
#[test] #[test]
fn capability_report_preserves_informational_sampled_formats_and_limits() { fn capability_report_preserves_informational_sampled_formats_and_limits() {
let report = select_physical_device(&[device( let report = select_physical_device(&[device(
@@ -501,6 +544,23 @@ fn swapchain_plan_prefers_srgb_mailbox_and_clamps_extent() {
assert_eq!(plan.present_mode, vk::PresentModeKHR::MAILBOX.as_raw()); assert_eq!(plan.present_mode, vk::PresentModeKHR::MAILBOX.as_raw());
assert_eq!(plan.extent, (1024, 720)); assert_eq!(plan.extent, (1024, 720));
assert_eq!(plan.image_count, 3); assert_eq!(plan.image_count, 3);
assert_eq!(
plan.image_usage,
vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw()
);
}
#[test]
fn swapchain_plan_enables_transfer_source_when_surface_supports_it() {
let mut request = swapchain_request();
request.capabilities.supported_usage_flags |= vk::ImageUsageFlags::TRANSFER_SRC.as_raw();
let plan = plan_vulkan_swapchain(&request).expect("swapchain plan");
assert_eq!(
plan.image_usage,
(vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_SRC).as_raw()
);
} }
#[test] #[test]
@@ -564,7 +624,7 @@ fn swapchain_plan_json_and_recreation_reports_are_stable() {
let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan"); let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan");
assert_eq!( assert_eq!(
render_swapchain_plan_json(&plan), render_swapchain_plan_json(&plan),
"{\"schema\":1,\"extent\":[1024,720],\"format\":50,\"color_space\":0,\"present_mode\":1,\"image_count\":3}" "{\"schema\":1,\"extent\":[1024,720],\"format\":50,\"color_space\":0,\"present_mode\":1,\"image_count\":3,\"image_usage\":16}"
); );
let report = swapchain_recreation_report( let report = swapchain_recreation_report(
@@ -609,13 +669,13 @@ fn triangle_shader_manifest_hashes_are_stable() {
TRIANGLE_VERTEX_SOURCE_SHA256 TRIANGLE_VERTEX_SOURCE_SHA256
); );
assert_eq!(report.modules[0].spirv_path, TRIANGLE_VERTEX_SPIRV_PATH); assert_eq!(report.modules[0].spirv_path, TRIANGLE_VERTEX_SPIRV_PATH);
assert_eq!(report.modules[0].word_count, 253); assert_eq!(report.modules[0].word_count, 358);
assert_eq!( assert_eq!(
report.modules[0].sha256, report.modules[0].sha256,
"4d3ceca7b42ebc971d831b0a0d816457397bd9aeda47fb8d44c4b1aeaa5e7ba0" "4e2051ac43b933a57e5557e596bcc95952b4efbfcb45315180f89581e094398d"
); );
assert_eq!(report.modules[0].descriptor_sets, 0); 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!( assert_eq!(
report.modules[0].compile_command, report.modules[0].compile_command,
TRIANGLE_VERTEX_COMPILE_COMMAND TRIANGLE_VERTEX_COMPILE_COMMAND
@@ -627,11 +687,11 @@ fn triangle_shader_manifest_hashes_are_stable() {
assert!(!report.modules[0].interface_hash.is_empty()); assert!(!report.modules[0].interface_hash.is_empty());
assert_eq!( assert_eq!(
report.modules[1].sha256, report.modules[1].sha256,
"5a7441be03cd3c25d557268b2e58d5aa50504c87bffcb4c3fd7cbcf007db0b96" "8ab7bf835e166892b04b10fa1100258daf355d2f693ad311d014dbee22de5c7b"
); );
assert_eq!( assert_eq!(
report.manifest_hash, report.manifest_hash,
"11e3feb65200ebd2ac87b7e776e9c6433a5da9d71a651bfadea89a51be17ff05" "cf471972978ddb5c8919711ad64c93d0d64e18b1f590a89ce014f1e2a743cfb5"
); );
} }
@@ -10,9 +10,10 @@ use crate::{
}; };
/// Vulkan backend migration readiness. /// Vulkan backend migration readiness.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum VulkanPlanningBackendState { pub enum VulkanPlanningBackendState {
/// Planning facade is configured and able to accept command lists. /// Planning facade is configured and able to accept command lists.
#[default]
Configured, Configured,
/// Adapter is tracking a recoverable runtime surface/depth pipeline fault. /// Adapter is tracking a recoverable runtime surface/depth pipeline fault.
Degraded, Degraded,
@@ -20,12 +21,6 @@ pub enum VulkanPlanningBackendState {
Error, Error,
} }
impl Default for VulkanPlanningBackendState {
fn default() -> Self {
Self::Configured
}
}
/// Diagnostics for planning-facade request tracking. /// Diagnostics for planning-facade request tracking.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct VulkanPlanningRequestReport { pub struct VulkanPlanningRequestReport {
@@ -168,5 +163,6 @@ fn default_stage0_swapchain_plan() -> VulkanSwapchainPlan {
}, },
present_mode: vk::PresentModeKHR::FIFO.as_raw(), present_mode: vk::PresentModeKHR::FIFO.as_raw(),
image_count: 2, image_count: 2,
image_usage: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(),
} }
} }
+34 -6
View File
@@ -96,6 +96,8 @@ pub struct VulkanSwapchainPlan {
pub present_mode: i32, pub present_mode: i32,
/// Selected image count. /// Selected image count.
pub image_count: u32, pub image_count: u32,
/// Selected image-usage flags as raw Vulkan bits.
pub image_usage: u32,
} }
/// Swapchain planning error. /// Swapchain planning error.
@@ -425,6 +427,7 @@ pub fn plan_vulkan_swapchain(
format, format,
present_mode, present_mode,
image_count: select_image_count(request.capabilities), image_count: select_image_count(request.capabilities),
image_usage: select_swapchain_image_usage(request.capabilities),
}) })
} }
@@ -521,6 +524,7 @@ pub fn render_swapchain_plan_json(plan: &VulkanSwapchainPlan) -> String {
color_space: i32, color_space: i32,
present_mode: i32, present_mode: i32,
image_count: u32, image_count: u32,
image_usage: u32,
} }
serialize_json_or_fallback( serialize_json_or_fallback(
@@ -531,8 +535,9 @@ pub fn render_swapchain_plan_json(plan: &VulkanSwapchainPlan) -> String {
color_space: plan.format.color_space, color_space: plan.format.color_space,
present_mode: plan.present_mode, present_mode: plan.present_mode,
image_count: plan.image_count, image_count: plan.image_count,
image_usage: plan.image_usage,
}, },
"{\"schema\":0,\"extent\":[0,0],\"format\":0,\"color_space\":0,\"present_mode\":0,\"image_count\":0}", "{\"schema\":0,\"extent\":[0,0],\"format\":0,\"color_space\":0,\"present_mode\":0,\"image_count\":0,\"image_usage\":0}",
) )
} }
@@ -806,11 +811,34 @@ fn supports_depth_stencil_request(
if depth.depth_bits == 0 && depth.stencil_bits == 0 { if depth.depth_bits == 0 && depth.stencil_bits == 0 {
return true; return true;
} }
required_depth_stencil_formats(depth).iter().any(|format| { select_depth_stencil_attachment_format(&device.supported_depth_stencil_formats, depth).is_some()
device }
.supported_depth_stencil_formats
.contains(&format.as_raw()) fn select_swapchain_image_usage(capabilities: VulkanSwapchainSurfaceCapabilities) -> u32 {
}) let mut usage = vk::ImageUsageFlags::COLOR_ATTACHMENT;
let supported = vk::ImageUsageFlags::from_raw(capabilities.supported_usage_flags);
if supported.contains(vk::ImageUsageFlags::TRANSFER_SRC) {
usage |= vk::ImageUsageFlags::TRANSFER_SRC;
}
usage.as_raw()
}
/// Selects the first canonical depth/stencil attachment format supported by a device.
///
/// The order is part of the Windows Vulkan compatibility contract and is shared
/// by device admission and future render-pass allocation.
#[must_use]
pub fn select_depth_stencil_attachment_format(
supported_formats: &[i32],
depth: DepthStencilSupport,
) -> Option<i32> {
if depth.depth_bits == 0 && depth.stencil_bits == 0 {
return None;
}
required_depth_stencil_formats(depth)
.iter()
.map(|format| format.as_raw())
.find(|format| supported_formats.contains(format))
} }
fn informational_capabilities( fn informational_capabilities(
@@ -20,7 +20,7 @@ pub(crate) const SPIRV_VALIDATOR_BINARY_SHA256: &str =
pub(crate) const TRIANGLE_VERTEX_SOURCE_PATH: &str = pub(crate) const TRIANGLE_VERTEX_SOURCE_PATH: &str =
"adapters/fparkan-render-vulkan/shaders/triangle.vert"; "adapters/fparkan-render-vulkan/shaders/triangle.vert";
pub(crate) const TRIANGLE_VERTEX_SOURCE_SHA256: &str = pub(crate) const TRIANGLE_VERTEX_SOURCE_SHA256: &str =
"1e57f14d193fc61457c0749081c452ad25669998913107df12f3ccc3c33e0341"; "fe3721202477220d2d9677b6572d7f3baecb374e94f04eea9d7286ed5e98b6c9";
pub(crate) const TRIANGLE_VERTEX_SPIRV_PATH: &str = pub(crate) const TRIANGLE_VERTEX_SPIRV_PATH: &str =
"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv"; "adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
pub(crate) const TRIANGLE_VERTEX_COMPILE_COMMAND: &str = pub(crate) const TRIANGLE_VERTEX_COMPILE_COMMAND: &str =
@@ -29,7 +29,7 @@ pub(crate) const TRIANGLE_VERTEX_VALIDATE_COMMAND: &str =
"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv"; "spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
const TRIANGLE_FRAGMENT_SOURCE_PATH: &str = "adapters/fparkan-render-vulkan/shaders/triangle.frag"; const TRIANGLE_FRAGMENT_SOURCE_PATH: &str = "adapters/fparkan-render-vulkan/shaders/triangle.frag";
const TRIANGLE_FRAGMENT_SOURCE_SHA256: &str = const TRIANGLE_FRAGMENT_SOURCE_SHA256: &str =
"f19e74d001d07fb537d4b0f9e621f9b8bc40eeb68816130220853abea6bd4445"; "fb3ed435af48e4fb4a817f160b467f7a561c7c547c87b6f0c1e89f5f58af7329";
const TRIANGLE_FRAGMENT_SPIRV_PATH: &str = const TRIANGLE_FRAGMENT_SPIRV_PATH: &str =
"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv"; "adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
const TRIANGLE_FRAGMENT_COMPILE_COMMAND: &str = const TRIANGLE_FRAGMENT_COMPILE_COMMAND: &str =
@@ -213,7 +213,7 @@ pub fn triangle_shader_manifest() -> Vec<VulkanShaderModuleManifest> {
stage: VulkanShaderStage::Vertex, stage: VulkanShaderStage::Vertex,
entry_point: "main", entry_point: "main",
descriptor_sets: 0, descriptor_sets: 0,
push_constant_bytes: 0, push_constant_bytes: 64,
source_path: TRIANGLE_VERTEX_SOURCE_PATH, source_path: TRIANGLE_VERTEX_SOURCE_PATH,
source_sha256: TRIANGLE_VERTEX_SOURCE_SHA256, source_sha256: TRIANGLE_VERTEX_SOURCE_SHA256,
spirv_path: TRIANGLE_VERTEX_SPIRV_PATH, spirv_path: TRIANGLE_VERTEX_SPIRV_PATH,
@@ -225,8 +225,8 @@ pub fn triangle_shader_manifest() -> Vec<VulkanShaderModuleManifest> {
name: "triangle.frag", name: "triangle.frag",
stage: VulkanShaderStage::Fragment, stage: VulkanShaderStage::Fragment,
entry_point: "main", entry_point: "main",
descriptor_sets: 0, descriptor_sets: 1,
push_constant_bytes: 0, push_constant_bytes: 68,
source_path: TRIANGLE_FRAGMENT_SOURCE_PATH, source_path: TRIANGLE_FRAGMENT_SOURCE_PATH,
source_sha256: TRIANGLE_FRAGMENT_SOURCE_SHA256, source_sha256: TRIANGLE_FRAGMENT_SOURCE_SHA256,
spirv_path: TRIANGLE_FRAGMENT_SPIRV_PATH, spirv_path: TRIANGLE_FRAGMENT_SPIRV_PATH,
+11 -7
View File
@@ -6,13 +6,17 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-assets = { path = "../../crates/fparkan-assets" } fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
fparkan-corpus = { path = "../../crates/fparkan-corpus" } fparkan-corpus = { path = "../../crates/fparkan-corpus", version = "0.1.0" }
fparkan-prototype = { path = "../../crates/fparkan-prototype" } fparkan-prototype = { path = "../../crates/fparkan-prototype", version = "0.1.0" }
fparkan-inspection = { path = "../../crates/fparkan-inspection" } fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
fparkan-resource = { path = "../../crates/fparkan-resource" } fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
fparkan-runtime = { path = "../../crates/fparkan-runtime" } fparkan-resource = { path = "../../crates/fparkan-resource", version = "0.1.0" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" } 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"
[lints] [lints]
workspace = true workspace = true
+784 -77
View File
@@ -21,20 +21,241 @@
#![allow(clippy::print_stderr, clippy::print_stdout)] #![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` command-line tools. //! `FParkan` command-line tools.
use fparkan_assets::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_corpus::{discover, render_report_json, report, DiscoverOptions};
use fparkan_inspection::inspect_archive_file; use fparkan_inspection::{
use fparkan_inspection::ArchiveInspection; 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_prototype::build_prototype_graph_report;
use fparkan_resource::{resource_name, CachedResourceRepository}; use fparkan_resource::{resource_name, CachedResourceRepository};
use fparkan_runtime::{ use fparkan_runtime::{
create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest, create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
}; };
use fparkan_vfs::DirectoryVfs; use fparkan_vfs::{DirectoryVfs, Vfs};
use std::fmt::Write; use serde::Serialize;
use std::fmt::Write as _;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
const ARCHIVE_INSPECT_SCHEMA: &str = "fparkan-archive-inspect-v1";
const PROTOTYPE_INSPECT_SCHEMA: &str = "fparkan-prototype-inspect-v2";
const MISSION_GRAPH_SCHEMA: &str = "fparkan-mission-graph-v1";
const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1";
const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1";
const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1";
const WEAR_INSPECT_SCHEMA: &str = "fparkan-wear-inspect-v1";
const SCRIPT_INSPECT_SCHEMA: &str = "fparkan-script-inspect-v2";
#[derive(Serialize)]
struct ArchiveInspectOutput<'a> {
schema_version: &'static str,
path: &'a str,
kind: &'a str,
entries: usize,
#[serde(skip_serializing_if = "Option::is_none")]
lookup_order_valid: Option<bool>,
}
#[derive(Serialize)]
struct PrototypeInspectOutput {
schema_version: &'static str,
key: String,
roots: usize,
node_count: usize,
edge_count: usize,
unit_component_records: Vec<UnitComponentInspectOutput>,
edges: Vec<PrototypeGraphEdgeInspectOutput>,
prototype_requests: usize,
resolved: usize,
unit_references: usize,
unit_components: usize,
direct_references: usize,
wear_requests: usize,
wear: usize,
materials: usize,
textures: usize,
lightmaps: usize,
is_success: bool,
failures: Vec<GraphFailureOutput>,
}
#[derive(Serialize)]
struct MissionGraphOutput {
schema_version: &'static str,
mission: String,
objects: usize,
paths: usize,
clans: usize,
extras: usize,
roots: usize,
node_count: usize,
edge_count: usize,
direct_references: usize,
unit_references: usize,
unit_components: usize,
prototype_requests: usize,
wear_requests: usize,
wear: usize,
materials: usize,
textures: usize,
lightmaps: usize,
is_success: bool,
failures: usize,
}
#[derive(Serialize)]
struct MissionInspectOutput {
schema_version: &'static str,
mission: String,
objects: Vec<MissionObjectInspectOutput>,
}
#[derive(Serialize)]
struct MissionObjectInspectOutput {
index: usize,
resource: String,
position: [f32; 3],
orientation_raw: [f32; 3],
scale: [f32; 3],
}
#[derive(Serialize)]
struct TerrainInspectOutput {
schema_version: &'static str,
path: String,
positions: usize,
min: [f32; 3],
max: [f32; 3],
faces: usize,
slots: usize,
material_tags: Vec<TerrainMaterialTagCount>,
shade_pairs: usize,
#[serde(skip_serializing_if = "Option::is_none")]
shade_lookup_key_min: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
shade_lookup_key_max: Option<u16>,
shade_batch_boundaries: usize,
}
#[derive(Serialize)]
struct TerrainMaterialTagCount {
tag: u16,
faces: usize,
}
#[derive(Serialize)]
struct WearInspectOutput {
schema_version: &'static str,
archive: String,
resource: String,
materials: usize,
lightmaps: usize,
#[serde(skip_serializing_if = "Option::is_none")]
first_material: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last_material: Option<String>,
}
#[derive(Serialize)]
struct ModelInspectOutput<'a> {
schema_version: &'static str,
archive: &'a str,
resource: &'a str,
streams: usize,
nodes: usize,
node_stride: usize,
slots: usize,
positions: usize,
indices: usize,
batches: usize,
#[serde(skip_serializing_if = "Option::is_none")]
animation_keys: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
animation_frame_count: Option<u32>,
node38: Vec<ModelNodeInspectOutput>,
}
#[derive(Serialize)]
struct ModelNodeInspectOutput {
index: usize,
parent_or_link_raw: u16,
anim_map_start: u16,
fallback_key: u16,
has_lod0_group0: bool,
}
#[derive(Serialize)]
struct GraphFailureOutput {
root_index: usize,
edge: &'static str,
requiredness: &'static str,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
archive: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
resource: Option<String>,
}
#[derive(Serialize)]
struct UnitComponentInspectOutput {
root_index: usize,
component_index: usize,
archive_raw_hex: String,
resource_raw_hex: String,
kind: u32,
parent_or_link: i32,
description_raw_hex: String,
tail0: u32,
tail1: u32,
}
#[derive(Serialize)]
struct PrototypeGraphEdgeInspectOutput {
id: u32,
from: u32,
to: u32,
kind: &'static str,
requiredness: &'static str,
root_index: Option<usize>,
parent_edge: Option<u32>,
unit_component_index: Option<usize>,
archive: Option<String>,
resource_raw_hex: Option<String>,
}
#[derive(Serialize)]
struct ScriptInspectOutput<'a> {
schema_version: &'static str,
path: &'a str,
opcode_handler_count: u32,
events: usize,
instructions: usize,
references: usize,
trailing_bytes: usize,
first_header_word_candidates: Vec<ScriptHeaderWordCount>,
}
#[derive(Serialize)]
struct ScriptHeaderWordCount {
value: u32,
instructions: usize,
}
#[derive(Serialize)]
struct VarSetInspectOutput<'a> {
schema_version: &'static str,
path: &'a str,
declarations: usize,
float_defaults: usize,
dword_defaults: usize,
first_name: Option<&'a str>,
last_name: Option<&'a str>,
}
fn main() { fn main() {
let args: Vec<String> = std::env::args().skip(1).collect(); let args: Vec<String> = std::env::args().skip(1).collect();
let result = run(&args); let result = run(&args);
@@ -83,10 +304,115 @@ fn run(args: &[String]) -> Result<(), String> {
let rest = strip_format_json(rest)?; let rest = strip_format_json(rest)?;
graph_mission(&rest) graph_mission(&rest)
} }
[domain, command, rest @ ..] if domain == "mission" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_mission(&rest)
}
[domain, command, rest @ ..] if domain == "terrain" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_terrain(&rest)
}
[domain, command, rest @ ..] if domain == "wear" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_wear(&rest)
}
[domain, command, rest @ ..] if domain == "model" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_model(&rest)
}
[domain, command, rest @ ..] if domain == "script" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_script(&rest)
}
[domain, command, rest @ ..] if domain == "varset" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_varset(&rest)
}
_ => Err(usage()), _ => Err(usage()),
} }
} }
fn inspect_script(args: &[String]) -> Result<(), String> {
let path = parse_file_path(args, "script inspect")?;
let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
let package =
fparkan_script::decode(&bytes).map_err(|err| format!("{}: {err}", path.display()))?;
let display_path = path.display().to_string();
println!("{}", script_inspect_json(&display_path, &package)?);
Ok(())
}
fn script_inspect_json(
path: &str,
package: &fparkan_script::ScriptPackage,
) -> Result<String, String> {
let instructions = package
.events
.iter()
.map(|event| event.instructions.len())
.sum();
let references = package
.events
.iter()
.flat_map(|event| &event.instructions)
.map(|instruction| instruction.references.len())
.sum();
let mut candidates = std::collections::BTreeMap::<u32, usize>::new();
for instruction in package.events.iter().flat_map(|event| &event.instructions) {
*candidates.entry(instruction.header_words[0]).or_insert(0) += 1;
}
serialize_json(&ScriptInspectOutput {
schema_version: SCRIPT_INSPECT_SCHEMA,
path,
opcode_handler_count: package.opcode_handler_count,
events: package.events.len(),
instructions,
references,
trailing_bytes: package.trailing_bytes.len(),
first_header_word_candidates: candidates
.into_iter()
.map(|(value, instructions)| ScriptHeaderWordCount {
value,
instructions,
})
.collect(),
})
}
fn inspect_varset(args: &[String]) -> Result<(), String> {
let path = parse_file_path(args, "varset inspect")?;
let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
let varset =
fparkan_script::parse_varset(&bytes).map_err(|err| format!("{}: {err}", path.display()))?;
let display_path = path.display().to_string();
println!("{}", varset_inspect_json(&display_path, &varset)?);
Ok(())
}
fn varset_inspect_json(path: &str, varset: &fparkan_script::VarSet) -> Result<String, String> {
let float_defaults = varset
.declarations
.iter()
.filter(|declaration| declaration.type_name == fparkan_script::VarSetType::Float)
.count();
let dword_defaults = varset.declarations.len() - float_defaults;
serialize_json(&VarSetInspectOutput {
schema_version: "fparkan-varset-inspect-v1",
path,
declarations: varset.declarations.len(),
float_defaults,
dword_defaults,
first_name: varset
.declarations
.first()
.map(|declaration| declaration.name.as_str()),
last_name: varset
.declarations
.last()
.map(|declaration| declaration.name.as_str()),
})
}
fn exit_code(result: &Result<(), String>) -> i32 { fn exit_code(result: &Result<(), String>) -> i32 {
if result.is_ok() { if result.is_ok() {
0 0
@@ -152,10 +478,13 @@ fn inspect_prototype(args: &[String]) -> Result<(), String> {
let vfs = Arc::new(DirectoryVfs::new(root)); let vfs = Arc::new(DirectoryVfs::new(root));
let repository = CachedResourceRepository::new(vfs.clone()); let repository = CachedResourceRepository::new(vfs.clone());
let roots = [resource_name(key.as_bytes())]; let roots = [resource_name(key.as_bytes())];
let (graph, resolved, mut report) = let (mut graph, resolved, mut report) =
build_prototype_graph_report(&repository, vfs.as_ref(), &roots); build_prototype_graph_report(&repository, vfs.as_ref(), &roots);
extend_graph_report_with_visual_dependencies(&repository, &mut report, &graph, &resolved); extend_graph_report_with_visual_dependencies(&repository, &mut report, &mut graph, &resolved);
println!("{}", prototype_inspect_json(&key, &graph, &report)); println!(
"{}",
prototype_inspect_json(&key, &graph, &report).map_err(|err| err.to_string())?
);
Ok(()) Ok(())
} }
@@ -163,22 +492,83 @@ fn prototype_inspect_json(
key: &str, key: &str,
graph: &fparkan_prototype::PrototypeGraph, graph: &fparkan_prototype::PrototypeGraph,
report: &fparkan_prototype::PrototypeGraphReport, report: &fparkan_prototype::PrototypeGraphReport,
) -> String { ) -> Result<String, serde_json::Error> {
format!( serde_json::to_string(&PrototypeInspectOutput {
"{{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":{},\"roots\":{},\"prototype_requests\":{},\"resolved\":{},\"unit_references\":{},\"unit_components\":{},\"direct_references\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}", schema_version: PROTOTYPE_INSPECT_SCHEMA,
json_string(key), key: key.to_string(),
report.root_count, roots: report.root_count,
graph.prototype_requests.len(), node_count: graph.nodes.len(),
report.resolved_count, edge_count: graph.edges.len(),
report.unit_reference_count, unit_component_records: graph
report.unit_component_count, .root_unit_components
report.direct_reference_count, .iter()
report.wear_resolved_count, .enumerate()
report.material_resolved_count, .flat_map(|(root_index, records)| {
report.texture_resolved_count, records
report.lightmap_resolved_count, .iter()
report.failures.len() .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,
unit_components: report.unit_component_count,
direct_references: report.direct_reference_count,
wear_requests: report.wear_request_count,
wear: report.wear_resolved_count,
materials: report.material_resolved_count,
textures: report.texture_resolved_count,
lightmaps: report.lightmap_resolved_count,
is_success: report.is_success(),
failures: report
.failures
.iter()
.take(16)
.map(graph_failure_output)
.collect(),
})
} }
fn graph_mission(args: &[String]) -> Result<(), String> { fn graph_mission(args: &[String]) -> Result<(), String> {
@@ -200,29 +590,68 @@ fn graph_mission(args: &[String]) -> Result<(), String> {
) )
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
println!( println!(
"{{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":{},\"objects\":{},\"paths\":{},\"clans\":{},\"extras\":{},\"roots\":{},\"direct_references\":{},\"unit_references\":{},\"unit_components\":{},\"prototype_requests\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}", "{}",
json_string(&mission), serialize_json(&MissionGraphOutput {
loaded.object_count, schema_version: MISSION_GRAPH_SCHEMA,
loaded.path_count, mission: mission.clone(),
loaded.clan_count, objects: loaded.object_count,
loaded.extra_count, paths: loaded.path_count,
loaded.graph_root_count, clans: loaded.clan_count,
loaded.graph_direct_reference_count, extras: loaded.extra_count,
loaded.graph_unit_reference_count, roots: loaded.graph_root_count,
loaded.graph_unit_component_count, node_count: loaded.graph_node_count,
loaded.graph_resolved_count, edge_count: loaded.graph_edge_count,
loaded.graph_wear_resolved_count, direct_references: loaded.graph_direct_reference_count,
loaded.graph_material_resolved_count, unit_references: loaded.graph_unit_reference_count,
loaded.graph_texture_resolved_count, unit_components: loaded.graph_unit_component_count,
loaded.graph_lightmap_resolved_count, prototype_requests: loaded.graph_resolved_count,
loaded.graph_failure_count wear_requests: loaded.graph_wear_request_count,
wear: loaded.graph_wear_resolved_count,
materials: loaded.graph_material_resolved_count,
textures: loaded.graph_texture_resolved_count,
lightmaps: loaded.graph_lightmap_resolved_count,
is_success: loaded.graph_failure_count == 0,
failures: loaded.graph_failure_count,
})?
);
Ok(())
}
fn inspect_mission(args: &[String]) -> Result<(), String> {
let root = parse_root_alias(args)?;
let mission = parse_required(args, &["--mission"], "--mission")?;
let mission_path = normalize_relative(mission.as_bytes(), PathPolicy::StrictLegacy)
.map_err(|err| err.to_string())?;
let vfs = DirectoryVfs::new(root);
let bytes = vfs.read(&mission_path).map_err(|err| err.to_string())?;
let document =
decode_mission_payload(bytes, TmaProfile::Strict).map_err(|err| err.to_string())?;
let objects = document
.objects
.iter()
.enumerate()
.map(|(index, object)| MissionObjectInspectOutput {
index,
resource: String::from_utf8_lossy(&object.resource_name.raw).into_owned(),
position: object.position,
orientation_raw: object.orientation,
scale: object.scale,
})
.collect();
println!(
"{}",
serialize_json(&MissionInspectOutput {
schema_version: MISSION_INSPECT_SCHEMA,
mission,
objects,
})?
); );
Ok(()) Ok(())
} }
fn inspect_archive(args: &[String]) -> Result<(), String> { fn inspect_archive(args: &[String]) -> Result<(), String> {
let path = parse_archive_path(args)?; let path = parse_archive_path(args)?;
let inspection = inspect_archive_file(&path, 0).map_err(|err| err.to_string())?; let inspection = inspect_archive_file(&path, 0).map_err(|err| err.clone())?;
match inspection { match inspection {
ArchiveInspection::Nres { ArchiveInspection::Nres {
@@ -237,14 +666,14 @@ fn inspect_archive(args: &[String]) -> Result<(), String> {
"NRes", "NRes",
entries, entries,
Some(lookup_order_valid), Some(lookup_order_valid),
) )?
); );
Ok(()) Ok(())
} }
ArchiveInspection::Rsli { entries } => { ArchiveInspection::Rsli { entries } => {
println!( println!(
"{}", "{}",
archive_inspect_json(&path.display().to_string(), "RsLi", entries, None) archive_inspect_json(&path.display().to_string(), "RsLi", entries, None)?
); );
Ok(()) Ok(())
} }
@@ -254,55 +683,212 @@ fn inspect_archive(args: &[String]) -> Result<(), String> {
} }
} }
fn inspect_terrain(args: &[String]) -> Result<(), String> {
let path = parse_file_path(args, "terrain inspect")?;
let bounds = inspect_land_msh_bounds_file(&path)?;
let terrain = load_land_msh_from_path(&path)?;
let mut material_tags = terrain
.faces
.iter()
.fold(std::collections::BTreeMap::new(), |mut counts, face| {
*counts.entry(face.material_tag).or_insert(0usize) += 1;
counts
})
.into_iter()
.map(|(tag, faces)| TerrainMaterialTagCount { tag, faces })
.collect::<Vec<_>>();
material_tags.sort_unstable_by_key(|entry| entry.tag);
let shade_pairs = (0..terrain.slots.slots_raw.len())
.filter_map(|slot_index| terrain.slot_material_pairs(slot_index))
.flatten()
.collect::<Vec<_>>();
let shade_lookup_key_min = shade_pairs.iter().map(|pair| pair.shade_lookup_key()).min();
let shade_lookup_key_max = shade_pairs.iter().map(|pair| pair.shade_lookup_key()).max();
let shade_batch_boundaries = shade_pairs
.iter()
.filter(|pair| pair.flags & 0x0010 != 0)
.count();
println!(
"{}",
serialize_json(&TerrainInspectOutput {
schema_version: TERRAIN_INSPECT_SCHEMA,
path: path.display().to_string(),
positions: bounds.positions,
min: bounds.min,
max: bounds.max,
faces: terrain.faces.len(),
slots: terrain.slots.slots_raw.len(),
material_tags,
shade_pairs: shade_pairs.len(),
shade_lookup_key_min,
shade_lookup_key_max,
shade_batch_boundaries,
})?
);
Ok(())
}
fn inspect_wear(args: &[String]) -> Result<(), String> {
let root = parse_root_alias(args)?;
let archive = parse_required(args, &["--archive"], "--archive")?;
let resource = parse_required(args, &["--resource"], "--resource")?;
let inspection = inspect_wear_from_root(&root, &archive, &resource)?;
println!(
"{}",
serialize_json(&WearInspectOutput {
schema_version: WEAR_INSPECT_SCHEMA,
archive,
resource,
materials: inspection.materials,
lightmaps: inspection.lightmaps,
first_material: inspection.first_material,
last_material: inspection.last_material,
})?
);
Ok(())
}
fn inspect_model(args: &[String]) -> Result<(), String> {
let root = parse_root_alias(args)?;
let archive = parse_required(args, &["--archive"], "--archive")?;
let resource = parse_required(args, &["--resource"], "--resource")?;
let inspection = inspect_model_from_root(&root, &archive, &resource)?;
println!("{}", model_inspect_json(&archive, &resource, &inspection)?);
Ok(())
}
fn model_inspect_json(
archive: &str,
resource: &str,
inspection: &ModelInspection,
) -> Result<String, String> {
serialize_json(&ModelInspectOutput {
schema_version: MODEL_INSPECT_SCHEMA,
archive,
resource,
streams: inspection.streams,
nodes: inspection.nodes,
node_stride: inspection.node_stride,
slots: inspection.slots,
positions: inspection.positions,
indices: inspection.indices,
batches: inspection.batches,
animation_keys: inspection.animation_keys,
animation_frame_count: inspection.animation_frame_count,
node38: inspection
.node38
.iter()
.map(|node| ModelNodeInspectOutput {
index: node.index,
parent_or_link_raw: node.parent_or_link_raw,
anim_map_start: node.anim_map_start,
fallback_key: node.fallback_key,
has_lod0_group0: node.has_lod0_group0,
})
.collect(),
})
}
fn archive_inspect_json( fn archive_inspect_json(
path: &str, path: &str,
kind: &str, kind: &str,
entries: usize, entries: usize,
lookup_order_valid: Option<bool>, lookup_order_valid: Option<bool>,
) -> String { ) -> Result<String, String> {
let mut out = format!( serialize_json(&ArchiveInspectOutput {
"{{\"schema_version\":\"fparkan-archive-inspect-v1\",\"path\":{},\"kind\":{},\"entries\":{}", schema_version: ARCHIVE_INSPECT_SCHEMA,
json_string(path), path,
json_string(kind), kind,
entries entries,
); lookup_order_valid,
if let Some(valid) = lookup_order_valid { })
let _ = write!(out, ",\"lookup_order_valid\":{valid}");
}
out.push('}');
out
} }
fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> { fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> {
parse_file_path(args, "archive inspect")
}
fn parse_file_path(args: &[String], command: &str) -> Result<PathBuf, String> {
match args { match args {
[path] => Ok(PathBuf::from(path)), [path] => Ok(PathBuf::from(path)),
[flag, path] if flag == "--file" => Ok(PathBuf::from(path)), [flag, path] if flag == "--file" => Ok(PathBuf::from(path)),
_ => Err("archive inspect requires <file> or --file <file>".to_string()), _ => Err(format!("{command} requires <file> or --file <file>")),
} }
} }
fn json_string(value: &str) -> String { fn serialize_json<T: Serialize>(value: &T) -> Result<String, String> {
let mut out = String::with_capacity(value.len() + 2); serde_json::to_string(value).map_err(|err| err.to_string())
out.push('"'); }
for ch in value.chars() {
match ch { fn graph_failure_output(failure: &fparkan_prototype::PrototypeGraphFailure) -> GraphFailureOutput {
'"' => out.push_str("\\\""), GraphFailureOutput {
'\\' => out.push_str("\\\\"), root_index: failure.root_index,
'\n' => out.push_str("\\n"), edge: prototype_graph_edge_label(failure.edge),
'\r' => out.push_str("\\r"), requiredness: prototype_graph_requiredness_label(failure.requiredness),
'\t' => out.push_str("\\t"), message: failure.message.clone(),
c if c.is_control() => { archive: failure
let _ = write!(out, "\\u{:04x}", c as u32); .provenance
} .as_ref()
c => out.push(c), .and_then(|provenance| provenance.archive.clone()),
} resource: failure.provenance.as_ref().and_then(|provenance| {
provenance
.resource
.as_ref()
.map(|raw| String::from_utf8_lossy(raw).into_owned())
}),
}
}
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",
fparkan_prototype::PrototypeGraphEdge::MissionToObjectsRegistry => {
"mission_to_objects_registry"
}
fparkan_prototype::PrototypeGraphEdge::UnitDatToComponent => "unit_dat_to_component",
fparkan_prototype::PrototypeGraphEdge::PrototypeToMesh => "prototype_to_mesh",
fparkan_prototype::PrototypeGraphEdge::MeshToWear => "mesh_to_wear",
fparkan_prototype::PrototypeGraphEdge::WearToMaterial => "wear_to_material",
fparkan_prototype::PrototypeGraphEdge::MaterialToTexture => "material_to_texture",
fparkan_prototype::PrototypeGraphEdge::WearToLightmap => "wear_to_lightmap",
}
}
fn prototype_graph_requiredness_label(
requiredness: fparkan_prototype::PrototypeGraphRequiredness,
) -> &'static str {
match requiredness {
fparkan_prototype::PrototypeGraphRequiredness::Required => "required",
fparkan_prototype::PrototypeGraphRequiredness::Optional => "optional",
fparkan_prototype::PrototypeGraphRequiredness::Fallback => "fallback",
} }
out.push('"');
out
} }
fn usage() -> String { 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)] #[cfg(test)]
@@ -331,9 +917,31 @@ mod tests {
); );
} }
#[test]
fn script_inspect_json_has_canonical_field_order() {
let package =
fparkan_script::decode(&[73, 0, 0, 0, 0, 0, 0, 0]).expect("minimal script package");
assert_eq!(
script_inspect_json("script.scr", &package),
Ok("{\"schema_version\":\"fparkan-script-inspect-v2\",\"path\":\"script.scr\",\"opcode_handler_count\":73,\"events\":0,\"instructions\":0,\"references\":0,\"trailing_bytes\":0,\"first_header_word_candidates\":[]}".to_string())
);
}
#[test]
fn varset_inspect_json_has_canonical_field_order() {
let varset =
fparkan_script::parse_varset(b"VAR( float, f0, 0)\nVAR( DWORD, d0, 0xffffffff)\n")
.expect("minimal varset");
assert_eq!(
varset_inspect_json("varset.var", &varset),
Ok("{\"schema_version\":\"fparkan-varset-inspect-v1\",\"path\":\"varset.var\",\"declarations\":2,\"float_defaults\":1,\"dword_defaults\":1,\"first_name\":\"f0\",\"last_name\":\"d0\"}".to_string())
);
}
#[test] #[test]
fn archive_json_has_schema_version() { fn archive_json_has_schema_version() {
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true)); let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true))
.expect("serialize archive inspection");
assert!(json.contains("\"schema_version\":\"fparkan-archive-inspect-v1\"")); assert!(json.contains("\"schema_version\":\"fparkan-archive-inspect-v1\""));
assert!(json.contains("\"kind\":\"NRes\"")); assert!(json.contains("\"kind\":\"NRes\""));
@@ -353,11 +961,110 @@ mod tests {
..fparkan_prototype::PrototypeGraphReport::default() ..fparkan_prototype::PrototypeGraphReport::default()
}; };
let json = prototype_inspect_json("root", &graph, &report); let json = prototype_inspect_json("root", &graph, &report).expect("serialize");
assert_eq!( assert_eq!(
json, json,
"{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":\"root\",\"roots\":1,\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"failures\":0}" "{\"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\":[]}"
);
}
#[test]
fn mission_graph_json_has_canonical_field_order() {
let json = serialize_json(&MissionGraphOutput {
schema_version: MISSION_GRAPH_SCHEMA,
mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
objects: 2,
paths: 3,
clans: 4,
extras: 5,
roots: 6,
node_count: 7,
edge_count: 8,
direct_references: 9,
unit_references: 10,
unit_components: 11,
prototype_requests: 12,
wear_requests: 13,
wear: 14,
materials: 15,
textures: 16,
lightmaps: 17,
is_success: true,
failures: 0,
})
.expect("serialize");
assert_eq!(
json,
"{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":\"MISSIONS/Autodemo.00/data.tma\",\"objects\":2,\"paths\":3,\"clans\":4,\"extras\":5,\"roots\":6,\"node_count\":7,\"edge_count\":8,\"direct_references\":9,\"unit_references\":10,\"unit_components\":11,\"prototype_requests\":12,\"wear_requests\":13,\"wear\":14,\"materials\":15,\"textures\":16,\"lightmaps\":17,\"is_success\":true,\"failures\":0}"
);
}
#[test]
fn mission_inspect_output_retains_raw_transform_fields() {
let json = serialize_json(&MissionInspectOutput {
schema_version: MISSION_INSPECT_SCHEMA,
mission: "MISSIONS/test/data.tma".to_string(),
objects: vec![MissionObjectInspectOutput {
index: 1,
resource: "unit.dat".to_string(),
position: [1.0, 2.0, 3.0],
orientation_raw: [4.0, 5.0, 6.0],
scale: [7.0, 8.0, 9.0],
}],
})
.expect("serialize mission inspection");
assert_eq!(
json,
"{\"schema_version\":\"fparkan-mission-inspect-v1\",\"mission\":\"MISSIONS/test/data.tma\",\"objects\":[{\"index\":1,\"resource\":\"unit.dat\",\"position\":[1.0,2.0,3.0],\"orientation_raw\":[4.0,5.0,6.0],\"scale\":[7.0,8.0,9.0]}]}"
);
}
#[test]
fn terrain_inspect_output_retains_axis_bounds() {
let json = serialize_json(&TerrainInspectOutput {
schema_version: TERRAIN_INSPECT_SCHEMA,
path: "DATA/MAPS/AutoMAP/Land.msh".to_string(),
positions: 3,
min: [-1.0, -2.0, -3.0],
max: [4.0, 5.0, 6.0],
faces: 2,
slots: 4,
material_tags: vec![
TerrainMaterialTagCount { tag: 0, faces: 1 },
TerrainMaterialTagCount { tag: 3, faces: 1 },
],
shade_pairs: 2,
shade_lookup_key_min: Some(1),
shade_lookup_key_max: Some(2),
shade_batch_boundaries: 1,
})
.expect("serialize terrain inspection");
assert_eq!(
json,
"{\"schema_version\":\"fparkan-terrain-inspect-v1\",\"path\":\"DATA/MAPS/AutoMAP/Land.msh\",\"positions\":3,\"min\":[-1.0,-2.0,-3.0],\"max\":[4.0,5.0,6.0],\"faces\":2,\"slots\":4,\"material_tags\":[{\"tag\":0,\"faces\":1},{\"tag\":3,\"faces\":1}],\"shade_pairs\":2,\"shade_lookup_key_min\":1,\"shade_lookup_key_max\":2,\"shade_batch_boundaries\":1}"
);
}
#[test]
fn wear_inspect_output_retains_material_bounds() {
let json = serialize_json(&WearInspectOutput {
schema_version: WEAR_INSPECT_SCHEMA,
archive: "system.rlb".to_string(),
resource: "SHADE.WEA".to_string(),
materials: 1,
lightmaps: 0,
first_material: Some("LIGHT1".to_string()),
last_material: Some("LIGHT1".to_string()),
})
.expect("serialize wear inspection");
assert_eq!(
json,
"{\"schema_version\":\"fparkan-wear-inspect-v1\",\"archive\":\"system.rlb\",\"resource\":\"SHADE.WEA\",\"materials\":1,\"lightmaps\":0,\"first_material\":\"LIGHT1\",\"last_material\":\"LIGHT1\"}"
); );
} }
} }
+14 -8
View File
@@ -6,14 +6,20 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-assets = { path = "../../crates/fparkan-assets" } fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
fparkan-platform = { path = "../../crates/fparkan-platform" } fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
fparkan-render = { path = "../../crates/fparkan-render" } fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" } fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
fparkan-runtime = { path = "../../crates/fparkan-runtime" } fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" } fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version = "0.1.0" }
fparkan-world = { path = "../../crates/fparkan-world" } 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] [lints]
workspace = true workspace = true
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -6,9 +6,9 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-runtime = { path = "../../crates/fparkan-runtime" } fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" } fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
fparkan-world = { path = "../../crates/fparkan-world" } fparkan-world = { path = "../../crates/fparkan-world", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+139 -5
View File
@@ -22,10 +22,11 @@
//! `FParkan` headless runtime entrypoint. //! `FParkan` headless runtime entrypoint.
use fparkan_runtime::{ use fparkan_runtime::{
create, load_mission, step_headless, EngineConfig, EngineMode, EngineServices, MissionRequest, advance_reference_movement, create, load_mission, step_headless, EngineConfig, EngineMode,
EngineServices, MissionRequest,
}; };
use fparkan_vfs::DirectoryVfs; use fparkan_vfs::DirectoryVfs;
use fparkan_world::InputSnapshot; use fparkan_world::{InputSnapshot, OriginalObjectId};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
@@ -55,7 +56,7 @@ fn run() -> Result<(), String> {
let loaded = load_mission(&mut engine, MissionRequest { key: mission }) let loaded = load_mission(&mut engine, MissionRequest { key: mission })
.map_err(|err| format!("{err}"))?; .map_err(|err| format!("{err}"))?;
println!( println!(
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} graph_failures={}", "mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} script_varset_declarations={} script_init_states={} script_varset_states={} graph_failures={}",
loaded.object_count, loaded.object_count,
loaded.areal_count, loaded.areal_count,
loaded.surface_count, loaded.surface_count,
@@ -65,9 +66,27 @@ fn run() -> Result<(), String> {
loaded.graph_material_resolved_count, loaded.graph_material_resolved_count,
loaded.graph_texture_resolved_count, loaded.graph_texture_resolved_count,
loaded.graph_lightmap_resolved_count, loaded.graph_lightmap_resolved_count,
loaded.script_bundle_count,
loaded.script_event_count,
loaded.script_varset_declaration_count,
loaded.script_init_state_count,
loaded.script_varset_state_count,
loaded.graph_failure_count loaded.graph_failure_count
); );
} }
if let Some(movement) = args.reference_movement {
let reached = advance_reference_movement(
&mut engine,
OriginalObjectId(movement.original_id),
movement.target_xy,
movement.max_step,
)
.map_err(|err| format!("{err}"))?;
println!(
"reference_movement original_id={} target_xy=[{},{}] max_step={} reached={reached}",
movement.original_id, movement.target_xy[0], movement.target_xy[1], movement.max_step,
);
}
let mut last = None; let mut last = None;
for _ in 0..args.ticks { for _ in 0..args.ticks {
last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?); last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?);
@@ -81,10 +100,19 @@ fn run() -> Result<(), String> {
Ok(()) Ok(())
} }
#[derive(Debug)]
struct Args { struct Args {
root: Option<PathBuf>, root: Option<PathBuf>,
mission: Option<String>, mission: Option<String>,
ticks: u64, ticks: u64,
reference_movement: Option<ReferenceMovement>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct ReferenceMovement {
original_id: u32,
target_xy: [f32; 2],
max_step: f32,
} }
impl Args { impl Args {
@@ -93,6 +121,7 @@ impl Args {
root: None, root: None,
mission: None, mission: None,
ticks: 1, ticks: 1,
reference_movement: None,
}; };
let mut iter = args.iter(); let mut iter = args.iter();
while let Some(arg) = iter.next() { while let Some(arg) = iter.next() {
@@ -118,16 +147,121 @@ impl Args {
.parse() .parse()
.map_err(|_| "--ticks must be an integer".to_string())?; .map_err(|_| "--ticks must be an integer".to_string())?;
} }
"--move-object" => {
if parsed.reference_movement.is_some() {
return Err("--move-object may be specified once".to_string());
}
let original_id = iter
.next()
.ok_or_else(|| "--move-object requires an original object id".to_string())?
.parse()
.map_err(|_| {
"--move-object object id must be an unsigned integer".to_string()
})?;
let x = parse_finite_argument(
iter.next(),
"--move-object requires a finite X target",
)?;
let y = parse_finite_argument(
iter.next(),
"--move-object requires a finite Y target",
)?;
let max_step = parse_finite_argument(
iter.next(),
"--move-object requires a finite positive maximum step",
)?;
if max_step <= 0.0 {
return Err("--move-object maximum step must be positive".to_string());
}
parsed.reference_movement = Some(ReferenceMovement {
original_id,
target_xy: [x, y],
max_step,
});
}
_ => return Err(usage()), _ => return Err(usage()),
} }
} }
if parsed.mission.is_some() && parsed.root.is_none() { if parsed.mission.is_some() && parsed.root.is_none() {
return Err("--mission requires --root".to_string()); return Err("--mission requires --root".to_string());
} }
if parsed.reference_movement.is_some() && parsed.mission.is_none() {
return Err("--move-object requires --mission".to_string());
}
Ok(parsed) Ok(parsed)
} }
} }
fn usage() -> String { fn parse_finite_argument(value: Option<&String>, error: &str) -> Result<f32, String> {
"usage: fparkan-headless [--root <path> --mission <path>] [--ticks <n>]".to_string() let value: f32 = value
.ok_or_else(|| error.to_string())?
.parse()
.map_err(|_| error.to_string())?;
value
.is_finite()
.then_some(value)
.ok_or_else(|| error.to_string())
}
fn usage() -> String {
"usage: fparkan-headless [--root <path> --mission <path>] [--move-object <original-id> <x> <y> <max-step>] [--ticks <n>]".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(ToString::to_string).collect()
}
#[test]
fn move_object_parses_a_single_finite_reference_command() {
let parsed = Args::parse(&args(&[
"--root",
"C:/game",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--move-object",
"7",
"12.5",
"-3",
"0.25",
"--ticks",
"2",
]))
.expect("args");
assert_eq!(parsed.ticks, 2);
assert_eq!(
parsed.reference_movement,
Some(ReferenceMovement {
original_id: 7,
target_xy: [12.5, -3.0],
max_step: 0.25,
})
);
}
#[test]
fn move_object_requires_a_loaded_mission_and_valid_step() {
assert_eq!(
Args::parse(&args(&["--move-object", "7", "1", "2", "1"])).expect_err("mission"),
"--move-object requires --mission"
);
assert_eq!(
Args::parse(&args(&[
"--root",
"C:/game",
"--mission",
"M/data.tma",
"--move-object",
"7",
"1",
"2",
"0",
]))
.expect_err("step"),
"--move-object maximum step must be positive"
);
}
} }
+2 -2
View File
@@ -6,8 +6,8 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-inspection = { path = "../../crates/fparkan-inspection" } fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
fparkan-render = { path = "../../crates/fparkan-render" } fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+10 -9
View File
@@ -19,7 +19,7 @@
) )
)] )]
#![allow(clippy::print_stderr, clippy::print_stdout)] #![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` asset viewer composition root. //! `FParkan` asset inspection composition root.
use fparkan_inspection::{ use fparkan_inspection::{
inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection, inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection,
@@ -68,14 +68,14 @@ fn inspect_archive(args: &[String]) -> Result<String, String> {
lookup_order_valid, lookup_order_valid,
sample, sample,
} => Ok(format!( } => Ok(format!(
"{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}", "{{\"report_kind\":\"archive-inspection\",\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
json_string(&file.display().to_string()), json_string(&file.display().to_string()),
entries, entries,
lookup_order_valid, lookup_order_valid,
render_nres_entries(&sample) render_nres_entries(&sample)
)), )),
ArchiveInspection::Rsli { entries } => Ok(format!( ArchiveInspection::Rsli { entries } => Ok(format!(
"{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}", "{{\"report_kind\":\"archive-inspection\",\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
json_string(&file.display().to_string()), json_string(&file.display().to_string()),
entries entries
)), )),
@@ -92,7 +92,7 @@ fn inspect_model(args: &[String]) -> Result<String, String> {
let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?; let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?;
Ok(format!( Ok(format!(
"{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}", "{{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
json_string(&query.archive), json_string(&query.archive),
json_string(&query.name), json_string(&query.name),
inspection.streams, inspection.streams,
@@ -122,6 +122,7 @@ impl ViewerModelService {
mesh: GpuMeshId(1), mesh: GpuMeshId(1),
material_slots: vec![GpuMaterialId(7)], material_slots: vec![GpuMaterialId(7)],
material_index: 0, material_index: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(),
transform: identity_transform(), transform: identity_transform(),
range: IndexRange { start: 0, count: 3 }, range: IndexRange { start: 0, count: 3 },
stable_order: 0, stable_order: 0,
@@ -136,7 +137,7 @@ impl ViewerModelService {
.count(); .count();
Ok(format!( Ok(format!(
"{{\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model\",\"draw_commands\":{draw_commands}}}", "{{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model-inspection\",\"draw_commands\":{draw_commands}}}",
json_string(fixture) json_string(fixture)
)) ))
} }
@@ -147,7 +148,7 @@ fn inspect_texture(args: &[String]) -> Result<String, String> {
let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?; let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?;
Ok(format!( Ok(format!(
"{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}", "{{\"report_kind\":\"texture-inspection\",\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
json_string(&query.archive), json_string(&query.archive),
json_string(&query.name), json_string(&query.name),
inspection.width, inspection.width,
@@ -180,7 +181,7 @@ fn inspect_map(args: &[String]) -> Result<String, String> {
fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String { fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String {
match kind { match kind {
"land-msh" => format!( "land-msh" => format!(
"{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}", "{{\"report_kind\":\"map-inspection\",\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
json_string(path), json_string(path),
inspection.streams, inspection.streams,
inspection.positions, inspection.positions,
@@ -188,7 +189,7 @@ fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection
inspection.slots inspection.slots
), ),
"land-map" => format!( "land-map" => format!(
"{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}", "{{\"report_kind\":\"map-inspection\",\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
json_string(path), json_string(path),
inspection.areals, inspection.areals,
inspection.declared_areals, inspection.declared_areals,
@@ -342,7 +343,7 @@ mod tests {
fn model_fixture_uses_viewer_service_and_render_commands() -> Result<(), String> { fn model_fixture_uses_viewer_service_and_render_commands() -> Result<(), String> {
assert_eq!( assert_eq!(
run(&strings(&["model", "--fixture", "synthetic/model-basic"]))?, run(&strings(&["model", "--fixture", "synthetic/model-basic"]))?,
"{\"kind\":\"model\",\"fixture\":\"synthetic/model-basic\",\"service\":\"synthetic-model\",\"draw_commands\":1}" "{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"fixture\":\"synthetic/model-basic\",\"service\":\"synthetic-model-inspection\",\"draw_commands\":1}"
); );
Ok(()) Ok(())
} }
+5 -4
View File
@@ -7,12 +7,13 @@ repository.workspace = true
build = "build.rs" build = "build.rs"
[dependencies] [dependencies]
fparkan-platform = { path = "../../crates/fparkan-platform" } fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" } fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version = "0.1.0" }
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
winit = "0.30" winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
[lints] [lints]
workspace = true workspace = true
File diff suppressed because it is too large Load Diff
+36 -4
View File
@@ -4,7 +4,6 @@
allow( allow(
clippy::cast_possible_truncation, clippy::cast_possible_truncation,
clippy::cast_possible_wrap, clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used, clippy::expect_used,
clippy::float_cmp, clippy::float_cmp,
clippy::identity_op, clippy::identity_op,
@@ -782,16 +781,29 @@ fn write_f32_bits(out: &mut Vec<u8>, value: f32) {
} }
fn compose_pose(parent: Pose, child: Pose) -> Result<Pose, AnimationError> { fn compose_pose(parent: Pose, child: Pose) -> Result<Pose, AnimationError> {
let translated = rotate_point(parent.rotation, child.translation);
Ok(Pose { Ok(Pose {
translation: [ translation: [
parent.translation[0] + child.translation[0], parent.translation[0] + translated[0],
parent.translation[1] + child.translation[1], parent.translation[1] + translated[1],
parent.translation[2] + child.translation[2], parent.translation[2] + translated[2],
], ],
rotation: normalize_quat(mul_quat(parent.rotation, child.rotation))?, rotation: normalize_quat(mul_quat(parent.rotation, child.rotation))?,
}) })
} }
fn rotate_point(rotation: [f32; 4], point: [f32; 3]) -> [f32; 3] {
let [x, y, z, w] = rotation;
let tx = 2.0 * (y * point[2] - z * point[1]);
let ty = 2.0 * (z * point[0] - x * point[2]);
let tz = 2.0 * (x * point[1] - y * point[0]);
[
point[0] + w * tx + (y * tz - z * ty),
point[1] + w * ty + (z * tx - x * tz),
point[2] + w * tz + (x * ty - y * tx),
]
}
fn mul_quat(left: [f32; 4], right: [f32; 4]) -> [f32; 4] { fn mul_quat(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
let [lx, ly, lz, lw] = left; let [lx, ly, lz, lw] = left;
let [rx, ry, rz, rw] = right; let [rx, ry, rz, rw] = right;
@@ -1175,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] #[test]
fn generated_valid_quaternions_remain_finite() { fn generated_valid_quaternions_remain_finite() {
for index in 1..64_u16 { for index in 1..64_u16 {
+11 -11
View File
@@ -6,19 +6,19 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-material = { path = "../fparkan-material" } fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
fparkan-msh = { path = "../fparkan-msh" } fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-mission-format = { path = "../fparkan-mission-format" } fparkan-mission-format = { path = "../fparkan-mission-format", version = "0.1.0" }
fparkan-prototype = { path = "../fparkan-prototype" } fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
fparkan-resource = { path = "../fparkan-resource" } fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
fparkan-texm = { path = "../fparkan-texm" } fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" }
fparkan-terrain = { path = "../fparkan-terrain" } fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
fparkan-terrain-format = { path = "../fparkan-terrain-format" } fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
[dev-dependencies] [dev-dependencies]
fparkan-vfs = { path = "../fparkan-vfs" } fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -6,17 +6,17 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" } fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
fparkan-fx = { path = "../fparkan-fx" } fparkan-fx = { path = "../fparkan-fx", version = "0.1.0" }
fparkan-material = { path = "../fparkan-material" } fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
fparkan-msh = { path = "../fparkan-msh" } fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
fparkan-mission-format = { path = "../fparkan-mission-format" } fparkan-mission-format = { path = "../fparkan-mission-format", version = "0.1.0" }
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
fparkan-prototype = { path = "../fparkan-prototype" } fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-rsli = { path = "../fparkan-rsli" } fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
fparkan-texm = { path = "../fparkan-texm" } fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" }
fparkan-terrain-format = { path = "../fparkan-terrain-format" } fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+88 -68
View File
@@ -35,6 +35,8 @@ use std::collections::{BTreeMap, BTreeSet};
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -69,6 +71,8 @@ pub struct DiscoverOptions {
pub struct ManifestEntry { pub struct ManifestEntry {
/// Normalized relative path. /// Normalized relative path.
pub path: String, pub path: String,
/// Byte-exact relative host path used for reopening corpus files.
pub host_rel_path: PathBuf,
/// File size in bytes. /// File size in bytes.
pub size: u64, pub size: u64,
/// SHA-256 content fingerprint. /// SHA-256 content fingerprint.
@@ -188,7 +192,7 @@ pub fn discover(root: &Path, options: DiscoverOptions) -> Result<CorpusManifest,
} }
let mut files = Vec::new(); let mut files = Vec::new();
walk(root, root, options, &mut files)?; walk(root, root, options, &mut files)?;
files.sort_by(|a, b| a.path.cmp(&b.path)); files.sort_by(|a, b| a.host_rel_path.cmp(&b.host_rel_path));
let kind = classify(root, &files); let kind = classify(root, &files);
let casefold_collisions = detect_casefold_collisions(&files); let casefold_collisions = detect_casefold_collisions(&files);
@@ -243,17 +247,22 @@ fn walk(
let rel = path let rel = path
.strip_prefix(root) .strip_prefix(root)
.map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?; .map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?;
let rel_text = rel #[cfg(unix)]
let rel_bytes = rel.as_os_str().as_bytes();
#[cfg(not(unix))]
let rel_bytes = rel
.to_str() .to_str()
.ok_or_else(|| CorpusError::InvalidPath(path.display().to_string()))?; .ok_or_else(|| CorpusError::InvalidPath(path.display().to_string()))?
let normalized = normalize_relative(rel_text.as_bytes(), PathPolicy::HostCompatible) .as_bytes();
.map_err(|_| CorpusError::InvalidPath(rel_text.to_string()))?; let normalized = normalize_relative(rel_bytes, PathPolicy::HostCompatible)
.map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?;
let bytes = fs::read(&path).map_err(|source| CorpusError::Io { let bytes = fs::read(&path).map_err(|source| CorpusError::Io {
path: path.clone(), path: path.clone(),
source, source,
})?; })?;
out.push(ManifestEntry { out.push(ManifestEntry {
path: normalized.as_str().to_string(), path: normalized.display_lossy().to_string(),
host_rel_path: rel.to_path_buf(),
size: metadata.len(), size: metadata.len(),
hash: sha256(&bytes), hash: sha256(&bytes),
}); });
@@ -285,7 +294,7 @@ fn detect_casefold_collisions(files: &[ManifestEntry]) -> Vec<Vec<String>> {
let mut grouped: BTreeMap<Vec<u8>, BTreeSet<String>> = BTreeMap::new(); let mut grouped: BTreeMap<Vec<u8>, BTreeSet<String>> = BTreeMap::new();
for file in files { for file in files {
grouped grouped
.entry(ascii_lookup_key(file.path.as_bytes()).0) .entry(ascii_lookup_key(path_identity_bytes(&file.host_rel_path)).0)
.or_default() .or_default()
.insert(file.path.clone()); .insert(file.path.clone());
} }
@@ -353,7 +362,7 @@ fn inspect_report_file(
) -> CorpusFileRecord { ) -> CorpusFileRecord {
let lower = entry.path.to_ascii_lowercase(); let lower = entry.path.to_ascii_lowercase();
let mut variant = inspect_path_metrics(&lower, metrics); let mut variant = inspect_path_metrics(&lower, metrics);
let path = root.join(&entry.path); let path = root.join(&entry.host_rel_path);
let bytes = match fs::read(&path) { let bytes = match fs::read(&path) {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(source) => { Err(source) => {
@@ -439,6 +448,17 @@ fn inspect_report_file(
} }
} }
fn path_identity_bytes(path: &Path) -> &[u8] {
#[cfg(unix)]
{
path.as_os_str().as_bytes()
}
#[cfg(not(unix))]
{
path.to_str().unwrap_or_default().as_bytes()
}
}
fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> String { fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> String {
let mut variant = "file"; let mut variant = "file";
if lower.ends_with("data.tma") { if lower.ends_with("data.tma") {
@@ -767,11 +787,7 @@ mod tests {
fn report_json_contains_metrics_and_hashes_not_paths_or_payloads() { fn report_json_contains_metrics_and_hashes_not_paths_or_payloads() {
let manifest = CorpusManifest { let manifest = CorpusManifest {
kind: CorpusKind::Part1, kind: CorpusKind::Part1,
files: vec![ManifestEntry { files: vec![manifest_entry("secret/payload.bin", 4, sha256(b"DATA"))],
path: "secret/payload.bin".to_string(),
size: 4,
hash: sha256(b"DATA"),
}],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
}; };
let report = report(Path::new("."), &manifest).expect("report"); let report = report(Path::new("."), &manifest).expect("report");
@@ -791,11 +807,7 @@ mod tests {
let root = temp_dir("report-missing"); let root = temp_dir("report-missing");
let manifest = CorpusManifest { let manifest = CorpusManifest {
kind: CorpusKind::Unknown, kind: CorpusKind::Unknown,
files: vec![ManifestEntry { files: vec![manifest_entry("missing.lib", 1, sha256(b"missing"))],
path: "missing.lib".to_string(),
size: 1,
hash: sha256(b"missing"),
}],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
}; };
@@ -814,11 +826,7 @@ mod tests {
fs::write(root.join("bad.lib"), b"NRes").expect("bad nres"); fs::write(root.join("bad.lib"), b"NRes").expect("bad nres");
let manifest = CorpusManifest { let manifest = CorpusManifest {
kind: CorpusKind::Unknown, kind: CorpusKind::Unknown,
files: vec![ManifestEntry { files: vec![manifest_entry("bad.lib", 4, sha256(b"NRes"))],
path: "bad.lib".to_string(),
size: 4,
hash: sha256(b"NRes"),
}],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
}; };
@@ -857,11 +865,11 @@ mod tests {
fs::write(root.join("archive.lib"), &archive).expect("archive"); fs::write(root.join("archive.lib"), &archive).expect("archive");
let manifest = CorpusManifest { let manifest = CorpusManifest {
kind: CorpusKind::Unknown, kind: CorpusKind::Unknown,
files: vec![ManifestEntry { files: vec![manifest_entry(
path: "archive.lib".to_string(), "archive.lib",
size: u64::try_from(archive.len()).expect("archive size"), u64::try_from(archive.len()).expect("archive size"),
hash: sha256(&archive), sha256(&archive),
}], )],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
}; };
@@ -886,11 +894,11 @@ mod tests {
fs::write(root.join("WORLD/MAP/land.map"), build_nres(&[])).expect("land map"); fs::write(root.join("WORLD/MAP/land.map"), build_nres(&[])).expect("land map");
let manifest = CorpusManifest { let manifest = CorpusManifest {
kind: CorpusKind::Unknown, kind: CorpusKind::Unknown,
files: vec![ManifestEntry { files: vec![manifest_entry(
path: "WORLD/MAP/land.map".to_string(), "WORLD/MAP/land.map",
size: 16, 16,
hash: sha256(b"land.map"), sha256(b"land.map"),
}], )],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
}; };
@@ -909,11 +917,11 @@ mod tests {
fs::write(root.join("WORLD/MAP/land.msh"), build_nres(&[])).expect("land msh"); fs::write(root.join("WORLD/MAP/land.msh"), build_nres(&[])).expect("land msh");
let manifest = CorpusManifest { let manifest = CorpusManifest {
kind: CorpusKind::Unknown, kind: CorpusKind::Unknown,
files: vec![ManifestEntry { files: vec![manifest_entry(
path: "WORLD/MAP/land.msh".to_string(), "WORLD/MAP/land.msh",
size: 16, 16,
hash: sha256(b"land.msh"), sha256(b"land.msh"),
}], )],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
}; };
@@ -932,11 +940,11 @@ mod tests {
fs::write(root.join("MISSIONS/test/data.tma"), b"malformed tma").expect("tma"); fs::write(root.join("MISSIONS/test/data.tma"), b"malformed tma").expect("tma");
let manifest = CorpusManifest { let manifest = CorpusManifest {
kind: CorpusKind::Unknown, kind: CorpusKind::Unknown,
files: vec![ManifestEntry { files: vec![manifest_entry(
path: "MISSIONS/test/data.tma".to_string(), "MISSIONS/test/data.tma",
size: 12, 12,
hash: sha256(b"malformed tma"), sha256(b"malformed tma"),
}], )],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
}; };
@@ -955,11 +963,7 @@ mod tests {
fs::write(root.join("units/unit.dat"), vec![0u8; 120]).expect("unit"); fs::write(root.join("units/unit.dat"), vec![0u8; 120]).expect("unit");
let manifest = CorpusManifest { let manifest = CorpusManifest {
kind: CorpusKind::Unknown, kind: CorpusKind::Unknown,
files: vec![ManifestEntry { files: vec![manifest_entry("units/unit.dat", 120, sha256(&[0u8; 120]))],
path: "units/unit.dat".to_string(),
size: 120,
hash: sha256(&[0u8; 120]),
}],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
}; };
@@ -977,11 +981,7 @@ mod tests {
fs::write(root.join("patch.nl"), b"NL malformed").expect("rsli"); fs::write(root.join("patch.nl"), b"NL malformed").expect("rsli");
let manifest = CorpusManifest { let manifest = CorpusManifest {
kind: CorpusKind::Unknown, kind: CorpusKind::Unknown,
files: vec![ManifestEntry { files: vec![manifest_entry("patch.nl", 12, sha256(b"NL malformed"))],
path: "patch.nl".to_string(),
size: 12,
hash: sha256(b"NL malformed"),
}],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
}; };
@@ -1052,16 +1052,8 @@ mod tests {
let manifest = CorpusManifest { let manifest = CorpusManifest {
kind: CorpusKind::Unknown, kind: CorpusKind::Unknown,
files: vec![ files: vec![
ManifestEntry { manifest_entry("Textures/Foo.TEX", 1, sha256(b"first")),
path: "Textures/Foo.TEX".to_string(), manifest_entry("textures/foo.tex", 1, sha256(b"second")),
size: 1,
hash: sha256(b"first"),
},
ManifestEntry {
path: "textures/foo.tex".to_string(),
size: 1,
hash: sha256(b"second"),
},
], ],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
}; };
@@ -1081,11 +1073,7 @@ mod tests {
fn fingerprint_changes() { fn fingerprint_changes() {
let mut manifest = CorpusManifest { let mut manifest = CorpusManifest {
kind: CorpusKind::Unknown, kind: CorpusKind::Unknown,
files: vec![ManifestEntry { files: vec![manifest_entry("a", 1, sha256(b"before"))],
path: "a".to_string(),
size: 1,
hash: sha256(b"before"),
}],
casefold_collisions: Vec::new(), casefold_collisions: Vec::new(),
}; };
let a = fingerprint(&manifest); let a = fingerprint(&manifest);
@@ -1118,6 +1106,29 @@ mod tests {
let _ = fs::remove_file(tmp); let _ = fs::remove_file(tmp);
} }
#[cfg(unix)]
#[test]
fn discover_supports_non_utf8_host_paths() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let root = temp_dir("non-utf8");
let file_name = OsString::from_vec(vec![0xFF, b'.', b'b', b'i', b'n']);
let file_path = root.join(&file_name);
if let Err(err) = fs::write(&file_path, b"raw") {
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
let _ = fs::remove_dir_all(root);
return;
}
let manifest = discover(&root, DiscoverOptions::default()).expect("manifest");
assert_eq!(manifest.files.len(), 1);
assert_eq!(manifest.files[0].path, "\u{FFFD}.bin");
assert_eq!(manifest.files[0].host_rel_path, PathBuf::from(&file_name));
let _ = fs::remove_dir_all(root);
}
struct TestNresEntry<'a> { struct TestNresEntry<'a> {
name: &'a str, name: &'a str,
type_id: u32, type_id: u32,
@@ -1164,6 +1175,15 @@ mod tests {
out out
} }
fn manifest_entry(path: &str, size: u64, hash: Sha256Digest) -> ManifestEntry {
ManifestEntry {
path: path.to_string(),
host_rel_path: PathBuf::from(path),
size,
hash,
}
}
fn push_u32(out: &mut Vec<u8>, value: u32) { fn push_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes()); out.extend_from_slice(&value.to_le_bytes());
} }
+2 -2
View File
@@ -6,10 +6,10 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" } fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
[dev-dependencies] [dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+10 -7
View File
@@ -6,13 +6,16 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-msh = { path = "../fparkan-msh" } fparkan-diagnostics = { path = "../fparkan-diagnostics", version = "0.1.0" }
fparkan-nres = { path = "../fparkan-nres" } fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
fparkan-rsli = { path = "../fparkan-rsli" } fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
fparkan-resource = { path = "../fparkan-resource" } fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
fparkan-terrain-format = { path = "../fparkan-terrain-format" } fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-texm = { path = "../fparkan-texm" } fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
fparkan-vfs = { path = "../fparkan-vfs" } fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" }
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+707 -25
View File
@@ -20,14 +20,23 @@
)] )]
//! Shared inspection helpers for format-backed tooling. //! Shared inspection helpers for format-backed tooling.
use fparkan_msh::{decode_msh, validate_msh}; use fparkan_diagnostics::{
diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase, SourceSpan,
};
use fparkan_material::{decode_wear, resolve_material, MaterialFallback};
use fparkan_msh::{
decode_msh, node38_metadata, selected_slot, validate_msh, Group, Lod, ModelAsset, NodeId,
};
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile}; use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
use fparkan_path::{normalize_relative, PathPolicy};
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository}; use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
use fparkan_rsli::decode as decode_rsli; use fparkan_rsli::decode as decode_rsli;
use fparkan_terrain_format::{decode_land_map, decode_land_msh}; use fparkan_terrain_format::{decode_land_map, decode_land_msh, LandMeshDocument};
use fparkan_texm::decode_texm; use fparkan_texm::decode_texm;
use fparkan_vfs::DirectoryVfs; use fparkan_vfs::{DirectoryVfs, Vfs};
use std::fs; use std::fs;
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
@@ -78,6 +87,29 @@ pub struct ModelInspection {
pub indices: usize, pub indices: usize,
/// Batch count. /// Batch count.
pub batches: usize, pub batches: usize,
/// Original node record stride.
pub node_stride: usize,
/// Standard-node metadata in source order, when the model uses `Node38`.
pub node38: Vec<Node38Inspection>,
/// Number of decoded type-8 animation keys, when available.
pub animation_keys: Option<usize>,
/// Declared type-19 animation frame count, when available.
pub animation_frame_count: Option<u32>,
}
/// Inspection view of a standard 38-byte model node.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Node38Inspection {
/// Source node index.
pub index: usize,
/// Opaque source field at byte offset two.
pub parent_or_link_raw: u16,
/// Type-19 frame-map offset, or `0xFFFF`.
pub anim_map_start: u16,
/// Type-8 fallback key index.
pub fallback_key: u16,
/// Whether LOD zero/group zero selects a geometry slot.
pub has_lod0_group0: bool,
} }
/// Texture inspection payload. /// Texture inspection payload.
@@ -95,6 +127,38 @@ pub struct TextureInspection {
pub pages: usize, pub pages: usize,
} }
/// Diffuse TEXM selected through an original WEAR and MAT0 material chain.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WearMaterialTexture {
/// Source WEAR archive.
pub wear_archive: String,
/// Source WEAR resource.
pub wear_resource: String,
/// Positional WEAR selector used by an MSH batch.
pub material_index: u16,
/// Resolved MAT0 resource name after the original fallback chain.
pub material_name: String,
/// Fallback route used while resolving the MAT0 resource.
pub material_fallback: MaterialFallback,
/// Texture name selected from phase zero of the resolved MAT0 document.
pub texture_name: String,
/// Decoded RGBA8 mip zero suitable for the Vulkan upload boundary.
pub image: fparkan_texm::RgbaImage,
}
/// Compact inspection summary of a WEAR resource stored in an archive.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WearInspection {
/// Number of material rows.
pub materials: usize,
/// Number of lightmap rows.
pub lightmaps: usize,
/// First material resource name, when present.
pub first_material: Option<String>,
/// Last material resource name, when present.
pub last_material: Option<String>,
}
/// Land map/msh inspection payload. /// Land map/msh inspection payload.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct MapInspection { pub struct MapInspection {
@@ -116,6 +180,17 @@ pub struct MapInspection {
pub grid_height: u32, pub grid_height: u32,
} }
/// Axis-aligned position bounds of a decoded `Land.msh`.
#[derive(Clone, Debug, PartialEq)]
pub struct LandMeshBoundsInspection {
/// Number of source positions covered by the bounds.
pub positions: usize,
/// Per-axis inclusive minimum position.
pub min: [f32; 3],
/// Per-axis inclusive maximum position.
pub max: [f32; 3],
}
/// Supported land file kinds. /// Supported land file kinds.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LandFileKind { pub enum LandFileKind {
@@ -131,22 +206,92 @@ pub enum LandFileKind {
/// ///
/// Returns a string error when the archive cannot be read or decoded. /// Returns a string error when the archive cannot be read or decoded.
pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> { pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; inspect_archive_file_diagnostic(path, sample_limit)
.map_err(|diagnostic| render_human(&diagnostic))
}
/// Inspects a format archive and returns a structured diagnostic on failure.
///
/// # Errors
///
/// Returns a [`Diagnostic`] when the archive cannot be read or decoded.
// Diagnostic is deliberately returned by value as the public structured-error contract.
#[allow(clippy::result_large_err)]
pub fn inspect_archive_file_diagnostic(
path: &Path,
sample_limit: usize,
) -> Result<ArchiveInspection, Diagnostic> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path.file_name().ok_or_else(|| {
diagnostic(
DiagnosticCode("S1.VFS.PATH"),
format!("{}: archive path has no file name", path.display()),
)
.with_context(DiagnosticContext {
phase: Some(Phase::Read),
path: Some(path.display().to_string()),
..DiagnosticContext::default()
})
})?;
#[cfg(unix)]
let raw_name = file_name.as_bytes();
#[cfg(not(unix))]
let raw_name = file_name
.to_str()
.ok_or_else(|| {
diagnostic(
DiagnosticCode("S1.VFS.PATH"),
format!("{}: archive file name is not valid text", path.display()),
)
.with_context(DiagnosticContext {
phase: Some(Phase::Read),
path: Some(path.display().to_string()),
..DiagnosticContext::default()
})
})?
.as_bytes();
let normalized = normalize_relative(raw_name, PathPolicy::HostCompatible).map_err(|err| {
diagnostic(
DiagnosticCode("S1.VFS.PATH"),
format!("{}: {err}", path.display()),
)
.with_context(DiagnosticContext {
phase: Some(Phase::Read),
path: Some(path.display().to_string()),
..DiagnosticContext::default()
})
})?;
let vfs = DirectoryVfs::new(parent);
let bytes = vfs.read(&normalized).map_err(|err| {
diagnostic(
DiagnosticCode("S1.VFS.READ"),
format!("{}: {err}", path.display()),
)
.with_context(DiagnosticContext {
phase: Some(Phase::Read),
path: Some(path.display().to_string()),
..DiagnosticContext::default()
})
})?;
inspect_archive_bytes(&bytes, sample_limit, Some(path)) inspect_archive_bytes(&bytes, sample_limit, Some(path))
} }
/// Inspects archive bytes and returns a typed summary. /// Inspects archive bytes and returns a typed summary.
// Keeps the internal diagnostic flow aligned with the public structured-error contract.
#[allow(clippy::result_large_err)]
fn inspect_archive_bytes( fn inspect_archive_bytes(
bytes: &[u8], bytes: &[u8],
sample_limit: usize, sample_limit: usize,
source: Option<&Path>, source: Option<&Path>,
) -> Result<ArchiveInspection, String> { ) -> Result<ArchiveInspection, Diagnostic> {
if bytes.starts_with(b"NRes") { if bytes.starts_with(b"NRes") {
let document = decode_nres( let document = decode_nres(
Arc::from(bytes.to_vec().into_boxed_slice()), Arc::from(bytes.to_vec().into_boxed_slice()),
ReadProfile::Compatible, ReadProfile::Compatible,
) )
.map_err(|err| err.to_string())?; .map_err(|err| {
archive_parse_diagnostic("S1.NRES.DECODE", source, bytes, err.to_string())
})?;
let mut sample = Vec::new(); let mut sample = Vec::new();
for entry in document.entries().iter().take(sample_limit) { for entry in document.entries().iter().take(sample_limit) {
sample.push(NresEntrySummary { sample.push(NresEntrySummary {
@@ -165,15 +310,19 @@ fn inspect_archive_bytes(
Arc::from(bytes.to_vec().into_boxed_slice()), Arc::from(bytes.to_vec().into_boxed_slice()),
fparkan_rsli::ReadProfile::Compatible, fparkan_rsli::ReadProfile::Compatible,
) )
.map_err(|err| err.to_string())?; .map_err(|err| {
archive_parse_diagnostic("S1.RSLI.DECODE", source, bytes, err.to_string())
})?;
Ok(ArchiveInspection::Rsli { Ok(ArchiveInspection::Rsli {
entries: document.entries().len(), entries: document.entries().len(),
}) })
} else { } else {
match source { Err(archive_parse_diagnostic(
Some(path) => Err(format!("{}: unsupported archive magic", path.display())), "S1.RESOURCE.UNSUPPORTED_ARCHIVE",
None => Err("unsupported archive magic".to_string()), source,
} bytes,
"unsupported archive magic".to_string(),
))
} }
} }
@@ -188,10 +337,36 @@ pub fn inspect_model_from_root(
archive: &str, archive: &str,
resource: &str, resource: &str,
) -> Result<ModelInspection, String> { ) -> Result<ModelInspection, String> {
let bytes = read_resource_bytes(root, archive, resource)?; let bytes = read_resource_bytes_diagnostic(root, archive, resource)
let document = decode_nres(bytes, ReadProfile::Compatible).map_err(|err| err.to_string())?; .map_err(|err| render_human(&err))?;
let document = decode_nres(bytes.clone(), ReadProfile::Compatible).map_err(|err| {
render_human(&resource_parse_diagnostic(
"S1.NRES.DECODE",
archive,
resource,
&bytes,
err.to_string(),
))
})?;
let msh = decode_msh(&document).map_err(|err| err.to_string())?; let msh = decode_msh(&document).map_err(|err| err.to_string())?;
let validated = validate_msh(&msh).map_err(|err| err.to_string())?; let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
let node38 = if validated.node_stride == 38 {
(0..validated.node_count)
.filter_map(|index| {
let node = NodeId(u32::try_from(index).ok()?);
let metadata = node38_metadata(&validated, node)?;
Some(Node38Inspection {
index,
parent_or_link_raw: metadata.parent_or_link_raw,
anim_map_start: metadata.anim_map_start,
fallback_key: metadata.fallback_key,
has_lod0_group0: selected_slot(&validated, node, Lod(0), Group(0)).is_some(),
})
})
.collect()
} else {
Vec::new()
};
Ok(ModelInspection { Ok(ModelInspection {
streams: msh.streams().len(), streams: msh.streams().len(),
nodes: validated.node_count, nodes: validated.node_count,
@@ -199,9 +374,64 @@ pub fn inspect_model_from_root(
positions: validated.positions.len(), positions: validated.positions.len(),
indices: validated.indices.len(), indices: validated.indices.len(),
batches: validated.batches.len(), batches: validated.batches.len(),
node_stride: validated.node_stride,
node38,
animation_keys: validated
.animation
.as_ref()
.map(|animation| animation.keys.len()),
animation_frame_count: validated
.animation
.as_ref()
.map(|animation| animation.frame_count),
}) })
} }
/// Inspects a WEAR resource through repository-backed lookup.
///
/// # Errors
///
/// Returns a string error when the resource cannot be resolved or parsed as a
/// valid WEAR payload.
pub fn inspect_wear_from_root(
root: &Path,
archive: &str,
resource: &str,
) -> Result<WearInspection, String> {
let bytes = read_resource_bytes_diagnostic(root, archive, resource)
.map_err(|err| render_human(&err))?;
let wear = decode_wear(&bytes).map_err(|err| err.to_string())?;
Ok(WearInspection {
materials: wear.entries.len(),
lightmaps: wear.lightmaps.len(),
first_material: wear
.entries
.first()
.map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()),
last_material: wear
.entries
.last()
.map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()),
})
}
/// Loads and validates a model resource through repository-backed lookup.
///
/// # Errors
///
/// Returns a string error when the resource cannot be resolved or parsed as a
/// valid model payload.
pub fn load_model_from_root(
root: &Path,
archive: &str,
resource: &str,
) -> Result<ModelAsset, String> {
let document = load_model_document_from_root_diagnostic(root, archive, resource)
.map_err(|err| render_human(&err))?;
let msh = decode_msh(&document).map_err(|err| err.to_string())?;
validate_msh(&msh).map_err(|err| err.to_string())
}
/// Inspects a texture through repository-backed resource lookup. /// Inspects a texture through repository-backed resource lookup.
/// ///
/// # Errors /// # Errors
@@ -213,7 +443,8 @@ pub fn inspect_texture_from_root(
archive: &str, archive: &str,
resource: &str, resource: &str,
) -> Result<TextureInspection, String> { ) -> Result<TextureInspection, String> {
let bytes = read_resource_bytes(root, archive, resource)?; let bytes = read_resource_bytes_diagnostic(root, archive, resource)
.map_err(|err| render_human(&err))?;
let document = decode_texm(bytes).map_err(|err| err.to_string())?; let document = decode_texm(bytes).map_err(|err| err.to_string())?;
Ok(TextureInspection { Ok(TextureInspection {
width: document.width(), width: document.width(),
@@ -224,6 +455,113 @@ pub fn inspect_texture_from_root(
}) })
} }
/// Loads a decoded TEXM document through repository-backed lookup.
///
/// # Errors
///
/// Returns a string error when the resource cannot be resolved or parsed as a
/// valid TEXM payload.
pub fn load_texture_from_root(
root: &Path,
archive: &str,
resource: &str,
) -> Result<fparkan_texm::TexmDocument, String> {
let bytes = read_resource_bytes_diagnostic(root, archive, resource)
.map_err(|err| render_human(&err))?;
decode_texm(bytes).map_err(|err| err.to_string())
}
/// Loads and decodes TEXM mip 0 as RGBA8 through repository-backed lookup.
///
/// # Errors
///
/// Returns a string error when the resource cannot be resolved, decoded, or
/// converted to the shared RGBA8 upload representation.
pub fn load_texture_mip0_rgba8_from_root(
root: &Path,
archive: &str,
resource: &str,
) -> Result<fparkan_texm::RgbaImage, String> {
let document = load_texture_from_root(root, archive, resource)?;
fparkan_texm::decode_mip_rgba8(&document, 0).map_err(|err| err.to_string())
}
/// Resolves phase-zero diffuse TEXM through `WEAR → MAT0 → Textures.lib`.
///
/// The selector is the positional `Batch20.material_index`, not WEAR's legacy
/// text id. MAT0 fallback remains owned by `fparkan-material`: exact requested
/// entry, then `DEFAULT`, then the first material entry.
///
/// # Errors
///
/// Returns a string error if WEAR/MAT0 resolution, phase selection, or TEXM
/// decoding fails. An empty phase-zero texture name is an intentional
/// untextured material and is reported rather than substituted with a texture.
pub fn load_wear_material_texture_mip0_rgba8_from_root(
root: &Path,
wear_archive: &str,
wear_resource: &str,
material_index: u16,
) -> Result<WearMaterialTexture, String> {
let wear_bytes = read_resource_bytes_diagnostic(root, wear_archive, wear_resource)
.map_err(|err| render_human(&err))?;
let wear = decode_wear(&wear_bytes).map_err(|err| err.to_string())?;
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
let material =
resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?;
let texture = material.document.primary_texture().ok_or_else(|| {
"MAT0 phase zero declares an intentionally untextured material".to_string()
})?;
let texture_name = String::from_utf8_lossy(&texture.0).into_owned();
let image = load_texture_mip0_rgba8_from_root(root, "Textures.lib", &texture_name)?;
Ok(WearMaterialTexture {
wear_archive: wear_archive.to_string(),
wear_resource: wear_resource.to_string(),
material_index,
material_name: String::from_utf8_lossy(&material.name.0).into_owned(),
material_fallback: material.fallback,
texture_name,
image,
})
}
/// Resolves phase-zero diffuse TEXM through a standalone map-local WEAR file.
///
/// Map terrain keeps its two WEAR tables adjacent to `Land.msh`, while their
/// MAT0 and TEXM resources remain in the game root's normal archives. This
/// preserves that split instead of treating the sidecar as an archive entry.
///
/// # Errors
///
/// Returns a string error if the standalone WEAR file, global material
/// resources, or selected diffuse TEXM cannot be decoded.
pub fn load_standalone_wear_material_texture_mip0_rgba8_from_root(
root: &Path,
wear_path: &Path,
material_index: u16,
) -> Result<WearMaterialTexture, String> {
let wear_bytes =
fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?;
let wear = decode_wear(&wear_bytes).map_err(|err| err.to_string())?;
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
let material =
resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?;
let texture = material.document.primary_texture().ok_or_else(|| {
"MAT0 phase zero declares an intentionally untextured material".to_string()
})?;
let texture_name = String::from_utf8_lossy(&texture.0).into_owned();
let image = load_texture_mip0_rgba8_from_root(root, "Textures.lib", &texture_name)?;
Ok(WearMaterialTexture {
wear_archive: "<standalone>".to_string(),
wear_resource: wear_path.display().to_string(),
material_index,
material_name: String::from_utf8_lossy(&material.name.0).into_owned(),
material_fallback: material.fallback,
texture_name,
image,
})
}
/// Inspects a terrain land file by path. /// Inspects a terrain land file by path.
/// ///
/// # Errors /// # Errors
@@ -240,6 +578,59 @@ pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspectio
} }
} }
/// Loads and validates a standalone `Land.msh` file.
///
/// # Errors
///
/// Returns a human-readable error if the file cannot be read, decoded as `NRes`,
/// or decoded as the specialized terrain mesh format.
pub fn load_land_msh_from_path(path: &Path) -> Result<LandMeshDocument, String> {
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
.map_err(|err| err.to_string())?;
decode_land_msh(&document).map_err(|err| err.to_string())
}
/// Inspects the source-coordinate bounds of a standalone `Land.msh` file.
///
/// # Errors
///
/// Returns a string error when the file cannot be read, decoded, or contains
/// no finite source positions.
pub fn inspect_land_msh_bounds_file(path: &Path) -> Result<LandMeshBoundsInspection, String> {
let mesh = load_land_msh_from_path(path)?;
inspect_land_msh_bounds(&mesh)
}
/// Computes source-coordinate bounds for an already validated `Land.msh`.
///
/// # Errors
///
/// Returns a string error when the mesh has no finite source positions.
pub fn inspect_land_msh_bounds(
mesh: &LandMeshDocument,
) -> Result<LandMeshBoundsInspection, String> {
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
for position in &mesh.positions {
if !position.iter().all(|value| value.is_finite()) {
return Err("Land.msh contains a non-finite source position".to_string());
}
for axis in 0..3 {
min[axis] = min[axis].min(position[axis]);
max[axis] = max[axis].max(position[axis]);
}
}
if mesh.positions.is_empty() {
return Err("Land.msh contains no source positions".to_string());
}
Ok(LandMeshBoundsInspection {
positions: mesh.positions.len(),
min,
max,
})
}
fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> { fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> {
let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?; let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?;
Ok(MapInspection { Ok(MapInspection {
@@ -268,32 +659,135 @@ fn inspect_land_map(document: &NresDocument) -> Result<MapInspection, String> {
}) })
} }
fn read_resource_bytes(root: &Path, archive: &str, name: &str) -> Result<Arc<[u8]>, String> { // Preserves the shared structured-error type without changing callers to boxed errors.
#[allow(clippy::result_large_err)]
fn read_resource_bytes_diagnostic(
root: &Path,
archive: &str,
name: &str,
) -> Result<Arc<[u8]>, Diagnostic> {
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))); let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
let archive_path = archive_path(archive.as_bytes()).map_err(|err| err.to_string())?; let archive_path = archive_path(archive.as_bytes()).map_err(|err| {
diagnostic(DiagnosticCode("S1.PATH.ARCHIVE"), err.to_string()).with_context(
DiagnosticContext {
phase: Some(Phase::Resolve),
path: Some(archive.to_string()),
archive_entry: Some(name.to_string()),
..DiagnosticContext::default()
},
)
})?;
let resource_name = resource_name(name.as_bytes()); let resource_name = resource_name(name.as_bytes());
let archive_handle = repository let archive_handle = repository.open_archive(&archive_path).map_err(|err| {
.open_archive(&archive_path) diagnostic(DiagnosticCode("S1.RESOURCE.OPEN_ARCHIVE"), err.to_string()).with_context(
.map_err(|err| format!("{err}"))?; DiagnosticContext {
phase: Some(Phase::Read),
path: Some(archive.to_string()),
archive_entry: Some(name.to_string()),
..DiagnosticContext::default()
},
)
})?;
let Some(handle) = repository let Some(handle) = repository
.find(archive_handle, &resource_name) .find(archive_handle, &resource_name)
.map_err(|err| format!("{err}"))? .map_err(|err| {
diagnostic(DiagnosticCode("S1.RESOURCE.FIND"), err.to_string()).with_context(
DiagnosticContext {
phase: Some(Phase::Resolve),
path: Some(archive.to_string()),
archive_entry: Some(name.to_string()),
..DiagnosticContext::default()
},
)
})?
else { else {
return Err(format!( return Err(diagnostic(
"resource not found: {archive}/{}", DiagnosticCode("S1.RESOURCE.MISSING_ENTRY"),
String::from_utf8_lossy(name.as_bytes()) format!(
)); "resource not found: {archive}/{}",
String::from_utf8_lossy(name.as_bytes())
),
)
.with_context(DiagnosticContext {
phase: Some(Phase::Resolve),
path: Some(archive.to_string()),
archive_entry: Some(name.to_string()),
..DiagnosticContext::default()
}));
}; };
let bytes = repository.read(handle).map_err(|err| format!("{err}"))?; let bytes = repository.read(handle).map_err(|err| {
diagnostic(DiagnosticCode("S1.RESOURCE.READ"), err.to_string()).with_context(
DiagnosticContext {
phase: Some(Phase::Read),
path: Some(archive.to_string()),
archive_entry: Some(name.to_string()),
..DiagnosticContext::default()
},
)
})?;
Ok(Arc::from(bytes.into_owned())) Ok(Arc::from(bytes.into_owned()))
} }
// Preserves the shared structured-error type without changing callers to boxed errors.
#[allow(clippy::result_large_err)]
fn load_model_document_from_root_diagnostic(
root: &Path,
archive: &str,
resource: &str,
) -> Result<NresDocument, Diagnostic> {
let bytes = read_resource_bytes_diagnostic(root, archive, resource)?;
decode_nres(bytes.clone(), ReadProfile::Compatible).map_err(|err| {
resource_parse_diagnostic("S1.NRES.DECODE", archive, resource, &bytes, err.to_string())
})
}
fn archive_parse_diagnostic(
code: &'static str,
source: Option<&Path>,
bytes: &[u8],
message: String,
) -> Diagnostic {
diagnostic(DiagnosticCode(code), message).with_context(DiagnosticContext {
phase: Some(Phase::Parse),
path: source.map(|path| path.display().to_string()),
span: Some(SourceSpan {
offset: 0,
length: u64::try_from(bytes.len().min(4)).unwrap_or(4),
}),
..DiagnosticContext::default()
})
}
fn resource_parse_diagnostic(
code: &'static str,
archive: &str,
resource: &str,
bytes: &[u8],
message: String,
) -> Diagnostic {
diagnostic(DiagnosticCode(code), message).with_context(DiagnosticContext {
phase: Some(Phase::Parse),
path: Some(archive.to_string()),
archive_entry: Some(resource.to_string()),
span: Some(SourceSpan {
offset: 0,
length: u64::try_from(bytes.len().min(4)).unwrap_or(4),
}),
..DiagnosticContext::default()
})
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use fparkan_terrain_format::TerrainSlotTable;
use std::io::Write as _; use std::io::Write as _;
use std::path::PathBuf; use std::path::PathBuf;
const TEST_NRES_HEADER_LEN: usize = 16;
const TEST_NRES_NAME_LEN: usize = 36;
const TEST_NRES_VERSION_0100: u32 = 0x100;
#[test] #[test]
fn inspect_rsli_rejects_malformed_archive() { fn inspect_rsli_rejects_malformed_archive() {
let dir = temp_dir("inspect"); let dir = temp_dir("inspect");
@@ -306,6 +800,30 @@ mod tests {
assert!(error.contains("entry table out of bounds")); assert!(error.contains("entry table out of bounds"));
} }
#[test]
fn archive_diagnostic_preserves_source_path_phase_and_span() {
let dir = temp_dir("inspect-diagnostic");
let path = dir.join("broken.nres");
fs::write(&path, b"NRes").expect("broken nres");
let diagnostic = inspect_archive_file_diagnostic(&path, 0).expect_err("diagnostic failure");
assert_eq!(diagnostic.code.0, "S1.NRES.DECODE");
let expected_path = path.display().to_string();
assert_eq!(
diagnostic.context.path.as_deref(),
Some(expected_path.as_str())
);
assert_eq!(diagnostic.context.phase, Some(Phase::Parse));
assert_eq!(
diagnostic.context.span,
Some(SourceSpan {
offset: 0,
length: 4
})
);
}
#[test] #[test]
fn nres_entry_summary_fields_are_readable() { fn nres_entry_summary_fields_are_readable() {
let dir = temp_dir("inspect-nres"); let dir = temp_dir("inspect-nres");
@@ -316,6 +834,117 @@ mod tests {
let _ = inspect_archive_file(&archive, 2); let _ = inspect_archive_file(&archive, 2);
} }
#[test]
fn model_archive_diagnostic_preserves_archive_entry_context() {
let dir = temp_dir("inspect-model-diagnostic");
let archive = dir.join("models.rlb");
fs::write(&archive, build_single_entry_nres(b"BROKEN.MSH", b"NRes")).expect("archive");
let diagnostic = load_model_document_from_root_diagnostic(&dir, "models.rlb", "BROKEN.MSH")
.expect_err("nested diagnostic failure");
assert_eq!(diagnostic.code.0, "S1.NRES.DECODE");
assert_eq!(diagnostic.context.phase, Some(Phase::Parse));
assert_eq!(diagnostic.context.path.as_deref(), Some("models.rlb"));
assert_eq!(
diagnostic.context.archive_entry.as_deref(),
Some("BROKEN.MSH")
);
assert_eq!(
diagnostic.context.span,
Some(SourceSpan {
offset: 0,
length: 4
})
);
}
#[test]
fn wear_material_texture_loader_preserves_original_selection_provenance() {
let dir = temp_dir("wear-material-texture");
fs::write(
dir.join("wear.rlb"),
build_single_entry_nres(b"MODEL.WEA", b"1\n0 MAT\n"),
)
.expect("wear archive");
let mut mat0 = vec![0; 4 + 34];
mat0[0..2].copy_from_slice(&1_u16.to_le_bytes());
mat0[22..25].copy_from_slice(b"TEX");
fs::write(
dir.join("material.lib"),
build_single_entry_nres_with_meta(b"MAT", fparkan_material::MAT0_KIND, 0, &mat0),
)
.expect("material archive");
fs::write(
dir.join("Textures.lib"),
build_single_entry_nres(b"TEX", &texm_argb8888_pixel([0x40, 0x11, 0x22, 0x33])),
)
.expect("texture archive");
let selected =
load_wear_material_texture_mip0_rgba8_from_root(&dir, "wear.rlb", "MODEL.WEA", 0)
.expect("resolved material texture");
assert_eq!(selected.material_name, "MAT");
assert_eq!(selected.material_fallback, MaterialFallback::Exact);
assert_eq!(selected.texture_name, "TEX");
assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
}
#[test]
fn standalone_wear_material_texture_loader_keeps_map_sidecar_separate() {
let dir = temp_dir("standalone-wear-material-texture");
let wear_path = dir.join("Land2.wea");
fs::write(&wear_path, b"1\n0 MAT\n").expect("standalone wear");
let mut mat0 = vec![0; 4 + 34];
mat0[0..2].copy_from_slice(&1_u16.to_le_bytes());
mat0[22..25].copy_from_slice(b"TEX");
fs::write(
dir.join("material.lib"),
build_single_entry_nres_with_meta(b"MAT", fparkan_material::MAT0_KIND, 0, &mat0),
)
.expect("material archive");
fs::write(
dir.join("Textures.lib"),
build_single_entry_nres(b"TEX", &texm_argb8888_pixel([0x40, 0x11, 0x22, 0x33])),
)
.expect("texture archive");
let selected =
load_standalone_wear_material_texture_mip0_rgba8_from_root(&dir, &wear_path, 0)
.expect("resolved material texture");
assert_eq!(selected.wear_archive, "<standalone>");
assert_eq!(selected.wear_resource, wear_path.display().to_string());
assert_eq!(selected.texture_name, "TEX");
assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
}
#[test]
fn land_mesh_bounds_preserve_each_source_axis() {
let mesh = LandMeshDocument {
streams: Vec::new(),
nodes_raw: Vec::new(),
slots: TerrainSlotTable {
header_raw: Vec::new(),
slots_raw: Vec::new(),
},
positions: vec![[4.0, -2.0, 8.0], [-3.0, 6.0, 1.0]],
normals: Vec::new(),
uv0: Vec::new(),
accelerator: Vec::new(),
aux14: Vec::new(),
aux18: Vec::new(),
faces: Vec::new(),
};
let bounds = inspect_land_msh_bounds(&mesh).expect("bounds");
assert_eq!(bounds.positions, 2);
assert_eq!(bounds.min, [-3.0, -2.0, 1.0]);
assert_eq!(bounds.max, [4.0, 6.0, 8.0]);
}
fn temp_dir(name: &str) -> PathBuf { fn temp_dir(name: &str) -> PathBuf {
let base = PathBuf::from("/tmp") let base = PathBuf::from("/tmp")
.join("fparkan-inspection-tests") .join("fparkan-inspection-tests")
@@ -324,4 +953,57 @@ mod tests {
fs::create_dir_all(&base).expect("tmp dir"); fs::create_dir_all(&base).expect("tmp dir");
base base
} }
fn build_single_entry_nres(name: &[u8], payload: &[u8]) -> Vec<u8> {
build_single_entry_nres_with_meta(name, 1, 0, payload)
}
fn build_single_entry_nres_with_meta(
name: &[u8],
type_id: u32,
attr2: u32,
payload: &[u8],
) -> Vec<u8> {
let mut out = vec![0; TEST_NRES_HEADER_LEN];
let payload_offset = u32::try_from(out.len()).expect("payload offset");
out.extend_from_slice(payload);
let padding = (8 - (out.len() % 8)) % 8;
out.resize(out.len() + padding, 0);
push_u32(&mut out, type_id);
push_u32(&mut out, 0);
push_u32(&mut out, attr2);
push_u32(&mut out, u32::try_from(payload.len()).expect("payload len"));
push_u32(&mut out, 0);
let mut raw_name = [0; TEST_NRES_NAME_LEN];
raw_name[..name.len()].copy_from_slice(name);
out.extend_from_slice(&raw_name);
push_u32(&mut out, payload_offset);
push_u32(&mut out, 0);
out[0..4].copy_from_slice(b"NRes");
out[4..8].copy_from_slice(&TEST_NRES_VERSION_0100.to_le_bytes());
out[8..12].copy_from_slice(&1_u32.to_le_bytes());
let total_size = u32::try_from(out.len()).expect("total size");
out[12..16].copy_from_slice(&total_size.to_le_bytes());
out
}
fn push_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
}
fn texm_argb8888_pixel(pixel: [u8; 4]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&0x6D78_6554_u32.to_le_bytes());
out.extend_from_slice(&1_u32.to_le_bytes());
out.extend_from_slice(&1_u32.to_le_bytes());
out.extend_from_slice(&1_u32.to_le_bytes());
out.extend_from_slice(&0_u32.to_le_bytes());
out.extend_from_slice(&0_u32.to_le_bytes());
out.extend_from_slice(&0_u32.to_le_bytes());
out.extend_from_slice(&8888_u32.to_le_bytes());
out.extend_from_slice(&pixel);
out
}
} }
+4 -4
View File
@@ -7,12 +7,12 @@ repository.workspace = true
[dependencies] [dependencies]
encoding_rs = "0.8" encoding_rs = "0.8"
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-resource = { path = "../fparkan-resource" } fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
[dev-dependencies] [dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
fparkan-vfs = { path = "../fparkan-vfs" } fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+1 -1
View File
@@ -7,7 +7,7 @@ repository.workspace = true
[dependencies] [dependencies]
encoding_rs = "0.8" encoding_rs = "0.8"
fparkan-binary = { path = "../fparkan-binary" } fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+2 -4
View File
@@ -7,10 +7,8 @@ repository.workspace = true
[dependencies] [dependencies]
encoding_rs = "0.8" encoding_rs = "0.8"
fparkan-nres = { path = "../fparkan-nres" } fparkan-animation = { path = "../fparkan-animation", version = "0.1.0" }
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
[dev-dependencies]
fparkan-animation = { path = "../fparkan-animation" }
[lints] [lints]
workspace = true workspace = true
+357
View File
@@ -21,6 +21,10 @@
//! Stage-3 MSH asset contract. //! Stage-3 MSH asset contract.
use encoding_rs::WINDOWS_1251; use encoding_rs::WINDOWS_1251;
use fparkan_animation::{
evaluate_hierarchy, AnimKey24, AnimationTime, NodePoseBuffer, ParentIndex, Pose, TimedPoseKey,
TimedPoseTrack,
};
use fparkan_nres::{EntryMeta, NresDocument, NresError}; use fparkan_nres::{EntryMeta, NresDocument, NresError};
/// Node table stream. /// Node table stream.
@@ -110,6 +114,22 @@ pub struct ModelAsset {
pub batches: Vec<Batch>, pub batches: Vec<Batch>,
/// Optional decoded node names. /// Optional decoded node names.
pub node_names: Option<Vec<Option<String>>>, pub node_names: Option<Vec<Option<String>>>,
/// Optional decoded node-animation streams.
pub animation: Option<ModelAnimation>,
}
/// Decoded MSH node-animation streams.
///
/// This preserves the exact type-8/type-19 boundary used by `Node38` without
/// assigning runtime-state ownership to a static asset.
#[derive(Clone, Debug, PartialEq)]
pub struct ModelAnimation {
/// Type-8 animation keys in source order.
pub keys: Vec<AnimKey24>,
/// Type-19 frame-to-key mapping words in source order.
pub frame_map: Vec<u16>,
/// Declared frame count from type-19 `attr2`.
pub frame_count: u32,
} }
/// Node id. /// Node id.
@@ -127,6 +147,17 @@ pub struct Node {
pub raw: Vec<u8>, pub raw: Vec<u8>,
} }
/// Raw fields of the standard 38-byte node layout that precede slot selection.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Node38Metadata {
/// Unassigned source link value at byte offset two.
pub parent_or_link_raw: u16,
/// Offset into the type-19 frame map, or `0xFFFF` for no map.
pub anim_map_start: u16,
/// Fallback type-8 key index.
pub fallback_key: u16,
}
/// Slot descriptor. /// Slot descriptor.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct Slot { pub struct Slot {
@@ -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))) (slot < model.slots.len()).then_some(SlotId(u32::from(raw)))
} }
/// Returns the undecorated metadata of a standard 38-byte node.
#[must_use]
pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadata> {
if model.node_stride != 38 {
return None;
}
let node_index = usize::try_from(node.0).ok()?;
if node_index >= model.node_count {
return None;
}
let offset = node_index.checked_mul(model.node_stride)?;
Some(Node38Metadata {
parent_or_link_raw: read_u16(&model.nodes_raw, offset + 2)?,
anim_map_start: read_u16(&model.nodes_raw, offset + 4)?,
fallback_key: read_u16(&model.nodes_raw, offset + 6)?,
})
}
/// Returns the source fallback pose of a standard node.
///
/// The legacy sampler uses this key when a node has no frame map or its current
/// frame falls outside that map. It is a static-pose input only; callers must
/// still recover animation state and the meaning of `parent_or_link_raw` before
/// assembling a runtime hierarchy.
#[must_use]
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
let metadata = node38_metadata(model, node)?;
model
.animation
.as_ref()?
.keys
.get(usize::from(metadata.fallback_key))
.map(AnimKey24::sampling_pose)
}
/// Evaluates the static fallback pose hierarchy of a standard `Node38` model.
///
/// `0xFFFF` denotes a root. Every non-root source link must name an earlier
/// node, which preserves the producer's parent-before-child ordering. Models
/// that do not meet this established `Node38` contract return `None` so callers
/// can retain an explicitly unhierarchical diagnostic path.
#[must_use]
pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
if model.node_stride != 38 || model.node_count == 0 {
return None;
}
let mut parents = Vec::with_capacity(model.node_count);
let mut poses = Vec::with_capacity(model.node_count);
for index in 0..model.node_count {
let node = NodeId(u32::try_from(index).ok()?);
let metadata = node38_metadata(model, node)?;
let parent = if metadata.parent_or_link_raw == u16::MAX {
ParentIndex(None)
} else {
let parent = usize::from(metadata.parent_or_link_raw);
(parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))?
};
parents.push(parent);
poses.push(node38_fallback_pose(model, node)?);
}
evaluate_hierarchy(&parents, &poses).ok()
}
/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
/// model at one explicitly supplied logical frame.
///
/// A node without a usable type-19 map, or a requested frame outside the
/// declared map length, retains its exact fallback key. Mapped keys are
/// sampled against their immediate successor using the decoded key times.
/// This is an offline asset-sampling contract; it does not claim ownership of
/// the original runtime's animation clock or x87 numeric profile.
///
/// Returns `None` when the model lacks a complete standard-node animation
/// representation or the map cannot be safely resolved.
#[must_use]
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
if model.node_stride != 38 || model.node_count == 0 {
return None;
}
let animation = model.animation.as_ref()?;
let mut parents = Vec::with_capacity(model.node_count);
let mut poses = Vec::with_capacity(model.node_count);
for index in 0..model.node_count {
let node = NodeId(u32::try_from(index).ok()?);
let metadata = node38_metadata(model, node)?;
let parent = if metadata.parent_or_link_raw == u16::MAX {
ParentIndex(None)
} else {
let parent = usize::from(metadata.parent_or_link_raw);
(parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))?
};
let fallback_index = usize::from(metadata.fallback_key);
let _fallback = animation.keys.get(fallback_index)?;
let key_index =
if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count {
fallback_index
} else {
let mapped_index =
usize::from(*animation.frame_map.get(
usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?,
)?);
if mapped_index < fallback_index {
mapped_index
} else {
fallback_index
}
};
let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?;
parents.push(parent);
poses.push(pose);
}
evaluate_hierarchy(&parents, &poses).ok()
}
fn sample_node38_key_pair(
keys: &[AnimKey24],
key_index: usize,
fallback_index: usize,
frame: u16,
) -> Option<Pose> {
let key = *keys.get(key_index)?;
if key_index == fallback_index {
return Some(key.sampling_pose());
}
let next = *keys.get(key_index.checked_add(1)?)?;
if next.time.0 <= key.time.0 {
return Some(key.sampling_pose());
}
let track = TimedPoseTrack::new(
key.sampling_pose(),
vec![
TimedPoseKey {
time: key.time,
pose: key.sampling_pose(),
},
TimedPoseKey {
time: next.time,
pose: next.sampling_pose(),
},
],
)
.ok()?;
track.sample(AnimationTime(f32::from(frame))).ok()
}
/// Returns draw batches for a validated slot. /// Returns draw batches for a validated slot.
/// ///
/// # Errors /// # Errors
@@ -526,6 +702,7 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
let node_names = read_optional_stream(document, STREAM_NAMES)? let node_names = read_optional_stream(document, STREAM_NAMES)?
.map(|raw| parse_res10_names(&raw.bytes, node_count)) .map(|raw| parse_res10_names(&raw.bytes, node_count))
.transpose()?; .transpose()?;
let animation = parse_optional_animation(document)?;
Ok(ModelAsset { Ok(ModelAsset {
node_stride, node_stride,
@@ -538,9 +715,37 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
indices, indices,
batches, batches,
node_names, node_names,
animation,
}) })
} }
fn parse_optional_animation(document: &NresDocument) -> Result<Option<ModelAnimation>, MshError> {
let keys = read_optional_stream(document, STREAM_ANIMATION_KEYS)?;
let frame_map = read_optional_stream(document, STREAM_ANIMATION_FRAME_MAP)?;
let (Some(keys), Some(frame_map)) = (keys, frame_map) else {
return Ok(None);
};
if !keys.bytes.len().is_multiple_of(24) {
return Err(invalid_resource_size("Res8", keys.bytes.len(), 24));
}
if !frame_map.bytes.len().is_multiple_of(2) {
return Err(invalid_resource_size("Res19", frame_map.bytes.len(), 2));
}
let keys = keys
.bytes
.chunks_exact(24)
.map(AnimKey24::decode)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| MshError::InvalidGeometry(format!("invalid Res8 animation key: {err}")))?;
let frame_count = frame_map.attributes.attr2;
let frame_map = parse_u16_array(&frame_map.bytes, "Res19")?;
Ok(Some(ModelAnimation {
keys,
frame_map,
frame_count,
}))
}
struct RawStream { struct RawStream {
attributes: EntryAttributes, attributes: EntryAttributes,
bytes: Vec<u8>, bytes: Vec<u8>,
@@ -1063,6 +1268,151 @@ mod tests {
assert_eq!(selected_slot(&model, NodeId(0), Lod(2), Group(4)), None); assert_eq!(selected_slot(&model, NodeId(0), Lod(2), Group(4)), None);
} }
#[test]
fn standard_node_exposes_fallback_pose_and_unassigned_link() {
let mut node = node38([u16::MAX; 15]);
node[2..4].copy_from_slice(&7_u16.to_le_bytes());
node[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
node[6..8].copy_from_slice(&0_u16.to_le_bytes());
let mut key = Vec::new();
push_f32(&mut key, 1.0);
push_f32(&mut key, 2.0);
push_f32(&mut key, 3.0);
push_f32(&mut key, 0.0);
push_u16(&mut key, 0);
push_u16(&mut key, 0);
push_u16(&mut key, 0);
push_u16(&mut key, 32_767);
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &node),
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_ANIMATION_KEYS, 0, b"Res8", &key),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
stream(STREAM_ANIMATION_FRAME_MAP, 0, b"Res19", &[]),
]))
.expect("nested NRes");
let model =
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
assert_eq!(
node38_metadata(&model, NodeId(0)),
Some(Node38Metadata {
parent_or_link_raw: 7,
anim_map_start: u16::MAX,
fallback_key: 0,
})
);
assert_eq!(
node38_fallback_pose(&model, NodeId(0)).map(|pose| pose.translation),
Some([1.0, 2.0, 3.0])
);
assert_eq!(
model
.animation
.as_ref()
.map(|animation| animation.keys.len()),
Some(1)
);
}
#[test]
fn standard_nodes_evaluate_fallback_parent_hierarchy() {
let mut root = node38([u16::MAX; 15]);
root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
root[6..8].copy_from_slice(&0_u16.to_le_bytes());
let mut child = node38([u16::MAX; 15]);
child[2..4].copy_from_slice(&0_u16.to_le_bytes());
child[6..8].copy_from_slice(&1_u16.to_le_bytes());
let mut nodes = root;
nodes.extend(child);
let mut keys = Vec::new();
for (x, y, z, qz, qw) in [
(1.0, 0.0, 0.0, 23_170_i16, 23_170_i16),
(2.0, 0.0, 0.0, 0_i16, 32_767_i16),
] {
push_f32(&mut keys, x);
push_f32(&mut keys, y);
push_f32(&mut keys, z);
push_f32(&mut keys, 0.0);
push_u16(&mut keys, 0);
push_u16(&mut keys, 0);
push_u16(&mut keys, qz.cast_unsigned());
push_u16(&mut keys, qw.cast_unsigned());
}
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes),
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
stream(STREAM_INDICES, 0, b"Res6", &[]),
stream(STREAM_ANIMATION_KEYS, 0, b"Res8", &keys),
stream(STREAM_BATCHES, 0, b"Res13", &[]),
stream(STREAM_ANIMATION_FRAME_MAP, 0, b"Res19", &[]),
]))
.expect("nested NRes");
let model =
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy");
assert!((hierarchy.poses[1].translation[0] - 1.0).abs() < 0.001);
assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001);
}
#[test]
fn standard_nodes_sample_mapped_key_before_fallback_key() {
let mut node = node38([u16::MAX; 15]);
node[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
node[4..6].copy_from_slice(&0_u16.to_le_bytes());
node[6..8].copy_from_slice(&2_u16.to_le_bytes());
let model = ModelAsset {
node_stride: 38,
node_count: 1,
nodes_raw: node,
slots: Vec::new(),
positions: Vec::new(),
normals: None,
uv0: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
animation: Some(ModelAnimation {
keys: vec![
AnimKey24 {
time: AnimationTime(0.0),
pose: Pose {
translation: [4.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(2.0),
pose: Pose {
translation: [8.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0, 1.0],
},
},
AnimKey24 {
time: AnimationTime(9.0),
pose: Pose::default(),
},
],
frame_map: vec![0, 0, 0],
frame_count: 3,
}),
};
let pose = node38_sampled_hierarchy(&model, 1)
.expect("mapped hierarchy")
.poses[0];
assert!((pose.translation[0] - 6.0).abs() < f32::EPSILON);
assert_eq!(
node38_sampled_hierarchy(&model, 9)
.expect("fallback hierarchy")
.poses[0],
Pose::default()
);
}
#[test] #[test]
fn type2_header_and_slot_tail_framing_are_exact() { fn type2_header_and_slot_tail_framing_are_exact() {
let too_small = decode_nested(&build_nres(&[ let too_small = decode_nested(&build_nres(&[
@@ -1355,6 +1705,13 @@ mod tests {
let model = validate_msh(&msh).unwrap_or_else(|err| { let model = validate_msh(&msh).unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
}); });
if model.node_stride == 38 {
assert!(
node38_fallback_hierarchy(&model).is_some(),
"{corpus} {path:?} {:?}: Node38 hierarchy is not parent-before-child",
entry.name_bytes()
);
}
let preserved = msh.preserved_streams().unwrap_or_else(|err| { let preserved = msh.preserved_streams().unwrap_or_else(|err| {
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
}); });
+2 -2
View File
@@ -6,8 +6,8 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" } fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+223 -20
View File
@@ -20,7 +20,7 @@
)] )]
//! Strict and lossless `NRes` archive support. //! Strict and lossless `NRes` archive support.
use fparkan_binary::{Cursor, DecodeError}; use fparkan_binary::{checked_allocation_len, Cursor, DecodeError};
use fparkan_path::{ascii_lookup_key, LookupKey}; use fparkan_path::{ascii_lookup_key, LookupKey};
use std::cmp::Ordering; use std::cmp::Ordering;
use std::fmt; use std::fmt;
@@ -51,6 +51,33 @@ pub enum WriteProfile {
CanonicalCompact, CanonicalCompact,
} }
/// Decode-time archive limits.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DecodeLimits {
/// Maximum accepted source archive bytes.
pub max_input_bytes: u64,
/// Maximum accepted entry count.
pub max_entries: u32,
/// Maximum accepted single payload byte length.
pub max_decoded_entry_bytes: u64,
/// Maximum accepted cumulative payload bytes.
pub max_total_decoded_bytes: u64,
/// Maximum accepted preserved-region bytes.
pub max_preserved_bytes: u64,
}
impl Default for DecodeLimits {
fn default() -> Self {
Self {
max_input_bytes: 256 * 1024 * 1024,
max_entries: 1_000_000,
max_decoded_entry_bytes: 256 * 1024 * 1024,
max_total_decoded_bytes: 512 * 1024 * 1024,
max_preserved_bytes: 256 * 1024 * 1024,
}
}
}
/// `NRes` archive header. /// `NRes` archive header.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct NresHeader { pub struct NresHeader {
@@ -343,16 +370,31 @@ impl From<DecodeError> for NresError {
/// Returns [`NresError`] when the header, directory, payload ranges, or strict /// Returns [`NresError`] when the header, directory, payload ranges, or strict
/// lookup permutation are malformed for the selected [`ReadProfile`]. /// lookup permutation are malformed for the selected [`ReadProfile`].
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<NresDocument, NresError> { pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<NresDocument, NresError> {
let header = parse_header(&bytes)?; decode_with_limits(bytes, profile, DecodeLimits::default())
let entries = parse_entries(&bytes, &header)?; }
/// Decodes `NRes` bytes with explicit archive limits.
///
/// # Errors
///
/// Returns [`NresError`] when the input exceeds configured limits, the header,
/// directory, payload ranges, or strict lookup permutation are malformed.
pub fn decode_with_limits(
bytes: Arc<[u8]>,
profile: ReadProfile,
limits: DecodeLimits,
) -> Result<NresDocument, NresError> {
let header = parse_header(&bytes, limits)?;
let entries = parse_entries(&bytes, &header, limits)?;
validate_names(&entries)?; validate_names(&entries)?;
validate_payload_ranges(&entries)?; validate_payload_ranges(&entries, limits)?;
let lookup_order_valid = match validate_lookup_order(&entries) { let lookup_order_valid = match validate_lookup_order(&entries) {
Ok(valid) => valid, Ok(()) => true,
Err(err) if profile == ReadProfile::Strict => return Err(err), Err(err) if profile == ReadProfile::Strict => return Err(err),
Err(_) => false, Err(_) => false,
}; };
let preserved_regions = find_preserved_regions(&bytes, &entries, header.directory_offset)?; let preserved_regions =
find_preserved_regions(&bytes, &entries, header.directory_offset, limits)?;
Ok(NresDocument { Ok(NresDocument {
bytes, bytes,
header, header,
@@ -437,6 +479,23 @@ impl NresDocument {
Ok(&self.bytes[entry.data_range.clone()]) Ok(&self.bytes[entry.data_range.clone()])
} }
/// Returns an owned view of an entry payload without copying its bytes.
///
/// The returned owner is the immutable archive buffer. Consumers may retain
/// the view after dropping this document.
///
/// # Errors
///
/// Returns [`NresError::EntryIdOutOfRange`] when `id` is not present in
/// this document.
pub fn payload_view(&self, id: EntryId) -> Result<(Arc<[u8]>, Range<usize>), NresError> {
let entry = self.entry(id).ok_or_else(|| NresError::EntryIdOutOfRange {
id: id.0,
entry_count: saturating_u32_len(self.entries.len()),
})?;
Ok((Arc::clone(&self.bytes), entry.data_range.clone()))
}
/// Encodes the document according to the selected write profile. /// Encodes the document according to the selected write profile.
#[must_use] #[must_use]
pub fn encode(&self, profile: WriteProfile) -> Vec<u8> { pub fn encode(&self, profile: WriteProfile) -> Vec<u8> {
@@ -684,7 +743,11 @@ impl NresEntry {
} }
} }
fn parse_header(bytes: &[u8]) -> Result<NresHeader, NresError> { fn parse_header(bytes: &[u8], limits: DecodeLimits) -> Result<NresHeader, NresError> {
enforce_limit(
u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?,
limits.max_input_bytes,
)?;
if bytes.len() < HEADER_LEN { if bytes.len() < HEADER_LEN {
let mut got = [0; 4]; let mut got = [0; 4];
let copy_len = bytes.len().min(4); let copy_len = bytes.len().min(4);
@@ -711,6 +774,7 @@ fn parse_header(bytes: &[u8]) -> Result<NresHeader, NresError> {
} }
let entry_count = let entry_count =
u32::try_from(entry_count_signed).map_err(|_| DecodeError::IntegerOverflow)?; u32::try_from(entry_count_signed).map_err(|_| DecodeError::IntegerOverflow)?;
enforce_limit(u64::from(entry_count), u64::from(limits.max_entries))?;
let total_size = cursor.read_u32_le()?; let total_size = cursor.read_u32_le()?;
let actual = u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?; let actual = u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?;
if u64::from(total_size) != actual { if u64::from(total_size) != actual {
@@ -750,8 +814,14 @@ fn parse_header(bytes: &[u8]) -> Result<NresHeader, NresError> {
}) })
} }
fn parse_entries(bytes: &[u8], header: &NresHeader) -> Result<Vec<NresEntry>, NresError> { fn parse_entries(
let mut entries = Vec::with_capacity(header.entry_count as usize); bytes: &[u8],
header: &NresHeader,
limits: DecodeLimits,
) -> Result<Vec<NresEntry>, NresError> {
let capacity =
checked_allocation_len(u64::from(header.entry_count), u64::from(limits.max_entries))?;
let mut entries = Vec::with_capacity(capacity);
let directory_offset = let directory_offset =
usize::try_from(header.directory_offset).map_err(|_| DecodeError::IntegerOverflow)?; usize::try_from(header.directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
for index in 0..header.entry_count { for index in 0..header.entry_count {
@@ -832,7 +902,7 @@ fn parse_entry(
}) })
} }
fn validate_payload_ranges(entries: &[NresEntry]) -> Result<(), NresError> { fn validate_payload_ranges(entries: &[NresEntry], limits: DecodeLimits) -> Result<(), NresError> {
let mut ranges: Vec<(u32, Range<usize>)> = entries let mut ranges: Vec<(u32, Range<usize>)> = entries
.iter() .iter()
.map(|entry| (entry.id.0, entry.data_range.clone())) .map(|entry| (entry.id.0, entry.data_range.clone()))
@@ -843,6 +913,15 @@ fn validate_payload_ranges(entries: &[NresEntry]) -> Result<(), NresError> {
.cmp(&right.1.start) .cmp(&right.1.start)
.then_with(|| left.1.end.cmp(&right.1.end)) .then_with(|| left.1.end.cmp(&right.1.end))
}); });
let mut total_payload_bytes = 0_u64;
for entry in entries {
let payload_len = u64::from(entry.meta.data_size);
enforce_limit(payload_len, limits.max_decoded_entry_bytes)?;
total_payload_bytes = total_payload_bytes
.checked_add(payload_len)
.ok_or(DecodeError::IntegerOverflow)?;
enforce_limit(total_payload_bytes, limits.max_total_decoded_bytes)?;
}
for pair in ranges.windows(2) { for pair in ranges.windows(2) {
if pair[0].1.end > pair[1].1.start { if pair[0].1.end > pair[1].1.start {
return Err(NresError::EntryDataOverlap { return Err(NresError::EntryDataOverlap {
@@ -863,7 +942,7 @@ fn validate_names(entries: &[NresEntry]) -> Result<(), NresError> {
Ok(()) Ok(())
} }
fn validate_lookup_order(entries: &[NresEntry]) -> Result<bool, NresError> { fn validate_lookup_order(entries: &[NresEntry]) -> Result<(), NresError> {
let entry_count = saturating_u32_len(entries.len()); let entry_count = saturating_u32_len(entries.len());
let mut seen = vec![false; entries.len()]; let mut seen = vec![false; entries.len()];
for (position, entry) in entries.iter().enumerate() { for (position, entry) in entries.iter().enumerate() {
@@ -881,7 +960,7 @@ fn validate_lookup_order(entries: &[NresEntry]) -> Result<bool, NresError> {
} }
seen[index_usize] = true; seen[index_usize] = true;
} }
for pair in entries.windows(2) { for (position, pair) in entries.windows(2).enumerate() {
let left_index = let left_index =
usize::try_from(pair[0].meta.sort_index).map_err(|_| DecodeError::IntegerOverflow)?; usize::try_from(pair[0].meta.sort_index).map_err(|_| DecodeError::IntegerOverflow)?;
let right_index = let right_index =
@@ -889,16 +968,19 @@ fn validate_lookup_order(entries: &[NresEntry]) -> Result<bool, NresError> {
let left = entries[left_index].name_bytes(); let left = entries[left_index].name_bytes();
let right = entries[right_index].name_bytes(); let right = entries[right_index].name_bytes();
if cmp_ascii_casefold(left, right) == Ordering::Greater { if cmp_ascii_casefold(left, right) == Ordering::Greater {
return Ok(false); return Err(NresError::SortOrderMismatch {
position: saturating_u32_len(position.saturating_add(1)),
});
} }
} }
Ok(true) Ok(())
} }
fn find_preserved_regions( fn find_preserved_regions(
bytes: &[u8], bytes: &[u8],
entries: &[NresEntry], entries: &[NresEntry],
directory_offset: u32, directory_offset: u32,
limits: DecodeLimits,
) -> Result<Vec<PreservedRegion>, NresError> { ) -> Result<Vec<PreservedRegion>, NresError> {
let mut ranges: Vec<Range<usize>> = entries let mut ranges: Vec<Range<usize>> = entries
.iter() .iter()
@@ -914,18 +996,44 @@ fn find_preserved_regions(
let directory_offset = let directory_offset =
usize::try_from(directory_offset).map_err(|_| DecodeError::IntegerOverflow)?; usize::try_from(directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
let mut preserved = Vec::new(); let mut preserved = Vec::new();
let mut preserved_bytes = 0_u64;
for range in ranges { for range in ranges {
if cursor < range.start { if cursor < range.start {
preserved_bytes = preserved_bytes
.checked_add(
u64::try_from(range.start - cursor)
.map_err(|_| DecodeError::IntegerOverflow)?,
)
.ok_or(DecodeError::IntegerOverflow)?;
enforce_limit(preserved_bytes, limits.max_preserved_bytes)?;
preserved.push(make_preserved_region(bytes, cursor..range.start)?); preserved.push(make_preserved_region(bytes, cursor..range.start)?);
} }
cursor = cursor.max(range.end); cursor = cursor.max(range.end);
} }
if cursor < directory_offset { if cursor < directory_offset {
preserved_bytes = preserved_bytes
.checked_add(
u64::try_from(directory_offset - cursor)
.map_err(|_| DecodeError::IntegerOverflow)?,
)
.ok_or(DecodeError::IntegerOverflow)?;
enforce_limit(preserved_bytes, limits.max_preserved_bytes)?;
preserved.push(make_preserved_region(bytes, cursor..directory_offset)?); preserved.push(make_preserved_region(bytes, cursor..directory_offset)?);
} }
Ok(preserved) Ok(preserved)
} }
fn enforce_limit(value: u64, limit: u64) -> Result<(), NresError> {
if value > limit {
return Err(DecodeError::LimitExceeded {
count: value,
limit,
}
.into());
}
Ok(())
}
fn make_preserved_region(bytes: &[u8], range: Range<usize>) -> Result<PreservedRegion, NresError> { fn make_preserved_region(bytes: &[u8], range: Range<usize>) -> Result<PreservedRegion, NresError> {
let all_zero = bytes[range.clone()].iter().all(|byte| *byte == 0); let all_zero = bytes[range.clone()].iter().all(|byte| *byte == 0);
Ok(PreservedRegion { Ok(PreservedRegion {
@@ -1122,6 +1230,8 @@ mod tests {
assert_eq!(entry.data_range().end, HEADER_LEN + 1); assert_eq!(entry.data_range().end, HEADER_LEN + 1);
assert_eq!(doc.header().directory_offset % 8, 0); assert_eq!(doc.header().directory_offset % 8, 0);
assert_eq!(doc.payload(EntryId(0)).expect("payload"), b"x"); assert_eq!(doc.payload(EntryId(0)).expect("payload"), b"x");
let (owner, range) = doc.payload_view(EntryId(0)).expect("payload view");
assert_eq!(&owner[range], b"x");
} }
#[test] #[test]
@@ -1176,7 +1286,10 @@ mod tests {
assert!(matches!( assert!(matches!(
decode(arc(bytes), ReadProfile::Strict), decode(arc(bytes), ReadProfile::Strict),
Err(NresError::DirectoryOutOfBounds { .. }) Err(NresError::Binary(DecodeError::LimitExceeded {
count,
limit
})) if count == i32::MAX as u64 && limit == u64::from(DecodeLimits::default().max_entries)
)); ));
} }
@@ -1469,7 +1582,7 @@ mod tests {
} }
#[test] #[test]
fn unsorted_lookup_table_falls_back_to_linear_lookup() { fn strict_rejects_unsorted_lookup_table() {
let mut bytes = build_archive(&[ let mut bytes = build_archive(&[
SyntheticEntry { SyntheticEntry {
type_id: 1, type_id: 1,
@@ -1497,9 +1610,99 @@ mod tests {
bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64] bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64]
.copy_from_slice(&1_u32.to_le_bytes()); .copy_from_slice(&1_u32.to_le_bytes());
let doc = decode(arc(bytes), ReadProfile::Strict).expect("strict nres"); assert!(matches!(
assert!(!doc.lookup_order_valid()); decode(arc(bytes), ReadProfile::Strict),
assert_eq!(doc.find("A"), Some(EntryId(1))); Err(NresError::SortOrderMismatch { position: 1 })
));
}
#[test]
fn decode_rejects_entry_count_above_limit() {
let bytes = build_archive(&[
SyntheticEntry {
type_id: 1,
attr1: 0,
attr2: 0,
attr3: 0,
name: "a",
payload: b"a",
},
SyntheticEntry {
type_id: 2,
attr1: 0,
attr2: 0,
attr3: 0,
name: "b",
payload: b"b",
},
]);
assert!(matches!(
decode_with_limits(
arc(bytes),
ReadProfile::Strict,
DecodeLimits {
max_entries: 1,
..DecodeLimits::default()
}
),
Err(NresError::Binary(DecodeError::LimitExceeded {
count: 2,
limit: 1
}))
));
}
#[test]
fn decode_rejects_input_bytes_above_limit() {
let bytes = build_archive(&[SyntheticEntry {
type_id: 1,
attr1: 0,
attr2: 0,
attr3: 0,
name: "payload",
payload: b"data",
}]);
let exact_size = u64::try_from(bytes.len()).expect("archive size");
assert!(matches!(
decode_with_limits(
arc(bytes),
ReadProfile::Strict,
DecodeLimits {
max_input_bytes: exact_size - 1,
..DecodeLimits::default()
}
),
Err(NresError::Binary(DecodeError::LimitExceeded {
count,
limit
})) if count == exact_size && limit == exact_size - 1
));
}
#[test]
fn decode_rejects_preserved_bytes_above_limit() {
let bytes = build_archive_with_nonzero_prefix_gap(&[SyntheticEntry {
type_id: 1,
attr1: 0,
attr2: 0,
attr3: 0,
name: "payload",
payload: b"data",
}]);
assert!(matches!(
decode_with_limits(
arc(bytes),
ReadProfile::Strict,
DecodeLimits {
max_preserved_bytes: 4,
..DecodeLimits::default()
}
),
Err(NresError::Binary(DecodeError::LimitExceeded { .. }))
));
} }
#[test] #[test]
@@ -1989,7 +2192,7 @@ mod tests {
let mut has_nonzero_preserved_region = false; let mut has_nonzero_preserved_region = false;
for path in &files { for path in &files {
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
let doc = decode(arc(bytes.clone()), ReadProfile::Strict) let doc = decode(arc(bytes.clone()), ReadProfile::Compatible)
.map_err(|err| format!("{}: {err}", path.display()))?; .map_err(|err| format!("{}: {err}", path.display()))?;
total_entries = total_entries total_entries = total_entries
.checked_add(doc.entry_count()) .checked_add(doc.entry_count())
+67 -6
View File
@@ -20,7 +20,9 @@
)] )]
//! Legacy path normalization and ASCII lookup semantics. //! Legacy path normalization and ASCII lookup semantics.
use std::cmp::Ordering;
use std::fmt; use std::fmt;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// Original bytes. /// Original bytes.
@@ -42,23 +44,41 @@ impl OriginalPathBytes {
} }
/// Normalized relative path. /// Normalized relative path.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] #[derive(Clone, Debug)]
pub struct NormalizedPath { pub struct NormalizedPath {
raw: Vec<u8>, raw: Vec<u8>,
display: String, display: String,
} }
impl NormalizedPath { impl NormalizedPath {
/// Returns string view. /// Returns normalized byte view used for identity, ordering, and hashing.
#[must_use] #[must_use]
pub fn as_str(&self) -> &str { pub fn identity_bytes(&self) -> &[u8] {
&self.display &self.raw
}
/// Returns an ASCII-only lookup key for case-insensitive archive matching.
#[must_use]
pub fn lookup_key(&self) -> LookupKey {
ascii_lookup_key(&self.raw)
} }
/// Returns normalized byte view. /// Returns normalized byte view.
#[must_use] #[must_use]
pub fn as_bytes(&self) -> &[u8] { pub fn as_bytes(&self) -> &[u8] {
&self.raw self.identity_bytes()
}
/// Returns a lossy display representation.
#[must_use]
pub fn display_lossy(&self) -> &str {
&self.display
}
/// Returns a lossy string view for UI and diagnostics only.
#[must_use]
pub fn as_str(&self) -> &str {
self.display_lossy()
} }
/// Returns an OS path owned path buffer. /// Returns an OS path owned path buffer.
@@ -68,6 +88,32 @@ impl NormalizedPath {
} }
} }
impl PartialEq for NormalizedPath {
fn eq(&self, other: &Self) -> bool {
self.raw == other.raw
}
}
impl Eq for NormalizedPath {}
impl PartialOrd for NormalizedPath {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for NormalizedPath {
fn cmp(&self, other: &Self) -> Ordering {
self.raw.cmp(&other.raw)
}
}
impl Hash for NormalizedPath {
fn hash<H: Hasher>(&self, state: &mut H) {
self.raw.hash(state);
}
}
/// Normalized path paired with its original byte image. /// Normalized path paired with its original byte image.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct NormalizedPathWithOriginal { pub struct NormalizedPathWithOriginal {
@@ -353,7 +399,8 @@ mod tests {
let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible) let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
.expect("raw legacy bytes"); .expect("raw legacy bytes");
assert_eq!(path.as_str(), "DATA/\u{FFFD}.bin"); assert_eq!(path.display_lossy(), "DATA/\u{FFFD}.bin");
assert_eq!(path.identity_bytes(), b"DATA/\xFF.bin");
} }
#[test] #[test]
@@ -364,4 +411,18 @@ mod tests {
assert_eq!(path.normalized().as_str(), "DATA/Maps/Intro/Land.msh"); assert_eq!(path.normalized().as_str(), "DATA/Maps/Intro/Land.msh");
assert_eq!(path.original().as_bytes(), raw); assert_eq!(path.original().as_bytes(), raw);
} }
#[test]
fn lossy_display_does_not_affect_identity_or_ordering() {
let first = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
.expect("first raw path");
let second = normalize_relative(b"DATA/\xFE.bin", PathPolicy::HostCompatible)
.expect("second raw path");
assert_eq!(first.display_lossy(), second.display_lossy());
assert_ne!(first, second);
assert_ne!(first.identity_bytes(), second.identity_bytes());
assert_ne!(first.cmp(&second), Ordering::Equal);
assert_ne!(first.lookup_key(), second.lookup_key());
}
} }
+5 -5
View File
@@ -7,13 +7,13 @@ repository.workspace = true
[dependencies] [dependencies]
encoding_rs = "0.8" encoding_rs = "0.8"
fparkan-binary = { path = "../fparkan-binary" } fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-resource = { path = "../fparkan-resource" } fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
fparkan-vfs = { path = "../fparkan-vfs" } fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
[dev-dependencies] [dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
+240 -29
View File
@@ -128,6 +128,15 @@ pub struct PrototypeGraph {
pub prototype_requests: Vec<PrototypeKey>, pub prototype_requests: Vec<PrototypeKey>,
/// Mission object-local spans of effective prototype requests. /// Mission object-local spans of effective prototype requests.
pub root_prototype_request_spans: Vec<std::ops::Range<usize>>, pub root_prototype_request_spans: Vec<std::ops::Range<usize>>,
/// Ordered raw unit DAT component records for each mission root.
///
/// This vector is aligned with [`Self::roots`]. Direct roots and legacy
/// unit bindings have an empty record list. Records are preserved without
/// assigning runtime semantics to their `kind`, links, descriptions, or
/// opaque tails.
pub root_unit_components: Vec<Vec<UnitComponentRecord>>,
/// Whether visual dependency expansion has already been applied.
pub visual_dependencies_expanded: bool,
/// Materialized prototype dependency graph nodes. /// Materialized prototype dependency graph nodes.
pub nodes: Vec<PrototypeGraphNode>, pub nodes: Vec<PrototypeGraphNode>,
/// Materialized prototype dependency graph edges. /// Materialized prototype dependency graph edges.
@@ -160,6 +169,12 @@ pub struct PrototypeGraphProvenance {
pub root_index: usize, pub root_index: usize,
/// Immediate parent edge that discovered this edge. /// Immediate parent edge that discovered this edge.
pub parent_edge: Option<PrototypeGraphEdgeId>, pub parent_edge: Option<PrototypeGraphEdgeId>,
/// Ordered Unit DAT record selected for this path, when the root is a
/// decoded multi-component unit.
///
/// The index identifies source provenance only; it does not classify the
/// component as physics, Control, animation, or any other subsystem.
pub unit_component_index: Option<usize>,
/// Source archive when available. /// Source archive when available.
pub archive: Option<String>, pub archive: Option<String>,
/// Source resource key when available. /// Source resource key when available.
@@ -179,6 +194,14 @@ pub enum PrototypeGraphNodeKind {
Prototype, Prototype,
/// Mesh dependency. /// Mesh dependency.
MeshResource, MeshResource,
/// WEAR dependency.
WearResource,
/// MAT0 dependency.
MaterialResource,
/// TEXM texture dependency.
TextureResource,
/// TEXM lightmap dependency.
LightmapResource,
/// Non-geometric prototype. /// Non-geometric prototype.
NonGeometric, NonGeometric,
} }
@@ -197,6 +220,21 @@ pub struct PrototypeGraphNode {
} }
impl PrototypeGraphNode { impl PrototypeGraphNode {
/// Creates a typed resource node.
#[must_use]
pub fn resource(
kind: PrototypeGraphNodeKind,
resource: ResourceKey,
id: PrototypeGraphNodeId,
) -> Self {
Self {
id,
kind,
key: None,
resource: Some(resource),
}
}
/// Creates a mesh resource node. /// Creates a mesh resource node.
#[must_use] #[must_use]
pub const fn mesh(resource: ResourceKey, id: PrototypeGraphNodeId) -> Self { pub const fn mesh(resource: ResourceKey, id: PrototypeGraphNodeId) -> Self {
@@ -244,6 +282,14 @@ pub enum PrototypeGraphEdgeKind {
UnitDatToComponent, UnitDatToComponent,
/// Prototype to mesh dependency. /// Prototype to mesh dependency.
PrototypeToMesh, PrototypeToMesh,
/// Mesh resource to WEAR table.
MeshToWear,
/// WEAR table to MAT0 material.
WearToMaterial,
/// MAT0 material to TEXM texture.
MaterialToTexture,
/// WEAR table to TEXM lightmap.
WearToLightmap,
} }
/// Prototype graph edge record. /// Prototype graph edge record.
@@ -604,6 +650,7 @@ fn resolve_direct_prototype(
struct ResolvedPrototypeRequests { struct ResolvedPrototypeRequests {
expected_count: usize, expected_count: usize,
prototypes: Vec<EffectivePrototype>, prototypes: Vec<EffectivePrototype>,
unit_components: Vec<UnitComponentRecord>,
} }
fn resolve_prototype_requests( fn resolve_prototype_requests(
@@ -619,6 +666,7 @@ fn resolve_prototype_requests(
Ok(ResolvedPrototypeRequests { Ok(ResolvedPrototypeRequests {
expected_count: 1, expected_count: 1,
prototypes: prototype.into_iter().collect(), prototypes: prototype.into_iter().collect(),
unit_components: Vec::new(),
}) })
} }
@@ -640,22 +688,21 @@ fn resolve_unit_dat_prototype_requests(
}; };
if let Ok(unit) = decode_unit_dat(&bytes) { if let Ok(unit) = decode_unit_dat(&bytes) {
if !unit.records.is_empty() { let mut prototypes = Vec::with_capacity(unit.records.len());
let mut prototypes = Vec::with_capacity(unit.records.len()); for record in &unit.records {
for record in &unit.records { let prototype = resolve_unit_component(repository, record)?.ok_or_else(|| {
let prototype = resolve_unit_component(repository, record)?.ok_or_else(|| { PrototypeError::Resource(ResourceError::Format(format!(
PrototypeError::Resource(ResourceError::Format(format!( "unit component {} did not resolve",
"unit component {} did not resolve", String::from_utf8_lossy(cstr_bytes(&record.resource_raw))
String::from_utf8_lossy(cstr_bytes(&record.resource_raw)) )))
))) })?;
})?; prototypes.push(prototype);
prototypes.push(prototype);
}
return Ok(ResolvedPrototypeRequests {
expected_count: unit.records.len(),
prototypes,
});
} }
return Ok(ResolvedPrototypeRequests {
expected_count: unit.records.len(),
prototypes,
unit_components: unit.records,
});
} }
let binding = decode_unit_dat_binding(&bytes)?; let binding = decode_unit_dat_binding(&bytes)?;
@@ -666,6 +713,7 @@ fn resolve_unit_dat_prototype_requests(
Ok(ResolvedPrototypeRequests { Ok(ResolvedPrototypeRequests {
expected_count: 1, expected_count: 1,
prototypes: prototype.into_iter().collect(), prototypes: prototype.into_iter().collect(),
unit_components: Vec::new(),
}) })
} }
@@ -710,8 +758,15 @@ pub fn build_prototype_graph(
graph.roots.push(key); graph.roots.push(key);
let start = graph.prototype_requests.len(); let start = graph.prototype_requests.len();
let expansion = resolve_prototype_requests(repository, vfs, root)?; let expansion = resolve_prototype_requests(repository, vfs, root)?;
let root_provenance = provenance_for_root(root_index, root); graph
for prototype in expansion.prototypes { .root_unit_components
.push(expansion.unit_components.clone());
let unit_component_count = expansion.unit_components.len();
for (prototype_index, prototype) in expansion.prototypes.into_iter().enumerate() {
let unit_component_index = is_unit_dat_root
.then_some(prototype_index)
.filter(|index| *index < unit_component_count);
let root_provenance = provenance_for_root(root_index, root, unit_component_index);
let prototype_node = PrototypeGraphNode::prototype( let prototype_node = PrototypeGraphNode::prototype(
prototype.key.clone(), prototype.key.clone(),
PrototypeGraphNodeId(next_node), PrototypeGraphNodeId(next_node),
@@ -750,6 +805,7 @@ pub fn build_prototype_graph(
provenance: Some(provenance_for_mesh( provenance: Some(provenance_for_mesh(
root_index, root_index,
root_to_prototype_edge_id, root_to_prototype_edge_id,
unit_component_index,
dependency, dependency,
)), )),
}); });
@@ -805,16 +861,23 @@ pub fn build_prototype_graph_report(
root_node, root_node,
)); ));
let start = graph.prototype_requests.len(); let start = graph.prototype_requests.len();
let root_provenance = provenance_for_root(root_index, root);
match resolve_prototype_requests(repository, vfs, root) { match resolve_prototype_requests(repository, vfs, root) {
Ok(expansion) => { Ok(expansion) => {
let expected = expansion.expected_count; let expected = expansion.expected_count;
graph
.root_unit_components
.push(expansion.unit_components.clone());
if edge == PrototypeGraphEdge::MissionToUnitDat { if edge == PrototypeGraphEdge::MissionToUnitDat {
report.unit_component_count += expected; report.unit_component_count += expected;
} }
let actual = expansion.prototypes.len(); let actual = expansion.prototypes.len();
for prototype in expansion.prototypes { let unit_component_count = expansion.unit_components.len();
for (prototype_index, prototype) in expansion.prototypes.into_iter().enumerate() {
let unit_component_index = is_unit_dat_root
.then_some(prototype_index)
.filter(|index| *index < unit_component_count);
let root_provenance =
provenance_for_root(root_index, root, unit_component_index);
let prototype_node = PrototypeGraphNode::prototype( let prototype_node = PrototypeGraphNode::prototype(
prototype.key.clone(), prototype.key.clone(),
PrototypeGraphNodeId(next_node), PrototypeGraphNodeId(next_node),
@@ -855,6 +918,7 @@ pub fn build_prototype_graph_report(
provenance: Some(provenance_for_mesh( provenance: Some(provenance_for_mesh(
root_index, root_index,
root_to_prototype_edge_id, root_to_prototype_edge_id,
unit_component_index,
dependency, dependency,
)), )),
}); });
@@ -876,6 +940,7 @@ pub fn build_prototype_graph_report(
provenance: Some(PrototypeGraphProvenance { provenance: Some(PrototypeGraphProvenance {
root_index, root_index,
parent_edge: None, parent_edge: None,
unit_component_index: None,
archive: None, archive: None,
resource: Some(root.0.clone()), resource: Some(root.0.clone()),
span: None, span: None,
@@ -892,12 +957,16 @@ pub fn build_prototype_graph_report(
provenance: Some(PrototypeGraphProvenance { provenance: Some(PrototypeGraphProvenance {
root_index, root_index,
parent_edge: None, parent_edge: None,
unit_component_index: None,
archive: None, archive: None,
resource: Some(root.0.clone()), resource: Some(root.0.clone()),
span: None, span: None,
}), }),
}), }),
} }
if graph.root_unit_components.len() <= root_index {
graph.root_unit_components.push(Vec::new());
}
let end = graph.prototype_requests.len(); let end = graph.prototype_requests.len();
graph.root_prototype_request_spans.push(start..end); graph.root_prototype_request_spans.push(start..end);
} }
@@ -906,14 +975,42 @@ pub fn build_prototype_graph_report(
} }
fn graph_error_edge(edge: PrototypeGraphEdge, err: &PrototypeError) -> PrototypeGraphEdge { fn graph_error_edge(edge: PrototypeGraphEdge, err: &PrototypeError) -> PrototypeGraphEdge {
let _ = err; match err {
edge PrototypeError::Decode(_)
| PrototypeError::InvalidSize
| PrototypeError::InvalidUnitDatMagic(_)
if edge == PrototypeGraphEdge::MissionToUnitDat =>
{
PrototypeGraphEdge::MissionToUnitDat
}
PrototypeError::Resource(ResourceError::Format(message))
if message.starts_with("missing unit DAT:") =>
{
PrototypeGraphEdge::MissionToUnitDat
}
PrototypeError::Resource(ResourceError::Format(message))
if message.starts_with("unit component ") =>
{
PrototypeGraphEdge::UnitDatToComponent
}
PrototypeError::Resource(ResourceError::Format(message))
if message.contains("explicit mesh reference missing:") =>
{
PrototypeGraphEdge::PrototypeToMesh
}
_ => edge,
}
} }
fn provenance_for_root(root_index: usize, root: &ResourceName) -> PrototypeGraphProvenance { fn provenance_for_root(
root_index: usize,
root: &ResourceName,
unit_component_index: Option<usize>,
) -> PrototypeGraphProvenance {
PrototypeGraphProvenance { PrototypeGraphProvenance {
root_index, root_index,
parent_edge: None, parent_edge: None,
unit_component_index,
archive: None, archive: None,
resource: Some(root.0.clone()), resource: Some(root.0.clone()),
span: None, span: None,
@@ -923,11 +1020,13 @@ fn provenance_for_root(root_index: usize, root: &ResourceName) -> PrototypeGraph
fn provenance_for_mesh( fn provenance_for_mesh(
root_index: usize, root_index: usize,
parent_edge: PrototypeGraphEdgeId, parent_edge: PrototypeGraphEdgeId,
unit_component_index: Option<usize>,
dependency: &ResourceKey, dependency: &ResourceKey,
) -> PrototypeGraphProvenance { ) -> PrototypeGraphProvenance {
PrototypeGraphProvenance { PrototypeGraphProvenance {
root_index, root_index,
parent_edge: Some(parent_edge), parent_edge: Some(parent_edge),
unit_component_index,
archive: Some(dependency.archive.as_str().to_string()), archive: Some(dependency.archive.as_str().to_string()),
resource: Some(dependency.name.0.clone()), resource: Some(dependency.name.0.clone()),
span: None, span: None,
@@ -1012,7 +1111,7 @@ fn collect_registry_refs(
} }
let archive_id = match repository.open_archive(registry_archive) { let archive_id = match repository.open_archive(registry_archive) {
Ok(id) => id, Ok(id) => id,
Err(ResourceError::MissingArchive) => return Ok(None), Err(ResourceError::MissingArchive { .. }) => return Ok(None),
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
}; };
let Some((registry_entry, _matched_name)) = let Some((registry_entry, _matched_name)) =
@@ -1082,7 +1181,7 @@ fn find_mesh_resource(
) -> Result<Option<ResourceKey>, PrototypeError> { ) -> Result<Option<ResourceKey>, PrototypeError> {
let archive_id = match repository.open_archive(archive) { let archive_id = match repository.open_archive(archive) {
Ok(id) => id, Ok(id) => id,
Err(ResourceError::MissingArchive) => return Ok(None), Err(ResourceError::MissingArchive { .. }) => return Ok(None),
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
}; };
let candidates = mesh_name_candidates(&model_key.0); let candidates = mesh_name_candidates(&model_key.0);
@@ -1581,6 +1680,81 @@ mod tests {
assert!(err.to_string().contains("missing unit DAT")); assert!(err.to_string().contains("missing unit DAT"));
} }
#[test]
fn empty_unit_dat_resolves_as_zero_component_root() {
let mut vfs = MemoryVfs::default();
let dat_path = resource_archive_path(b"UNITS/AUTO/empty.dat").expect("dat path");
vfs.insert(dat_path, Arc::from(vec![0_u8; 8].into_boxed_slice()));
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
let roots = [resource_name(b"UNITS/AUTO/empty.dat")];
let (graph, resolved, report) = build_prototype_graph_report(&repo, vfs.as_ref(), &roots);
assert_eq!(graph.roots.len(), 1);
assert!(graph.prototype_requests.is_empty());
assert!(resolved.is_empty());
assert_eq!(report.unit_reference_count, 1);
assert_eq!(report.unit_component_count, 0);
assert_eq!(report.resolved_count, 0);
assert!(report.failures.is_empty());
assert!(report.is_success());
}
#[test]
fn malformed_unit_dat_failure_is_classified_on_unit_edge() {
let mut vfs = MemoryVfs::default();
let dat_path = resource_archive_path(b"UNITS/AUTO/bad.dat").expect("dat path");
vfs.insert(
dat_path,
Arc::from([1_u8, 2, 3].to_vec().into_boxed_slice()),
);
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
let (_graph, _resolved, report) = build_prototype_graph_report(
&repo,
vfs.as_ref(),
&[resource_name(b"UNITS/AUTO/bad.dat")],
);
assert_eq!(report.failures.len(), 1);
assert_eq!(
report.failures[0].edge,
PrototypeGraphEdge::MissionToUnitDat
);
}
#[test]
fn missing_unit_component_failure_is_classified_on_component_edge() {
let mut vfs = MemoryVfs::default();
let dat_path = resource_archive_path(b"UNITS/AUTO/compound.dat").expect("dat path");
let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
vfs.insert(
dat_path,
Arc::from(
build_unit_dat(&[(b"objects.rlb".as_slice(), b"missing_component".as_slice())])
.into_boxed_slice(),
),
);
vfs.insert(objects_path, Arc::from(build_nres(&[]).into_boxed_slice()));
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
let (_graph, _resolved, report) = build_prototype_graph_report(
&repo,
vfs.as_ref(),
&[resource_name(b"UNITS/AUTO/compound.dat")],
);
assert_eq!(report.failures.len(), 1);
assert_eq!(
report.failures[0].edge,
PrototypeGraphEdge::UnitDatToComponent
);
assert!(report.failures[0].message.contains("missing_component"));
}
#[test] #[test]
fn unit_dat_expands_components_in_order() { fn unit_dat_expands_components_in_order() {
let mut vfs = MemoryVfs::default(); let mut vfs = MemoryVfs::default();
@@ -1641,6 +1815,45 @@ mod tests {
assert_eq!(graph.prototype_requests.len(), 2); assert_eq!(graph.prototype_requests.len(), 2);
assert_eq!(graph.prototype_requests[0].0 .0, b"component_a"); assert_eq!(graph.prototype_requests[0].0 .0, b"component_a");
assert_eq!(graph.prototype_requests[1].0 .0, b"component_b"); assert_eq!(graph.prototype_requests[1].0 .0, b"component_b");
assert_eq!(graph.root_unit_components.len(), 1);
assert_eq!(graph.root_unit_components[0].len(), 2);
assert_eq!(
cstr_bytes(&graph.root_unit_components[0][0].resource_raw),
b"component_a"
);
assert_eq!(
cstr_bytes(&graph.root_unit_components[0][1].resource_raw),
b"component_b"
);
let component_edges: Vec<_> = graph
.edges
.iter()
.filter(|edge| edge.kind == PrototypeGraphEdgeKind::UnitDatToComponent)
.collect();
assert_eq!(component_edges.len(), 2);
assert_eq!(
component_edges[0]
.provenance
.as_ref()
.and_then(|provenance| provenance.unit_component_index),
Some(0)
);
assert_eq!(
component_edges[1]
.provenance
.as_ref()
.and_then(|provenance| provenance.unit_component_index),
Some(1)
);
assert!(graph
.edges
.iter()
.filter(|edge| edge.kind == PrototypeGraphEdgeKind::PrototypeToMesh)
.all(|edge| edge
.provenance
.as_ref()
.and_then(|value| value.unit_component_index)
.is_some()));
assert_eq!(resolved.len(), 2); assert_eq!(resolved.len(), 2);
assert_eq!(report.unit_reference_count, 1); assert_eq!(report.unit_reference_count, 1);
assert_eq!(report.unit_component_count, 2); assert_eq!(report.unit_component_count, 2);
@@ -1894,10 +2107,7 @@ mod tests {
assert_eq!(report.failures.len(), 1); assert_eq!(report.failures.len(), 1);
assert_eq!(report.failures[0].resource_raw, b"broken"); assert_eq!(report.failures[0].resource_raw, b"broken");
assert_eq!( assert_eq!(report.failures[0].edge, PrototypeGraphEdge::PrototypeToMesh);
report.failures[0].edge,
PrototypeGraphEdge::MissionToObjectsRegistry
);
assert!(report.failures[0].message.contains("broken")); assert!(report.failures[0].message.contains("broken"));
assert!(report.failures[0] assert!(report.failures[0]
.message .message
@@ -1929,6 +2139,7 @@ mod tests {
assert_eq!(report.failures.len(), 1); assert_eq!(report.failures.len(), 1);
assert_eq!(report.failures[0].resource_raw, b"broken"); assert_eq!(report.failures[0].resource_raw, b"broken");
assert_eq!(report.failures[0].edge, PrototypeGraphEdge::PrototypeToMesh);
assert!(report.failures[0] assert!(report.failures[0]
.message .message
.contains("static.rlb:missing.msh")); .contains("static.rlb:missing.msh"));
+1 -1
View File
@@ -6,7 +6,7 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-world = { path = "../fparkan-world" } fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+696 -4
View File
@@ -22,6 +22,366 @@
use fparkan_world::OriginalObjectId; use fparkan_world::OriginalObjectId;
/// A 64-byte transform block returned by the original Terrain camera ABI.
///
/// The original uses two selector-dependent transform pointers. Their exact
/// matrix convention is still under recovery, so words are deliberately kept
/// losslessly rather than treated as a renderer-ready matrix. The three
/// translation words have been confirmed at indices 3, 7, and 11.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RawCameraTransform {
/// The exact 16 little-endian dwords returned by the legacy camera.
pub words: [u32; 16],
}
impl RawCameraTransform {
/// Indices of the confirmed X, Y, and Z translation floats.
pub const TRANSLATION_WORD_INDICES: [usize; 3] = [3, 7, 11];
/// Returns the confirmed legacy world position (X, Y, Z).
#[must_use]
pub fn translation(self) -> [f32; 3] {
Self::TRANSLATION_WORD_INDICES.map(|index| f32::from_bits(self.words[index]))
}
/// Inverts a finite row-major affine transform without assigning it a
/// camera-space meaning.
///
/// The legacy SIMD dispatch multiplies these blocks as ordinary row-major
/// matrices and the confirmed camera samples have translation in the last
/// column. `Some` therefore means only that this block has a non-singular
/// affine inverse. Callers must still establish whether it is a
/// camera-to-world transform before using the result as a view matrix.
#[must_use]
pub fn try_inverse_affine_row_major(self) -> Option<[f32; 16]> {
let matrix = self.words.map(f32::from_bits);
if !matrix.iter().all(|value| value.is_finite())
|| matrix[12].abs() > f32::EPSILON
|| matrix[13].abs() > f32::EPSILON
|| matrix[14].abs() > f32::EPSILON
|| (matrix[15] - 1.0).abs() > f32::EPSILON
{
return None;
}
let [m00, m01, m02, _, m10, m11, m12, _, m20, m21, m22, _, _, _, _, _] = matrix;
let cofactor00 = m11.mul_add(m22, -(m12 * m21));
let cofactor01 = m02.mul_add(m21, -(m01 * m22));
let cofactor02 = m01.mul_add(m12, -(m02 * m11));
let determinant = m00.mul_add(cofactor00, m10.mul_add(cofactor01, m20 * cofactor02));
if !determinant.is_finite() || determinant == 0.0 {
return None;
}
let inverse_determinant = determinant.recip();
let inverse = [
cofactor00 * inverse_determinant,
m02.mul_add(m21, -(m01 * m22)) * inverse_determinant,
cofactor02 * inverse_determinant,
0.0,
m12.mul_add(m20, -(m10 * m22)) * inverse_determinant,
m00.mul_add(m22, -(m02 * m20)) * inverse_determinant,
m02.mul_add(m10, -(m00 * m12)) * inverse_determinant,
0.0,
m10.mul_add(m21, -(m11 * m20)) * inverse_determinant,
m01.mul_add(m20, -(m00 * m21)) * inverse_determinant,
m00.mul_add(m11, -(m01 * m10)) * inverse_determinant,
0.0,
0.0,
0.0,
0.0,
1.0,
];
let [translation_x, translation_y, translation_z] = self.translation();
let translation = [
-(inverse[0].mul_add(
translation_x,
inverse[1].mul_add(translation_y, inverse[2] * translation_z),
)),
-(inverse[4].mul_add(
translation_x,
inverse[5].mul_add(translation_y, inverse[6] * translation_z),
)),
-(inverse[8].mul_add(
translation_x,
inverse[9].mul_add(translation_y, inverse[10] * translation_z),
)),
];
Some([
inverse[0],
inverse[1],
inverse[2],
translation[0],
inverse[4],
inverse[5],
inverse[6],
translation[1],
inverse[8],
inverse[9],
inverse[10],
translation[2],
0.0,
0.0,
0.0,
1.0,
])
}
/// Reproduces Ngi32's `Direct3D7` view-matrix conversion for this transform.
///
/// The legacy renderer copies selector-0 into its camera state, then maps
/// its axes and translation in this exact order before calling
/// `IDirect3DDevice7::SetTransform(D3DTRANSFORMSTATE_VIEW, ...)`. This is
/// deliberately distinct from [`Self::try_inverse_affine_row_major`]: it
/// includes the original renderer's coordinate-system conversion.
///
/// The returned matrix is row-major D3D7 data, not yet a Vulkan view
/// matrix. A later adapter must explicitly account for clip-space and
/// shader-vector conventions.
#[must_use]
pub fn try_direct3d7_view_row_major(self) -> Option<[f32; 16]> {
let matrix = self.words.map(f32::from_bits);
if !matrix.iter().all(|value| value.is_finite())
|| matrix[12].abs() > f32::EPSILON
|| matrix[13].abs() > f32::EPSILON
|| matrix[14].abs() > f32::EPSILON
|| (matrix[15] - 1.0).abs() > f32::EPSILON
{
return None;
}
let [m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, _, _, _, _] = matrix;
Some([
-m01,
m02,
m00,
0.0,
-m11,
m12,
m10,
0.0,
-m21,
m22,
m20,
0.0,
m23.mul_add(m21, m13.mul_add(m11, m03 * m01)),
-(m23.mul_add(m22, m13.mul_add(m12, m03 * m02))),
-(m00.mul_add(m03, m23.mul_add(m20, m13 * m10))),
1.0,
])
}
}
/// Parameters consumed by Ngi32's `Direct3D7` projection-matrix builder.
///
/// These are a separate legacy-renderer boundary from
/// [`RawCameraProjection`]. The latter preserves Terrain's source ABI, while
/// this type records the already-resolved Ngi32 values submitted to `Direct3D7`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LegacyD3d7Projection {
/// Viewport rectangle as `(left, top, right, bottom)`.
pub viewport: [i32; 4],
/// Positive camera near-plane distance.
pub near_plane: f32,
/// Camera far-plane distance, greater than [`Self::near_plane`].
pub far_plane: f32,
/// Full field-of-view angle in radians.
pub field_of_view_radians: f32,
}
impl LegacyD3d7Projection {
/// Reconstructs the exact row-major matrix passed to D3D7 projection state.
///
/// Ngi32 uses the viewport's `width / height`, writes `cos(fov / 2)` to
/// the diagonal and `sin(fov / 2)` to both the depth scale and
/// homogeneous-W term. The ratio after D3D's perspective divide is
/// therefore the expected cotangent scale. This remains legacy D3D7 data
/// rather than a Vulkan projection.
#[must_use]
pub fn try_direct3d7_projection_row_major(self) -> Option<[f32; 16]> {
let width = self.viewport[2].checked_sub(self.viewport[0])?;
let height = self.viewport[3].checked_sub(self.viewport[1])?;
if width <= 0
|| height <= 0
|| !self.near_plane.is_finite()
|| !self.far_plane.is_finite()
|| !self.field_of_view_radians.is_finite()
|| self.near_plane <= 0.0
|| self.far_plane <= self.near_plane
|| self.field_of_view_radians <= 0.0
|| self.field_of_view_radians >= std::f32::consts::PI
{
return None;
}
// Ngi32 converts these signed viewport dimensions into single-precision
// arithmetic before building the legacy matrix.
#[allow(clippy::cast_precision_loss)]
let aspect = (width as f32) / (height as f32);
let half_fov = self.field_of_view_radians * 0.5;
let cosine = half_fov.cos();
let sine = half_fov.sin();
let depth_scale = sine / (1.0 - self.near_plane / self.far_plane);
[aspect, cosine, sine, depth_scale]
.iter()
.all(|value| value.is_finite())
.then_some([
cosine,
0.0,
0.0,
0.0,
0.0,
aspect * cosine,
0.0,
0.0,
0.0,
0.0,
depth_scale,
sine,
0.0,
0.0,
-(depth_scale * self.near_plane),
0.0,
])
}
}
/// Affine placement inputs consumed by `Iron3D`'s recovered Euler-matrix builder.
///
/// This records the exact matrix construction at `iron3d.dll` RVA `0x36610`.
/// It is intentionally distinct from a mission placement contract: the static
/// trace has not yet proved that TMA's three raw orientation floats reach this
/// builder unchanged for every object category.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LegacyIron3dEulerTransform {
/// World-space translation passed to the builder.
pub translation: [f32; 3],
/// Three angles in the builder's `(x, y, z)` input order, in radians.
pub orientation_radians: [f32; 3],
}
impl LegacyIron3dEulerTransform {
/// Reconstructs the builder's finite row-major affine matrix.
///
/// The recovered x87 code evaluates `Rz(z) * Ry(y) * Rx(x)` and writes
/// translation into the last column. This uses portable `f32` trigonometry;
/// any future x87-compatibility path must be separately capture-validated.
#[must_use]
pub fn try_row_major(self) -> Option<[f32; 16]> {
if !self
.translation
.iter()
.chain(self.orientation_radians.iter())
.all(|value| value.is_finite())
{
return None;
}
let [x, y, z] = self.orientation_radians;
let (sin_x, cos_x) = x.sin_cos();
let (sin_y, cos_y) = y.sin_cos();
let (sin_z, cos_z) = z.sin_cos();
let [translation_x, translation_y, translation_z] = self.translation;
let matrix = [
cos_z * cos_y,
cos_z * sin_y * sin_x - sin_z * cos_x,
cos_z * sin_y * cos_x + sin_z * sin_x,
translation_x,
sin_z * cos_y,
sin_z * sin_y * sin_x + cos_z * cos_x,
sin_z * sin_y * cos_x - cos_z * sin_x,
translation_y,
-sin_y,
cos_y * sin_x,
cos_y * cos_x,
translation_z,
0.0,
0.0,
0.0,
1.0,
];
matrix
.iter()
.all(|value| value.is_finite())
.then_some(matrix)
}
/// Applies the recovered rotation after a component-wise local scale.
///
/// This is the affine `R * (scale * point) + translation` convention used
/// by the static source-world bridge. The original dynamic transform path
/// needs its own capture evidence before it may replace this static path.
#[must_use]
pub fn try_transform_scaled_point(self, point: [f32; 3], scale: [f32; 3]) -> Option<[f32; 3]> {
if !point
.iter()
.chain(scale.iter())
.all(|value| value.is_finite())
{
return None;
}
let matrix = self.try_row_major()?;
let scaled = [
point[0] * scale[0],
point[1] * scale[1],
point[2] * scale[2],
];
let transformed = [
matrix[0] * scaled[0] + matrix[1] * scaled[1] + matrix[2] * scaled[2] + matrix[3],
matrix[4] * scaled[0] + matrix[5] * scaled[1] + matrix[6] * scaled[2] + matrix[7],
matrix[8] * scaled[0] + matrix[9] * scaled[1] + matrix[10] * scaled[2] + matrix[11],
];
transformed
.iter()
.all(|value| value.is_finite())
.then_some(transformed)
}
}
/// Raw camera state observed through the original Terrain camera interface.
///
/// Selector 0 supplies the currently active transform and selector 2 supplies
/// its paired transform. Keeping both lets a later compatibility layer derive
/// a view/projection convention without re-reading the legacy process.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RawCameraPose {
/// Transform returned by selector 0.
pub selector0: RawCameraTransform,
/// Transform returned by selector 2.
pub selector2: RawCameraTransform,
}
/// Raw projection state observed through the original `CBufferingCamera` ABI.
///
/// The five-float context block is intentionally represented as original words:
/// its indices are observed, but their semantic labels have not yet all been
/// recovered. The field-of-view value is the type-0 input to `tan(fov / 2)`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RawCameraProjection {
/// Viewport rectangle as `(left, top, right, bottom)`.
pub viewport: [i32; 4],
/// Original projection selector.
pub projection_type: u32,
/// Original IEEE-754 FOV value in radians.
pub field_of_view_radians_bits: u32,
/// Exact five-float context block returned by the primary renderer.
pub context_words: [u32; 5],
}
impl RawCameraProjection {
/// Returns the type-0 FOV input in radians.
#[must_use]
pub fn field_of_view_radians(self) -> f32 {
f32::from_bits(self.field_of_view_radians_bits)
}
/// Returns the five context values without assigning semantic labels.
#[must_use]
pub fn context_values(self) -> [f32; 5] {
self.context_words.map(f32::from_bits)
}
}
/// Immutable camera data visible to command generation. /// Immutable camera data visible to command generation.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct CameraSnapshot { pub struct CameraSnapshot {
@@ -29,6 +389,17 @@ pub struct CameraSnapshot {
pub view: [f32; 16], pub view: [f32; 16],
/// Projection matrix, row-major. /// Projection matrix, row-major.
pub projection: [f32; 16], pub projection: [f32; 16],
/// Optional unconverted source-camera state.
///
/// This does not alter rendering until the original matrix and projection
/// conventions have been recovered; it preserves the ABI boundary for the
/// runtime adapter and deterministic captures.
pub raw_pose: Option<RawCameraPose>,
/// Optional unconverted source-projection state.
///
/// This is retained independently of `projection`, because its legacy
/// context words have not yet been mapped to a Vulkan clip convention.
pub raw_projection: Option<RawCameraProjection>,
} }
impl Default for CameraSnapshot { impl Default for CameraSnapshot {
@@ -36,6 +407,8 @@ impl Default for CameraSnapshot {
Self { Self {
view: identity_transform(), view: identity_transform(),
projection: identity_transform(), projection: identity_transform(),
raw_pose: None,
raw_projection: None,
} }
} }
} }
@@ -71,6 +444,93 @@ pub enum RenderPhase {
Ui, Ui,
} }
/// Fixed-function blend behaviour represented without a graphics API type.
///
/// This is a compatibility contract, not yet a decoded MAT0 mapping.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum LegacyBlendMode {
/// Do not blend the fragment with the existing colour.
#[default]
Opaque,
/// Blend using source alpha.
SourceAlpha,
}
/// Depth-buffer behaviour represented without a graphics API type.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum LegacyDepthMode {
/// No depth attachment is used.
#[default]
Disabled,
/// Test depth and write passing fragments.
TestWrite,
/// Test depth without modifying it.
TestReadOnly,
}
/// Triangle culling behaviour represented without a graphics API type.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum LegacyCullMode {
/// Keep both front- and back-facing triangles.
#[default]
Disabled,
/// Cull back-facing triangles.
BackFace,
/// Cull front-facing triangles.
FrontFace,
}
/// Legacy fixed-function state that changes graphics-pipeline structure.
///
/// Alpha reference is deliberately not present: it is dynamic material data,
/// whereas this state records only whether an alpha-test shader variant is used.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct LegacyPipelineState {
/// Colour blend mode.
pub blend: LegacyBlendMode,
/// Depth test/write mode.
pub depth: LegacyDepthMode,
/// Face culling mode.
pub cull: LegacyCullMode,
/// Whether alpha-test shader logic is enabled.
pub alpha_test: bool,
}
/// Canonical, backend-neutral key for a graphics-pipeline variant.
///
/// The value is explicitly packed rather than hashed, so captures and caches
/// remain stable across processes and Rust toolchain updates.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PipelineKey(u8);
impl PipelineKey {
/// Returns the canonical packed representation.
#[must_use]
pub const fn packed(self) -> u8 {
self.0
}
}
impl From<LegacyPipelineState> for PipelineKey {
fn from(state: LegacyPipelineState) -> Self {
let blend = match state.blend {
LegacyBlendMode::Opaque => 0,
LegacyBlendMode::SourceAlpha => 1,
};
let depth = match state.depth {
LegacyDepthMode::Disabled => 0,
LegacyDepthMode::TestWrite => 1,
LegacyDepthMode::TestReadOnly => 2,
};
let cull = match state.cull {
LegacyCullMode::Disabled => 0,
LegacyCullMode::BackFace => 1,
LegacyCullMode::FrontFace => 2,
};
Self(blend | (depth << 1) | (cull << 3) | (u8::from(state.alpha_test) << 5))
}
}
/// Index range. /// Index range.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct IndexRange { pub struct IndexRange {
@@ -95,6 +555,8 @@ pub struct RenderSnapshotDraw {
pub material_slots: Vec<GpuMaterialId>, pub material_slots: Vec<GpuMaterialId>,
/// Batch material index into [`Self::material_slots`]. /// Batch material index into [`Self::material_slots`].
pub material_index: u16, pub material_index: u16,
/// Fixed-function state resolved for this draw.
pub pipeline_state: LegacyPipelineState,
/// Node transform matrix, row-major. /// Node transform matrix, row-major.
pub transform: [f32; 16], pub transform: [f32; 16],
/// Index range. /// Index range.
@@ -132,6 +594,8 @@ pub struct DrawCommand {
pub mesh: GpuMeshId, pub mesh: GpuMeshId,
/// Material. /// Material.
pub material: GpuMaterialId, pub material: GpuMaterialId,
/// Canonical graphics-pipeline variant.
pub pipeline_key: PipelineKey,
/// Transform matrix, row-major. /// Transform matrix, row-major.
pub transform: [f32; 16], pub transform: [f32; 16],
/// Index range. /// Index range.
@@ -369,6 +833,7 @@ pub fn build_commands(
object_id: draw.object_id, object_id: draw.object_id,
mesh: draw.mesh, mesh: draw.mesh,
material, material,
pipeline_key: draw.pipeline_state.into(),
transform: draw.transform, transform: draw.transform,
range: draw.range, range: draw.range,
stable_order: draw.stable_order, stable_order: draw.stable_order,
@@ -389,6 +854,11 @@ pub trait RenderBackend {
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError>; fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError>;
} }
/// Marker trait for backends that execute draws against a live GPU.
///
/// Planning and capture-only backends must not implement this trait.
pub trait GpuRenderBackend: RenderBackend {}
/// Backend that validates commands and intentionally produces no pixels. /// Backend that validates commands and intentionally produces no pixels.
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct NullBackend; pub struct NullBackend;
@@ -448,8 +918,13 @@ pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, Render
RenderCommand::Draw(draw) => { RenderCommand::Draw(draw) => {
out.extend_from_slice( out.extend_from_slice(
format!( format!(
"D,{:?},{},{},{},{}\n", "D,{:?},{},{},{},{},{}\n",
draw.phase, draw.id.0, draw.mesh.0, draw.material.0, draw.stable_order draw.phase,
draw.id.0,
draw.mesh.0,
draw.material.0,
draw.pipeline_key.packed(),
draw.stable_order
) )
.as_bytes(), .as_bytes(),
); );
@@ -600,6 +1075,191 @@ fn identity_transform() -> [f32; 16] {
mod tests { mod tests {
use super::*; use super::*;
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
let mut result = [0.0; 16];
for row in 0..4 {
for column in 0..4 {
result[row * 4 + column] = (0..4)
.map(|index| left[row * 4 + index] * right[index * 4 + column])
.sum();
}
}
result
}
fn assert_matrix_approximately_identity(matrix: [f32; 16]) {
for (index, value) in matrix.into_iter().enumerate() {
let expected = if index / 4 == index % 4 { 1.0 } else { 0.0 };
assert!(
(value - expected).abs() < 0.000_02,
"matrix element {index}: expected {expected}, got {value}"
);
}
}
#[test]
fn raw_camera_pose_preserves_words_and_extracts_confirmed_translation() {
let mut active = [0_u32; 16];
active[0] = 0x7FC0_0001;
active[3] = 491.562_5_f32.to_bits();
active[7] = 761.550_8_f32.to_bits();
active[11] = 7.361_0_f32.to_bits();
let paired = [0xA5A5_5A5A; 16];
let pose = RawCameraPose {
selector0: RawCameraTransform { words: active },
selector2: RawCameraTransform { words: paired },
};
assert_eq!(pose.selector0.words, active);
assert_eq!(pose.selector2.words, paired);
assert_eq!(
pose.selector0.translation(),
[491.562_5, 761.550_8, 7.361_0]
);
assert_eq!(CameraSnapshot::default().raw_pose, None);
}
#[test]
fn raw_camera_projection_preserves_live_context_words_without_labeling_them() {
let projection = RawCameraProjection {
viewport: [0, 0, 1024, 768],
projection_type: 0,
field_of_view_radians_bits: 1.04_f32.to_bits(),
context_words: [
0.0_f32.to_bits(),
0.5_f32.to_bits(),
700.0_f32.to_bits(),
0.1_f32.to_bits(),
0.99_f32.to_bits(),
],
};
assert_eq!(projection.field_of_view_radians(), 1.04);
assert_eq!(projection.context_values(), [0.0, 0.5, 700.0, 0.1, 0.99]);
assert_eq!(CameraSnapshot::default().raw_projection, None);
}
#[test]
fn raw_camera_transform_inverts_only_non_singular_affine_blocks() {
let source = [
0.0, -1.0, 0.0, 433.544_7, 0.948_985, 0.0, 0.315_322, 652.292_5, -0.315_322, 0.0,
0.948_985, 10.673_42, 0.0, 0.0, 0.0, 1.0,
];
let transform = RawCameraTransform {
words: source.map(f32::to_bits),
};
let inverse = transform
.try_inverse_affine_row_major()
.expect("observed affine transform is invertible");
assert_matrix_approximately_identity(multiply_row_major(source, inverse));
assert_matrix_approximately_identity(multiply_row_major(inverse, source));
let singular = RawCameraTransform { words: [0_u32; 16] };
assert_eq!(singular.try_inverse_affine_row_major(), None);
}
#[test]
fn raw_camera_transform_reproduces_direct3d7_view_axis_conversion() {
let transform = RawCameraTransform {
words: [
0.0_f32.to_bits(),
(-1.0_f32).to_bits(),
0.0_f32.to_bits(),
10.0_f32.to_bits(),
1.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
20.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
30.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
0.0_f32.to_bits(),
1.0_f32.to_bits(),
],
};
assert_eq!(
transform.try_direct3d7_view_row_major(),
Some([
1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, -10.0, -30.0, -20.0,
1.0,
])
);
assert_eq!(
RawCameraTransform { words: [0_u32; 16] }.try_direct3d7_view_row_major(),
None
);
}
#[test]
fn legacy_d3d7_projection_matches_recovered_camera_formula() {
let projection = LegacyD3d7Projection {
viewport: [0, 0, 1024, 768],
near_plane: 0.5,
far_plane: 700.0,
field_of_view_radians: 1.3,
};
let matrix = projection
.try_direct3d7_projection_row_major()
.expect("live Ngi32 projection parameters are valid");
let half_fov = 0.65_f32;
let depth_scale = half_fov.sin() * 700.0_f32 / 699.5;
assert_eq!(matrix[0], half_fov.cos());
assert_eq!(matrix[5], (4.0 / 3.0) * half_fov.cos());
assert_eq!(matrix[10], depth_scale);
assert_eq!(matrix[11], half_fov.sin());
assert_eq!(matrix[14], -(depth_scale * 0.5));
assert_eq!(
LegacyD3d7Projection {
viewport: [0, 0, 0, 768],
..projection
}
.try_direct3d7_projection_row_major(),
None
);
}
#[test]
fn iron3d_euler_builder_uses_rz_ry_rx_and_last_column_translation() {
let matrix = LegacyIron3dEulerTransform {
translation: [418.103_18, 717.433, 3.040_938_9],
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
}
.try_row_major()
.expect("finite recovered builder inputs");
assert!((matrix[0]).abs() < 0.000_001);
assert!((matrix[1] + 1.0).abs() < 0.000_001);
assert!((matrix[4] - 1.0).abs() < 0.000_001);
assert!((matrix[5]).abs() < 0.000_001);
assert_eq!(matrix[3], 418.103_18);
assert_eq!(matrix[7], 717.433);
assert_eq!(matrix[11], 3.040_938_9);
assert_eq!(matrix[15], 1.0);
assert_eq!(
LegacyIron3dEulerTransform {
translation: [10.0, 20.0, 30.0],
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
}
.try_transform_scaled_point([2.0, 3.0, 4.0], [2.0, 1.0, 0.5]),
Some([7.0, 24.0, 32.0])
);
assert_eq!(
LegacyIron3dEulerTransform {
translation: [0.0, 0.0, 0.0],
orientation_radians: [f32::NAN, 0.0, 0.0],
}
.try_row_major(),
None
);
}
fn snapshot_draw( fn snapshot_draw(
id: u64, id: u64,
phase: RenderPhase, phase: RenderPhase,
@@ -613,6 +1273,7 @@ mod tests {
mesh: GpuMeshId(10 + id), mesh: GpuMeshId(10 + id),
material_slots: vec![GpuMaterialId(31), GpuMaterialId(37)], material_slots: vec![GpuMaterialId(31), GpuMaterialId(37)],
material_index, material_index,
pipeline_state: LegacyPipelineState::default(),
transform: identity_transform(), transform: identity_transform(),
range: IndexRange { start: 0, count: 3 }, range: IndexRange { start: 0, count: 3 },
stable_order, stable_order,
@@ -630,6 +1291,7 @@ mod tests {
object_id: None, object_id: None,
mesh: GpuMeshId(2), mesh: GpuMeshId(2),
material: GpuMaterialId(3), material: GpuMaterialId(3),
pipeline_key: LegacyPipelineState::default().into(),
transform: [0.0; 16], transform: [0.0; 16],
range: IndexRange { start: 0, count: 3 }, range: IndexRange { start: 0, count: 3 },
stable_order: 4, stable_order: 4,
@@ -639,10 +1301,35 @@ mod tests {
}; };
assert_eq!( assert_eq!(
canonical_capture(&list).expect("capture"), canonical_capture(&list).expect("capture"),
b"B\nD,Opaque,1,2,3,4\nE\n" b"B\nD,Opaque,1,2,3,0,4\nE\n"
); );
} }
#[test]
fn pipeline_key_is_explicit_stable_and_sensitive_to_pipeline_structure() {
let base = LegacyPipelineState::default();
assert_eq!(PipelineKey::from(base).packed(), 0);
let variant = LegacyPipelineState {
blend: LegacyBlendMode::SourceAlpha,
depth: LegacyDepthMode::TestReadOnly,
cull: LegacyCullMode::BackFace,
alpha_test: true,
};
assert_eq!(PipelineKey::from(variant).packed(), 0b00_101_101);
assert_ne!(PipelineKey::from(base), PipelineKey::from(variant));
}
#[test]
fn alpha_test_flag_changes_key_without_encoding_material_threshold() {
let opaque = LegacyPipelineState::default();
let alpha_test = LegacyPipelineState {
alpha_test: true,
..opaque
};
assert_eq!(PipelineKey::from(alpha_test).packed(), 0b10_0000);
}
#[test] #[test]
fn null_backend_validates_without_capture() { fn null_backend_validates_without_capture() {
let mut backend = NullBackend; let mut backend = NullBackend;
@@ -655,6 +1342,7 @@ mod tests {
object_id: None, object_id: None,
mesh: GpuMeshId(2), mesh: GpuMeshId(2),
material: GpuMaterialId(3), material: GpuMaterialId(3),
pipeline_key: LegacyPipelineState::default().into(),
transform: [0.0; 16], transform: [0.0; 16],
range: IndexRange { start: 0, count: 0 }, range: IndexRange { start: 0, count: 0 },
stable_order: 4, stable_order: 4,
@@ -757,7 +1445,7 @@ mod tests {
assert_eq!( assert_eq!(
capture, capture,
b"B\nD,Opaque,1,11,31,10\nD,Opaque,2,12,31,20\nD,Transparent,3,13,31,0\nE\n" b"B\nD,Opaque,1,11,31,0,10\nD,Opaque,2,12,31,0,20\nD,Transparent,3,13,31,0,0\nE\n"
); );
Ok(()) Ok(())
} }
@@ -863,6 +1551,7 @@ mod tests {
object_id: None, object_id: None,
mesh: GpuMeshId(2), mesh: GpuMeshId(2),
material: GpuMaterialId(3), material: GpuMaterialId(3),
pipeline_key: LegacyPipelineState::default().into(),
transform: identity_transform(), transform: identity_transform(),
range: IndexRange { range: IndexRange {
start: u32::MAX, start: u32::MAX,
@@ -917,6 +1606,7 @@ mod tests {
object_id: None, object_id: None,
mesh: GpuMeshId(2), mesh: GpuMeshId(2),
material: GpuMaterialId(3), material: GpuMaterialId(3),
pipeline_key: LegacyPipelineState::default().into(),
transform: identity_transform(), transform: identity_transform(),
range: IndexRange { range: IndexRange {
start: 14, start: 14,
@@ -951,6 +1641,7 @@ mod tests {
object_id: None, object_id: None,
mesh: GpuMeshId(1), mesh: GpuMeshId(1),
material: GpuMaterialId(1), material: GpuMaterialId(1),
pipeline_key: LegacyPipelineState::default().into(),
transform: identity_transform(), transform: identity_transform(),
range: IndexRange { start: 0, count: 3 }, range: IndexRange { start: 0, count: 3 },
stable_order: 0, stable_order: 0,
@@ -961,6 +1652,7 @@ mod tests {
object_id: None, object_id: None,
mesh: GpuMeshId(1), mesh: GpuMeshId(1),
material: GpuMaterialId(1), material: GpuMaterialId(1),
pipeline_key: LegacyPipelineState::default().into(),
transform: identity_transform(), transform: identity_transform(),
range: IndexRange { start: 0, count: 3 }, range: IndexRange { start: 0, count: 3 },
stable_order: 0, stable_order: 0,
+5 -5
View File
@@ -6,11 +6,11 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" } fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-rsli = { path = "../fparkan-rsli" } fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
fparkan-vfs = { path = "../fparkan-vfs" } fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
File diff suppressed because it is too large Load Diff
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] } flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
[lints] [lints]
+348 -18
View File
@@ -20,6 +20,7 @@
)] )]
//! Stage-1 `RsLi` archive contract. //! Stage-1 `RsLi` archive contract.
use fparkan_binary::DecodeError;
use std::fmt; use std::fmt;
use std::io::Read; use std::io::Read;
use std::sync::Arc; use std::sync::Arc;
@@ -78,6 +79,33 @@ pub enum WriteProfile {
Lossless, Lossless,
} }
/// Decode and payload loading limits.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DecodeLimits {
/// Maximum accepted source archive bytes.
pub max_input_bytes: u64,
/// Maximum accepted entry count.
pub max_entries: u32,
/// Maximum accepted packed entry bytes.
pub max_packed_entry_bytes: u64,
/// Maximum accepted decoded entry bytes.
pub max_decoded_entry_bytes: u64,
/// Maximum accepted cumulative decoded bytes for a single load operation.
pub max_total_decoded_bytes: u64,
}
impl Default for DecodeLimits {
fn default() -> Self {
Self {
max_input_bytes: 256 * 1024 * 1024,
max_entries: 1_000_000,
max_packed_entry_bytes: 64 * 1024 * 1024,
max_decoded_entry_bytes: 128 * 1024 * 1024,
max_total_decoded_bytes: 128 * 1024 * 1024,
}
}
}
/// Error returned when mutable editing is attempted. /// Error returned when mutable editing is attempted.
#[derive(Debug)] #[derive(Debug)]
pub enum RsliMutationError { pub enum RsliMutationError {
@@ -105,6 +133,11 @@ pub enum RsliMutationError {
/// Format maximum (`u32::MAX`). /// Format maximum (`u32::MAX`).
max: usize, max: usize,
}, },
/// Method cannot be represented by the on-disk flags field.
UnsupportedMethod {
/// Requested method.
method: RsliMethod,
},
} }
impl std::fmt::Display for RsliMutationError { impl std::fmt::Display for RsliMutationError {
@@ -120,6 +153,9 @@ impl std::fmt::Display for RsliMutationError {
Self::PackedPayloadTooLarge { size, max } => { Self::PackedPayloadTooLarge { size, max } => {
write!(f, "packed payload is too large: {size} > {max}") write!(f, "packed payload is too large: {size} > {max}")
} }
Self::UnsupportedMethod { method } => {
write!(f, "unsupported authoring method: {method:?}")
}
} }
} }
} }
@@ -374,6 +410,8 @@ pub enum RsliError {
}, },
/// Integer conversion or arithmetic overflow. /// Integer conversion or arithmetic overflow.
IntegerOverflow, IntegerOverflow,
/// Shared bounded decode failure.
Binary(DecodeError),
} }
impl fmt::Display for RsliError { impl fmt::Display for RsliError {
@@ -432,11 +470,25 @@ impl fmt::Display for RsliError {
write!(f, "output size mismatch: expected={expected}, got={got}") write!(f, "output size mismatch: expected={expected}, got={got}")
} }
Self::IntegerOverflow => write!(f, "integer overflow"), Self::IntegerOverflow => write!(f, "integer overflow"),
Self::Binary(source) => write!(f, "{source}"),
} }
} }
} }
impl std::error::Error for RsliError {} impl std::error::Error for RsliError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Binary(source) => Some(source),
_ => None,
}
}
}
impl From<DecodeError> for RsliError {
fn from(value: DecodeError) -> Self {
Self::Binary(value)
}
}
/// Decodes an `RsLi` document. /// Decodes an `RsLi` document.
/// ///
@@ -446,7 +498,21 @@ impl std::error::Error for RsliError {}
/// compatibility quirks, or packed payloads are invalid for the selected /// compatibility quirks, or packed payloads are invalid for the selected
/// profile. /// profile.
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> { pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> {
decode_with_profile(bytes, profile.into()) decode_with_limits(bytes, profile, DecodeLimits::default())
}
/// Decodes an `RsLi` document with explicit archive limits.
///
/// # Errors
///
/// Returns [`RsliError`] when the input exceeds configured limits or the
/// archive is malformed for the selected profile.
pub fn decode_with_limits(
bytes: Arc<[u8]>,
profile: ReadProfile,
limits: DecodeLimits,
) -> Result<RsliDocument, RsliError> {
decode_with_profile_and_limits(bytes, profile.into(), limits)
} }
/// Decodes an `RsLi` document with explicit compatibility switches. /// Decodes an `RsLi` document with explicit compatibility switches.
@@ -459,17 +525,33 @@ pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, Rs
pub fn decode_with_profile( pub fn decode_with_profile(
bytes: Arc<[u8]>, bytes: Arc<[u8]>,
profile: RsliReadProfile, profile: RsliReadProfile,
) -> Result<RsliDocument, RsliError> {
decode_with_profile_and_limits(bytes, profile, DecodeLimits::default())
}
/// Decodes an `RsLi` document with explicit profile and archive limits.
///
/// # Errors
///
/// Returns [`RsliError`] when the input exceeds configured limits or the
/// archive is malformed for the selected profile.
pub fn decode_with_profile_and_limits(
bytes: Arc<[u8]>,
profile: RsliReadProfile,
limits: DecodeLimits,
) -> Result<RsliDocument, RsliError> { ) -> Result<RsliDocument, RsliError> {
let options = match profile { let options = match profile {
RsliReadProfile::Strict => ParseOptions { RsliReadProfile::Strict => ParseOptions {
allow_ao_trailer: false, allow_ao_trailer: false,
allow_deflate_eof_plus_one: false, allow_deflate_eof_plus_one: false,
allow_invalid_presorted_fallback: false, allow_invalid_presorted_fallback: false,
limits,
}, },
RsliReadProfile::Compatible(profile) => ParseOptions { RsliReadProfile::Compatible(profile) => ParseOptions {
allow_ao_trailer: profile.allow_ao_trailer, allow_ao_trailer: profile.allow_ao_trailer,
allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one, allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one,
allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback, allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback,
limits,
}, },
}; };
let ParsedRsli { let ParsedRsli {
@@ -545,6 +627,20 @@ impl RsliDocument {
/// Returns [`RsliError`] when `id` is invalid or the packed payload cannot /// Returns [`RsliError`] when `id` is invalid or the packed payload cannot
/// be decoded to the declared size. /// be decoded to the declared size.
pub fn load(&self, id: EntryId) -> Result<Vec<u8>, RsliError> { pub fn load(&self, id: EntryId) -> Result<Vec<u8>, RsliError> {
self.load_with_limits(id, DecodeLimits::default())
}
/// Loads and unpacks an entry with explicit decode limits.
///
/// # Errors
///
/// Returns [`RsliError`] when the packed payload exceeds configured
/// limits, `id` is invalid, or the payload cannot be decoded.
pub fn load_with_limits(
&self,
id: EntryId,
limits: DecodeLimits,
) -> Result<Vec<u8>, RsliError> {
let record = self.record_by_id(id)?; let record = self.record_by_id(id)?;
let packed = self.packed_slice(id, record)?; let packed = self.packed_slice(id, record)?;
decode_payload( decode_payload(
@@ -552,6 +648,7 @@ impl RsliDocument {
record.meta.method, record.meta.method,
record.key16, record.key16,
record.meta.unpacked_size, record.meta.unpacked_size,
limits,
) )
} }
@@ -651,6 +748,7 @@ impl RsliEditor {
/// Returns [`RsliMutationError`] when the entry id is unknown. /// Returns [`RsliMutationError`] when the entry id is unknown.
pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> { pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> {
let entry = self.entry_mut(id)?; let entry = self.entry_mut(id)?;
entry.meta.flags = flags_with_method(entry.meta.flags, method)?;
entry.meta.method = method; entry.meta.method = method;
self.dirty = true; self.dirty = true;
Ok(()) Ok(())
@@ -837,6 +935,7 @@ struct ParseOptions {
allow_ao_trailer: bool, allow_ao_trailer: bool,
allow_deflate_eof_plus_one: bool, allow_deflate_eof_plus_one: bool,
allow_invalid_presorted_fallback: bool, allow_invalid_presorted_fallback: bool,
limits: DecodeLimits,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -857,6 +956,10 @@ struct EntryRecord {
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliError> { fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliError> {
enforce_limit(
u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
options.limits.max_input_bytes,
)?;
if bytes.len() < 32 { if bytes.len() < 32 {
return Err(RsliError::EntryTableOutOfBounds { return Err(RsliError::EntryTableOutOfBounds {
table_offset: 32, table_offset: 32,
@@ -892,6 +995,10 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliErr
if count > usize::try_from(u32::MAX).map_err(|_| RsliError::IntegerOverflow)? { if count > usize::try_from(u32::MAX).map_err(|_| RsliError::IntegerOverflow)? {
return Err(RsliError::TooManyEntries { got: count }); return Err(RsliError::TooManyEntries { got: count });
} }
enforce_limit(
u64::try_from(count).map_err(|_| RsliError::IntegerOverflow)?,
u64::from(options.limits.max_entries),
)?;
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]); let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
@@ -935,6 +1042,14 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliErr
let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]); let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]);
let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]); let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]);
let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]); let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]);
enforce_limit(
u64::from(packed_size_declared),
options.limits.max_packed_entry_bytes,
)?;
enforce_limit(
u64::from(unpacked_size),
options.limits.max_decoded_entry_bytes,
)?;
let method_raw = u32::from(flags_signed.cast_unsigned()) & 0x1E0; let method_raw = u32::from(flags_signed.cast_unsigned()) & 0x1E0;
let method = parse_method(method_raw); let method = parse_method(method_raw);
@@ -1009,9 +1124,12 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliErr
} }
if presorted_flag == 0xABBA { if presorted_flag == 0xABBA {
if validate_permutation(&records).is_err() { let permutation = validate_permutation(&records);
let order = validate_lookup_order(&records);
if permutation.is_err() || order.is_err() {
if !options.allow_invalid_presorted_fallback { if !options.allow_invalid_presorted_fallback {
validate_permutation(&records)?; permutation?;
order?;
} }
rebuild_sorted_mapping(&mut records)?; rebuild_sorted_mapping(&mut records)?;
} }
@@ -1086,6 +1204,35 @@ fn validate_permutation(records: &[EntryRecord]) -> Result<(), RsliError> {
Ok(()) Ok(())
} }
fn validate_lookup_order(records: &[EntryRecord]) -> Result<(), RsliError> {
for pair in records.windows(2) {
let left_original = usize::try_from(i32::from(pair[0].meta.sort_to_original))
.map_err(|_| RsliError::IntegerOverflow)?;
let right_original = usize::try_from(i32::from(pair[1].meta.sort_to_original))
.map_err(|_| RsliError::IntegerOverflow)?;
let left = records
.get(left_original)
.ok_or(RsliError::CorruptEntryTable(
"sort_to_original is not a permutation",
))?;
let right = records
.get(right_original)
.ok_or(RsliError::CorruptEntryTable(
"sort_to_original is not a permutation",
))?;
if cmp_c_string(
c_name_bytes(&left.meta.name_raw),
c_name_bytes(&right.meta.name_raw),
) == std::cmp::Ordering::Greater
{
return Err(RsliError::CorruptEntryTable(
"presorted lookup names are not sorted",
));
}
}
Ok(())
}
fn parse_method(raw: u32) -> RsliMethod { fn parse_method(raw: u32) -> RsliMethod {
match raw { match raw {
0x000 => RsliMethod::Stored, 0x000 => RsliMethod::Stored,
@@ -1147,32 +1294,39 @@ fn decode_payload(
method: RsliMethod, method: RsliMethod,
key16: u16, key16: u16,
unpacked_size: u32, unpacked_size: u32,
limits: DecodeLimits,
) -> Result<Vec<u8>, RsliError> { ) -> Result<Vec<u8>, RsliError> {
enforce_limit(
u64::try_from(packed.len()).map_err(|_| RsliError::IntegerOverflow)?,
limits.max_packed_entry_bytes,
)?;
enforce_limit(u64::from(unpacked_size), limits.max_decoded_entry_bytes)?;
enforce_limit(u64::from(unpacked_size), limits.max_total_decoded_bytes)?;
let expected = usize::try_from(unpacked_size).map_err(|_| RsliError::IntegerOverflow)?; let expected = usize::try_from(unpacked_size).map_err(|_| RsliError::IntegerOverflow)?;
let out = match method { let out = match method {
RsliMethod::Stored => { RsliMethod::Stored => {
if packed.len() < expected { if packed.len() != expected {
return Err(RsliError::OutputSizeMismatch { return Err(RsliError::OutputSizeMismatch {
expected: unpacked_size, expected: unpacked_size,
got: u32::try_from(packed.len()).unwrap_or(u32::MAX), got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
}); });
} }
packed[..expected].to_vec() packed.to_vec()
} }
RsliMethod::XorOnly => { RsliMethod::XorOnly => {
if packed.len() < expected { if packed.len() != expected {
return Err(RsliError::OutputSizeMismatch { return Err(RsliError::OutputSizeMismatch {
expected: unpacked_size, expected: unpacked_size,
got: u32::try_from(packed.len()).unwrap_or(u32::MAX), got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
}); });
} }
xor_stream(&packed[..expected], key16) xor_stream(packed, key16)
} }
RsliMethod::Lzss => lzss_decompress_simple(packed, expected, None)?, RsliMethod::Lzss => lzss_decompress_simple(packed, expected, None)?,
RsliMethod::XorLzss => lzss_decompress_simple(packed, expected, Some(key16))?, RsliMethod::XorLzss => lzss_decompress_simple(packed, expected, Some(key16))?,
RsliMethod::AdaptiveLzss => lzss_huffman_decompress(packed, expected, None)?, RsliMethod::AdaptiveLzss => lzss_huffman_decompress(packed, expected, None)?,
RsliMethod::XorAdaptiveLzss => lzss_huffman_decompress(packed, expected, Some(key16))?, RsliMethod::XorAdaptiveLzss => lzss_huffman_decompress(packed, expected, Some(key16))?,
RsliMethod::RawDeflate => decode_deflate(packed)?, RsliMethod::RawDeflate => decode_deflate(packed, expected)?,
RsliMethod::Unknown(raw) => return Err(RsliError::UnsupportedMethod { raw }), RsliMethod::Unknown(raw) => return Err(RsliError::UnsupportedMethod { raw }),
}; };
if out.len() != expected { if out.len() != expected {
@@ -1276,15 +1430,61 @@ fn read_packed_byte(data: &[u8], pos: usize, state: &mut Option<XorState>) -> Op
}) })
} }
fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>, RsliError> { fn decode_deflate(packed: &[u8], expected_size: usize) -> Result<Vec<u8>, RsliError> {
let mut out = Vec::new(); let mut out = Vec::with_capacity(expected_size);
let mut chunk = [0u8; 4096];
let mut decoder = flate2::read::DeflateDecoder::new(packed); let mut decoder = flate2::read::DeflateDecoder::new(packed);
decoder loop {
.read_to_end(&mut out) let read = decoder
.map_err(|_| RsliError::DecompressionFailed("deflate"))?; .read(&mut chunk)
.map_err(|_| RsliError::DecompressionFailed("deflate"))?;
if read == 0 {
break;
}
let next_len = out
.len()
.checked_add(read)
.ok_or(RsliError::IntegerOverflow)?;
if next_len > expected_size {
return Err(RsliError::OutputSizeMismatch {
expected: u32::try_from(expected_size).unwrap_or(u32::MAX),
got: u32::try_from(next_len).unwrap_or(u32::MAX),
});
}
out.extend_from_slice(&chunk[..read]);
}
Ok(out) Ok(out)
} }
fn method_bits(method: RsliMethod) -> Result<u16, RsliMutationError> {
match method {
RsliMethod::Stored => Ok(0x000),
RsliMethod::XorOnly => Ok(0x020),
RsliMethod::Lzss => Ok(0x040),
RsliMethod::XorLzss => Ok(0x060),
RsliMethod::AdaptiveLzss => Ok(0x080),
RsliMethod::XorAdaptiveLzss => Ok(0x0A0),
RsliMethod::RawDeflate => Ok(0x100),
RsliMethod::Unknown(_) => Err(RsliMutationError::UnsupportedMethod { method }),
}
}
fn flags_with_method(flags: i32, method: RsliMethod) -> Result<i32, RsliMutationError> {
let method = i32::from(method_bits(method)?);
Ok((flags & !0x1E0) | method)
}
fn enforce_limit(value: u64, limit: u64) -> Result<(), RsliError> {
if value > limit {
return Err(DecodeError::LimitExceeded {
count: value,
limit,
}
.into());
}
Ok(())
}
const LZH_N: usize = 4096; const LZH_N: usize = 4096;
const LZH_F: usize = 60; const LZH_F: usize = 60;
const LZH_THRESHOLD: usize = 2; const LZH_THRESHOLD: usize = 2;
@@ -1702,6 +1902,30 @@ mod tests {
assert_eq!(doc.find("B"), Some(EntryId(0))); assert_eq!(doc.find("B"), Some(EntryId(0)));
} }
#[test]
fn strict_rejects_unsorted_presorted_mapping() {
let bytes = synthetic_rsli(
&[
SyntheticEntry::stored(b"B", 0, b"bee"),
SyntheticEntry::stored(b"A", 1, b"aye"),
],
true,
0x0103,
None,
);
assert!(matches!(
decode(arc(bytes.clone()), ReadProfile::Strict),
Err(RsliError::CorruptEntryTable(
"presorted lookup names are not sorted"
))
));
let doc = decode(arc(bytes), ReadProfile::Compatible).expect("compatible fallback");
assert_eq!(doc.find("A"), Some(EntryId(1)));
assert_eq!(doc.find("B"), Some(EntryId(0)));
}
#[test] #[test]
fn explicit_profile_controls_invalid_presorted_fallback() { fn explicit_profile_controls_invalid_presorted_fallback() {
let bytes = synthetic_rsli( let bytes = synthetic_rsli(
@@ -1756,8 +1980,8 @@ mod tests {
let packed = xor_stream(&plain, 1); let packed = xor_stream(&plain, 1);
let bytes = synthetic_rsli( let bytes = synthetic_rsli(
&[ &[
SyntheticEntry::with_payload(b"A", 0x020, 1, &plain, packed), SyntheticEntry::with_payload(b"B", 0x020, 1, &plain, packed),
SyntheticEntry::stored(b"B", 0, b"plain"), SyntheticEntry::stored(b"A", 0, b"plain"),
], ],
true, true,
0x2222, 0x2222,
@@ -2141,8 +2365,9 @@ mod tests {
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive"); let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
let mut editor = doc.editor().expect("editor"); let mut editor = doc.editor().expect("editor");
editor.set_name(EntryId(1), b"ZETA").expect("edit name"); editor.set_name(EntryId(1), b"ZETA").expect("edit name");
let repacked = deflate_bytes(b"repacked-alpha");
editor editor
.set_packed_payload(EntryId(0), b"repacked-alpha", 14) .set_packed_payload(EntryId(0), repacked, 14)
.expect("edit packed payload"); .expect("edit packed payload");
editor editor
.set_method(EntryId(0), RsliMethod::RawDeflate) .set_method(EntryId(0), RsliMethod::RawDeflate)
@@ -2163,10 +2388,106 @@ mod tests {
); );
assert_eq!( assert_eq!(
doc.entries()[original.0 as usize].method, doc.entries()[original.0 as usize].method,
RsliMethod::Stored RsliMethod::RawDeflate
); );
} }
#[test]
fn set_method_rejects_unknown_authoring_method() {
let bytes = synthetic_rsli(
&[SyntheticEntry::stored(b"A", 0, b"alpha")],
true,
0x7780,
None,
);
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
let mut editor = doc.editor().expect("editor");
assert!(matches!(
editor.set_method(EntryId(0), RsliMethod::Unknown(0x1E0)),
Err(RsliMutationError::UnsupportedMethod { .. })
));
}
#[test]
fn decode_rejects_entry_count_above_limit() {
let bytes = synthetic_rsli(
&[
SyntheticEntry::stored(b"A", 0, b"alpha"),
SyntheticEntry::stored(b"B", 1, b"beta"),
],
true,
0x7781,
None,
);
assert!(matches!(
decode_with_limits(
arc(bytes),
ReadProfile::Strict,
DecodeLimits {
max_entries: 1,
..DecodeLimits::default()
}
),
Err(RsliError::Binary(DecodeError::LimitExceeded {
count: 2,
limit: 1
}))
));
}
#[test]
fn stored_entries_require_exact_packed_size() {
let bytes = synthetic_rsli(
&[SyntheticEntry::with_payload(
b"A",
0x000,
0,
b"ok",
b"ok!".to_vec(),
)],
true,
0x7782,
None,
);
let doc = decode(arc(bytes), ReadProfile::Strict).expect("stored archive");
assert!(matches!(
doc.load(EntryId(0)),
Err(RsliError::OutputSizeMismatch {
expected: 2,
got: 3
})
));
}
#[test]
fn load_rejects_unpacked_size_above_limit_before_allocation() {
let bytes = synthetic_rsli(
&[SyntheticEntry::stored(b"A", 0, b"alpha")],
true,
0x7783,
None,
);
let doc = decode(arc(bytes), ReadProfile::Strict).expect("stored archive");
assert!(matches!(
doc.load_with_limits(
EntryId(0),
DecodeLimits {
max_decoded_entry_bytes: 4,
max_total_decoded_bytes: 4,
..DecodeLimits::default()
}
),
Err(RsliError::Binary(DecodeError::LimitExceeded {
count: 5,
limit: 4
}))
));
}
#[test] #[test]
fn editor_rejects_unknown_entry_id_and_invalid_name() { fn editor_rejects_unknown_entry_id_and_invalid_name() {
let bytes = synthetic_rsli( let bytes = synthetic_rsli(
@@ -2637,6 +2958,15 @@ mod tests {
bytes bytes
} }
fn deflate_bytes(plain: &[u8]) -> Vec<u8> {
use std::io::Write;
let mut encoder =
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast());
encoder.write_all(plain).expect("deflate write");
encoder.finish().expect("deflate finish")
}
fn two_plain_rows_for_transform_test() -> Vec<[u8; 32]> { fn two_plain_rows_for_transform_test() -> Vec<[u8; 32]> {
let mut a = [0u8; 32]; let mut a = [0u8; 32];
let mut b = [0u8; 32]; let mut b = [0u8; 32];
+10 -8
View File
@@ -6,14 +6,16 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-assets = { path = "../fparkan-assets" } fparkan-assets = { path = "../fparkan-assets", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
fparkan-platform = { path = "../fparkan-platform" } fparkan-platform = { path = "../fparkan-platform", version = "0.1.0" }
fparkan-prototype = { path = "../fparkan-prototype" } fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
fparkan-render = { path = "../fparkan-render" } fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
fparkan-resource = { path = "../fparkan-resource" } fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
fparkan-vfs = { path = "../fparkan-vfs" } fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
fparkan-world = { path = "../fparkan-world" } 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" }
[lints] [lints]
workspace = true workspace = true
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
+2 -2
View File
@@ -6,8 +6,8 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" } fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+256 -1
View File
@@ -62,6 +62,69 @@ pub struct CompactSurfaceMask(pub u16);
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MaterialClassMask(pub u8); pub struct MaterialClassMask(pub u8);
/// The two positional material-table selectors packed into a terrain face tag.
///
/// The high byte selects from map-local `Land1.wea`; `0xff` is the observed
/// no-selection sentinel. The low byte selects from `Land2.wea`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialLayers {
/// Optional high-byte selector for `Land1.wea`.
pub land1_selector: Option<u8>,
/// Low-byte selector for `Land2.wea`.
pub land2_selector: u8,
}
/// One `World3D` material-manager lookup key.
///
/// The original manager selects a WEAR table in the upper 16 bits and its
/// positional material row in the lower 16 bits.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialSelection {
/// Zero-based material-manager table index.
pub table_index: u16,
/// Positional material row in that WEAR table.
pub material_index: u16,
}
impl TerrainMaterialSelection {
/// Encodes the original material-manager phase lookup key.
#[must_use]
pub fn phase_key(self) -> u32 {
(u32::from(self.table_index) << 16) | u32::from(self.material_index)
}
}
impl TerrainMaterialLayers {
/// Constructs the decoded pair from its on-disk packed tag.
#[must_use]
pub const fn from_packed_tag(material_tag: u16) -> Self {
let [land2_selector, land1] = material_tag.to_le_bytes();
Self {
land1_selector: if land1 == u8::MAX { None } else { Some(land1) },
land2_selector,
}
}
/// Returns the table-zero `Land1.wea` selection when it exists.
#[must_use]
pub fn land1_selection(self) -> Option<TerrainMaterialSelection> {
self.land1_selector
.map(|material_index| TerrainMaterialSelection {
table_index: 0,
material_index: u16::from(material_index),
})
}
/// Returns the table-one `Land2.wea` selection.
#[must_use]
pub fn land2_selection(self) -> TerrainMaterialSelection {
TerrainMaterialSelection {
table_index: 1,
material_index: u16::from(self.land2_selector),
}
}
}
/// Terrain face with 28-byte source layout. /// Terrain face with 28-byte source layout.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerrainFace28 { pub struct TerrainFace28 {
@@ -81,6 +144,14 @@ pub struct TerrainFace28 {
pub raw: [u8; 28], pub raw: [u8; 28],
} }
impl TerrainFace28 {
/// Decodes the proven two-table selector layout of [`Self::material_tag`].
#[must_use]
pub const fn material_layers(&self) -> TerrainMaterialLayers {
TerrainMaterialLayers::from_packed_tag(self.material_tag)
}
}
/// Terrain stream descriptor. /// Terrain stream descriptor.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerrainStream { pub struct TerrainStream {
@@ -112,6 +183,59 @@ pub struct TerrainSlotTable {
pub slots_raw: Vec<[u8; SLOT_STRIDE]>, pub slots_raw: Vec<[u8; SLOT_STRIDE]>,
} }
/// The proven front fields of one 68-byte terrain slot record.
///
/// `Terrain.dll!CLandscape` supplies these fields to `GetShade`'s terrain
/// batch dispatcher: `pair_table_index` selects the pointer-table entry and
/// `pair_count` is the number of adjacent `u16` pairs to process. The pair
/// payload and the later material/blend interpretation remain external to the
/// disk record.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainSlotRenderDispatch {
/// Index into the runtime pair-pointer table.
pub pair_table_index: u16,
/// Number of adjacent `u16` pairs in that selected payload.
pub pair_count: u16,
}
/// One pair consumed by the original terrain material-batch dispatcher.
///
/// These four bytes live in the type-11 stream. `material_lookup` is handed to
/// `GetShade`'s material lookup, while bit `0x10` of `flags` creates a batch
/// boundary. Other flag bits remain raw.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialPair {
/// Lookup id supplied to the shade material manager.
pub material_lookup: u16,
/// Opaque pair flags; bit `0x10` is a proven batch boundary.
pub flags: u16,
}
impl TerrainMaterialPair {
/// Returns the opaque lookup key supplied to `GetShade`.
///
/// This is deliberately separate from the `Land1.wea`/`Land2.wea`
/// selectors carried by [`TerrainMaterialLayers`]. Although `GetShade`
/// owns a manager loaded from `Shade.wea`, this key is not a WEAR row:
/// observed map values exceed that table's row count.
#[must_use]
pub const fn shade_lookup_key(self) -> u16 {
self.material_lookup
}
}
impl TerrainSlotTable {
/// Returns the render-dispatch header for one decoded slot record.
#[must_use]
pub fn render_dispatch(&self, slot_index: usize) -> Option<TerrainSlotRenderDispatch> {
let raw = self.slots_raw.get(slot_index)?;
Some(TerrainSlotRenderDispatch {
pair_table_index: u16::from_le_bytes([raw[0], raw[1]]),
pair_count: u16::from_le_bytes([raw[2], raw[3]]),
})
}
}
/// Land mesh document. /// Land mesh document.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct LandMeshDocument { pub struct LandMeshDocument {
@@ -131,12 +255,38 @@ pub struct LandMeshDocument {
pub accelerator: Vec<[u8; 4]>, pub accelerator: Vec<[u8; 4]>,
/// Type 14 auxiliary words. /// Type 14 auxiliary words.
pub aux14: Vec<[u8; 4]>, pub aux14: Vec<[u8; 4]>,
/// Type 18 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]>, pub aux18: Vec<[u8; 4]>,
/// Faces. /// Faces.
pub faces: Vec<TerrainFace28>, pub faces: Vec<TerrainFace28>,
} }
impl LandMeshDocument {
/// Resolves the type-11 material pairs addressed by one 68-byte slot.
///
/// Returns `None` when the slot is absent or its pair range does not fit
/// the retained type-11 stream.
#[must_use]
pub fn slot_material_pairs(&self, slot_index: usize) -> Option<Vec<TerrainMaterialPair>> {
let dispatch = self.slots.render_dispatch(slot_index)?;
let start = usize::from(dispatch.pair_table_index);
let end = start.checked_add(usize::from(dispatch.pair_count))?;
let pairs = self.accelerator.get(start..end)?;
Some(
pairs
.iter()
.map(|raw| TerrainMaterialPair {
material_lookup: u16::from_le_bytes([raw[0], raw[1]]),
flags: u16::from_le_bytes([raw[2], raw[3]]),
})
.collect(),
)
}
}
/// Decoded `Land.map` document. /// Decoded `Land.map` document.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct LandMapDocument { pub struct LandMapDocument {
@@ -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] #[test]
fn decodes_minimal_land_map() { fn decodes_minimal_land_map() {
let nres = decode_nres(&minimal_land_map([(-1, -1), (-1, -1)], 0)).expect("nres"); let nres = decode_nres(&minimal_land_map([(-1, -1), (-1, -1)], 0)).expect("nres");
+2 -2
View File
@@ -6,10 +6,10 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-terrain-format = { path = "../fparkan-terrain-format" } fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
[dev-dependencies] [dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+323 -39
View File
@@ -18,11 +18,17 @@
clippy::unwrap_used 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 fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
use std::collections::VecDeque; use std::collections::VecDeque;
/// Terrain material-selector contract preserved from the decoded map mesh.
///
/// Applications access this semantic terrain API through this terrain crate
/// rather than taking a direct dependency on the binary-format parser.
pub use fparkan_terrain_format::{TerrainMaterialLayers, TerrainMaterialSelection};
/// Terrain world. /// Terrain world.
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct TerrainWorld { pub struct TerrainWorld {
@@ -30,12 +36,14 @@ pub struct TerrainWorld {
grid: RuntimeGrid, grid: RuntimeGrid,
adjacency: Vec<Vec<ArealId>>, adjacency: Vec<Vec<ArealId>>,
surfaces: Vec<RuntimeTriangle>, surfaces: Vec<RuntimeTriangle>,
surface_bvh: SurfaceBvh,
source_mesh: Option<LandMeshDocument>,
} }
/// Surface hit. /// Surface hit.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct SurfaceHit { pub struct SurfaceHit {
/// Height. /// Source Z height.
pub height: f32, pub height: f32,
/// Hit position. /// Hit position.
pub position: [f32; 3], pub position: [f32; 3],
@@ -134,7 +142,7 @@ impl std::error::Error for TerrainError {}
/// Surface query. /// Surface query.
pub trait SurfaceQuery { pub trait SurfaceQuery {
/// Height at position. /// Source Z height at an XY ground position.
/// ///
/// # Errors /// # Errors
/// ///
@@ -156,7 +164,7 @@ pub trait SurfaceQuery {
/// Navigation query. /// Navigation query.
pub trait NavigationQuery { pub trait NavigationQuery {
/// Locate areal. /// Locates an areal from the XY components of a world position.
/// ///
/// # Errors /// # Errors
/// ///
@@ -194,7 +202,7 @@ impl TerrainWorld {
polygon: areal polygon: areal
.vertices .vertices
.iter() .iter()
.map(|vertex| [vertex[0], vertex[2]]) .map(|vertex| [vertex[0], vertex[1]])
.collect(), .collect(),
}); });
} }
@@ -232,6 +240,8 @@ impl TerrainWorld {
grid, grid,
adjacency, adjacency,
surfaces: Vec::new(), 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> { pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> {
Ok(Self { Ok(Self {
surfaces: build_surfaces(mesh)?, surfaces: build_surfaces(mesh)?,
surface_bvh: SurfaceBvh::default(),
source_mesh: Some(mesh.clone()),
..Self::default() ..Self::default()
}) }
.with_surface_bvh())
} }
/// Builds terrain runtime data from decoded `Land.msh` and `Land.map`. /// Builds terrain runtime data from decoded `Land.msh` and `Land.map`.
@@ -260,6 +273,8 @@ impl TerrainWorld {
) -> Result<Self, TerrainError> { ) -> Result<Self, TerrainError> {
let mut world = Self::from_land_map(map)?; let mut world = Self::from_land_map(map)?;
world.surfaces = build_surfaces(mesh)?; world.surfaces = build_surfaces(mesh)?;
world.surface_bvh = SurfaceBvh::from_surfaces(&world.surfaces);
world.source_mesh = Some(mesh.clone());
Ok(world) Ok(world)
} }
@@ -275,12 +290,35 @@ impl TerrainWorld {
self.surfaces.len() self.surfaces.len()
} }
/// Returns the validated source mesh retained for renderer integration.
///
/// Runtime collision queries use their own compact triangle representation;
/// keeping this document preserves the original geometry and UV streams for
/// rendering without asking the application layer to decode an archive again.
#[must_use]
pub fn source_mesh(&self) -> Option<&LandMeshDocument> {
self.source_mesh.as_ref()
}
/// Returns source terrain positions without exposing parser ownership to apps.
#[must_use]
pub fn source_positions(&self) -> Option<&[[f32; 3]]> {
self.source_mesh
.as_ref()
.map(|mesh| mesh.positions.as_slice())
}
fn with_surface_bvh(mut self) -> Self {
self.surface_bvh = SurfaceBvh::from_surfaces(&self.surfaces);
self
}
fn locate_by_candidates( fn locate_by_candidates(
&self, &self,
position: [f32; 3], position: [f32; 3],
candidates: &[ArealId], candidates: &[ArealId],
) -> Result<Option<ArealId>, TerrainError> { ) -> Result<Option<ArealId>, TerrainError> {
let point = [position[0], position[2]]; let point = [position[0], position[1]];
for candidate in candidates { for candidate in candidates {
let Some(areal) = usize::try_from(candidate.0) let Some(areal) = usize::try_from(candidate.0)
.ok() .ok()
@@ -371,7 +409,8 @@ impl SurfaceQuery for TerrainWorld {
return Err(TerrainError::Unsupported); return Err(TerrainError::Unsupported);
} }
let mut best = None; let mut best = None;
for triangle in &self.surfaces { for index in self.surface_bvh.xy_candidates(position) {
let triangle = &self.surfaces[index];
if let Some(height) = triangle.height_at(position) { if let Some(height) = triangle.height_at(position) {
best = Some(best.map_or(height, |current: f32| current.max(height))); best = Some(best.map_or(height, |current: f32| current.max(height)));
} }
@@ -388,8 +427,11 @@ impl SurfaceQuery for TerrainWorld {
if self.surfaces.is_empty() { if self.surfaces.is_empty() {
return Err(TerrainError::Unsupported); return Err(TerrainError::Unsupported);
} }
let mut candidates = self.surface_bvh.ray_candidates(origin, direction);
candidates.sort_unstable();
let mut best: Option<SurfaceHit> = None; let mut best: Option<SurfaceHit> = None;
for triangle in &self.surfaces { for index in candidates {
let triangle = &self.surfaces[index];
if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 { if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
continue; continue;
} }
@@ -405,7 +447,7 @@ impl SurfaceQuery for TerrainWorld {
origin[2] + direction[2] * distance, origin[2] + direction[2] * distance,
]; ];
best = Some(SurfaceHit { best = Some(SurfaceHit {
height: position[1], height: position[2],
position, position,
distance, distance,
face: triangle.face, face: triangle.face,
@@ -444,20 +486,216 @@ struct RuntimeTriangle {
vertices: [[f32; 3]; 3], vertices: [[f32; 3]; 3],
} }
const SURFACE_BVH_LEAF_SIZE: usize = 8;
const SURFACE_BOUNDS_EPSILON: f32 = 1.0e-4;
/// Deterministic CPU index over validated terrain triangles.
///
/// The index changes only candidate selection: triangle math and observable
/// tie-breaking remain in [`SurfaceQuery`] above.
#[derive(Clone, Debug, Default)]
struct SurfaceBvh {
nodes: Vec<SurfaceBvhNode>,
triangle_indices: Vec<usize>,
root: Option<usize>,
}
#[derive(Clone, Debug)]
struct SurfaceBvhNode {
min: [f32; 3],
max: [f32; 3],
kind: SurfaceBvhNodeKind,
}
#[derive(Clone, Debug)]
enum SurfaceBvhNodeKind {
Leaf { first: usize, count: usize },
Branch { left: usize, right: usize },
}
impl SurfaceBvh {
fn from_surfaces(surfaces: &[RuntimeTriangle]) -> Self {
if surfaces.is_empty() {
return Self::default();
}
let mut index = Self {
nodes: Vec::with_capacity(surfaces.len().saturating_mul(2)),
triangle_indices: Vec::with_capacity(surfaces.len()),
root: None,
};
let indices: Vec<usize> = (0..surfaces.len()).collect();
index.root = Some(index.build(surfaces, indices));
index
}
fn build(&mut self, surfaces: &[RuntimeTriangle], mut indices: Vec<usize>) -> usize {
let (min, max) = surface_bounds(surfaces, &indices);
if indices.len() <= SURFACE_BVH_LEAF_SIZE {
indices.sort_unstable();
let first = self.triangle_indices.len();
let count = indices.len();
self.triangle_indices.extend(indices);
let node = self.nodes.len();
self.nodes.push(SurfaceBvhNode {
min,
max,
kind: SurfaceBvhNodeKind::Leaf { first, count },
});
return node;
}
let axis = longest_axis(min, max);
indices.sort_by(|left, right| {
surface_centroid(&surfaces[*left])[axis]
.total_cmp(&surface_centroid(&surfaces[*right])[axis])
.then_with(|| left.cmp(right))
});
let right_indices = indices.split_off(indices.len() / 2);
let left = self.build(surfaces, indices);
let right = self.build(surfaces, right_indices);
let node = self.nodes.len();
self.nodes.push(SurfaceBvhNode {
min,
max,
kind: SurfaceBvhNodeKind::Branch { left, right },
});
node
}
fn xy_candidates(&self, point: [f32; 2]) -> Vec<usize> {
let mut candidates = Vec::new();
let Some(root) = self.root else {
return candidates;
};
let mut pending = vec![root];
while let Some(node_index) = pending.pop() {
let node = &self.nodes[node_index];
if !point_within_surface_bounds(point, node.min, node.max) {
continue;
}
match node.kind {
SurfaceBvhNodeKind::Leaf { first, count } => {
candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
}
SurfaceBvhNodeKind::Branch { left, right } => {
pending.push(right);
pending.push(left);
}
}
}
candidates
}
fn ray_candidates(&self, origin: [f32; 3], direction: [f32; 3]) -> Vec<usize> {
let mut candidates = Vec::new();
let Some(root) = self.root else {
return candidates;
};
let mut pending = vec![root];
while let Some(node_index) = pending.pop() {
let node = &self.nodes[node_index];
if !ray_intersects_surface_bounds(origin, direction, node.min, node.max) {
continue;
}
match node.kind {
SurfaceBvhNodeKind::Leaf { first, count } => {
candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
}
SurfaceBvhNodeKind::Branch { left, right } => {
pending.push(right);
pending.push(left);
}
}
}
candidates
}
}
fn surface_bounds(surfaces: &[RuntimeTriangle], indices: &[usize]) -> ([f32; 3], [f32; 3]) {
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
for index in indices {
for vertex in surfaces[*index].vertices {
for axis in 0..3 {
min[axis] = min[axis].min(vertex[axis]);
max[axis] = max[axis].max(vertex[axis]);
}
}
}
(min, max)
}
fn surface_centroid(triangle: &RuntimeTriangle) -> [f32; 3] {
let mut centroid = [0.0; 3];
for vertex in triangle.vertices {
for axis in 0..3 {
centroid[axis] += vertex[axis] / 3.0;
}
}
centroid
}
fn longest_axis(min: [f32; 3], max: [f32; 3]) -> usize {
let extent = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
if extent[1] > extent[0] && extent[1] >= extent[2] {
1
} else if extent[2] > extent[0] && extent[2] > extent[1] {
2
} else {
0
}
}
fn point_within_surface_bounds(point: [f32; 2], min: [f32; 3], max: [f32; 3]) -> bool {
point[0] >= min[0] - SURFACE_BOUNDS_EPSILON
&& point[0] <= max[0] + SURFACE_BOUNDS_EPSILON
&& point[1] >= min[1] - SURFACE_BOUNDS_EPSILON
&& point[1] <= max[1] + SURFACE_BOUNDS_EPSILON
}
fn ray_intersects_surface_bounds(
origin: [f32; 3],
direction: [f32; 3],
min: [f32; 3],
max: [f32; 3],
) -> bool {
let mut near = 0.0_f32;
let mut far = f32::INFINITY;
for axis in 0..3 {
if direction[axis].abs() <= f32::EPSILON {
if origin[axis] < min[axis] - SURFACE_BOUNDS_EPSILON
|| origin[axis] > max[axis] + SURFACE_BOUNDS_EPSILON
{
return false;
}
continue;
}
let inverse = 1.0 / direction[axis];
let first = (min[axis] - origin[axis]) * inverse;
let second = (max[axis] - origin[axis]) * inverse;
near = near.max(first.min(second));
far = far.min(first.max(second));
if far < near {
return false;
}
}
far >= 0.0
}
impl RuntimeTriangle { impl RuntimeTriangle {
fn height_at(&self, position: [f32; 2]) -> Option<f32> { fn height_at(&self, position: [f32; 2]) -> Option<f32> {
let a = [self.vertices[0][0], self.vertices[0][2]]; let a = [self.vertices[0][0], self.vertices[0][1]];
let b = [self.vertices[1][0], self.vertices[1][2]]; let b = [self.vertices[1][0], self.vertices[1][1]];
let c = [self.vertices[2][0], self.vertices[2][2]]; let c = [self.vertices[2][0], self.vertices[2][1]];
let weights = barycentric_2d(position, a, b, c)?; let weights = barycentric_2d(position, a, b, c)?;
if weights if weights
.iter() .iter()
.all(|weight| *weight >= -1.0e-4 && *weight <= 1.0001) .all(|weight| *weight >= -1.0e-4 && *weight <= 1.0001)
{ {
Some( Some(
weights[0] * self.vertices[0][1] weights[0] * self.vertices[0][2]
+ weights[1] * self.vertices[1][1] + weights[1] * self.vertices[1][2]
+ weights[2] * self.vertices[2][1], + weights[2] * self.vertices[2][2],
) )
} else { } else {
None None
@@ -548,9 +786,9 @@ impl RuntimeGrid {
for areal in &map.areals { for areal in &map.areals {
for vertex in &areal.vertices { for vertex in &areal.vertices {
bounds_min[0] = bounds_min[0].min(vertex[0]); bounds_min[0] = bounds_min[0].min(vertex[0]);
bounds_min[1] = bounds_min[1].min(vertex[2]); bounds_min[1] = bounds_min[1].min(vertex[1]);
bounds_max[0] = bounds_max[0].max(vertex[0]); bounds_max[0] = bounds_max[0].max(vertex[0]);
bounds_max[1] = bounds_max[1].max(vertex[2]); bounds_max[1] = bounds_max[1].max(vertex[1]);
} }
} }
if !bounds_min[0].is_finite() 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() { if self.cells_x == 0 || self.cells_y == 0 || self.cells.is_empty() {
return None; return None;
} }
let point = [position[0], position[2]]; let point = [position[0], position[1]];
if point[0] < self.min[0] if point[0] < self.min[0]
|| point[0] > self.max[0] || point[0] > self.max[0]
|| point[1] < self.min[1] || point[1] < self.min[1]
@@ -748,18 +986,18 @@ mod tests {
assert_eq!(world.areal_count(), 2); assert_eq!(world.areal_count(), 2);
assert_eq!( assert_eq!(
world.locate_areal([0.25, 0.0, 0.25]).expect("locate"), world.locate_areal([0.25, 0.25, 41.0]).expect("locate"),
Some(ArealId(0)) Some(ArealId(0))
); );
assert_eq!( 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)) Some(ArealId(1))
); );
assert_eq!( assert_eq!(
world world
.route(RouteRequest { .route(RouteRequest {
start: [0.25, 0.0, 0.25], start: [0.25, 0.25, 0.0],
goal: [1.75, 0.0, 0.25], goal: [1.75, 0.25, 0.0],
}) })
.expect("route"), .expect("route"),
Some(ArealRoute { Some(ArealRoute {
@@ -775,8 +1013,8 @@ mod tests {
assert_eq!( assert_eq!(
world world
.route(RouteRequest { .route(RouteRequest {
start: [10.0, 0.0, 10.0], start: [10.0, 10.0, 0.0],
goal: [1.75, 0.0, 0.25], goal: [1.75, 0.25, 0.0],
}) })
.expect("route"), .expect("route"),
None None
@@ -793,8 +1031,8 @@ mod tests {
let hit = world let hit = world
.raycast( .raycast(
[0.25, 2.0, 0.25], [0.25, 0.25, 2.0],
[0.0, -1.0, 0.0], [0.0, 0.0, -1.0],
FullSurfaceMask(0x0000_0001), FullSurfaceMask(0x0000_0001),
) )
.expect("raycast") .expect("raycast")
@@ -806,8 +1044,8 @@ mod tests {
assert_eq!( assert_eq!(
world world
.raycast( .raycast(
[0.25, 2.0, 0.25], [0.25, 0.25, 2.0],
[0.0, -1.0, 0.0], [0.0, 0.0, -1.0],
FullSurfaceMask(0x8000_0000) FullSurfaceMask(0x8000_0000)
) )
.expect("raycast"), .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] #[test]
#[ignore = "requires licensed corpus"] #[ignore = "requires licensed corpus"]
fn licensed_corpus_land_maps_build_navigation_worlds() { fn licensed_corpus_land_maps_build_navigation_worlds() {
@@ -879,6 +1141,7 @@ mod tests {
let root = corpus_root(corpus); let root = corpus_root(corpus);
let mut files = 0usize; let mut files = 0usize;
let mut faces = 0usize; let mut faces = 0usize;
let mut indexed_queries = 0usize;
for path in files_under(&root) { for path in files_under(&root) {
if !path if !path
.file_name() .file_name()
@@ -897,12 +1160,33 @@ mod tests {
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
let world = TerrainWorld::from_land_msh(&mesh) let world = TerrainWorld::from_land_msh(&mesh)
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}")); .unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
if world.surface_count() > SURFACE_BVH_LEAF_SIZE {
let triangle = &world.surfaces[0];
let point = [
(triangle.vertices[0][0]
+ triangle.vertices[1][0]
+ triangle.vertices[2][0])
/ 3.0,
(triangle.vertices[0][1]
+ triangle.vertices[1][1]
+ triangle.vertices[2][1])
/ 3.0,
];
let candidates = world.surface_bvh.xy_candidates(point);
assert!(candidates.contains(&0), "{corpus} {path:?} face zero");
assert!(
candidates.len() < world.surface_count(),
"{corpus} {path:?} surface index did not reduce candidates"
);
indexed_queries += 1;
}
files += 1; files += 1;
faces += world.surface_count(); faces += world.surface_count();
} }
assert_eq!(files, expected_files, "{corpus} Land.msh count"); assert_eq!(files, expected_files, "{corpus} Land.msh count");
assert_eq!(faces, expected_faces, "{corpus} surface face count"); assert_eq!(faces, expected_faces, "{corpus} surface face count");
assert_eq!(indexed_queries, files, "{corpus} indexed Land.msh count");
} }
} }
@@ -920,9 +1204,9 @@ mod tests {
}, },
positions: vec![ positions: vec![
[0.0, 0.0, 0.0], [0.0, 0.0, 0.0],
[1.0, 1.0, 0.0], [1.0, 0.0, 1.0],
[0.0, 1.0, 1.0], [0.0, 1.0, 1.0],
[1.0, 2.0, 1.0], [1.0, 1.0, 2.0],
], ],
normals: Vec::new(), normals: Vec::new(),
uv0: Vec::new(), uv0: Vec::new(),
@@ -965,10 +1249,10 @@ mod tests {
areals: vec![ areals: vec![
Areal { Areal {
prefix_raw: [0; 56], prefix_raw: [0; 56],
anchor: [0.5, 0.0, 0.5], anchor: [0.5, 0.5, 0.0],
reserved_12: 0.0, reserved_12: 0.0,
area_metric: 1.0, area_metric: 1.0,
normal: [0.0, 1.0, 0.0], normal: [0.0, 0.0, 1.0],
logic_flag: 0, logic_flag: 0,
reserved_36: 0, reserved_36: 0,
class_id: 0, class_id: 0,
@@ -976,8 +1260,8 @@ mod tests {
vertices: vec![ vertices: vec![
[0.0, 0.0, 0.0], [0.0, 0.0, 0.0],
[1.0, 0.0, 0.0], [1.0, 0.0, 0.0],
[1.0, 0.0, 1.0], [1.0, 1.0, 0.0],
[0.0, 0.0, 1.0], [0.0, 1.0, 0.0],
], ],
links: vec![ links: vec![
EdgeLink { EdgeLink {
@@ -1009,10 +1293,10 @@ mod tests {
}, },
Areal { Areal {
prefix_raw: [0; 56], prefix_raw: [0; 56],
anchor: [1.5, 0.0, 0.5], anchor: [1.5, 0.5, 0.0],
reserved_12: 0.0, reserved_12: 0.0,
area_metric: 1.0, area_metric: 1.0,
normal: [0.0, 1.0, 0.0], normal: [0.0, 0.0, 1.0],
logic_flag: 0, logic_flag: 0,
reserved_36: 0, reserved_36: 0,
class_id: 0, class_id: 0,
@@ -1020,8 +1304,8 @@ mod tests {
vertices: vec![ vertices: vec![
[1.0, 0.0, 0.0], [1.0, 0.0, 0.0],
[2.0, 0.0, 0.0], [2.0, 0.0, 0.0],
[2.0, 0.0, 1.0], [2.0, 1.0, 0.0],
[1.0, 0.0, 1.0], [1.0, 1.0, 0.0],
], ],
links: vec![ links: vec![
EdgeLink { EdgeLink {
+1 -1
View File
@@ -6,7 +6,7 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-render = { path = "../fparkan-render" } fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+1 -1
View File
@@ -8,7 +8,7 @@ repository.workspace = true
[dependencies] [dependencies]
[dev-dependencies] [dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+2 -2
View File
@@ -6,8 +6,8 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" } fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+142 -86
View File
@@ -25,12 +25,13 @@ use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fs; use std::fs;
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
#[cfg(windows)] #[cfg(windows)]
use std::os::windows::fs::MetadataExt; use std::os::windows::fs::MetadataExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::Arc;
use std::time::SystemTime;
/// VFS metadata. /// VFS metadata.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -105,7 +106,6 @@ pub trait Vfs: Send + Sync {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct DirectoryVfs { pub struct DirectoryVfs {
root: PathBuf, root: PathBuf,
fingerprint_cache: Arc<Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>>,
} }
impl DirectoryVfs { impl DirectoryVfs {
@@ -114,32 +114,23 @@ impl DirectoryVfs {
pub fn new(root: impl AsRef<Path>) -> Self { pub fn new(root: impl AsRef<Path>) -> Self {
Self { Self {
root: root.as_ref().to_path_buf(), root: root.as_ref().to_path_buf(),
fingerprint_cache: Arc::default(),
} }
} }
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> { fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
join_under(&self.root, path).map_err(|_| VfsError::Path)?; join_under(&self.root, path).map_err(|_| VfsError::Path)?;
resolve_casefolded(&self.root, path.as_str()) resolve_casefolded(&self.root, path)
} }
fn metadata_from_host_file(&self, path: &Path) -> Result<VfsMetadata, VfsError> { fn metadata_from_host_file(path: &Path) -> Result<VfsMetadata, VfsError> {
let metadata = fs::symlink_metadata(path).map_err(VfsError::Io)?; let metadata = fs::symlink_metadata(path).map_err(VfsError::Io)?;
metadata_from_host_file_with_cache(path, &metadata, &self.fingerprint_cache) metadata_from_host_file(path, &metadata)
} }
} }
#[derive(Clone, Debug, Eq, PartialEq)]
struct CachedHostFingerprint {
len: u64,
modified: Option<SystemTime>,
identity: Option<u64>,
fingerprint: Sha256Digest,
}
impl Vfs for DirectoryVfs { impl Vfs for DirectoryVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> { fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
self.metadata_from_host_file(&self.host_path(path)?) Self::metadata_from_host_file(&self.host_path(path)?)
} }
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> { fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
@@ -171,21 +162,60 @@ impl Vfs for DirectoryVfs {
let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?; let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?;
entries.push(VfsEntry { entries.push(VfsEntry {
path: prefix.clone(), path: prefix.clone(),
metadata: metadata_from_host_file_with_cache( metadata: metadata_from_host_file(&base, &metadata)?,
&base,
&metadata,
&self.fingerprint_cache,
)?,
}); });
return Ok(entries); return Ok(entries);
} }
list_recursive(&self.root, &base, &self.fingerprint_cache, &mut entries)?; list_recursive(&self.root, &base, &mut entries)?;
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str())); entries.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
Ok(entries) Ok(entries)
} }
} }
fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError> { fn resolve_casefolded(root: &Path, normalized: &NormalizedPath) -> Result<PathBuf, VfsError> {
#[cfg(unix)]
{
return resolve_casefolded_unix(root, normalized);
}
#[cfg(not(unix))]
{
resolve_casefolded_text(root, normalized.display_lossy())
}
}
#[cfg(unix)]
fn resolve_casefolded_unix(root: &Path, normalized: &NormalizedPath) -> Result<PathBuf, VfsError> {
let mut current = root.to_path_buf();
for segment in normalized.as_bytes().split(|byte| *byte == b'/') {
current = resolve_casefolded_segment(&current, segment, normalized)?;
}
Ok(current)
}
#[cfg(unix)]
fn resolve_casefolded_segment(
dir: &Path,
segment: &[u8],
normalized: &NormalizedPath,
) -> Result<PathBuf, VfsError> {
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
let mut matches = Vec::new();
for entry in read_dir {
let entry = entry.map_err(VfsError::Io)?;
let name = entry.file_name();
if name.as_bytes().eq_ignore_ascii_case(segment) {
if entry.file_type().map_err(VfsError::Io)?.is_symlink() {
return Err(VfsError::Path);
}
matches.push(entry.path());
}
}
select_casefolded_match(normalized.display_lossy(), dir, segment, matches)
}
#[cfg(not(unix))]
fn resolve_casefolded_text(root: &Path, normalized: &str) -> Result<PathBuf, VfsError> {
let mut current = root.to_path_buf(); let mut current = root.to_path_buf();
for segment in normalized.split('/') { for segment in normalized.split('/') {
let read_dir = fs::read_dir(&current).map_err(VfsError::Io)?; let read_dir = fs::read_dir(&current).map_err(VfsError::Io)?;
@@ -211,10 +241,11 @@ fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError
fn select_casefolded_match( fn select_casefolded_match(
normalized: &str, normalized: &str,
current: &Path, current: &Path,
segment: &str, segment: impl AsRef<[u8]>,
mut matches: Vec<PathBuf>, mut matches: Vec<PathBuf>,
) -> Result<PathBuf, VfsError> { ) -> Result<PathBuf, VfsError> {
matches.sort(); matches.sort();
let segment = String::from_utf8_lossy(segment.as_ref());
match matches.len() { match matches.len() {
0 => Err(VfsError::NotFound(normalized.to_string())), 0 => Err(VfsError::NotFound(normalized.to_string())),
1 => Ok(matches.remove(0)), 1 => Ok(matches.remove(0)),
@@ -226,12 +257,7 @@ fn select_casefolded_match(
} }
} }
fn list_recursive( fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<(), VfsError> {
root: &Path,
dir: &Path,
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
out: &mut Vec<VfsEntry>,
) -> Result<(), VfsError> {
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?; let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
let mut children = Vec::new(); let mut children = Vec::new();
for entry in read_dir { for entry in read_dir {
@@ -245,68 +271,35 @@ fn list_recursive(
return Err(VfsError::Path); return Err(VfsError::Path);
} }
if metadata.is_dir() { if metadata.is_dir() {
list_recursive(root, &child, fingerprint_cache, out)?; list_recursive(root, &child, out)?;
continue; continue;
} }
if !metadata.is_file() { if !metadata.is_file() {
continue; continue;
} }
let rel = child.strip_prefix(root).map_err(|_| VfsError::Path)?; let rel = child.strip_prefix(root).map_err(|_| VfsError::Path)?;
let rel_text = rel.to_str().ok_or(VfsError::Path)?; #[cfg(unix)]
let path = fparkan_path::normalize_relative( let rel_bytes = rel.as_os_str().as_bytes();
rel_text.as_bytes(), #[cfg(not(unix))]
fparkan_path::PathPolicy::HostCompatible, let rel_bytes = rel.to_str().ok_or(VfsError::Path)?.as_bytes();
) let path =
.map_err(|_| VfsError::Path)?; fparkan_path::normalize_relative(rel_bytes, fparkan_path::PathPolicy::HostCompatible)
.map_err(|_| VfsError::Path)?;
out.push(VfsEntry { out.push(VfsEntry {
path, path,
metadata: metadata_from_host_file_with_cache(&child, &metadata, fingerprint_cache)?, metadata: metadata_from_host_file(&child, &metadata)?,
}); });
} }
Ok(()) Ok(())
} }
fn metadata_from_host_file_with_cache( fn metadata_from_host_file(path: &Path, metadata: &fs::Metadata) -> Result<VfsMetadata, VfsError> {
path: &Path,
metadata: &fs::Metadata,
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
) -> Result<VfsMetadata, VfsError> {
if !metadata.is_file() { if !metadata.is_file() {
return Err(VfsError::Path); return Err(VfsError::Path);
} }
let len = metadata.len(); let len = metadata.len();
let modified = metadata.modified().ok();
if let Some(cached) = fingerprint_cache
.lock()
.map_err(|_| VfsError::Path)?
.get(path)
.cloned()
.filter(|cached| {
cached.len == len
&& cached.modified == modified
&& cached.identity == file_identity(metadata)
})
{
return Ok(VfsMetadata {
len,
fingerprint: cached.fingerprint,
});
}
let bytes = fs::read(path).map_err(VfsError::Io)?; let bytes = fs::read(path).map_err(VfsError::Io)?;
let fingerprint = sha256(&bytes); let fingerprint = sha256(&bytes);
fingerprint_cache
.lock()
.map_err(|_| VfsError::Path)?
.insert(
path.to_path_buf(),
CachedHostFingerprint {
len,
modified,
identity: file_identity(metadata),
fingerprint,
},
);
Ok(VfsMetadata { len, fingerprint }) Ok(VfsMetadata { len, fingerprint })
} }
@@ -344,11 +337,11 @@ impl MemoryVfs {
let matches = self let matches = self
.lookup .lookup
.get(&key) .get(&key)
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?; .ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))?;
match matches.as_slice() { match matches.as_slice() {
[single] => Ok(single.as_slice()), [single] => Ok(single.as_slice()),
[] => Err(VfsError::NotFound(path.as_str().to_string())), [] => Err(VfsError::NotFound(path.display_lossy().to_string())),
_ => Err(VfsError::Ambiguous(path.as_str().to_string())), _ => Err(VfsError::Ambiguous(path.display_lossy().to_string())),
} }
} }
} }
@@ -362,10 +355,14 @@ fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
#[cfg(windows)] #[cfg(windows)]
#[allow(clippy::unnecessary_wraps)] #[allow(clippy::unnecessary_wraps)]
fn file_identity(metadata: &fs::Metadata) -> Option<u64> { fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
// Stable Rust does not expose the Windows file index. Keep a best-effort
// replacement detector from metadata that is available through the
// supported standard-library API; `read` also compares size and
// last-write time before returning bytes.
Some( Some(
(metadata.volume_serial_number() as u64).rotate_left(40) metadata.creation_time().rotate_left(17)
^ ((metadata.file_index_high() as u64) << 32) ^ metadata.last_write_time().rotate_left(31)
^ metadata.file_index_low() as u64, ^ u64::from(metadata.file_attributes()).rotate_left(47),
) )
} }
@@ -380,7 +377,7 @@ impl Vfs for MemoryVfs {
let bytes = self let bytes = self
.files .files
.get(resolved) .get(resolved)
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?; .ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))?;
Ok(VfsMetadata { Ok(VfsMetadata {
len: bytes.len() as u64, len: bytes.len() as u64,
fingerprint: sha256(bytes), fingerprint: sha256(bytes),
@@ -392,7 +389,7 @@ impl Vfs for MemoryVfs {
self.files self.files
.get(resolved) .get(resolved)
.cloned() .cloned()
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string())) .ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))
} }
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> { fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
@@ -476,7 +473,7 @@ impl Vfs for OverlayVfs {
Err(err) => return Err(err), Err(err) => return Err(err),
} }
} }
Err(VfsError::NotFound(path.as_str().to_string())) Err(VfsError::NotFound(path.display_lossy().to_string()))
} }
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> { fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
@@ -487,7 +484,7 @@ impl Vfs for OverlayVfs {
Err(err) => return Err(err), Err(err) => return Err(err),
} }
} }
Err(VfsError::NotFound(path.as_str().to_string())) Err(VfsError::NotFound(path.display_lossy().to_string()))
} }
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> { fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
@@ -496,7 +493,7 @@ impl Vfs for OverlayVfs {
match layer.list(prefix) { match layer.list(prefix) {
Ok(entries) => { Ok(entries) => {
for entry in entries { for entry in entries {
let key = entry.path.as_str().to_ascii_uppercase(); let key = ascii_lookup_key(entry.path.as_bytes()).0;
by_key.entry(key).or_insert(entry); by_key.entry(key).or_insert(entry);
} }
} }
@@ -505,7 +502,7 @@ impl Vfs for OverlayVfs {
} }
} }
let mut entries: Vec<_> = by_key.into_values().collect(); let mut entries: Vec<_> = by_key.into_values().collect();
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str())); entries.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
Ok(entries) Ok(entries)
} }
} }
@@ -514,6 +511,10 @@ impl Vfs for OverlayVfs {
mod tests { mod tests {
use super::*; use super::*;
use fparkan_path::{normalize_relative, PathPolicy}; use fparkan_path::{normalize_relative, PathPolicy};
#[cfg(unix)]
use std::ffi::OsString;
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
#[test] #[test]
fn directory_vfs_resolves_ascii_casefolded_segments() { fn directory_vfs_resolves_ascii_casefolded_segments() {
@@ -634,6 +635,33 @@ mod tests {
std::fs::remove_dir_all(outside).expect("cleanup outside"); std::fs::remove_dir_all(outside).expect("cleanup outside");
} }
#[cfg(unix)]
#[test]
fn directory_vfs_resolves_non_utf8_host_entries_by_raw_bytes() {
let root = unique_test_dir("non-utf8");
let data_dir = root.join("DATA");
std::fs::create_dir_all(&data_dir).expect("mkdir");
let file_name = OsString::from_vec(vec![0xFF, b'.', b'b', b'i', b'n']);
let raw_path = data_dir.join(&file_name);
if let Err(err) = std::fs::write(&raw_path, b"raw") {
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
std::fs::remove_dir_all(root).expect("cleanup");
return;
}
let vfs = DirectoryVfs::new(&root);
let path = normalize_relative(b"data/\xFF.bin", PathPolicy::HostCompatible).expect("path");
assert_eq!(vfs.read(&path).expect("read raw path").as_ref(), b"raw");
let entries = vfs
.list(&normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix"))
.expect("list");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path.identity_bytes(), b"DATA/\xFF.bin");
std::fs::remove_dir_all(root).expect("cleanup");
}
#[test] #[test]
fn casefold_selector_reports_ambiguous_segments() { fn casefold_selector_reports_ambiguous_segments() {
let err = select_casefolded_match( let err = select_casefolded_match(
@@ -714,6 +742,34 @@ mod tests {
assert_eq!(entries[0].metadata.len, 4); assert_eq!(entries[0].metadata.len, 4);
} }
#[test]
fn overlay_vfs_keeps_lossy_equivalent_entries_distinct() {
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
let mut high = MemoryVfs::default();
let mut low = MemoryVfs::default();
high.insert(
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("high path"),
Arc::from(b"high".as_slice()),
);
low.insert(
normalize_relative(b"DATA/\xFE.bin", PathPolicy::HostCompatible).expect("low path"),
Arc::from(b"low".as_slice()),
);
let overlay = OverlayVfs::from_layers(vec![Arc::new(high), Arc::new(low)]);
let entries = overlay.list(&prefix).expect("list");
assert_eq!(entries.len(), 2);
assert_eq!(
entries[0].path.display_lossy(),
entries[1].path.display_lossy()
);
assert_ne!(
entries[0].path.identity_bytes(),
entries[1].path.identity_bytes()
);
}
fn unique_test_dir(name: &str) -> PathBuf { fn unique_test_dir(name: &str) -> PathBuf {
let mut path = std::env::temp_dir(); let mut path = std::env::temp_dir();
path.push(format!("fparkan-vfs-{name}-{}", std::process::id())); path.push(format!("fparkan-vfs-{name}-{}", std::process::id()));
+1 -1
View File
@@ -6,7 +6,7 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" } fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
[lints] [lints]
workspace = true workspace = true
+113 -2
View File
@@ -23,7 +23,7 @@
use fparkan_binary::sha256; use fparkan_binary::sha256;
use std::collections::VecDeque; use std::collections::VecDeque;
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v2\0"; const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v3\0";
/// Object handle with generation. /// Object handle with generation.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -50,6 +50,20 @@ pub struct Tick(pub u64);
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StateHash(pub [u8; 32]); pub struct StateHash(pub [u8; 32]);
/// Exact source transform carried by one world object.
///
/// Values are IEEE-754 bit patterns so mission transforms retain their input
/// identity in deterministic state before a controller interprets them.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TransformState {
/// Position words in source axis order.
pub position: [u32; 3],
/// Orientation words in source axis order.
pub orientation: [u32; 3],
/// Non-uniform scale words in source axis order.
pub scale: [u32; 3],
}
/// World phase. /// World phase.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorldPhase { pub enum WorldPhase {
@@ -110,6 +124,8 @@ pub struct WorldSnapshot {
pub tick: Tick, pub tick: Tick,
/// Live object handles. /// Live object handles.
pub objects: Vec<ObjectHandle>, pub objects: Vec<ObjectHandle>,
/// Exact transforms for live registered objects in object-handle order.
pub transforms: Vec<(ObjectHandle, TransformState)>,
/// Commands processed during this step. /// Commands processed during this step.
pub events: Vec<WorldEvent>, pub events: Vec<WorldEvent>,
/// State hash. /// State hash.
@@ -167,6 +183,7 @@ struct Slot {
owner_id: Option<OwnerId>, owner_id: Option<OwnerId>,
mirror_id: Option<OriginalObjectId>, mirror_id: Option<OriginalObjectId>,
registration_sequence: Option<u64>, registration_sequence: Option<u64>,
transform: TransformState,
} }
/// World. /// World.
@@ -248,6 +265,7 @@ pub fn construct_object(world: &mut World, draft: ObjectDraft) -> Result<ObjectH
owner_id: None, owner_id: None,
mirror_id: None, mirror_id: None,
registration_sequence: None, registration_sequence: None,
transform: TransformState::default(),
}); });
Ok(handle) Ok(handle)
} }
@@ -335,6 +353,43 @@ pub fn identity_metadata(
}) })
} }
/// Sets the exact source transform for a live object.
///
/// # Errors
///
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
pub fn set_transform(
world: &mut World,
handle: ObjectHandle,
transform: TransformState,
) -> Result<(), WorldError> {
checked_slot_mut(world, handle)?.transform = transform;
Ok(())
}
/// Returns the exact source transform for a live object.
///
/// # Errors
///
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
pub fn transform_state(world: &World, handle: ObjectHandle) -> Result<TransformState, WorldError> {
Ok(checked_slot(world, handle)?.transform)
}
/// Returns the live registered handle carrying an original mission id.
#[must_use]
pub fn handle_by_original_id(world: &World, original_id: OriginalObjectId) -> Option<ObjectHandle> {
world.slots.iter().enumerate().find_map(|(index, slot)| {
let index = u32::try_from(index).ok()?;
(slot.live && slot.registered && slot.original_id == Some(original_id)).then_some(
ObjectHandle {
generation: slot.generation,
slot: index,
},
)
})
}
/// Requests deletion. /// Requests deletion.
/// ///
/// # Errors /// # Errors
@@ -423,6 +478,7 @@ where
let snapshot = WorldSnapshot { let snapshot = WorldSnapshot {
tick: world.tick, tick: world.tick,
objects: live_registered(world), objects: live_registered(world),
transforms: live_registered_with_transforms(world),
events, events,
hash: canonical_state_hash(world), hash: canonical_state_hash(world),
}; };
@@ -459,6 +515,7 @@ fn canonical_state_bytes(world: &World) -> Vec<u8> {
push_optional_u32(&mut out, slot.mirror_id.map(|id| id.0)); push_optional_u32(&mut out, slot.mirror_id.map(|id| id.0));
push_optional_u16(&mut out, slot.owner_id.map(|id| id.0)); push_optional_u16(&mut out, slot.owner_id.map(|id| id.0));
push_optional_u64(&mut out, slot.registration_sequence); push_optional_u64(&mut out, slot.registration_sequence);
push_transform(&mut out, slot.transform);
} }
push_len(&mut out, world.queue.len()); push_len(&mut out, world.queue.len());
for command in &world.queue { for command in &world.queue {
@@ -639,6 +696,17 @@ fn push_handle(out: &mut Vec<u8>, handle: ObjectHandle) {
push_u32(out, handle.slot); push_u32(out, handle.slot);
} }
fn push_transform(out: &mut Vec<u8>, transform: TransformState) {
for word in transform
.position
.into_iter()
.chain(transform.orientation)
.chain(transform.scale)
{
push_u32(out, word);
}
}
fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> { fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> {
let slot = world let slot = world
.slots .slots
@@ -689,6 +757,24 @@ fn live_registered(world: &World) -> Vec<ObjectHandle> {
.collect() .collect()
} }
fn live_registered_with_transforms(world: &World) -> Vec<(ObjectHandle, TransformState)> {
world
.slots
.iter()
.enumerate()
.filter_map(|(idx, slot)| {
let slot_index = u32::try_from(idx).ok()?;
(slot.live && slot.registered).then_some((
ObjectHandle {
generation: slot.generation,
slot: slot_index,
},
slot.transform,
))
})
.collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -704,6 +790,25 @@ mod tests {
assert_eq!(after.objects, vec![handle]); assert_eq!(after.objects, vec![handle]);
} }
#[test]
fn transform_is_exactly_snapshotted_and_changes_state_hash() {
let mut world = new(WorldConfig);
let handle =
construct_object(&mut world, ObjectDraft { original_id: None }).expect("object");
register_object(&mut world, handle).expect("register");
let before = step(&mut world, &InputSnapshot).expect("before");
let transform = TransformState {
position: [1.0_f32.to_bits(), (-2.0_f32).to_bits(), 3.0_f32.to_bits()],
orientation: [0.0_f32.to_bits(), 0.5_f32.to_bits(), (-0.25_f32).to_bits()],
scale: [1.0_f32.to_bits(), 2.0_f32.to_bits(), 0.5_f32.to_bits()],
};
set_transform(&mut world, handle, transform).expect("set transform");
let after = step(&mut world, &InputSnapshot).expect("after");
assert_eq!(transform_state(&world, handle), Ok(transform));
assert_eq!(after.transforms, vec![(handle, transform)]);
assert_ne!(before.hash, after.hash);
}
#[test] #[test]
fn registration_sequence_stale_and_duplicate_original_contracts() { fn registration_sequence_stale_and_duplicate_original_contracts() {
let mut world = new(WorldConfig); let mut world = new(WorldConfig);
@@ -723,10 +828,16 @@ mod tests {
.expect("second"); .expect("second");
register_object(&mut world, first).expect("register first"); register_object(&mut world, first).expect("register first");
register_object(&mut world, second).expect("register second"); register_object(&mut world, second).expect("register second");
assert_eq!(
handle_by_original_id(&world, OriginalObjectId(7)),
Some(first)
);
assert_eq!(handle_by_original_id(&world, OriginalObjectId(9)), None);
assert_eq!(registration_sequence(&world, first), Ok(Some(0))); assert_eq!(registration_sequence(&world, first), Ok(Some(0)));
assert_eq!(registration_sequence(&world, second), Ok(Some(1))); assert_eq!(registration_sequence(&world, second), Ok(Some(1)));
request_delete(&mut world, first).expect("delete"); request_delete(&mut world, first).expect("delete");
assert_eq!(handle_by_original_id(&world, OriginalObjectId(7)), None);
assert_eq!( assert_eq!(
register_object(&mut world, first), register_object(&mut world, first),
Err(WorldError::StaleHandle) Err(WorldError::StaleHandle)
@@ -1053,7 +1164,7 @@ mod tests {
handles.push(handle); handles.push(handle);
} }
for (index, handle) in handles.iter().copied().enumerate() { for (index, handle) in handles.iter().copied().enumerate() {
if (seed as usize + index) % 3 == 0 { if (seed as usize + index).is_multiple_of(3) {
request_delete(&mut world, handle).expect("delete"); request_delete(&mut world, handle).expect("delete");
} else { } else {
enqueue( enqueue(
-5
View File
@@ -14,17 +14,12 @@ deny = [
] ]
[licenses] [licenses]
unlicensed = "deny"
copyleft = "allow"
allow = [ allow = [
"Apache-2.0", "Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause", "BSD-3-Clause",
"CC0-1.0",
"GPL-2.0-only", "GPL-2.0-only",
"ISC", "ISC",
"MIT", "MIT",
"MPL-2.0",
"Unicode-3.0", "Unicode-3.0",
"Zlib", "Zlib",
] ]
+13 -27
View File
@@ -113,36 +113,22 @@ key, configuration, device profile, initial state, input/time script и верс
## Local evidence requests ## Local evidence requests
На текущем рабочем месте закрыты статические, corpus и headless runtime gates. Поддерживаемая desktop-платформа — только Windows/Vulkan. Локальный smoke
Для локально воспроизводимого Desktop backend подтверждено только command/state trace `fparkan-vulkan-smoke` уже подтверждает настоящий Win32 surface/swapchain,
в существующем GL-воркфлоу: 300 кадров, controlled resize и отсутствие validation warnings/errors.
Это закрывает фундамент Stage 0, но не доказывает визуальный паритет игры.
- `fixtures/acceptance/macos-gl33-triangle-capture.json`; Следующие доказательства всё ещё нужны именно для Iron3D-compatible рендера:
`S3-GL-001` пока не закрыт: текущая evidence не отражает полноценный - capability-gated capture из оригинального GOG процесса с camera/matrix,
`winit`+`fparkan-render-vulkan` path с real surface/present pipeline. draw/state и frame-boundary provenance;
Для закрытия требования требуется постоянный workspace-владельческий backend на - фиксированные Windows Vulkan captures статической модели, lightmapped модели
`winit`/`fparkan-platform-winit` + `fparkan-render-vulkan` с реальным и terrain после совпадения backend-neutral command capture;
surface/present pipeline, command/state parity и licensed frame capture. - затем controlled captures анимации, FX, прозрачности, теней и атмосферы.
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2 Linux/macOS, GLES2, RG40XX и удалённые portability runners не являются
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же acceptance-гейтами этого проекта. Они не должны появляться в списке блокеров
command/state trace. Локальный Docker probe существующего Rust image не нашёл или подменять Windows evidence.
`libGL`, `libEGL`, `libGLES` или `libOSMesa`, поэтому закрытие этого gate требует
отдельно предоставленного Docker image с Rust + Mesa/EGL/OSMesa либо разрешения
на установку соответствующего проверочного окружения.
Для текущей macOS-focused цели `S3-GL-002`, `L3-DEVICE-001` и `L5-RG40-001`
помечены как `omitted`: они остаются требованиями portable target scope, но не
блокируют локальный macOS acceptance-аудит. При возврате RG40XX/GLES2 в область
цели эти gates снова должны требовать внешнего evidence.
`L3-DEVICE-001` и `L5-RG40-001` не закрываются локально без RG40XX H или
эквивалентного удалённого runner-а. Требуемое доказательство: запуск выбранной
миссии при 640x480 на целевом профиле, сохранённые stdout/stderr, build
fingerprint, manifest игрового каталога, frame/tick budget, memory budget и
итоговый pass/fail report. Desktop/headless результаты не считаются заменой
on-device smoke.
## Closure criteria ## Closure criteria
+50
View File
@@ -0,0 +1,50 @@
# Кампания, сохранения и восстановление сессии
## Известная файловая поверхность
Demo содержит `MISSIONS/dispatcher.ini` и `SAVE/saveslots.cfg`.
`dispatcher.ini` хранит campaign progression (в demo — `[COMPLETE]`), а
`saveslots.cfg` — ordered UI-метаданные slots, не полный snapshot мира.
В Части 2 slots 1 и 7 помечены занятыми без соответствующего payload; поэтому
проверяются независимо: наличие metadata record, физического файла и
format/version/integrity payload.
Campaign различает существование миссии, её доступность, старт, успешное или
неуспешное завершение и уже применённый результат. Обработка одного
mission-complete event идемпотентна. Пустой slot и повреждённый существующий
payload — разные состояния.
## Контракт standalone save
Полный snapshot сохраняет campaign/mission context, time/pause/step phase,
stable object IDs, ownership, transforms, lifecycle/properties/cross-links,
world changes, Behavior/Control/AI/research/economy state, script variables/IP/
timers, authoritative RNG, gameplay-relevant FX и queued messages. Camera/UI
можно сохранить как presentation context; GPU/audio handles и draw buffers
восстанавливаются, а не сериализуются.
Снимок разрешён только после calculation и deferred operations, вне queue
traversal, после применённых network messages и до чтения mutable state
renderer-ом. Native pointers, vtable/allocator addresses и resource mapping
pointers запрещены: ссылки идут через stable IDs и resource keys.
Новый формат — versioned chunks (`WORLD`, `OBJECTS`, `BEHAVIOR`, `PHYSICS`,
`AI`, `SCRIPT`, `RESEARCH`, `RNG`, `CAMERA_UI`, optional network) с magic,
format/profile/content fingerprint, size и checksum. Неизвестный optional
chunk пропускается; required chunk блокирует load. Это дизайн FParkan, не
утверждение о binary format оригинала.
Запись транзакционна: временный файл → повторное чтение/checksum → fs sync →
атомарная замена payload → обновление slot metadata. Загрузка создаёт mission
и objects без публикации, восстанавливает IDs/cross-links/controllers/RNG,
регистрирует их, валидирует и только затем разрешает следующий tick.
## Проверки и граница
`save -> load` обязан давать тот же canonical state hash, OriginalObjectId,
cross-links и продолжение RNG. Corrupt required chunk не публикует частичный
мир; crash при записи не уничтожает старый slot; completion event идемпотентен.
Для native format нужны controlled original saves, binary diffs и trace
serializer-а. До этого FParkan может иметь совместимую семантику собственных
saves, но не заявляет byte/network interoperability с оригинальными файлами.
+335
View File
@@ -0,0 +1,335 @@
# Сценарная VM, формулы и игровые свойства
## Подтверждённый surface
Миссионный сценарный слой задаёт стартовые события, completion/failure,
messages, teleports, задачи, research и campaign transitions. Точки входа и
файлы: `ai.dll: CreateSuperAI/GetSuperAI`, `MisLoad.dll: LoadResearch`,
`ArealMap.dll: CalcFullResearchCost`, `MISSIONS/SCRIPTS/*.scr`, `*.fml`,
`*.trf`, `varset.var`, `MISSIONS/dispatcher.ini`, `mission.cfg`, `messages.cfg`
и `briefing.cfg`.
`.scr` — binary package с version checks, symbol/event sections и offsets;
полная opcode grammar не доказана. Его внешний framing теперь читает
`fparkan-script`: первый little-endian `u32` является числом required opcode
handlers, второй — числом event records. Каждый event хранит `name_len`,
`name_len + 1` raw bytes с обязательным NUL, opaque event word и count вложенных
records. Вложенный record сохраняет семь `u32` header words (в disk order),
список `u32` references после шестого header word и trailing seventh word.
Никакой из этих words ещё не получает semantic name. `.fml` — текстовый
symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaults.
`fparkan-script::parse_varset` уже читает подтверждённые numeric
`VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются
opaque, поэтому legacy non-UTF-8 text не ломает загрузку); `STRING(...)` и
`FUNCTION(...)` пока сохранены за границей этого numeric contract;
GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration:
31 `float` и 200 `DWORD` (от `f0` до `fY`);
loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при
`not found` откатывается к этому shared file. Runtime повторяет данный порядок
транзакционно и публикует selected `MissionScriptVarSet` с путём/provenance, но
ещё не исполняет declarations как VM state;
`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
consumer-inferred.
## Безопасная модель исполнения
Новая VM разделяет immutable package (bytecode, symbols, events, constants),
per-mission variables/timers/frames, bindings logical-name/ObjectId/clan/
research key и typed commands к World3D/Behavior/UI/campaign. После varset
defaults и bindings она dispatches Init/start, на каждом tick обновляет timers,
ставит готовые events в стабильную очередь и исполняет bounded instruction
budget. Опасное удаление идёт через World3D queue и общий deferred lifecycle.
До восстановления opcode table package mode читает header/strings/symbols/
event offsets/raw bytecode losslessly. Статический анализ уже выделил отдельный
five-way evaluator condition records (`ai.dll` VA `0x10005180`): tags `1..5`,
type guards, object lookup и completion flag. Это не следует выдавать за
instruction dispatcher или jump table `.scr`: bytecode opcode table всё ещё
требует отдельного доказательства. Unknown opcode нельзя пропустить как один
byte: это ломает синхронизацию. Для каждого доказанного opcode фиксируются
number, size, operands, control flow, effects, errors и минимальный test.
GOG `ai.dll` доказывает этот framing двумя consumer-ами: loader по
`0x10001000` открывает `<bundle>.scr`, `varset.var`, `<bundle>.fml`, затем
собирает ровно 73 pointers handlers; `0x10011b20` читает описанную count-driven
структуру. Команда
```powershell
cargo run -p fparkan-cli -- script inspect `
'C:\GOG Games\Parkan - Iron Strategy\MISSIONS\SCRIPTS\c1m2p.scr' --format json
```
на исходном пакете возвращает `opcode_handler_count=73`, 9 events, 17 nested
records, 20 references и 0 trailing bytes. Это corpus evidence для reader-а,
но не разрешение на исполнение неизвестных 73 opcodes.
Теперь установлен selector: loader `0x10001000` создаёт 73 pointers в
фиксированном порядке, а `0x10011e70` копирует их без перестановки в runtime
array. Во всех 58 GOG `.scr` первый header word каждого nested record равен
`0..72` либо `0xffff_ffff`: соответственно 2095 handler selectors и 3992
sentinel records. Поэтому `ScriptInstruction::dispatch_selector()` возвращает
`Handler(0..72)`, `Sentinel` или сохраняемый `Unknown(u32)`. Первый handler
(`Handler(0)`, VA `0x10008034`) только устанавливает current context и flag
`+0x50 = 1`; это не даёт ему игрового имени и не заменяет runtime trace.
`Handler(1)` — второй table entry, VA `0x10007fd0`, — не создаёт игровую
команду. Он сохраняет active VM context, берёт один instruction-derived index
через current event/instruction offsets `+0x48/+0x4c`, а затем разрешает его
в varset object по `this + 0x18`. Resolver `0x10002d30` проверяет
`0 <= index < count` и возвращает record `base + index * 0x30`; invalid index
вызывает C++ exception, а не становится нулём. Полученный 48-byte record
передаётся в `0x10013190`, который возвращает x87 floating result: kinds `0`
и `4` идут через отдельный opaque conversion path, kind `1` — signed integer,
kind `2` выбирает одну из двух static scalar constants по нулевости payload,
kind `3` — float, kind `5` — unsigned integer; остальные и пустые cases дают
один fixed fallback scalar. Это доказанный numeric
bridge для VM, но пока не Rust handler: неизвестны точный disk operand slot,
ownership значения на FPU stack и следующий consumer, поэтому нельзя назвать
его арифметическим opcode или silently заменить portable `f32` execution.
Отдельный проход по всем 58 GOG `.scr` (6 087 instruction records) не нашёл
ни одного selector `1`: из них 2 095 записей выбирают один из handlers, а
3 992 являются sentinel. Значит, это установленная, но не corpus-reachable
ветка данного издания; её нельзя делать приоритетным execution path без
отдельного dynamic/evidence route.
`Handler(2)` (третья entry table, VA `0x10009610`) уже имеет статический
contract, но ещё не Rust execution: он выбирает active event/instruction через
runtime offsets `+0x48/+0x4c` и разрешает семь 32-bit slots через varset object
`+0x18`. Их доказанный dataflow: slot 0 даёт один `u32` и base string, slot 1
даёт numeric scalar, slots 2 и 3 — по `u32`, slots 4, 5 и 6 принимают только
kind `5`/`3` и иначе дают `0.0`. Затем он вызывает `0x100059f0` объекта по
`this + 0x7c` и очищает flag `+0x50`.
Этот callee больше не opaque. Он строит key из семи значений, ищет matching
record в своей collection по `this + 0x24` и при совпадении обновляет только
record fields `+0x0c` и `+0x14`, затем вызывает его refresh path `0x10005070`.
При отсутствии record он лениво ищет в event table имена `<base>_Start` и
`<base>_Continue`, сохраняет их IDs в indexed state и materializes новый
internal record. В этой ветке не видно прямого World3D/Behavior call, поэтому
это доказанная scheduler/event-record boundary, а не команда движения, атаки
или строительства. Semantic names семи slots и consumer нового record остаются
открытыми; до dynamic capture Rust возвращает явный
unsupported result, а не «примерный» game command.
У этой границы также нет скрытого immediate dispatch: после добавления новой
записи `0x100059f0` вызывает `0x1000f920`, а Ghidra 12.1.2 декомпилирует эту
функцию как пустой `return`. Следовательно, найденные `<base>_Start` и
`<base>_Continue` только кэшируются в scheduler state; их фактический consumer
находится в отдельном позднем update path. Воспроизводимый read-only extractor:
`tools/ghidra/ExportAiVmHandler2Dispatch.java`.
Corpus priority теперь измерен, а не предполагается: во всех 58 GOG `.scr`
имеются 6 087 instruction records, из них 3 992 sentinel; самый частый
non-sentinel selector — `Handler(30)`, 246 records. Его VA `0x1000c266`
читает первые два reference words активной instruction, разрешает каждый
через varset (`0x10002d30` и `0x10013570`) и вызывает внешний callback с
тремя `u32`: `(0, first, second)`. Callback не принадлежит `ai.dll`: его
кладёт десятый argument экспортного `CreateSuperAI`. Тот же callback встречен
у `Handler(57)` с первым word `2` и у отдельного lifecycle path с первым word
`1`; предметная семантика этих modes ещё не доказана. В частности, это пока
не основание назвать Handler(30) сообщением, приказом или UI opcode. Точный
text-to-varset resolver расположен за wrapper `0x10011ea0` в
`0x100174a0`. Воспроизводимые exports: `ExportAiVmHandler30.java`,
`FindAiVmHandler30Callback.java`, `ExportAiVarSetLoader.java`.
Следующий pass восстанавливает эту индексацию. `0x100174a0` добавляет каждый
recognized source declaration в encounter order как 48-byte record; GOG shared
`varset.var` не содержит `STRING(...)`, поэтому его 231 numeric `VAR` entries
образуют точно это index space. `0x10013570` возвращает `DWORD` record kind
raw `u32`; float kind проходит `__ftol`, чей x87 rounding profile ещё требует
capture. Полный GOG scan всех 246 Handler(30) instructions показывает 492
operand references: все 492 in-range и указывают на `DWORD`. Поэтому
`VarSet::resolve_handler30` уже materializes точный opaque callback command
`(mode=0, first, second)` для данного corpus path, но явно отклоняет float,
out-of-range и incomplete instructions вместо silent coercion. Extractors:
`ExportAiVarSetParser.java`, `ExportAiVarSetU32Resolver.java`.
Следующий static pass закрывает equality/update policy. Identity ровно равна
`(slot0 word, slot4 IEEE-754 bits, slot5 IEEE-754 bits)`, поэтому `-0.0` и
`+0.0` различаются. Новый 100-byte record получает slot1 в поле `+0x14`,
slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+0x0c`. При
совпавшем key refresh случается только когда slot1 сравнивается unequal
(включая NaN); он заменяет `+0x14` и `+0x0c`, затем прибавляет сохранённый
`+0x28` к `+0x24` с x86 wrapping arithmetic. `fparkan-script` отражает эту
изолированную часть как `Handler2RecordScheduler`; он не выполняет bytecode,
не назначает игровых имён и не делает event lookup за original VM.
На границе mission runtime выбранный TMA clan `first_resource` теперь
материализуется как отдельный `MissionScriptBundle`: loader нормализует
`<base>.scr`, декодирует его тем же bounded reader-ом и публикует immutable
package вместе с clan provenance. Headless report выводит число таких packages
и их named events. Это именно wiring входных данных, не VM execution: Init и
остальные events пока не dispatch-ятся, а ошибка чтения сохраняет
transactional rollback mission loader-а.
TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не
задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
`ClanID`, ore, speed и free-time properties.
Research/economy работают в simulation: `LoadResearch` и
`CalcFullResearchCost` доказывают данные и вычислимую стоимость, но не полный
layout prerequisites/modifiers/unlocks. Formula evaluator требует strict
grammar/version, typed operands, deterministic numeric policy, bounded stack и
явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
### Handler(19): AutoDemo Init varset initialization
`Handler(19)` is the twentieth VM-table entry at GOG `ai.dll` VA `0x1000aa38`.
It is the only instruction in the `Init` event of the two `default.scr` bundles
referenced by `MISSIONS\\Autodemo.00\\data.tma`; together those bundles account for
the observed 18 named script events. Each instruction references varset records
`224`, `225`, and `226`: `ClanBaseX`, `ClanBaseY`, and `ClanID` respectively.
The original writes three raw DWORD values in order. It converts the VM fields
at `+0x80` and `+0x84` through the x87 `__ftol` helper and stores the resulting
words into references 0 and 1. It copies the raw word from `+0x7c` into reference
2, then clears VM field `+0x50`. The default targets are `DWORD` records; the
shared setter preserves the incoming word for that type. Therefore this is not
a license to replace the first two conversions with Rust float casts: their
rounding behavior remains an x87 compatibility boundary until it has captured
test vectors.
The missing source-field provenance is now constrained by the public creation
boundary. `CreateSuperAI` at `ai.dll` VA `0x1000f710` allocates `0x8b0` bytes
and calls constructor `0x10001000` with its first eight arguments. That
constructor calls `0x10006340(this + 0x7c, clan_id, base_x, base_y)`: these are
the fields later read by `Handler(19)`. `base_x` and `base_y` are unsigned and
the constructor rejects values greater than `10000`. The actual `__ftol` helper
at `0x1001df70` saves the x87 control word, sets its rounding-control bits to
truncate, executes `fistp qword`, then restores the control word.
Consequently the runtime resolves and retains this one proved vertical slice
during mission loading: each selected clan's TMA anchor is accepted only in the recovered
`0..=10000` base range, truncated through the recovered x87 rule, and paired
with its zero-based clan index. For every `Init` instruction whose selector is
`Handler(19)`, `VarSet::resolve_handler19` produces the three per-clan DWORD
writes. The runtime then materializes an independent declaration-ordered value
array for each selected clan and applies those writes, so later recovered
handlers can consume `ClanBaseX`, `ClanBaseY`, and `ClanID` as runtime cells
rather than loader defaults. Other Init selectors and all other events remain
decoded but unexecuted. AutoDemo validates the path end-to-end: its non-integral first
anchor (`500.2857`) yields captured `ClanBaseX=500`, and the live GOG process
contains two initialized SuperAI entries `(500, 752, 0)` and `(728, 449, 1)`;
the Rust loader reports `script_init_states=2` and `script_varset_states=2`.
`GetSuperAI` returns element `n` of the 64-pointer global table at preferred
`ai.dll + 0x55398` for `n <= 63`. The read-only
`tools/capture-ai-init.ps1` probe observed the running GOG AutoDemo values
`(500, 752, 0)` for entry 0 and `(728, 449, 1)` for entry 1 at fields
`(+0x80, +0x84, +0x7c)`. These values are integral samples, not a rounding
profile.
The Rust reader exposes `VarSet::resolve_handler19`. It accepts the already
converted first two words and the third raw word, produces three typed writes,
and rejects missing, out-of-range, or non-`DWORD` targets. The runtime only
binds it to the proven creation/anchor path above; it does not guess the
remaining script event semantics. The associated Ghidra scripts are
`ExportAiVmHandler19.java`, `ExportAiVmHandler19Setter.java`,
`ExportAiVmHandler19SetterCallee.java`, and `ExportAiGetSuperAi.java`.
The creation and conversion boundaries are reproducible with
`ExportAiCreateSuperAi.java`, `ExportAiSuperAiConstructor.java`, and
`ExportAiFtol.java`.
### Runtime Handler(30) operand binding
`resolve_handler30_with_values` preserves the recovered declaration-kind ABI
but reads operands from instantiated per-clan cells rather than textual
defaults. Runtime exposes `resolve_loaded_handler30` for the exact opaque
`(mode=0, first, second)` callback command. It intentionally returns that
command without automatically invoking a game-side consumer: only one
consumer branch has a safe Rust meaning so far.
The live GOG AutoDemo closes that consumer boundary: the read-only callback
pointer at `ai.dll + 0x555e4` is `0x100611d0`, or `iron3d.dll + 0x611d0` at
the observed load base. Its recovered `__cdecl` ABI is `(mode, command,
payload)`, matching `Handler(30)` as `(0, first, second)`. In `mode == 0`,
`command == 0` and `payload == 0` selects `VOICE_MISSION_FAIL`, records the
failed status, and clears an IGame byte; `payload == 1` selects
`VOICE_MISSION_COMPLETE`, records completion, and sets that byte. Commands
3, 4, and 5 have additional game-side paths; mode 2 is a separate IGame
call.
`command == 1` is now statically traced, but still has no justified domain
name. Its payload is a signed integer key for a binary-search tree: the
receiver's `+0x04` is the sentinel/root, each node has child links at `+0x08`
and `+0x0c`, a key at `+0x10`, and a dispatched payload at `+0x14`. A matching
node reaches `FUN_10095600(node + 0x14)`. That payload has one-shot flag
`+0x19` and a second flag `+0x18`. On its first dispatch, Iron3D sets `+0x19`,
obtains a resource-manager string through virtual slot `+0x1c`, and passes the
payload's `+0x0c` through `FUN_10061fb0`. On later dispatches it uses
resource-manager IDs `0x181a` and `0x184f`, an IGame selector `0x2ed`, and
emits an opaque request with category `3` or `4` selected by `+0x18`. This
proves the lookup and one-shot/repeat split, not the UI/message subject or the
semantics of either resource ID; Rust therefore retains command `1` as
`Unhandled`.
Reproduce the callback and the command-one consumers with
`capture-ai-init.ps1`, `ExportIron3dAiCallback.java`,
`ExportIron3dAiCallbackCommand1.java`, and
`ExportIron3dAiCallbackCommand1Dispatch.java`.
Runtime now applies only this recovered branch as
`apply_loaded_script_host_callback`: `(0, 0, 0)` transitions a loaded mission
to `Failed`, `(0, 0, 1)` transitions it to `Completed`, and a repeated target
state is a no-op just as the Iron3D guards require. The effect is deliberately
separate from audio/UI playback; commands 1, 3, 4, 5 and mode 2 return
`Unhandled` until their consumers are recovered.
### Handler(8): problem-record state write
`Handler(8)` is the ninth VM-table entry at GOG `ai.dll` VA `0x10009b0d`.
All 179 corpus records have exactly one in-range `DWORD` reference; the two
observed entries are `ST_SOLVING=1` (122 records) and `ST_SOLVED=2` (57).
The handler resolves loader-bound `dCurrentProblem` through varset index
`this+0x868`, uses that live DWORD as a bounds-checked index into a table at
`this+0xa0` with 100-byte records, then resolves the instruction's one DWORD
and writes it to the selected record at `+0x18`.
The write has two statically proven exceptional branches. State `2` calls a
reset helper that zeroes record words `0..=3` and `6` before invoking two
opaque callback slots; state `3` does the same except word `3` is preserved.
Both then write `+0x18`. Every other state simply writes the state word.
`VarSet::resolve_handler8` emits a `Handler8StateChange` with the caller-owned
live record index, resolved state, and explicit reset kind. It does not invent
the table owner, the pre-reset helper, or callback semantics. Reproduce the
evidence with `ExportAiVmHandler8.java`, `ExportAiVmHandler8Callees.java`, and
`ExportAiVmHandler8Transitions.java`.
### Handler(15): typed target-call boundary
`Handler(15)` is the sixteenth VM-table entry at GOG `ai.dll` VA `0x10008054`
and the second most frequent non-sentinel selector: 236 records in 28 of 58
GOG packages. It is not a one-word opcode. Across the complete corpus,
references `0..3` are `DWORD`, `4..7` are `float`, and `8` is the DWORD mode.
The original resolves the first four through `0x10013570`, the next four
through x87 scalar helper `0x10013190`, then reads the mode through
`0x10013570`.
The shipped `varset.var` names the only observed mode values: `NONE=0` uses
9 total references; `TARGET_BY_LOGIC_ID=0x0201`, `TARGET_BY_TYPE=0x0203`,
`TARGET_NOT_DEFINED=0x0204`, and `TARGET_BY_NAME=0x0205` use 10; and
`TARGET_BY_PLACE=0x0202` uses 11. The corpus contains 34/122/80 records in
these three arities. The trailing references at positions 9 and, only for
`TARGET_BY_PLACE`, 10 are all in-range DWORD declarations.
After resolving those inputs, the handler looks up an opaque target through
virtual slot `+0x1c` on `this+0x3d8` using the first word. A missing target
sets VM flag `+0x50=5`; otherwise the handler builds a temporary call record,
applies the mode-specific tail, and invokes the target's `+0x0c` virtual slot
with that record and the third word. Its zero/non-zero result becomes
`+0x50=0/1`. The target type, the virtual method's semantic action, and its
return value remain unproven. Accordingly `VarSet::resolve_handler15` only
materializes a type-checked `Handler15Invocation` and `Handler15TargetPayload`;
it never executes the opaque target call. Missing, out-of-range, wrong-type,
and unobserved-mode inputs are explicit errors. Reproduce the static evidence
with `tools/ghidra/ExportAiVmHandler15.java`.
## Готовность
Все demo packages должны проходить package/version checks, offsets оставаться
в bytecode, а confirmed disassembler — не терять синхронизацию. VM считается
готовой после deterministic Init/basic mission events, stable object bindings,
typed research/property tests и save/load script state. Для закрытия остаются
dispatcher/jump table, minimal differential packages и traces world/variable
effects; до них unknown opcode — явная unsupported branch, не no-op.
+74
View File
@@ -0,0 +1,74 @@
# Shell, HUD, шрифты и локализация
## Доказанная граница
`iron_3d.exe` создаёт процесс и окно; `iron3d.dll` экспортирует `createShell`,
`deleteShell` и `getIShell`; `services.dll` предоставляет `getGUIServer` и
`getDisplay`. Поэтому меню, briefing, HUD и системные диалоги — отдельная
оболочка над World3D, а не игровые объекты, созданные ради отрисовки.
```text
Win32 messages
-> shell/GUI input и manual events
-> simulation World3D
-> world render
-> shell/HUD overlay и presentation state
```
Точное положение legacy DirectDraw flip относительно последнего UI draw пока
требует API capture. Разделение world и UI pass доказано, но не следует
выдавать его за восстановленный layout классов виджетов.
## Ресурсы и идентичность
В demo найдены `ui/shell_ctrls.cfg`, `ui/menu_resources.cfg`, `ui/cursor.cfg`,
`ui/game_resources.cfg`, `ui/hq.cfg`, `DATA/TextRes.cfg`, `gamefont.rlb`,
`sprites.lib` и `Palettes.lib`. Конфигурации задают controls/resources/cursor,
overlays и HQ; `TextRes.cfg` связывает символические ключи с текстом.
`gamefont.rlb` содержит две LZSS-записи (`0x040`), `sprites.lib` — 24 записи
raw Deflate (`0x100`). `sprites.lib::INTERF8.TEX` использует известный
`deflate_eof_plus_one` quirk. UI обязан пользоваться общим RsLi reader, а не
отдельной похожей распаковкой.
Ключ ресурса, legacy path, локализованный текст и исходные bytes — четыре
разные идентичности. Archive keys остаются ASCII-casefold; `TextRes.cfg`,
briefing/messages и script strings могут требовать ANSI/CP1251 decoding.
Unicode-представление хранит raw bytes для roundtrip и diagnostics.
`gamefont.rlb` и `sprites.lib` побайтно совпадают в Частях 1 и 2. Во второй
части добавлен `ui_factory.lib` (NRes с шестью Texm) и расширен
`ui/minimap.lib`; при этом пересобранные `iron3d.dll`/`services.dll` требуют
отдельной трассировки lifecycle и HUD state.
## Контракт новой реализации
UI scene хранит корневые widgets, focus, viewport/clip rectangles и modal
depth. Demo `640x480` — полезный baseline, но не доказанное универсальное
design resolution. Отдельно хранятся legacy-layout coordinates, реальный
viewport, scale/letterbox policy, mouse-to-layout transform, clipping и
z-order. Cursor, sprite и hit-test обязаны применять одно преобразование.
Font contract включает glyph image, advance, bearing/offset, line height и
fallback glyph. Binary glyph metrics `gamefont.rlb` пока не восстановлены:
payload читается lossless через RsLi, а семантика устанавливается по consumer
trace.
Нормализованное событие проходит modal widget, затем shell command map; лишь
разрешённая gameplay-команда превращается в World3D manual event. Held state и
axes попадают в calculation snapshot. Это не позволяет UI-click одновременно
исполнить команду мира. HUD только читает presentation view (selected object,
resources, mission text, timers, research/build state, camera mode); authority
остаётся у simulation.
## Проверки и открытые вопросы
- UI cfg читаются до EOF с сохранением неизвестных fields; symbolic key
разрешается в RsLi entry независимо от регистра.
- Visual rect и hit-test совпадают при разных viewport; modal scene блокирует
gameplay input; missing optional sprite/font имеет named fallback.
- UI-only и world-only command captures собираются раздельно.
Не закрыты grammar всех `ui/*.cfg`, hierarchy original widgets, glyph metrics,
HUD state machine и pixel-perfect layout. Нужны GUI-factory hooks, event traces
и captures меню, briefing, HQ и игрового HUD.
+74 -6
View File
@@ -6,10 +6,78 @@ Baseline command:
cargo xtask ci cargo xtask ci
``` ```
Result on 2026-06-23: Result on 2026-07-18:
- canonical pipeline now uses a fixed MSRV/toolchain, policy checks, - canonical pipeline passes locally with Rust 1.97.1: formatting, policy,
full-format workspace test command, `clippy`/`doc`/`cargo deny` gates and shader provenance, workspace tests, `clippy`, docs and `cargo deny` are
typed manifest parsing in `xtask`; executed through `cargo xtask ci`;
- `rpath`/offline mode is still useful for synthetic local checks; - `rust-toolchain.toml` pins Rust 1.97.1 and installs only the
- full online dependency resolution remains unavailable in the sandbox. `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.
Native Vulkan evidence:
- Windows x86_64 local smoke passed again on 2026-07-18 with Rust 1.97.1, the
system Vulkan loader 1.4.350, and an AMD Radeon Pro WX 3200 Series;
- the smoke created a Win32 surface and swapchain, presented 300 frames,
exercised a controlled resize (three observed resize events and two
swapchain recreations), and shut down with zero validation warnings and
errors;
- Windows is the sole supported runtime target for this project; Linux and
macOS smoke gates are explicitly out of scope.
Scope labels:
- Stage 0 codebase gates: locally evidenced.
- Stage 0 Windows native runtime: locally evidenced.
- Linux/macOS runtime and cross-platform hosted CI: out of scope.
## Current architecture contract
The standalone engine keeps binary formats, the resource graph, simulation,
animation math and backend-neutral render commands independent from the GPU
adapter. Windows presentation uses `winit`, `raw-window-handle`, `ash-window`
and `ash`; only the Vulkan/FFI adapters may contain narrowly documented
`unsafe` code. Raw Vulkan handles do not cross that boundary.
The baseline is Vulkan 1.1 with surface, Win32-surface and swapchain support,
binary semaphores/fences and a classic render-pass path. Device capability is
queried at runtime; dynamic rendering, descriptor indexing, synchronization2,
timeline semaphores and extended dynamic state are optional capability-gated
enhancements, not requirements. The canonical initial texture upload is RGBA8
UNORM. Headless builds remain independent of `winit`, Vulkan and a window
system.
## Current stage model
The canonical Vulkan-revision plan has six dependency-ordered stages numbered
0--5. Keeping its original numbering matters: Stage 4 is an evidence-gated
animation/FX runtime, while Stage 5 is the mission/world vertical slice that
depends on it. They must not be reported as one completed stage.
0. reproducible Windows/Vulkan foundation;
1. paths, VFS and lossless archives;
2. prototype graph and prepared CPU assets;
3. static Vulkan model/terrain viewer;
4. animation and FX runtime, with reference-only semantics until runtime
captures close the x87 and effect-lifecycle evidence gaps;
5. transactional map, mission and world vertical slice, rendered from the
same immutable snapshot through Vulkan.
This is a local, Windows-only adoption of the Notion page "План реализации
stage 0--5: Vulkan revision" (reviewed on 2026-07-18). Its former
Linux/macOS portability and hosted-CI goals are intentionally not imported:
they conflict with the current supported-platform boundary above. The
portable architectural rules that do apply -- backend-neutral commands,
runtime capability queries, narrow Vulkan/FFI `unsafe`, offline shader
validation, and command capture before pixel comparison -- are retained in
this audit and the rendering tome.
Contract tests and failure tests precede implementation. Synthetic checks never
read licensed roots; licensed corpus checks use absolute paths from the local
manifest. Backend-neutral command capture precedes pixel comparison, and GPU
addresses, allocator addresses and driver timing are excluded from deterministic
state hashes.
+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`.
+65
View File
@@ -0,0 +1,65 @@
# Original Engine Hashes
Страница фиксирует минимальный статический baseline, на который должны
ссылаться capture fixtures и Stage 4 evidence.
## Scope
- Источник: локальная статическая сверка Part 1 (`IS`) и Part 2 (`IS2`).
- Метод: SHA-256, export/import tables, `objdump -p`, `strings`.
- Эта страница не заменяет динамические traces: она задаёт only-if-match
binary baseline для дальнейших runtime captures.
## Stable binaries across Part 1 / Part 2
| Binary | SHA-256 | Size | Notes |
| --- | --- | ---: | --- |
| `Ngi32.dll` | `bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5` | 253952 | Shared low-level render/resource/audio boundary |
| `World3D.dll` | `17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e` | 208896 | Shared gameplay/world/render lifecycle baseline |
| `Terrain.dll` | `6d3e68f0e15b297f6c184af3113baf1f31e19c3326c18f0150dec659242ed667` | 708608 | Shared terrain/shade/world baseline |
| `iron_3d.exe` / `iron_3d_p2.exe` | `f476af85c034a4b4f34f49d0806e4dff397b5da0ee26d382a7674231144979f7` | 36864 | Shared launcher binary |
## GOG research baseline
The canonical disassembly source is the windowed GOG installation, not the
Part 1/Part 2 test installations. Its `Terrain.dll` is a distinct revision:
| Binary | SHA-256 | Size | Notes |
| --- | --- | ---: | --- |
| `Terrain.dll` | `af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead` | 499712 | GOG camera receiver evidence; do not reuse Part 1/2 RVAs without a matching hash |
## Divergent binaries across Part 1 / Part 2
Эти модули нельзя автоматически считать behavior-compatible между частями:
- `AniMesh.dll`
- `Effect.dll`
- `iron3d.dll`
- `services.dll`
- `Control.dll`
- `ArealMap.dll`
## Practical use
1. Frame-order traces для `World3D.dll`, `Terrain.dll` и `Ngi32.dll` можно
привязывать к shared profile, пока hash совпадает.
2. Animation and FX captures обязаны храниться раздельно для Part 1 и Part 2,
потому что `AniMesh.dll` и `Effect.dll` отличаются.
3. Любой runtime fixture должен записывать минимум:
- `game_part`
- `module_name`
- `module_sha256`
- `mission`
- `frame_or_tick`
- `schema_version`
## Export / import focus for Stage 4
- `World3D.dll`: `stdCalculateGame`, `stdRenderGame`, `sendEndOfRender`
- `Terrain.dll`: `GetShade`, `GetWorld`, `stdSetCurrentCamera2`
- `AniMesh.dll`: `LoadAgent`, `LoadAniMesh`
- `Effect.dll`: `CreateFxManager`, `InitializeSettings`
- `Ngi32.dll`: `niGet3DRender`, `n3dPrimitive`, `n3dEndScene`, `rsLoadTexture`, `rsLoadMultiTexture`
Если будущий capture fixture не указывает, к какому hash он относится, такой
fixture нельзя считать acceptance evidence.
+107
View File
@@ -0,0 +1,107 @@
# Stage 4 Capture Schema
Stage 4 нельзя закрывать набором ad-hoc логов. Нужна схема, по которой
animation, FX и rendered frame captures сравниваются между Part 1, Part 2 и
современной реализацией.
## Goals
- сделать captures пригодными для автоматического diff;
- не хранить host-specific пути, временные каталоги и нестабильные handles;
- связывать frame traces, command captures и pixel artifacts общим identity.
## Common envelope
```json
{
"schema_version": "fparkan-stage4-capture-v1",
"capture_kind": "frame-trace | animation-pose | fx-lifecycle | render-frame",
"game_part": "part1 | part2",
"mission": "MISSIONS/.../data.tma",
"frame_id": 123,
"tick": 123,
"module_hashes": {
"World3D.dll": "sha256...",
"Terrain.dll": "sha256...",
"AniMesh.dll": "sha256...",
"Effect.dll": "sha256..."
},
"tool_version": "codex/manual/fixture version",
"notes": []
}
```
## Capture kinds
### `frame-trace`
Используется для порядка фаз и внешних вызовов.
Required fields:
- `events`: ordered list of `{ phase, symbol, sequence, object_id?, fx_id?, camera_id? }`
- `queue_counters`: deferred operations, visible objects, emitted FX, UI callbacks
- `rng_state`: optional, if recoverable
### `animation-pose`
Используется для x87 / portable sampler parity.
Required fields:
- `clip_id`
- `node_index`
- `sample_time`
- `numeric_profile`
- `translation`
- `rotation_quat`
- `scale`
- `matrix_hash`
### `fx-lifecycle`
Используется для create/update/emit/stop parity.
Required fields:
- `fx_id`
- `instance_id`
- `time`
- `opcode_events`
- `rng_calls`
- `resource_refs`
- `emissions`
### `render-frame`
Связывает backend-neutral snapshot с live Vulkan output.
Required fields:
- `camera`
- `visible_object_ids`
- `draws`
- `pipeline_keys`
- `resource_ids`
- `validation`
- `pixel_artifact`
## Stability rules
1. Не записывать абсолютные host paths.
2. Не записывать raw pointer addresses как identity fields.
3. `frame_id` должен совпадать между trace, command capture и pixel artifact.
4. GPU-specific transient handles допустимы только внутри diagnostics fields и
не участвуют в canonical equality.
5. Любой capture without `module_hashes` считается informational, а не
acceptance-grade.
## Acceptance mapping
- `S4-TRACE-*` rows читают `frame-trace`
- `S4-ANIM-*` rows читают `animation-pose`
- `S4-FX-*` rows читают `fx-lifecycle`
- `S4-VK-*` и `S4-PIXEL-*` rows читают `render-frame`
Эта схема intentionally минимальна. Новые поля можно добавлять, но нельзя
ломать перечисленные identity and parity anchors без смены `schema_version`.
+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. Там вводится Если вы впервые разбираете игровой движок, начните с тома I и II. Там вводится
+19
View File
@@ -43,6 +43,25 @@ struct Node38 {
`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает `slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает
отсутствие геометрии для комбинации LOD/group. отсутствие геометрии для комбинации LOD/group.
Validated `ModelAsset` также сохраняет decoded type 8 keys и type 19 map как
`ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`,
то есть доказанный static input. `parent_or_link == 0xFFFF` означает root;
иначе это parent index, обязательно меньший индекса child. Этот контракт
подтверждён на licensed animation gates обеих частей и защищён fallback-ом:
модель с нарушенным порядком не получает придуманную hierarchy.
В legacy-camera static preview стандартный узел уже получает свой fallback pose
до внешнего TMA/Iron3D transform. Parent pose поворачивает child translation,
затем translation суммируется, а rotations умножаются; после полученной global
pose применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия
намеренно дублируется на draw-range узла, потому что один source vertex может
быть нарисован разными node poses. Это static fallback hierarchy, а не полная
animation parity: dynamic type-19 frame-map sampling остаётся отдельной задачей.
Для воспроизводимого исследования `fparkan-cli model inspect --root <game>
--archive <archive> --resource <model.msh>` выводит Node38 metadata, включая
parent index, fallback key и наличие LOD0/group0 geometry.
## Slot and batch ## Slot and batch
Type 2 содержит header `0x8C`, затем `Slot68`: Type 2 содержит header `0x8C`, затем `Slot68`:
+27
View File
@@ -40,6 +40,33 @@ end-of-render.
Подготовленный item должен ссылаться на immutable данные кадра. Изменение phase Подготовленный item должен ссылаться на immutable данные кадра. Изменение phase
или texture cache посреди прохода не должно менять уже собранную очередь. или texture cache посреди прохода не должно менять уже собранную очередь.
## GOG camera dispatch evidence
For the GOG `World3D.dll` baseline with SHA-256
`17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`,
the exported `stdRenderGame` is RVA `0x13BD0`. It first calls
`Terrain::stdSetCurrentCamera2(camera)`, stores the camera pointer only for
the frame, and clears it before return. `sendEndOfRender` is a separate export
at RVA `0x13D90`. This is frame-order evidence only: the camera ABI, matrices
and viewport values still require dynamic capture or further decompilation.
Receiver-side GOG Terrain evidence refines this contract. `stdSetCurrentCamera2`
(RVA `0x4FD40` in Terrain SHA-256
`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`) queries
selector `18` on the supplied object and invokes slot `+12` on that result. It
does not store the supplied pointer in `stdGetCurrentCamera2`; that getter
returns a Terrain global populated by selector `8` during Terrain initialization.
`Terrain.dll!LoadCamera` adds a separate factory-side contract. Its GOG entry
at RVA `0x4EBE0` is `__stdcall` with four machine-word arguments, allocates
`0x1A4` bytes, calls the constructor at RVA `0x4EC60`, and returns the interface
at object offset `+0x134`. The constructor passes arguments 3 and 4 into base
initialization at `this + 4`, writes argument 3 to `this + 0x138`, and calls a
helper at RVA `0x4CEF0` with arguments 1, 2 and 4 plus `this`. Argument 1 is a
mode string: only `REFLECTION` and `REFLECTION_SHIFTED` produce a non-zero mode
value at `this + 0x1A0`. This is factory ABI evidence, not a recovered view or
projection matrix contract.
## Parity risks ## Parity risks
- x87 precision and rounding; - x87 precision and rounding;
+51
View File
@@ -0,0 +1,51 @@
# Renderer Truth Table
Эта страница нужна для одной вещи: не позволять путям smoke, planning и capture
выглядеть как «почти готовый renderer». Каждый путь доказывает разный класс
свойств, и acceptance не должен смешивать их.
## Краткая матрица
| Path | Native window / swapchain | Draws pixels | Uses original assets | Acceptance class | Что доказывает | Чего не доказывает |
| --- | --- | --- | --- | --- | --- | --- |
| `fparkan-vulkan-smoke` / `VulkanSmokeRenderer` | Yes | Yes | Static MSH plus sampled TEXM | `covered-gpu` for Stage 0 smoke and explicit MSH/TEXM/descriptor bridge IDs | Loader, instance, surface, swapchain, submit/present, validation-clean triangle path; original MSH indexed draw; TEXM RGBA8 staging upload; per-batch WEAR/MAT0 diffuse descriptors and fragment sampling | MAT0 phase animation, lightmaps, legacy blend/depth/cull states, terrain, camera/node transforms, gameplay rendering |
| `VulkanPlanningBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Deterministic command validation, canonical capture, frame submission planning | Любой live GPU draw, pixel parity, validation-clean asset frame |
| `RecordingBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Stable command capture for backend-neutral tests | Native window, Vulkan, GPU resource lifetime, pixels |
| `NullBackend` | No | No | Optional CPU-side IDs only | Usually `covered` for validation-only rows | Command stream framing and bounds validation | Capture stability, GPU execution, pixels |
| `VulkanAssetRenderer` | Yes | Yes | Yes | `covered-gpu` | Static original asset rendering: MSH/Texm/WEAR/MAT0/terrain through Vulkan | Animation/FX parity unless explicitly wired |
| `fparkan-game --backend static-vulkan` | Yes, GOG `Autodemo.00` | Yes, full static AutoDemo preview | Eight mission objects, 66 MSH components, original diffuse TEXM and Land2 terrain base layer | `covered-gpu` for the bounded static-scene bridge | Diagnostic XY framing with recovered translation/scale/Z Euler placement, fallback node hierarchy, optional captured legacy camera, 32-bit GPU indices, diffuse descriptors, terrain base draws and synchronized teardown telemetry | Material phases, Land1 blend/microtexture/lightmaps, animated keys, FX, camera control and gameplay parity |
| Future rendered `fparkan-game` mode | Yes | Yes | Yes | `covered-gpu` plus original-evidence IDs | Mission-driven render snapshot execution and pixel capture | Original-runtime parity for animation/FX/x87 without dedicated captures |
## Rules
1. IDs со смыслом `VK`, `GPU`, `DRAW`, `PIXEL`, `VALIDATION` или `RENDERED`
на Stage 3+ не могут закрываться через `NullBackend`, `RecordingBackend`
или `VulkanPlanningBackend`.
2. `covered-planning` означает command planning/capture evidence. Этот статус
никогда не считается доказательством draw пикселей.
3. `covered-stub` зарезервирован для явно помеченных `STUB` acceptance rows и
не считается compatibility closure для FX lifecycle.
4. `covered-gpu` требует live native handles, реальный draw path и связанный
renderer artifact: report, capture или approved pixel.
## Current repository status
- Реальный Vulkan в репозитории имеет smoke triangle path и узкий static asset bridge. `VulkanStaticDrawRange` сохраняет исходный `Batch20.material_index`; когда smoke запускается с `--wear-root`, `--wear-archive`, `--wear-name` без override, он дедуплицирует selectors, проходит каждый через `WEAR → MAT0 → Textures.lib`, создаёт по одному image/descriptor set и бинит set непосредственно перед соответствующим indexed draw. Direct TEXM и `--material-index` — намеренно однотекстурные compatibility modes. Fresh GOG `fortif.rlb::FR_L_MTP.msh` подтверждает 237 batch draws, но его selectors все `0`, поэтому live report содержит один binding `MTP_01.0`; unit contract подтверждает точное сопоставление двух разных selectors с разными descriptor sets. Это не доказывает material phase animation, lightmaps, alpha/depth/cull state, terrain, camera/node transforms или pixel approval.
- Lightmap остаётся отдельным, не реализованным contract: оригинальный `World3D.dll` экспортирует самостоятельный `SetLightMapLib` наряду с `SetTexturesLib` и `SetMaterialLib`; WEAR содержит независимый блок `LIGHTMAPS`. Текущая документация не подтверждает связь этих slots с `Batch20.material_index` или их UV/channel semantics, поэтому viewer не подменяет lightmap diffuse texture и не добавляет недоказанное binding.
- `apps/fparkan-game` по умолчанию выдаёт `render-planning` JSON report поверх
synthetic window descriptor и `VulkanPlanningBackend`. Opt-in `--backend static-vulkan`
создаёт native `winit` window. Для GOG `MISSIONS/Autodemo.00/data.tma` он уже
рендерит весь статический набор из восьми mission objects и 66 MSH components с
diagnostic XY camera (or an optional captured legacy camera), доказанным `Rz * Ry * Rx` placement transform,
fallback node hierarchy и Land2 base terrain. Последний validation-clean
текущий трёхкадровый запуск после применения placement transform в XY path
имел 71 material descriptor, `clip_visible_vertices=75543` и readback hash
`5444013368935681345`. Это `covered-gpu` для ограниченного
static-scene bridge, но не pixel parity и не доказательство material phase,
Land1 blend, microtexture, lightmap, dynamic animation, FX, live camera
selection или gameplay rendering.
- `apps/fparkan-viewer` сейчас inspection-only CLI и не открывает live Vulkan
asset viewer.
- Следующий реальный milestone для rendered acceptance: `VulkanAssetRenderer`
с upload/draw/capture path для хотя бы одной оригинальной модели и одного
terrain slice.
+37
View File
@@ -128,6 +128,12 @@ directory_offset = total_size - directory_size
Reader проверяет, что `directory_offset >= 16`, умножение не переполнено, а Reader проверяет, что `directory_offset >= 16`, умножение не переполнено, а
каталог заканчивается точно на `total_size`. каталог заканчивается точно на `total_size`.
В repository NRes payload возвращается как slice с `Arc`-владельцем полного
неизменяемого архива: валидация формата не создаёт промежуточную копию каждого
payload. Такой view учитывается в том же bounded decoded-payload cache по
длине диапазона и сохраняет deterministic eviction. Это применимо только к
raw NRes; сжатый RsLi по-прежнему материализуется после декодирования.
### Запись каталога NRes ### Запись каталога NRes
```c ```c
@@ -462,6 +468,37 @@ string длиной 32 байта. Требование обязательног
Части 2. Все соответствуют формуле размера, `kind == 1` и Части 2. Все соответствуют формуле размера, `kind == 1` и
`archive_name == "objects.rlb"`. `archive_name == "objects.rlb"`.
При построении mission prototype graph FParkan сохраняет для каждого root
упорядоченный список этих raw records. Список передаётся в
`MissionObjectDraft` без преобразования `kind`, `parent_or_link`, description
или tails в предполагаемые роли. Так runtime уже располагает исходными данными
составного unit для будущих Control/physics/AniMesh consumers, но не выдаёт
структурное сходство за доказанную семантику.
Каждый graph edge от `UnitDatRoot` к effective component и далее к его MSH
dependency также несёт `unit_component_index`. Это индекс исходной записи в
порядке файла, а не тип компонента; по нему diagnostics и будущие consumers
могут однозначно вернуться к raw 112-byte record.
Visual dependency expansion наследует этот индекс на edges к WEAR, MAT0,
diffuse texture и lightmap. Поэтому ошибка или asset в любой из этих фаз
сохраняет путь до конкретной записи unit DAT, а не только до миссионного object.
Для воспроизводимой проверки используется `fparkan-cli prototype inspect`.
Схема JSON `fparkan-prototype-inspect-v2` содержит lossless hex каждого
32-byte поля unit record и materialized graph edges с `unit_component_index`.
Например, для GOG AutoDemo:
```powershell
cargo run -p fparkan-cli -- prototype inspect `
--root 'C:\GOG Games\Parkan - Iron Strategy' `
--key 'UNITS\UNITS\AutoDEMO\w_m_wlk2.dat' --format json
```
Вывод фиксирует 18 component records, их MSH/WEAR/MAT0/Texm dependencies и
точные parent edges. JSON предназначен для анализа и regression evidence, а
не для интерпретации `kind`, links или opaque tails как готовой game logic.
## Вспомогательные форматы ## Вспомогательные форматы
MSH, материал и текстура отвечают за видимую форму. Полноценный прототип MSH, материал и текстура отвечают за видимую форму. Полноценный прототип
+29
View File
@@ -135,6 +135,14 @@ repeat relation_count:
TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как
raw-поля, пока их потребительская семантика не закрыта. raw-поля, пока их потребительская семантика не закрыта.
Runtime уже использует первую строку как доказанный путь выбора пакета: к ней
добавляется `.scr`, если расширение отсутствует, и полученный compiled package
декодируется losslessly до регистрации мира. Для каждого клана сохраняются
индекс, исходный base path, нормализованный `.scr` path и пакет событий. Это
не запускает opcode и не приписывает ему gameplay-эффект, но делает сценарный
input частью транзакции загрузки: missing/invalid package отменяет mission
load до публикации частичного world state.
Mode `0` имеет отдельный count-driven layout: Mode `0` имеет отдельный count-driven layout:
```text ```text
@@ -555,6 +563,27 @@ candidate areas, затем выполняется точная геометри
Если область не найдена, caller получает явный miss и решает, допустим ли Если область не найдена, caller получает явный miss и решает, допустим ли
fallback к ближайшей области. fallback к ближайшей области.
### Индекс поверхности
`TerrainWorld` хранит отдельный deterministic BVH по валидированным
`TerrainFace28` triangles. Он не заменяет `Land.map` grid: BVH отвечает за
низкоуровневые surface queries (`height_at` и `raycast`), тогда как grid и
areal graph остаются навигационным контрактом.
При построении индекс делит triangles по centroid вдоль самой протяжённой оси;
в leaf остаётся не более восьми face indices. XY query проходит только листья,
чьи AABB покрывают точку, а raycast — только AABB, пересечённые лучом. После
выбора кандидатов raycast сортирует их по исходному face index, поэтому при
равной дистанции сохраняется прежний deterministic tie-break; `height_at`
по-прежнему выбирает максимальную высоту среди действительно покрывающих
точку triangles. Индекс меняет стоимость запроса, но не геометрическую
семантику.
Synthetic test проверяет сокращение candidate set и прежние height/raycast
results. Licensed run проходит все 33 `Land.msh` Части 1 и 32 файла Части 2:
для каждого mesh с числом faces выше leaf limit query у центра первого face
включает этот face и использует меньше кандидатов, чем полный mesh.
### Маршрут ### Маршрут
После определения начальной и целевой областей маршрут строится по графу После определения начальной и целевой областей маршрут строится по графу
File diff suppressed because it is too large Load Diff
+173
View File
@@ -133,6 +133,33 @@ Wizard получает желаемое направление и corridor, а
описывает типы, defaults, ranges и строки через макросоподобные формы описывает типы, defaults, ranges и строки через макросоподобные формы
`VAR(...)` и `STRING(...)`. `VAR(...)` и `STRING(...)`.
Compiled package больше не opaque blob: `fparkan-script` losslessly читает
проверенный внешний framing. Сначала идут `opcode_handler_count` и
`event_count` (`u32 LE`), затем именованные события с NUL-terminated raw
именем и nested records. Reader сохраняет все seven raw header words и
references каждого record, жёстко ограничивает counts/allocations и отдельно
сохраняет trailing bytes. На `c1m2p.scr` GOG reader получает `73` handlers,
`9` events, `17` records и `20` references без trailing bytes. Это структура
файла, но не таблица семантик: названия opcode/words появятся только после
handler contracts и runtime traces.
Связь первого header word с dispatch теперь доказана статически: `ai.dll`
создаёт 73 handler pointers в известном порядке и копирует table без
перестановки. По всем 58 GOG packages первый word — индекс `0..72` либо
`0xffff_ffff` sentinel; `fparkan-script` отражает это как typed
`ScriptDispatchSelector`, сохраняя неожиданные значения `Unknown`. Первый
handler лишь инициирует execution context (`+0x50 = 1`); его gameplay meaning
пока не назван.
Первый часто встречающийся table entry с side effect — `Handler(2)` (176
records в corpus). Он берёт active instruction, разрешает семь slots через
varset, приводит три значения к float по observed kinds `5`/`3`, затем
materializes или refresh-ит внутренний event record. Его base string связывает
`<base>_Start` и `<base>_Continue` с event table; прямого World3D/Behavior
call на этой ветке не найдено. Slot names и consumer record ещё не установлены
динамически, поэтому Rust не исполняет handler как гипотетическую команду
движения/атаки/строительства.
Безопасная runtime-модель: Безопасная runtime-модель:
```text ```text
@@ -154,6 +181,36 @@ IDs и отправляет команды через игровые interfaces,
открытым направлением. До появления decompiler-а `.scr` binary body сохраняется открытым направлением. До появления decompiler-а `.scr` binary body сохраняется
lossless, а доказанные symbol/event tables документируются отдельно. lossless, а доказанные symbol/event tables документируются отдельно.
### Подтверждённый evaluator выражений
Ghidra 12.1.2 decompile GOG `ai.dll` фиксирует отдельный evaluator по VA
`0x10005180` (не dispatcher инструкций `.scr`). Он получает индекс записи,
ищет её в контейнере `this + 0x34`, переключается по `u32 tag` в offset `+0`
и при успешном результате пишет completion byte в `+0x0c`. Из кода доказаны
ровно пять ветвей `tag = 1..5`; `tag = 1..3` требуют `u32` subtype в
`+0x04 == 0`, а `tag = 4..5``+0x04 == 1`. Поле payload находится по
`+0x08`.
Ветки 1--3 делают lookup через object/interface, достижимый от
`this + 0x60 + 0x35c`, и используют его virtual slot `+0x1c`. Ветка 2
дополнительно читает virtual slots `+0x10`/`+0x18` возвращённого объекта и
разрешает result tags `1`, `0x12`, `0x13`. Ветка 3 сравнивает virtual slot
`+0x44` с текущим object value. Ветки 4--5 используют `payload` как index в
контейнере `this + 0x4c`, обходят его child indices и делают тот же lookup;
их разные success-guards пока не именуются семантически. Это доказывает
typed condition/evaluation layer, но **не** формат `.scr`, размеры инструкций
или связь чисел tag с языковыми операторами.
Выгрузка воспроизводится без изменения PE:
```powershell
& 'C:\Tools\ghidra_12.1.2_PUBLIC\support\analyzeHeadless.bat' `
C:\temp\fparkan-ghidra ai -import 'C:\GOG Games\Parkan - Iron Strategy\ai.dll' `
-processor x86:LE:32:default `
-scriptPath C:\Develop\fparkan\tools\ghidra `
-postScript ExportAiExpressionDispatcher.java -deleteProject
```
### TRF и preload-данные ### TRF и preload-данные
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
@@ -206,6 +263,82 @@ CreateCollObject
тригонометрию и `g_FastProc`. Это подтверждает его положение между gameplay тригонометрию и `g_FastProc`. Это подтверждает его положение между gameplay
object и геометрией мира. object и геометрией мира.
Статическая сверка GOG `Control.dll` уточняет границу, но пока не раскрывает
per-tick solver. PE32 image base — `0x10000000`; exports имеют следующие RVA:
```text
InitializeSettings 0x32260
LoadControlSystem 0x32280
LoadPhysicalModel 0x32580
CreateCollManager 0x325d0
CreateCollObject 0x32600
```
`LoadControlSystem` возвращается `ret 0x20`, следовательно ABI снимает со
стека восемь 32-bit аргументов. Оно выбирает allocation размером `0x668` при
mode `9` и `0x670` для другого mode, создаёт внутренний reader через virtual
dispatch с selector `10`, переносит несколько caller strings в локальные
buffers и передаёт собранную settings-структуру дальше через virtual slot
`+0x08` с key `0x80000020`. Это доказывает loader/configuration boundary и
два layout variants, но не даёт права назвать поля скоростью или acceleration.
`LoadPhysicalModel` аналогично создаёт `0xa0`-byte reader и возвращается
`ret 0x0c`; `CreateCollManager` и `CreateCollObject` возвращают interface
pointer с поправкой `+4` после внутренней инициализации.
Headless Ghidra 12.1.2 decompile GOG binary подтверждает ABI формой экспортов
`LoadControlSystem(char*, char*, char*, char*, char*, u32, void*, i32)`,
`LoadPhysicalModel(u32, u32, u32)`, `CreateCollManager(u32)` и
`CreateCollObject(u32, u32)`. Первые пять параметров Control loader — строки,
а mode передаётся последним; decompiler не восстанавливает предметные имена
остальных слов. `InitializeSettings` получает `CreateGameSettings()` из
World3D и делает virtual call slot `+0x24` с literal `0x15` и строкой по RVA
`0x42478`. Reproducible extractor находится в
`tools/ghidra/ExportControlFunctions.java`; он декомпилирует только эти exports
в локальном Ghidra project и не изменяет оригинальную DLL.
Именно update methods этих private objects, а не пять exports, остаются
следующим объектом динамической трассировки. Поэтому reference movement в
новом runtime намеренно не использует неподтверждённые параметры Control.
### Связь с AniMesh
PE import table GOG `AniMesh.dll` показывает, что это прямой consumer
`CreateCollManager`, `CreateCollObject` и `LoadControlSystem`; других DLL,
которые статически импортируют эти три named exports, не найдено. Внутренний
AniMesh path по VA `0x100032e7` вызывает Control thunks в строгой наблюдаемой
последовательности:
```text
LoadControlSystem (thunk 0x1001934e)
-> CreateCollObject (thunk 0x10019348)
-> CreateCollManager (thunk 0x10019342)
```
После factory calls caller immediately берёт returned interface vtable и
делает дальнейшие virtual calls; это связывает Control с загрузкой
AniMesh/unit components, а не с одной глобальной настройкой процесса. В
частности, `CreateCollObject` получает два stack arguments, а
`CreateCollManager` — один. Предметные значения этих arguments, ownership
private objects и per-tick update slots пока не восстановлены; порядок
создания не доказывает скорость, collision algorithm или AI semantics.
Headless Ghidra call-site decompile уточняет configuration provenance:
AniMesh передаёт в `LoadControlSystem` шесть 32-byte strings из одного
configuration block по offsets `+0x80`, `+0xa0`, `+0xc0`, `+0xe0`, `+0x100` и
`+0x120`, затем resource context и mode из owner `+0x6d8`. Returned Control
interface сразу получает пять virtual calls, связывающих его с owner slots
`+0x158`, `+0x160`, `+0x164`, `+0x168` и `+0x18c`; collision object затем
связывается с `+0x170`. Это достаточное основание хранить будущий Control
component как ordered raw-string/resource provenance, но не для присвоения
этим строкам смысловых имён до трассировки private update methods. Extractor:
`tools/ghidra/ExportAniMeshControlCaller.java`.
Runtime сохраняет ordered raw Unit DAT records рядом с каждым mission object
draft. Это создаёт проверяемую границу передачи данных от loader-а к будущему
Control consumer-у: никакой компонент пока не получает имя `Control` только
по `kind`, `parent_or_link` или description; semantic binding появится лишь
после trace private update/load methods.
### Control system и physical model ### Control system и physical model
`LoadControlSystem` загружает настройки controller-а: ограничения скорости, `LoadControlSystem` загружает настройки controller-а: ограничения скорости,
@@ -290,6 +423,46 @@ Control получает world-interface Terrain и использует пов
последовательности, а интеграция использует одну политику `dt` и округления. последовательности, а интеграция использует одну политику `dt` и округления.
Иначе одинаковая миссия постепенно расходится даже без сети. Иначе одинаковая миссия постепенно расходится даже без сети.
#### Reference controller в текущем runtime
`fparkan-runtime::advance_reference_movement` — намеренно маленький
детерминированный мост между сохранённым mission transform и `TerrainWorld`.
Он получает `OriginalObjectId`, явную XY-цель и положительный максимум шага,
находит только live/registered object по исходному ID, двигает XY не более чем
на этот шаг и записывает высоту из `TerrainWorld::height_at`. Orientation и
scale остаются исходными IEEE-754 words; функция не изменяет clock, очередь
World3D или animation state. Возвращаемое значение означает достижение именно
заданной XY-цели.
Это **не** восстановленный Control, Behavior или navigation controller:
функция не строит маршрут, не использует `dt`, скорость, terrain normal,
коллизии, acceleration и оригинальные AI decisions. Она существует как
проверяемый reference path, который фиксирует границу будущих controller-ов и
не позволяет renderer-у или gameplay обходить terrain query. Non-finite input,
неположительный шаг, отсутствующая миссия/объект и XY вне поверхности дают
явную ошибку без частичного изменения transform.
Licensed test на GOG `Autodemo.00` запускает этот путь для live mission object,
находит существующую поверхность и проверяет точные XY/Z words после snap. Это
доказывает связывание current runtime data, но не доказывает семантику
оригинального движения.
Путь доступен и через самостоятельный composition root:
```powershell
fparkan-headless --root "C:\GOG Games\Parkan - Iron Strategy" `
--mission MISSIONS/Autodemo.00/data.tma `
--move-object 0 419.10318 717.433 0.25 --ticks 1
```
`--move-object` принимает original object ID, target X/Y и maximum step;
требует `--root` и `--mission`, допускается один раз за запуск и отвергает
non-finite/неположительный шаг ещё при разборе аргументов. Приложение печатает
`reached`, затем normal headless tick/hash. Проверка на GOG 18 июля 2026 года
загрузила 8 objects, 343 areals и 3 174 terrain surfaces без graph failures;
команда для объекта `0` вернула `reached=false`, что подтверждает именно
ограниченный шаг, а не телепортацию к цели.
### Различия Control в Части 2 ### Различия Control в Части 2
`Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals `Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals
+37
View File
@@ -219,6 +219,13 @@ graph.
текстурированную статическую модель из исходного архива. Красивый viewer всё ещё текстурированную статическую модель из исходного архива. Красивый viewer всё ещё
означает только asset compatibility, а не готовую игру. означает только asset compatibility, а не готовую игру.
Текущее состояние репозитория нужно формулировать строже. `apps/fparkan-viewer`
сейчас является inspection CLI и synthetic command producer, а не live Vulkan
asset viewer. Реальный Vulkan в репозитории сегодня доказан только через
Stage 0 smoke triangle path; Stage 3 GPU vertical slice для оригинального
`MSH` + `Texm` + `WEAR/MAT0` + terrain остаётся блокером. Для различения
smoke, planning и live GPU путей используйте [таблицу правды renderer paths](../rendering/renderer_truth_table.md).
### Этап 4. Анимация и эффекты ### Этап 4. Анимация и эффекты
- реализовать MSH type 8/type 19 sampling и hierarchy; - реализовать MSH type 8/type 19 sampling и hierarchy;
@@ -232,6 +239,13 @@ graph.
923/1 065 FXID создаются без parser errors; перезапуск одинакового effect seed 923/1 065 FXID создаются без parser errors; перезапуск одинакового effect seed
даёт идентичный список emitted primitives. даёт идентичный список emitted primitives.
Текущее состояние репозитория опять же уже, чем целевой этап. В коде есть
portable reference sampler и детерминированный FX reference stub, но нет
runtime-captured parity для lifecycle/opcode semantics и нет Stage 4 rendered
acceptance поверх live Vulkan asset renderer. Поэтому rendered Stage 4 следует
считать заблокированным входным gate Stage 3, а parallel Stage 4 work вести
через captures, schemas и backend-neutral snapshots.
### Этап 5. Карта и мир ### Этап 5. Карта и мир
- реализовать `Land.msh` и corrected `TerrainFace28` layout; - реализовать `Land.msh` и corrected `TerrainFace28` layout;
@@ -360,6 +374,29 @@ Demo mission total: 201 objects -> 501 prototypes -> 501 object MSH/WEAR.
### Deterministic simulation replay ### Deterministic simulation replay
#### Mission transform state in the world contract
`fparkan-world` now carries a `TransformState` for every live object: the
three TMA position words, three orientation words and three scale words are
preserved as exact IEEE-754 bit patterns. This stores source identity before a
movement or physics controller interprets axes, units or Euler order.
`WorldSnapshot` publishes transforms in stable object-handle order and the
canonical SHA-256 state hash includes every transform word.
Mission loading assigns this state after construction and before registration.
A headless licensed GOG AutoDemo run on 2026-07-18 loaded eight objects, 343
areals and 3,174 terrain surfaces with zero graph failures, then completed two
deterministic ticks. This is the state foundation for a future route/movement
controller; it does not claim recovered velocity, collision or original
behavior-controller semantics.
The ordinary planning renderer now consumes this snapshot state before falling
back to a mission draft. Its current transform bridge applies the preserved
position and non-uniform scale only; raw orientation remains uninterpreted in
this backend-neutral path. A GOG AutoDemo planning run on 2026-07-18 completed
two ticks with eight objects, 66 draws and state hash
`a54855a4f47ffa380911228f295dd49a9a7b88d6ff271a23db48ba318b1fbbb4`.
Записывается начальная миссия, seed, input events, network messages и значения Записывается начальная миссия, seed, input events, network messages и значения
внешних часов. На контрольных ticks сохраняется canonical state hash: внешних часов. На контрольных ticks сохраняется canonical state hash:
+89 -3
View File
@@ -198,9 +198,9 @@ legacy ABI: n3d*, vrt*, bsp* compatibility entries
Адреса указаны как RVA конкретной исследованной сборки: Адреса указаны как RVA конкретной исследованной сборки:
```text ```text
World3D stdCalculateGame 0x13910 World3D stdCalculateGame 0x139A0
World3D stdRenderGame 0x13B60 World3D stdRenderGame 0x13BD0
World3D sendEndOfRender 0x13D20 World3D sendEndOfRender 0x13D90
World3D UpdateManualEvents 0x10E10 World3D UpdateManualEvents 0x10E10
World3D ClearManualEvents 0x11180 World3D ClearManualEvents 0x11180
World3D DeleteGameObject 0x087B0 World3D DeleteGameObject 0x087B0
@@ -215,6 +215,13 @@ RVA используются только для сопоставления и
implementation не должна встраивать их как постоянные игровые идентификаторы. implementation не должна встраивать их как постоянные игровые идентификаторы.
Таблица внутренних RVA хранится по SHA-256 конкретного модуля. Таблица внутренних RVA хранится по SHA-256 конкретного модуля.
Операционные evidence-артефакты, которые должны оставаться синхронизированными
с кодом и acceptance, вынесены в отдельные страницы:
- [Hashes и import/export summary оригинального движка](../evidence/original_engine_hashes.md)
- [Stage 4 capture schema](../evidence/stage4_capture_schema.md)
- [Renderer truth table](../rendering/renderer_truth_table.md)
Подтверждённые hashes неизменённых DLL: Подтверждённые hashes неизменённых DLL:
```text ```text
@@ -222,6 +229,85 @@ World3D.dll 17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e
Ngi32.dll bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5 Ngi32.dll bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5
``` ```
Повторный headless IDA/Hex-Rays review GOG `World3D.dll` с этим hash уточнил
RVA export-ов: `stdCalculateGame=0x139A0`, `stdRenderGame=0x13BD0`,
`sendEndOfRender=0x13D90`, `stdSetCurrentCamera=0x13E60` и
`stdGetCurrentCamera=0x13E80`. Предыдущая таблица была сдвинута и не должна
использоваться для hooks или differential capture.
`stdRenderGame(camera)` сначала вызывает экспорт Terrain
`stdSetCurrentCamera2(camera)`, затем сохраняет текущий camera pointer в
глобальном состоянии World3D. После этого виден запрос camera interface через
selector `6`, запрос связанного service через selector `264`, renderer/world
boundary slots и traversal render queues. В конце pointer очищается; dispatch
end-of-render callbacks вынесен также в отдельный `sendEndOfRender`.
Это доказывает порядок передачи camera и границы frame lifecycle, но не layout
camera object, не матрицы projection/view и не значения viewport selectors.
Отдельная проверка GOG `Terrain.dll` (`AF87D1B2E728A0BE73C52BE3B44CC196AB46DA7799F25A15D40F8C9B0B425EAD`,
499 712 bytes) уточняет receiver side. `stdSetCurrentCamera2` находится по
RVA `0x4FD40`: при инициализированном Terrain он требует у переданного объекта
interface selector `18` и вызывает slot `+12` результата. Он **не** записывает
переданный pointer в `stdGetCurrentCamera2`. Последний возвращает Terrain global,
который внутренний initialization path устанавливает результатом selector `8` на
Terrain object. Следовательно, selector `18`, slot `+12` и global selector `8`
должны оставаться именованными evidence boundary до dynamic capture; считать
`stdGetCurrentCamera2` getter-ом переданной camera было бы ошибкой.
Повторный headless-IDA review той же GOG базы уточняет рабочий static contract
`CBufferingCamera`. Метод Terrain RVA `0x4D740` копирует ровно 64 байта
(16 dword) в component offset `+0x10`. Frame-preparation метод RVA `0x4D9C0`
получает viewport rectangle через virtual slot `+0x3C`, выводит width, height,
centre и aspect, а projection читает через camera interface. Для projection type
`0` он использует float по component offset `+0x234` в `tan(angle / 2)`; для
type `2` получает five-float block через slot `+0x70`; иной non-zero type
завершается `Not supported projection type`. Это доказывает зависимость
projection от live camera/viewport, но не row/column convention, единицы угла,
near/far mapping, handedness или initial camera selection. Важно, что строки
`ICamera::SetTransformMatrix` (RVA `0x4F830`) и
`ICamera::GetTransformMatrix` (RVA `0x4F850`) ведут только в obsolete-call
stubs и не дают usable ABI.
Live elevated read-only probe впервые подтвердил relocation-aware runtime связь:
`Terrain.dll` был загружен по `0x02510000`, global `base + 0x7355C` содержал
non-null `0x0B37DF08`, а первый dword этого объекта был `0x025765B4` — ровно
relocated `off_100665B4` из `LoadCamera` construction path. Это доказывает
live camera object с outer vtable, но одновременно исправляет прежнее слишком
сильное сопоставление offsets: raw read `global + 0x10` не дал finite 4x4 matrix,
а `+0x234` был zero в данном sample. Receiver static procedures `0x4D740`/
`0x4D9C0` и exported global ещё не доказаны как один layout без interface
adjustment; их offsets остаются unassigned до recovery selector relationship.
Локальная IDA-база уточняет адреса этой связи: `stdGetCurrentCamera2` — это
шестибайтный getter по RVA `0x4FD80`, который возвращает `dword` по RVA
`0x7355C`. Единственный найденный **direct static** initializer этого global — функция Terrain
по RVA `0x4D4D0`: она запрашивает selector `8` у своего `this` и сохраняет
полученный interface pointer. Это доказывает адрес хранения и путь заполнения,
но не разрешает трактовать pointer как конкретный layout камеры либо читать его
как runtime evidence без доступа к процессу на том же уровне привилегий.
Elevated live sampling теперь доказывает, что direct static xref не исчерпывает
runtime writers: за 25 секунд autoplay global переключился между тремя heap
pointer, все с relocated outer vtables `0x025765B4`/`0x02576558`. У двух объектов
paired blocks `+0x2C/+0x3C/+0x4C` и `+0x6C/+0x7C/+0x8C` синхронно несли
world-like translation, например `(491.562, 761.551, 7.361)`; третий давал
normalized-looking `(0.098, 0.018, 0.856)` и не совпадал с paired block.
Наблюдение согласуется с автоматическими camera switches, но не маркирует mode;
оно запрещает называть `0x4D4D0` единственным runtime writer и требует recovery
indirect/unanalyzed write path.
Outer vtable `off_100665B4` теперь даёт exact transform adjustment для
world-like sample. Slot `+0x54` вызывает у subobject `outer + 4` slot `+0x20`
с selector `0`, затем копирует returned `+0x0C/+0x1C/+0x2C`. В live объекте
selector field `outer + 0x10` был `0xFFFFFFFF`; реализация selector `0` при
этом возвращает `outer + 0x20`. Значит observed triple
`outer + 0x2C/+0x3C/+0x4C` — доказанная translation часть active affine
transform, а не корреляция. Selector `2` возвращает paired block `outer + 0x60`;
outer slot `+0x70` применяет `atan2` к его axis values, что доказывает
orientation-angle path. Названия полей, angle order и handedness пока не
установлены, но raw affine transform и translation можно сохранять как
backend-neutral camera pose без догадок.
### Vtable и interface negotiation ### Vtable и interface negotiation
Вызовы вида `object->vfunc(offset)` доказывают порядок slots, даже когда имя Вызовы вида `object->vfunc(offset)` доказывают порядок slots, даже когда имя
+61 -23
View File
@@ -1,5 +1,7 @@
# Acceptance coverage manifest. # Acceptance coverage manifest.
# Format: <acceptance-id>\t<covered|partial|blocked|omitted>\t<evidence> # Format: <acceptance-id>\t<covered|covered-planning|covered-stub|covered-gpu|partial|blocked|omitted>\t<evidence>
# Scope note: Stage 0 native runtime acceptance is Windows-only.
# Scope note: Linux/macOS and hosted CI are outside the project scope.
L0-COPYRIGHT-001 covered cargo test -p fparkan-corpus --offline report_json_contains_metrics_and_hashes_not_paths_or_payloads L0-COPYRIGHT-001 covered cargo test -p fparkan-corpus --offline report_json_contains_metrics_and_hashes_not_paths_or_payloads
L0-P1-001 covered cargo test -p fparkan-corpus --offline licensed_part1_manifest_profile_and_counts_match_baseline L0-P1-001 covered cargo test -p fparkan-corpus --offline licensed_part1_manifest_profile_and_counts_match_baseline
L0-P1-002 covered cargo test -p fparkan-corpus --offline licensed_part1_has_no_casefold_relative_path_collisions L0-P1-002 covered cargo test -p fparkan-corpus --offline licensed_part1_has_no_casefold_relative_path_collisions
@@ -15,7 +17,7 @@ S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rus
S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift
S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job
S0-ARCH-010 covered cargo xtask acceptance audit emits measured commit_sha, git_dirty, runner_identity, rust_toolchain, and msrv metadata into the JSON artifact S0-ARCH-010 covered cargo xtask acceptance audit emits measured commit_sha, git_dirty, runner_identity, rust_toolchain, and msrv metadata into the JSON artifact
S0-ARCH-011 covered .github/workflows/ci.yml runs cargo run -p fparkan-vulkan-smoke --locked -- --out target/fparkan/native-smoke/macos.json and cargo xtask native-smoke audit enforces a passed macOS 300-frame report with measured resize/recreate, validation=0, clean git provenance, exact commit SHA shape, and a platform-consistent target triple S0-ARCH-011 covered cargo xtask native-smoke audit requires a passed Windows 300-frame report with measured resize/recreate, validation=0, clean git provenance, exact commit SHA shape, and a Windows target triple
S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context
S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable
S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent
@@ -30,7 +32,7 @@ S0-PLAT-001 covered cargo test -p fparkan-platform-winit --offline window_port_r
S0-PLAT-002 covered cargo clippy -p fparkan-platform -p fparkan-platform-winit --all-targets --all-features --locked -- -D warnings S0-PLAT-002 covered cargo clippy -p fparkan-platform -p fparkan-platform-winit --all-targets --all-features --locked -- -D warnings
S0-PLAT-003 covered cargo test -p fparkan-platform-winit --offline smoke_window_plan_requires_native_handles_and_nonzero_extent smoke_window_plan_rejects_zero_extent S0-PLAT-003 covered cargo test -p fparkan-platform-winit --offline smoke_window_plan_requires_native_handles_and_nonzero_extent smoke_window_plan_rejects_zero_extent
S0-PLAT-004 covered cargo test -p fparkan-platform-winit --offline smoke_window_app_requires_created_native_window smoke_window_app_rejects_synthetic_window_without_native_handles S0-PLAT-004 covered cargo test -p fparkan-platform-winit --offline smoke_window_app_requires_created_native_window smoke_window_app_rejects_synthetic_window_without_native_handles
S0-VK-001 covered cargo test -p fparkan-render-vulkan --offline planning_backend_tracks_render_request_and_simulated_present S0-VK-001 covered-gpu Windows fparkan-vulkan-smoke native audit: real instance, Win32 surface, logical device, swapchain and indexed triangle presentation
S0-VK-002 covered cargo test -p fparkan-render-vulkan --offline device_scoring_is_deterministic_and_prefers_discrete_unified_queue S0-VK-002 covered cargo test -p fparkan-render-vulkan --offline device_scoring_is_deterministic_and_prefers_discrete_unified_queue
S0-VK-003 covered cargo test -p fparkan-render-vulkan --offline portability_subset_is_reported_and_enabled_when_exposed S0-VK-003 covered cargo test -p fparkan-render-vulkan --offline portability_subset_is_reported_and_enabled_when_exposed
S0-VK-004 covered cargo test -p fparkan-render-vulkan --offline rejects_missing_graphics_present_swapchain_and_format S0-VK-004 covered cargo test -p fparkan-render-vulkan --offline rejects_missing_graphics_present_swapchain_and_format
@@ -50,8 +52,8 @@ S0-VK-017 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_j
S0-VK-018 covered cargo test -p fparkan-render-vulkan --offline triangle_shader_manifest_hashes_are_stable checked_in_shader_manifest_matches_generated_report S0-VK-018 covered cargo test -p fparkan-render-vulkan --offline triangle_shader_manifest_hashes_are_stable checked_in_shader_manifest_matches_generated_report
S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_report_json_is_stable S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_report_json_is_stable
S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers
S0-VK-021 covered cargo test -p fparkan-render-vulkan --offline frame_submission_plan_json_is_stable S0-VK-021 covered-gpu Windows fparkan-vulkan-smoke native audit: real submit/present loop, 300 frames and validation-clean teardown
S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline planning_backend_tracks_render_request_and_simulated_present S0-VK-022 covered-gpu Windows fparkan-vulkan-smoke native audit: resize drives swapchain recreation before clean shutdown
S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_and_bootstrap_status S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_and_bootstrap_status
S0-VK-024 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_loader_probe formats_vulkan_api_version S0-VK-024 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_loader_probe formats_vulkan_api_version
S0-VK-025 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_instance_probe parses_instance_probe_as_loader_probe S0-VK-025 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_instance_probe parses_instance_probe_as_loader_probe
@@ -64,6 +66,7 @@ S0-VK-031 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_wi
S0-VK-032 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_swapchain S0-VK-032 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_swapchain
S0-VK-033 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_logical_device S0-VK-033 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_logical_device
S0-VK-034 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_required_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports S0-VK-034 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_required_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
S0-VK-035 covered cargo test -p fparkan-render-vulkan --offline final_validation_snapshot_is_captured_after_surface_device_swapchain_drop
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
@@ -102,6 +105,8 @@ S1-NRES-022 covered cargo test -p fparkan-nres --offline canonical_compact_round
S1-NRES-023 covered cargo test -p fparkan-nres --offline editor_payload_update_rewrites_offsets_and_size S1-NRES-023 covered cargo test -p fparkan-nres --offline editor_payload_update_rewrites_offsets_and_size
S1-NRES-024 covered cargo test -p fparkan-nres --offline editor_rename_rebuilds_search_mapping S1-NRES-024 covered cargo test -p fparkan-nres --offline editor_rename_rebuilds_search_mapping
S1-NRES-025 covered cargo test -p fparkan-nres --offline editor_rejects_invalid_authoring_names S1-NRES-025 covered cargo test -p fparkan-nres --offline editor_rejects_invalid_authoring_names
S1-NRES-026 covered cargo test -p fparkan-nres --offline strict_rejects_unsorted_lookup_table
S1-LIMIT-001 covered cargo test -p fparkan-nres --offline rejects_directory_size_before_allocation decode_rejects_entry_count_above_limit decode_rejects_input_bytes_above_limit decode_rejects_preserved_bytes_above_limit
S1-NRES-PROP-001 covered cargo test -p fparkan-nres --offline generated_archives_preserve_lossless_and_canonical_semantics S1-NRES-PROP-001 covered cargo test -p fparkan-nres --offline generated_archives_preserve_lossless_and_canonical_semantics
S1-NRES-PROP-002 covered cargo test -p fparkan-nres --offline generated_editor_updates_roundtrip S1-NRES-PROP-002 covered cargo test -p fparkan-nres --offline generated_editor_updates_roundtrip
S1-NRES-FUZZ-001 covered cargo test -p fparkan-nres --offline arbitrary_small_inputs_do_not_panic_or_overallocate S1-NRES-FUZZ-001 covered cargo test -p fparkan-nres --offline arbitrary_small_inputs_do_not_panic_or_overallocate
@@ -114,6 +119,8 @@ S1-PATH-006 covered cargo test -p fparkan-path --offline rejects_absolute_drive_
S1-PATH-007 covered cargo test -p fparkan-path --offline join_under_keeps_normalized_path_below_root S1-PATH-007 covered cargo test -p fparkan-path --offline join_under_keeps_normalized_path_below_root
S1-PATH-008 covered cargo test -p fparkan-path --offline original_separators_and_raw_bytes_are_preserved S1-PATH-008 covered cargo test -p fparkan-path --offline original_separators_and_raw_bytes_are_preserved
S1-PATH-009 covered cargo test -p fparkan-path --offline accepts_non_utf8_legacy_bytes S1-PATH-009 covered cargo test -p fparkan-path --offline accepts_non_utf8_legacy_bytes
S1-PATH-010 covered cargo test -p fparkan-vfs --offline directory_vfs_resolves_non_utf8_host_entries_by_raw_bytes
S1-PATH-011 covered cargo test -p fparkan-path -p fparkan-vfs -p fparkan-resource --offline lossy_display_does_not_affect_identity_or_ordering overlay_vfs_keeps_lossy_equivalent_entries_distinct lossy_equivalent_archive_paths_remain_distinct
S1-VFS-005 covered cargo test -p fparkan-vfs --offline memory_vfs_list_prefix_is_boundary_safe S1-VFS-005 covered cargo test -p fparkan-vfs --offline memory_vfs_list_prefix_is_boundary_safe
S1-RSLI-001 covered cargo test -p fparkan-rsli --offline parses_minimal_empty_library S1-RSLI-001 covered cargo test -p fparkan-rsli --offline parses_minimal_empty_library
S1-RSLI-002 covered cargo test -p fparkan-rsli --offline rejects_invalid_header_fields S1-RSLI-002 covered cargo test -p fparkan-rsli --offline rejects_invalid_header_fields
@@ -138,16 +145,25 @@ S1-RSLI-020 covered cargo test -p fparkan-rsli --offline rejects_registered_quir
S1-RSLI-021 covered cargo test -p fparkan-rsli --offline named_deflate_eof_plus_one_quirk_accepts_only_approved_entry S1-RSLI-021 covered cargo test -p fparkan-rsli --offline named_deflate_eof_plus_one_quirk_accepts_only_approved_entry
S1-RSLI-022 covered cargo test -p fparkan-rsli --offline unknown_header_bytes_are_lossless S1-RSLI-022 covered cargo test -p fparkan-rsli --offline unknown_header_bytes_are_lossless
S1-RSLI-023 covered cargo test -p fparkan-rsli --offline no_op_lossless_roundtrip_preserves_bytes S1-RSLI-023 covered cargo test -p fparkan-rsli --offline no_op_lossless_roundtrip_preserves_bytes
S1-RSLI-024 covered cargo test -p fparkan-rsli --offline strict_rejects_unsorted_presorted_mapping
S1-RSLI-025 covered cargo test -p fparkan-rsli --offline editor_can_mutate_names_and_payloads set_method_rejects_unknown_authoring_method
S1-LIMIT-002 covered cargo test -p fparkan-rsli --offline decode_rejects_entry_count_above_limit load_rejects_unpacked_size_above_limit_before_allocation
S1-RSLI-PROP-001 covered cargo test -p fparkan-rsli --offline generated_supported_methods_decode_expected_bytes S1-RSLI-PROP-001 covered cargo test -p fparkan-rsli --offline generated_supported_methods_decode_expected_bytes
S1-RSLI-FUZZ-001 covered cargo test -p fparkan-rsli --offline arbitrary_small_inputs_do_not_panic S1-RSLI-FUZZ-001 covered cargo test -p fparkan-rsli --offline arbitrary_small_inputs_do_not_panic
S1-RES-001 covered cargo test -p fparkan-resource --offline cached_repository_reads_synthetic_nres S1-RES-001 covered cargo test -p fparkan-resource --offline cached_repository_reads_synthetic_nres
S1-RES-002 covered cargo test -p fparkan-resource --offline entry_handles_are_archive_qualified S1-RES-002 covered cargo test -p fparkan-resource --offline entry_handles_are_archive_qualified
S1-RES-003 covered cargo test -p fparkan-resource --offline archive_cache_and_decoded_payload_cache_evict_independently S1-RES-003 covered cargo test -p fparkan-resource --offline archive_cache_and_decoded_payload_cache_evict_independently
S1-RES-004 covered cargo test -p fparkan-resource --offline entry_read_error_carries_archive_path_and_entry_name S1-RES-004 covered cargo test -p fparkan-resource --offline entry_read_error_carries_archive_path_and_entry_name
S1-RES-005 covered cargo test -p fparkan-resource --offline archive_cache_evicts_by_byte_budget
S1-RES-006 covered cargo test -p fparkan-resource --offline archive_cache_eviction_makes_old_handles_stale
S1-RES-007 covered cargo test -p fparkan-resource --offline lossy_equivalent_archive_paths_remain_distinct
S1-DIAG-001 covered cargo test -p fparkan-inspection --offline archive_diagnostic_preserves_source_path_phase_and_span model_archive_diagnostic_preserves_archive_entry_context
S1-VFS-001 covered cargo test -p fparkan-vfs --offline memory_vfs_uses_exact_lookup S1-VFS-001 covered cargo test -p fparkan-vfs --offline memory_vfs_uses_exact_lookup
S1-VFS-002 covered cargo test -p fparkan-vfs --offline overlay_vfs_uses_first_matching_layer S1-VFS-002 covered cargo test -p fparkan-vfs --offline overlay_vfs_uses_first_matching_layer
S1-VFS-003 covered cargo test -p fparkan-vfs --offline directory_vfs_resolves_ascii_casefolded_segments S1-VFS-003 covered cargo test -p fparkan-vfs --offline directory_vfs_resolves_ascii_casefolded_segments
S1-VFS-004 covered cargo test -p fparkan-vfs --offline casefold_selector_reports_ambiguous_segments S1-VFS-004 covered cargo test -p fparkan-vfs --offline casefold_selector_reports_ambiguous_segments
S1-LICENSED-001 covered local testdata corpora via FPARKAN_CORPUS_PART1_ROOT and FPARKAN_CORPUS_PART2_ROOT; cargo test -p fparkan-nres -p fparkan-resource -p fparkan-rsli --offline -- --ignored
S1-LICENSED-002 covered local testdata corpora via FPARKAN_CORPUS_PART1_ROOT and FPARKAN_CORPUS_PART2_ROOT; cargo test -p fparkan-nres -p fparkan-resource -p fparkan-rsli --offline -- --ignored
L2-P1-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts L2-P1-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts
L2-P2-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts L2-P2-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts
L2-P1-REG-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_registry_payloads_are_record_aligned L2-P1-REG-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_registry_payloads_are_record_aligned
@@ -191,6 +207,20 @@ S2-GRAPH-003 covered cargo test -p fparkan-assets --offline repository_plan_dedu
S2-GRAPH-004 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures S2-GRAPH-004 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures
S2-GRAPH-005 covered cargo test -p fparkan-cli --offline prototype_graph_json_has_canonical_field_order S2-GRAPH-005 covered cargo test -p fparkan-cli --offline prototype_graph_json_has_canonical_field_order
S2-GRAPH-006 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures S2-GRAPH-006 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures
S2-GRAPH-EDGE-001 covered cargo test -p fparkan-assets --offline graph_materializes_visual_dependency_nodes_and_edges
S2-GRAPH-PROV-001 covered cargo test -p fparkan-assets --offline graph_visual_dependency_edges_preserve_root_and_parent_provenance
S2-GRAPH-IDEMP-001 covered cargo test -p fparkan-assets --offline graph_visual_dependency_expansion_is_idempotent
S2-UNIT-EMPTY-001 covered cargo test -p fparkan-prototype --offline empty_unit_dat_resolves_as_zero_component_root
S2-ASSET-MODEL-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
S2-ASSET-WEAR-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
S2-ASSET-MATERIAL-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
S2-ASSET-TEXM-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
S2-ASSET-ID-001 covered cargo test -p fparkan-assets --offline forced_model_id_collision_is_rejected forced_wear_id_collision_is_rejected forced_material_id_collision_is_rejected forced_texture_id_collision_is_rejected
S2-ASSET-LIMIT-001 covered cargo test -p fparkan-assets --offline profiled_asset_preparation_reports_unique_asset_counts asset_preparation_limits_reject_texture_pixel_budget
S2-RUNTIME-DRAFT-001 covered local testdata/IS and testdata/IS2; cargo test -p fparkan-runtime --offline -- --ignored selected_is_and_is2_missions_preserve_runtime_object_drafts
S2-CLI-JSON-001 covered cargo test -p fparkan-cli --offline prototype_graph_json_has_canonical_field_order mission_graph_json_has_canonical_field_order
S2-LICENSED-MISSION-P1-001 covered local testdata/IS; cargo test -p fparkan-runtime --offline -- --ignored selected_is_and_is2_missions_preserve_runtime_object_drafts
S2-LICENSED-MISSION-P2-001 covered local testdata/IS2; cargo test -p fparkan-runtime --offline -- --ignored selected_is_and_is2_missions_preserve_runtime_object_drafts
S2-PROP-001 covered cargo test -p fparkan-prototype --offline generated_acyclic_prototype_graph_resolves_deterministically S2-PROP-001 covered cargo test -p fparkan-prototype --offline generated_acyclic_prototype_graph_resolves_deterministically
S2-FUZZ-001 covered cargo test -p fparkan-prototype --offline arbitrary_unit_and_registry_bytes_are_bounded_and_panic_free S2-FUZZ-001 covered cargo test -p fparkan-prototype --offline arbitrary_unit_and_registry_bytes_are_bounded_and_panic_free
L3-P1-MSH-001 covered cargo test -p fparkan-msh --offline licensed_corpus_msh_assets_validate L3-P1-MSH-001 covered cargo test -p fparkan-msh --offline licensed_corpus_msh_assets_validate
@@ -203,8 +233,8 @@ L3-P1-WEAR-001 covered cargo test -p fparkan-material --offline licensed_corpus_
L3-P2-WEAR-001 covered cargo test -p fparkan-material --offline licensed_corpus_mat0_and_wear_parse L3-P2-WEAR-001 covered cargo test -p fparkan-material --offline licensed_corpus_mat0_and_wear_parse
L3-P1-ASSET-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations L3-P1-ASSET-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
L3-P2-ASSET-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations L3-P2-ASSET-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
L3-P1-CAPTURE-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures S2-PLANNING-CAPTURE-P1-001 covered-planning cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
L3-P2-CAPTURE-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures S2-PLANNING-CAPTURE-P2-001 covered-planning cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
S3-WEAR-001 covered cargo test -p fparkan-material --offline wear_preserves_legacy_id_but_selects_by_index S3-WEAR-001 covered cargo test -p fparkan-material --offline wear_preserves_legacy_id_but_selects_by_index
S3-WEAR-002 covered cargo test -p fparkan-material --offline wear_requires_declared_rows S3-WEAR-002 covered cargo test -p fparkan-material --offline wear_requires_declared_rows
S3-WEAR-003 covered cargo test -p fparkan-material --offline wear_preserves_legacy_id_but_selects_by_index S3-WEAR-003 covered cargo test -p fparkan-material --offline wear_preserves_legacy_id_but_selects_by_index
@@ -255,17 +285,25 @@ S3-MAT-RESOLVE-002 covered cargo test -p fparkan-material --offline resolve_mate
S3-MAT-RESOLVE-003 covered cargo test -p fparkan-material --offline resolve_material_uses_first_entry_only_after_missing_default S3-MAT-RESOLVE-003 covered cargo test -p fparkan-material --offline resolve_material_uses_first_entry_only_after_missing_default
S3-MAT-RESOLVE-004 covered cargo test -p fparkan-material --offline resolve_material_empty_texture_means_untextured S3-MAT-RESOLVE-004 covered cargo test -p fparkan-material --offline resolve_material_empty_texture_means_untextured
S3-MAT-RESOLVE-005 covered cargo test -p fparkan-material --offline resolve_material_without_lightmap_keeps_lightmap_absent S3-MAT-RESOLVE-005 covered cargo test -p fparkan-material --offline resolve_material_without_lightmap_keeps_lightmap_absent
S3-RENDER-001 covered cargo test -p fparkan-render --offline one_snapshot_draw_produces_one_draw_command S2-PLANNING-RENDER-001 covered cargo test -p fparkan-render --offline one_snapshot_draw_produces_one_draw_command
S3-RENDER-002 covered cargo test -p fparkan-render --offline material_index_maps_through_resolved_material_slots S2-PLANNING-RENDER-002 covered cargo test -p fparkan-render --offline material_index_maps_through_resolved_material_slots
S3-RENDER-003 covered cargo test -p fparkan-render --offline node_transform_is_retained S2-PLANNING-RENDER-003 covered cargo test -p fparkan-render --offline node_transform_is_retained
S3-RENDER-004 covered cargo test -p fparkan-render --offline command_order_uses_phase_then_stable_key S2-PLANNING-RENDER-004 covered cargo test -p fparkan-render --offline command_order_uses_phase_then_stable_key
S3-RENDER-005 covered cargo test -p fparkan-render --offline command_capture_independent_of_snapshot_construction_order S2-PLANNING-RENDER-005 covered cargo test -p fparkan-render --offline command_capture_independent_of_snapshot_construction_order
S3-RENDER-006 covered cargo test -p fparkan-render --offline invalid_range_returns_contextual_error S2-PLANNING-RENDER-006 covered cargo test -p fparkan-render --offline invalid_range_returns_contextual_error
S3-RENDER-007 covered cargo test -p fparkan-render --offline capture_is_stable S2-PLANNING-RENDER-007 covered cargo test -p fparkan-render --offline capture_is_stable
S3-RENDER-008 covered cargo test -p fparkan-render --offline recording_backend_stores_captures S2-PLANNING-RENDER-008 covered-planning cargo test -p fparkan-render --offline recording_backend_stores_captures
S3-RENDER-009 covered cargo xtask policy S2-PLANNING-RENDER-009 covered cargo xtask policy
S3-GL-001 omitted permanent macOS Desktop GL 3.3 adapter is not implemented; historical CGL probe is retained as external evidence only S3-VK-MESH-UPLOAD-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-msh.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH; original MSH reports 128 vertices and 252 indices
S3-GL-002 omitted outside the current macOS-focused goal scope; GLES2 remains documented for portable/non-macOS targets S3-VK-TEXM-UPLOAD-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-texm-upload.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_ROOT> --texture-archive Textures.lib --texture-name DEFAULT.0; original TEXM 16x16 decoded RGBA8, staged into device-local image and transitioned to SHADER_READ_ONLY_OPTIMAL
S3-VK-DESCRIPTOR-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-texm-sampled.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_ROOT> --texture-archive Textures.lib --texture-name DEFAULT.0; original TEXM is bound as set=0,binding=0 combined image sampler and sampled in fragment shader; validation_warning_count=0 and validation_error_count=0
S3-VK-PIPELINE-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-alpha-test-smoke.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_ROOT> --texture-archive Textures.lib --texture-name DEFAULT.0; PipelineKey selects a deduplicated live Vulkan pipeline per static draw range; capability-selected depth attachment supports TestWrite/TestReadOnly; fragment alpha test receives per-range dynamic cutoff through push constants; baseline key is live-proven, while Batch20/MAT0 state/reference mapping remains an evidence gap
S3-VK-DRAW-MODEL-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-fr-l-mtp-per-batch-materials.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive fortif.rlb --model-name FR_L_MTP.msh --wear-root <GOG_ROOT> --wear-archive fortif.rlb --wear-name FR_L_MTP.WEA; live Win32 Vulkan issues 237 source-preserving indexed batch draws and binds the deduplicated WEAR→MAT0 material descriptor selected by each Batch20.material_index (this corpus model has selector 0 only)
S3-VK-DRAW-TERRAIN-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-land-11-smoke.json --frames 300 --resize-frame 120 --timeout-seconds 90 --terrain-path <GOG_ROOT>/DATA/MAPS/11/Land.msh --texture-root <GOG_ROOT> --texture-archive Textures.lib --texture-name DEFAULT.0; validated Land.msh TerrainFace28 order becomes one live indexed Vulkan terrain draw (6458 vertices, 24672 indices); terrain material/slot/camera semantics remain an evidence gap
S3-VK-PIXEL-CAPTURE-001 blocked awaits Stage 3 fixed-camera pixel capture approval flow
S3-VK-VALIDATION-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-msh.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH; validation_warning_count=0 and validation_error_count=0
S3-GL-001 omitted legacy OpenGL adapters are outside the Windows-only Vulkan renderer scope
S3-GL-002 omitted GLES2 and portable-device targets are outside the Windows-only Vulkan renderer scope
S3-GL-003 blocked legacy fparkan-render-gl adapter removed while Vulkan renderer path is being brought in as the stage-3 backend S3-GL-003 blocked legacy fparkan-render-gl adapter removed while Vulkan renderer path is being brought in as the stage-3 backend
S3-VIEWER-001 covered cargo test -p fparkan-viewer --offline model_fixture_uses_viewer_service_and_render_commands S3-VIEWER-001 covered cargo test -p fparkan-viewer --offline model_fixture_uses_viewer_service_and_render_commands
S4-ANIM-001 covered cargo test -p fparkan-animation --offline anim_key24_decodes_signed_quaternion S4-ANIM-001 covered cargo test -p fparkan-animation --offline anim_key24_decodes_signed_quaternion
@@ -276,7 +314,7 @@ S4-ANIM-005 covered cargo test -p fparkan-animation --offline exact_key_time_ret
S4-ANIM-006 covered cargo test -p fparkan-animation --offline pose_track_blends_translation_and_rotation S4-ANIM-006 covered cargo test -p fparkan-animation --offline pose_track_blends_translation_and_rotation
S4-ANIM-007 covered cargo test -p fparkan-animation --offline quaternion_shortest_path_sign_flip_is_stable S4-ANIM-007 covered cargo test -p fparkan-animation --offline quaternion_shortest_path_sign_flip_is_stable
S4-ANIM-008 covered cargo test -p fparkan-animation --offline zero_or_degenerate_key_interval_is_rejected S4-ANIM-008 covered cargo test -p fparkan-animation --offline zero_or_degenerate_key_interval_is_rejected
S4-ANIM-009 omitted current sampler is a portable reference path; runtime-captured x87 parity vectors are not implemented in the macOS-focused scope S4-ANIM-009 omitted current sampler is a portable reference path; runtime-captured x87 parity vectors are not implemented
S4-ANIM-010 omitted current sampler accepts the profile marker but does not implement an independent x87 compatibility path S4-ANIM-010 omitted current sampler accepts the profile marker but does not implement an independent x87 compatibility path
S4-ANIM-011 covered cargo test -p fparkan-animation --offline blend_optional_pose_uses_valid_side S4-ANIM-011 covered cargo test -p fparkan-animation --offline blend_optional_pose_uses_valid_side
S4-ANIM-012 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles S4-ANIM-012 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles
@@ -403,7 +441,7 @@ L5-P1-MISSION-002 covered cargo test -p fparkan-runtime --offline licensed_corpo
L5-P2-MISSION-002 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations L5-P2-MISSION-002 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
L5-P1-HEADLESS-001 covered cargo test -p fparkan-runtime --offline selected_is_and_is2_missions_execute_10000_deterministic_ticks L5-P1-HEADLESS-001 covered cargo test -p fparkan-runtime --offline selected_is_and_is2_missions_execute_10000_deterministic_ticks
L5-P2-HEADLESS-001 covered cargo test -p fparkan-runtime --offline selected_is_and_is2_missions_execute_10000_deterministic_ticks L5-P2-HEADLESS-001 covered cargo test -p fparkan-runtime --offline selected_is_and_is2_missions_execute_10000_deterministic_ticks
L5-P1-RENDER-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures L5-P1-RENDER-001 covered-planning cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
L5-P2-RENDER-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures L5-P2-RENDER-001 covered-planning cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
L3-DEVICE-001 omitted outside the current macOS-focused goal scope; RG40XX-capable device/profile evidence remains documented for the portable target scope L3-DEVICE-001 omitted RG40XX and portable-device profiles are outside the Windows-only runtime scope
L5-RG40-001 omitted outside the current macOS-focused goal scope; RG40XX 640x480 on-device mission smoke/performance/memory evidence remains documented for the portable target scope L5-RG40-001 omitted RG40XX 640x480 on-device mission smoke/performance/memory evidence is outside the Windows-only runtime scope
1 # Acceptance coverage manifest.
2 # Format: <acceptance-id>\t<covered|partial|blocked|omitted>\t<evidence> # Format: <acceptance-id>\t<covered|covered-planning|covered-stub|covered-gpu|partial|blocked|omitted>\t<evidence>
3 # Scope note: Stage 0 native runtime acceptance is Windows-only.
4 # Scope note: Linux/macOS and hosted CI are outside the project scope.
5 L0-COPYRIGHT-001
6 L0-P1-001
7 L0-P1-002
17 S0-ARCH-008
18 S0-ARCH-009
19 S0-ARCH-010
20 S0-ARCH-011
21 S0-DIAG-001
22 S0-DIAG-002
23 S0-CORPUS-001
32 S0-PLAT-002
33 S0-PLAT-003
34 S0-PLAT-004
35 S0-VK-001
36 S0-VK-002
37 S0-VK-003
38 S0-VK-004
52 S0-VK-018
53 S0-VK-019
54 S0-VK-020
55 S0-VK-021
56 S0-VK-022
57 S0-VK-023
58 S0-VK-024
59 S0-VK-025
66 S0-VK-032
67 S0-VK-033
68 S0-VK-034
69 S0-VK-035
70 S0-LIMIT-001
71 S0-LIMIT-002
72 L1-P1-NRES-001
105 S1-NRES-023
106 S1-NRES-024
107 S1-NRES-025
108 S1-NRES-026
109 S1-LIMIT-001
110 S1-NRES-PROP-001
111 S1-NRES-PROP-002
112 S1-NRES-FUZZ-001
119 S1-PATH-007
120 S1-PATH-008
121 S1-PATH-009
122 S1-PATH-010
123 S1-PATH-011
124 S1-VFS-005
125 S1-RSLI-001
126 S1-RSLI-002
145 S1-RSLI-021
146 S1-RSLI-022
147 S1-RSLI-023
148 S1-RSLI-024
149 S1-RSLI-025
150 S1-LIMIT-002
151 S1-RSLI-PROP-001
152 S1-RSLI-FUZZ-001
153 S1-RES-001
154 S1-RES-002
155 S1-RES-003
156 S1-RES-004
157 S1-RES-005
158 S1-RES-006
159 S1-RES-007
160 S1-DIAG-001
161 S1-VFS-001
162 S1-VFS-002
163 S1-VFS-003
164 S1-VFS-004
165 S1-LICENSED-001
166 S1-LICENSED-002
167 L2-P1-UNIT-001
168 L2-P2-UNIT-001
169 L2-P1-REG-001
207 S2-GRAPH-004
208 S2-GRAPH-005
209 S2-GRAPH-006
210 S2-GRAPH-EDGE-001
211 S2-GRAPH-PROV-001
212 S2-GRAPH-IDEMP-001
213 S2-UNIT-EMPTY-001
214 S2-ASSET-MODEL-001
215 S2-ASSET-WEAR-001
216 S2-ASSET-MATERIAL-001
217 S2-ASSET-TEXM-001
218 S2-ASSET-ID-001
219 S2-ASSET-LIMIT-001
220 S2-RUNTIME-DRAFT-001
221 S2-CLI-JSON-001
222 S2-LICENSED-MISSION-P1-001
223 S2-LICENSED-MISSION-P2-001
224 S2-PROP-001
225 S2-FUZZ-001
226 L3-P1-MSH-001
233 L3-P2-WEAR-001
234 L3-P1-ASSET-001
235 L3-P2-ASSET-001
236 L3-P1-CAPTURE-001 S2-PLANNING-CAPTURE-P1-001
237 L3-P2-CAPTURE-001 S2-PLANNING-CAPTURE-P2-001
238 S3-WEAR-001
239 S3-WEAR-002
240 S3-WEAR-003
285 S3-MAT-RESOLVE-003
286 S3-MAT-RESOLVE-004
287 S3-MAT-RESOLVE-005
288 S3-RENDER-001 S2-PLANNING-RENDER-001
289 S3-RENDER-002 S2-PLANNING-RENDER-002
290 S3-RENDER-003 S2-PLANNING-RENDER-003
291 S3-RENDER-004 S2-PLANNING-RENDER-004
292 S3-RENDER-005 S2-PLANNING-RENDER-005
293 S3-RENDER-006 S2-PLANNING-RENDER-006
294 S3-RENDER-007 S2-PLANNING-RENDER-007
295 S3-RENDER-008 S2-PLANNING-RENDER-008
296 S3-RENDER-009 S2-PLANNING-RENDER-009
297 S3-GL-001 S3-VK-MESH-UPLOAD-001
298 S3-GL-002 S3-VK-TEXM-UPLOAD-001
299 S3-VK-DESCRIPTOR-001
300 S3-VK-PIPELINE-001
301 S3-VK-DRAW-MODEL-001
302 S3-VK-DRAW-TERRAIN-001
303 S3-VK-PIXEL-CAPTURE-001
304 S3-VK-VALIDATION-001
305 S3-GL-001
306 S3-GL-002
307 S3-GL-003
308 S3-VIEWER-001
309 S4-ANIM-001
314 S4-ANIM-006
315 S4-ANIM-007
316 S4-ANIM-008
317 S4-ANIM-009
318 S4-ANIM-010
319 S4-ANIM-011
320 S4-ANIM-012
441 L5-P2-MISSION-002
442 L5-P1-HEADLESS-001
443 L5-P2-HEADLESS-001
444 L5-P1-RENDER-001
445 L5-P2-RENDER-001
446 L3-DEVICE-001
447 L5-RG40-001
+34 -11
View File
@@ -64,6 +64,7 @@
`S0-VK-032` `S0-VK-032`
`S0-VK-033` `S0-VK-033`
`S0-VK-034` `S0-VK-034`
`S0-VK-035`
`S0-LIMIT-001` `S0-LIMIT-001`
`S0-LIMIT-002` `S0-LIMIT-002`
`L1-P1-NRES-001` `L1-P1-NRES-001`
@@ -191,6 +192,20 @@
`S2-GRAPH-004` `S2-GRAPH-004`
`S2-GRAPH-005` `S2-GRAPH-005`
`S2-GRAPH-006` `S2-GRAPH-006`
`S2-GRAPH-EDGE-001`
`S2-GRAPH-PROV-001`
`S2-GRAPH-IDEMP-001`
`S2-UNIT-EMPTY-001`
`S2-ASSET-MODEL-001`
`S2-ASSET-WEAR-001`
`S2-ASSET-MATERIAL-001`
`S2-ASSET-TEXM-001`
`S2-ASSET-ID-001`
`S2-ASSET-LIMIT-001`
`S2-RUNTIME-DRAFT-001`
`S2-CLI-JSON-001`
`S2-LICENSED-MISSION-P1-001`
`S2-LICENSED-MISSION-P2-001`
`S2-PROP-001` `S2-PROP-001`
`S2-FUZZ-001` `S2-FUZZ-001`
`L3-P1-MSH-001` `L3-P1-MSH-001`
@@ -203,8 +218,8 @@
`L3-P2-WEAR-001` `L3-P2-WEAR-001`
`L3-P1-ASSET-001` `L3-P1-ASSET-001`
`L3-P2-ASSET-001` `L3-P2-ASSET-001`
`L3-P1-CAPTURE-001` `S2-PLANNING-CAPTURE-P1-001`
`L3-P2-CAPTURE-001` `S2-PLANNING-CAPTURE-P2-001`
`S3-WEAR-001` `S3-WEAR-001`
`S3-WEAR-002` `S3-WEAR-002`
`S3-WEAR-003` `S3-WEAR-003`
@@ -255,15 +270,23 @@
`S3-MAT-RESOLVE-003` `S3-MAT-RESOLVE-003`
`S3-MAT-RESOLVE-004` `S3-MAT-RESOLVE-004`
`S3-MAT-RESOLVE-005` `S3-MAT-RESOLVE-005`
`S3-RENDER-001` `S2-PLANNING-RENDER-001`
`S3-RENDER-002` `S2-PLANNING-RENDER-002`
`S3-RENDER-003` `S2-PLANNING-RENDER-003`
`S3-RENDER-004` `S2-PLANNING-RENDER-004`
`S3-RENDER-005` `S2-PLANNING-RENDER-005`
`S3-RENDER-006` `S2-PLANNING-RENDER-006`
`S3-RENDER-007` `S2-PLANNING-RENDER-007`
`S3-RENDER-008` `S2-PLANNING-RENDER-008`
`S3-RENDER-009` `S2-PLANNING-RENDER-009`
`S3-VK-MESH-UPLOAD-001`
`S3-VK-TEXM-UPLOAD-001`
`S3-VK-DESCRIPTOR-001`
`S3-VK-PIPELINE-001`
`S3-VK-DRAW-MODEL-001`
`S3-VK-DRAW-TERRAIN-001`
`S3-VK-PIXEL-CAPTURE-001`
`S3-VK-VALIDATION-001`
`S3-GL-001` `S3-GL-001`
`S3-GL-002` `S3-GL-002`
`S3-GL-003` `S3-GL-003`
+1
View File
@@ -64,5 +64,6 @@
`S0-VK-032` `S0-VK-032`
`S0-VK-033` `S0-VK-033`
`S0-VK-034` `S0-VK-034`
`S0-VK-035`
`S0-LIMIT-001` `S0-LIMIT-001`
`S0-LIMIT-002` `S0-LIMIT-002`
+1
View File
@@ -35,6 +35,7 @@ schema = 1
"4" = [ "4" = [
"fparkan-animation", "fparkan-animation",
"fparkan-fx", "fparkan-fx",
"fparkan-script",
] ]
"5" = [ "5" = [
"fparkan-game", "fparkan-game",
+3
View File
@@ -73,6 +73,9 @@ nav:
- WEAR и MAT0: reference/materials.md - WEAR и MAT0: reference/materials.md
- Texm: reference/texm.md - Texm: reference/texm.md
- Render frame: reference/render-frame.md - Render frame: reference/render-frame.md
- Renderer Truth Table: rendering/renderer_truth_table.md
- Original Engine Hashes: evidence/original_engine_hashes.md
- Stage 4 Capture Schema: evidence/stage4_capture_schema.md
- Приложения: - Приложения:
- Глоссарий: appendices/glossary.md - Глоссарий: appendices/glossary.md
- Границы знания: appendices/knowledge-boundaries.md - Границы знания: appendices/knowledge-boundaries.md
+1 -4
View File
@@ -1,9 +1,6 @@
[toolchain] [toolchain]
channel = "1.87.0" channel = "1.97.1"
components = ["clippy", "rustfmt", "rust-docs"] components = ["clippy", "rustfmt", "rust-docs"]
targets = [ targets = [
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc", "x86_64-pc-windows-msvc",
"aarch64-apple-darwin",
"x86_64-apple-darwin",
] ]
+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)
}

Some files were not shown because too many files have changed in this diff Show More